動画をあとで見る + 簡易NG機能。 ZenzaWatchとの連携も可能。
// ==UserScript== // @name MylistPocket // @namespace https://github.com/segabito/ // @description 動画をあとで見る + 簡易NG機能。 ZenzaWatchとの連携も可能。 // @match *://www.nicovideo.jp/* // @match *://ext.nicovideo.jp/ // @match *://ext.nicovideo.jp/#* // @match *://ch.nicovideo.jp/* // @match *://com.nicovideo.jp/* // @match *://commons.nicovideo.jp/* // @match *://dic.nicovideo.jp/* // @match *://ex.nicovideo.jp/* // @match *://info.nicovideo.jp/* // @match *://search.nicovideo.jp/* // @match *://uad.nicovideo.jp/* // @match *://site.nicovideo.jp/* // @match *://anime.nicovideo.jp/* // @match https://www.google.com/search?* // @match https://www.google.co.jp/search?* // @match https://*.bing.com/* // @exclude *://ads*.nicovideo.jp/* // @exclude *://www.upload.nicovideo.jp/* // @exclude *://www.nicovideo.jp/watch/*?edit=* // @exclude *://ch.nicovideo.jp/tool/* // @exclude *://flapi.nicovideo.jp/* // @exclude *://dic.nicovideo.jp/p/* // @exclude *://ext.nicovideo.jp/thumb/* // @exclude *://ext.nicovideo.jp/thumb_channel/* // @version 0.5.14 // @grant none // @author segabito macmoto // @license public domain // @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js // ==/UserScript== /* eslint-disable */ const AntiPrototypeJs = function() { if (this.promise !== null || !window.Prototype || window.PureArray) { return this.promise || Promise.resolve(window.PureArray || window.Array); } if (document.getElementsByClassName.toString().indexOf('B,A') >= 0) { delete document.getElementsByClassName; } const waitForDom = new Promise(resolve => { if (['interactive', 'complete'].includes(document.readyState)) { return resolve(); } document.addEventListener('DOMContentLoaded', resolve, {once: true}); }); const f = Object.assign(document.createElement('iframe'), { srcdoc: '<html><title>ここだけ時間が10年遅れてるスレ</title></html>', id: 'prototype', loading: 'eager' }); Object.assign(f.style, {position: 'absolute', left: '-100vw', top: '-100vh'}); return this.promise = waitForDom .then(() => new Promise(res => { f.onload = res; document.body.append(f); })).then(() => { window.PureArray = f.contentWindow.Array; delete window.Array.prototype.toJSON; delete window.String.prototype.toJSON; f.remove(); return Promise.resolve(window.PureArray); }).catch(err => console.error(err)); }.bind({promise: null}); AntiPrototypeJs().then(() => { const PRODUCT = 'MylistPocket'; const monkey = (PRODUCT) => { const console = window.console; const {workerUtil} = window.MylistPocketLib; //const $ = window.jQuery; console.log(`%c${PRODUCT}`, 'font-family: "Apple LiGothic"; padding: 4px; background: red; color: white; font-size: 150%;' ); const TOKEN = 'r:' + (Math.random()); const CONSTANT = { BASE_Z_INDEX: 100000 }; const MylistPocket = {debug: {}}; window.MylistPocket = MylistPocket; const protocol = location.protocol; const global = { debug: MylistPocket.debug, TOKEN, PRODUCT }; const __css__ = (` a[href*='watch/'] > g-img { position: inherit; } .mylistPocketHoverMenu { display: none; opacity: 0.8; position: absolute; z-index: ${CONSTANT.BASE_Z_INDEX + 100000}; font-size: 8pt; padding: 0; line-height: 26px; font-weight: bold; text-align: center; transition: box-shadow 0.2s ease, opacity 0.4s ease, padding 0.2s ease; user-select: none; } .mylistPocketHoverMenu.is-busy { opacity: 0 !important; pointer-events: none; } .mylistPocketHoverMenu.is-otherDomain .wwwOnly { display: none; } .mylistPocketHoverMenu.is-otherDomain:not(.is-zenzaReady) .wwwZenzaOnly { display: none; } .mylistPocketHoverMenu .zenzaMenu { display: none; } .mylistPocketHoverMenu.is-zenzaReady .zenzaMenu { display: inline-block; } .mylistPocketButton { /*font-family: Menlo;*/ display: block; font-weight: bolder; cursor: pointer; width: 32px; height: 26px; background: #ccc; color: black; cursor: pointer; box-shadow: 1px 1px 1px #000; transition: 0.1s box-shadow ease, 0.1s transform ease; font-size: 16px; line-height: 24px; -webkit-user-select: none; -moz-use-select: none; user-select: none; outline: none; } .mylistPocketButton:hover { transform: scale(1.2); box-shadow: 4px 4px 5px #000; } .mylistPocketButton:active { transform: scale(1.0); box-shadow: none; transition: none; } .is-deflistUpdating .mylistPocketButton.deflist-add::after, .is-deflistSuccess .mylistPocketButton.deflist-add::after, .is-deflistFail .mylistPocketButton.deflist-add::after, .mylistPocketButton:hover::after, #mylistPocket-poupup [tooltip] { content: attr(tooltip); position: absolute; /*top: 0px; left: 50%;*/ top: 50%; right: -8px; padding: 2px 4px; white-space: nowrap; font-size: 12px; color: #fff; background: #333; transform: translate3d(-50%, -120%, 0); transform: translate3d(100%, -50%, 0); pointer-events: none; } .is-deflistUpdating .mylistPocketButton.deflist-add { cursor: wait; opacity: 0.9; transform: scale(1.0); box-shadow: none; transition: none; background: #888; border-style: inset; } .is-deflistSuccess .mylistPocketButton.deflist-add, .is-deflistFail .mylistPocketButton.deflist-add { transform: scale(1.0); box-shadow: none; transition: none; } .is-deflistSuccess .mylistPocketButton.deflist-add::after { content: attr(data-r###lt); background: #393; } .is-deflistFail .mylistPocketButton.deflist-add::after { content: attr(data-r###lt); background: #933; } .is-deflistUpdating .mylistPocketButton.deflist-add::after { content: '更新中'; background: #333; } .mylistPocketButton + .mylistPocketButton { margin-top: 4px; } .mylistPocketHoverMenu:hover { font-weibht: bolder; opacity: 1; } .mylistPocketHoverMenu:active { } .mylistPocketHoverMenu.is-show { display: block; } #mylistPocket-popup { display: none; perspective: 800px; } #mylistPocket-popup.is-firefox { /*perspective: none !important;*/ position: fixed; z-index: 200000; transform: translate3d(-50%, -50%, 0); opacity: 0; transition: 0.3s opacity ease; top: -9999px; left: -9999px; } #mylistPocket-popup.show { display: block; } #mylistPocket-popup.is-firefox.show { top: 50%; left: 50%; opacity: 1; } #mylistPocket-popup .owner-icon { width: 64px; height: 64px; transform-origin: center; transform-origin: center; transition: 0.2s transform ease, 0.2s box-shadow ease ; } #mylistPocket-popup .owner-icon:hover { } #mylistPocket-popup .description a { color: #ffff00 !important; text-decoration: none !important; font-weight: normal !important; display: inline-block; } #mylistPocket-popup .description a.watch { position: relative; display: block; backface-visibility: hidden; } #mylistPocket-popup .description a[data-title]:hover::after { content: attr(data-title); position: absolute; top: -16px; left: 0; word-break: break-all; line-height: 12px; padding: 4px; font-size: 12px; color: #333; background: #ffc; opacity: 0.8; user-select: none; pointer-events: none; } #mylistPocket-popup .description a:visited { color: #ffff99 !important; } #mylistPocket-popup .description button { /*font-family: Menlo;*/ font-size: 16px; font-weight: bolder; margin: 4px 8px; padding: 4px 8px; cursor: pointer; border-radius: 0; background: #333; color: #ccc; border: solid 2px #ccc; outline: none; } #mylistPocket-popup .description button:hover { transform: translate(-2px,-2px); box-shadow: 2px 2px 2px #000; background: #666; transition: 0.2s transform ease, 0.2s box-shadow ease ; } #mylistPocket-popup .description button:active { transform: none; box-shadow: none; transition: none; } #mylistPocket-popup .description button:active::hover { opacity: 0; } #mylistPocket-popup .watch { display: block; position: relative; line-height: 60px; box-sizing: border-box; padding: 4px 16px;; min-height: 60px; width: 280px; margin: 8px 10px; background: #444; border-radius: 4px; } #mylistPocket-popup .watch:hover { background: #446; } #mylistPocket-popup .videoThumbnail { position: absolute; right: 16px; height: 60px; transform-origin: center; transition: 0.2s transform ease, 0.2s box-shadow ease ; } #mylistPocket-popup .videoThumbnail:hover { transform: scale(2); box-shadow: 0 0 8px #888; transition: 0.2s transform ease 0.5s, 0.2s box-shadow ease 0.5s ; } .zenzaPlayerContainer.is-error #mylistPocket-popup, .zenzaPlayerContainer.is-loading #mylistPocket-popup, .zenzaPlayerContainer.error #mylistPocket-popup, .zenzaPlayerContainer.loading #mylistPocket-popup { opacity: 0; pointer-events: none; } .mylistPocketHoverMenu.is-guest .is-need-login { display: none !important; } .xDomainLoaderFrame { position: fixed; left: -100%; top: -100%; width: 64px; height: 64px; opacity: 0; border: 0; } body.BaseLayout { margin-top: 0 !important; } ${ location.host === 'www.niovideo.jp' ? ` #siteHeader { position: sticky; left: 0 !important; will-change: transform; } body.nofix #siteHeader { position: static; } .RankingMainContainer-header { position: sticky; top: 36px; z-index: 1000; background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 1.0), rgba(255, 255, 255, 0.8), rgba(232, 232, 255, 0) ); } .nofix .RankingMainContainer-header { top: 0; } .RankingBaseItem { border-radius: 0 !important; box-shadow: none !important; border: 1px solid silver; pointer-events: none; user-select: none; display: grid; } .RankingBaseItem .Card-link { display: grid; grid-template-rows: 108px auto; } .RankingBaseItem .Card-media { position: static; pointer-events: auto; } .VideoThumbnail { border-radius: 0 !important; } .RankingBaseItem .Card-title { pointer-events: auto; user-select: auto; height: auto; max-height: 49px; -webkit-line-clamp: unset; } .RankingBaseItem .Card-secondary { width: 100%; user-select: none; pointer-events: none; align-self: end; overflow: hidden; } [data-nicoad-grade=gold] .Thumbnail.VideoThumbnail { background: #f7e01c; } [data-nicoad-grade=silver] .Thumbnail.VideoThumbnail { background: #dfeaec; } .MatrixRanking-body.GlobalHeader#siteHeader #siteHeaderInner { width: 1232px; } .MatrixRanking-body .RankingRowRank { line-height: 48px; height: 48px; pointer-events: none; user-select: none; } .MatrixRanking-body .RankingMatrixVideosRow { width: ${1232 + 64}px; margin-left: ${-64}px; } .MatrixRanking-body .RankingRowRank { position: sticky; left: -8px; z-index: 100; transform: none; padding-right: 16px; width: 64px; overflow: visible; text-align: right; mix-blend-mode: difference; text-shadow: 1px 1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, -1px -1px 0 #fff; } ` : ''} `).trim(); const nicoadHideCss = ` .nicoadVideoItem { display: none; } .MatrixRankingBannerAd, .RankingMatrixNicoadsRow, .RankingMainNicoad { display: none; } `.trim(); const responsiveCss = ` @media screen and (max-width: 1350px) { .RankingGenreListContainer { border-right: 0; border-left: 56px solid #fafafa; } .RankingGenreListContainer-categoryHelp { position: static; } .GlobalHeader#siteHeader #siteHeaderInner { width: ####px; } .RankingHeaderContainer-headerInner { margin-left: 64px; width: 1214px; } .LaneHeader { flex: 1 1 160px; width: 160px; } .LaneHeader+.LaneHeader { /*margin-left: 13px;*/ } .LaneHeader>p { white-space: normal; height: 32px; line-height: 16px; } .CustomButton { width: 136px; } .MatrixRanking-body .BaseLayout-block { width: ${1280}px; } .RankingMainContainer-decorateChunk+.RankingMainContainer-decorateChunk, .RankingMainContainer-decorateChunk>*+* { margin-top: 0; } .RankingMainContainer { width: ${####}px; } .MatrixRanking-body .RankingMatrixVideosRow { width: ${#### + 64}px; margin-left: ${-64}px; } .RankingMatrixNicoadsRow>*+*, .RankingMatrixVideosRow>:nth-child(n+3) { margin-left: 13px; } .RankingBaseItem { width: 160px; height: 196px; } .RankingBaseItem .Card-link { grid-template-rows: 90px auto; } .VideoItem.RankingBaseItem .VideoThumbnail { border-radius: 3px 3px 0 0; } [data-nicoad-grade] .Thumbnail.VideoThumbnail .Thumbnail-image { margin: 3px; background-size: calc(100% + 6px); } [data-nicoad-grade] .Thumbnail.VideoThumbnail:after { width: 40px; height: 40px; background-size: 80px 80px; } .Thumbnail.VideoThumbnail .VideoLength { bottom: 3px; right: 3px; } .VideoThumbnailComment { transform: scale(0.8333); } .RankingBaseItem-meta { position: static; padding: 0 4px 8px; } .VideoItem.RankingBaseItem .VideoItem-metaCount>.VideoMetaCount { white-space: nowrap; } .RankingMainContainer .ToTopButton { transform: translateX(calc(100vw / 2 - 100% - 36px)); user-select: none; } } `; const __tpl__ = (` <div class="mylistPocketHoverMenu scalingUI zen-family"> <button class="mylistPocketButton command deflist-add wwwZenzaOnly is-need-login" data-command="deflist" tooltip="とりあえずマイリスト">✚</button> <button class="mylistPocketButton command info" data-command="info" tooltip="動画情報を表示">?</button> <button class="mylistPocketButton command playlist-queue zenzaMenu" data-command="playlist-queue" tooltip="ZenzaWatchのプレイリストに追加">▶</button> </div> </div> <div id="mylistPocket-popup" class="zen-family"> <span slot="video-title">【実況】どんぐりころころの大冒険 Part1(最終回)</span> <a href="/watch/sm9" slot="watch-link"></a> <img slot="video-thumbnail" data-type="image"> <a slot="owner-page-link" href="https://www.nicovideo.jp/user/1234" class="owner-page-link target-change" data-type="link" rel="noopener"><img slot="owner-icon" class="owner-icon" src="https://nicovideo.cdn.nimg.jp/web/img/user/thumb/blank_s.jpg" data-type="image"></img></a> <span slot="upload-date" data-type="date">1970/01/01 00:00</span> <span slot="view-counter" data-type="int">12,345</span> <span slot="mylist-counter" data-type="int">6,789</span> <span slot="comment-counter" data-type="int">2,525</span> <span slot="duration" class="duration">1:23</span> <span slot="owner-id">1234</span> <span slot="locale-owner-name">ほげほげ</span> <div slot="error-description"></div> <div class="description" slot="description" data-type="html"></div> <span slot="last-res-body"></span> </div> <template id="mylistPocket-popup-template"> <style> :host(#mylistPocket-popup) { position: fixed; z-index: 200000; transform: translate3d(-50%, -50%, 0); opacity: 0; transition: 0.3s opacity ease; top: -9999px; left: -9999px; } :host(#mylistPocket-popup.show) { top: 50%; left: 50%; opacity: 1; pointer-events: auto; } .root.is-otherDomain .wwwOnly { display: none; } .root.is-otherDomain:not(.is-zenzaReady) .wwwZenzaOnly { display: none; } * { box-sizing: border-box; font-kerning: none; } a { color: #ffff00; font-weight: bold; display: inline-block; } a:visited { color: #ffff99; } button { font-size: 14px; padding: 8px 8px; cursor: pointer; border-radius: 0; margin: 0; background: #333; color: #ccc; border: solid 2px #ccc; outline: none; line-height: 20px; user-select: none; -webkit-user-select: none; -moz-user-select: none; } button:hover { transform: translate(-4px,-4px); box-shadow: 4px 4px 4px #000; background: #666; transition: 0.2s transform ease, 0.2s box-shadow ease ; } button.is-updating { cursor: wait; } button.is-active, button:active { transform: none; box-shadow: none; transition: none; } button.is-active::after, button:active::after { opacity: 0; } [tooltip] { position: relative; } .is-deflistUpdating .deflist-add::after, .is-deflistSuccess .deflist-add::after, .is-deflistFail .deflist-add::after, [tooltip]:hover::after { content: attr(tooltip); position: absolute; top: 0px; left: 50%; padding: 2px 4px; white-space: nowrap; font-size: 14px; color: #fff; background: #333; transform: translate3d(-50%, -120%, 0); pointer-events: none; } .root { text-align: left; outline-offset: 8px; border: 12px solid rgba(32, 32, 32, 0); border-radius: 20px; padding: 8px 0; background: rgba(0, 0, 0, 0.7); color: #ccc; box-shadow: 0 0 16px #000; transition: 0.6s -webkit-clip-path ease, 0.6s clip-path ease, 0.5s transform ease; /*0.4s border-radius ease-out 0.4s, 0.4s height ease-out 0.4s*/ ; } .root * { } .root.show { opacity: 1; pointer-events: auto !important; } .root.is-loading, .root.is-loading.is-ok, .root.is-loading.is-fail { text-align: center; position: relative; width: 190px; height: 190px; padding: 32px; opacity: 0.8; cursor: wait; border-radius: 100%; clip-path: circle(100px at center) !important; transition: none; outline: none; transform: none !important; } .root.is-firefox { } .root.is-loading > * { pointer-events: none; } .root.is-setting { transform: rotateX(180deg); } .root.is-setting > *:not(.setting-panel) { pointer-events: none; z-index: 1; } .root:not(.is-setting) > .setting-panel { pointer-events: none; } .root.is-setting > .setting-panel { display: block; opacity: 1; pointer-events: auto; } .root.is-loading .loading-inner, .root.is-loading.is-ok .loading-inner, .root.is-loading.is-fail .loading-inner { position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); } .loading-inner .spinner { font-size: 64px; display: inline-block; animation-name: spin; animation-iteration-count: infinite; animation-duration: 3s; animation-timing-function: linear; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(1800deg); } } .root.is-ok { width: 800px; /*clip-path: circle(800px at center);*/ } .root.is-ok.noclip { clip-path: none; } .root.is-fail { font-size: 120%; white-space: nowrap; text-align: center; padding: 16px; } .root.is-loading>*:not(.loading-now), .root.is-loading.is-ok>*:not(.loading-now), .root.is-loading.is-fail>*:not(.loading-now), .root.is-fail:not(.is-loading)>*:not(.error-info), .root.is-ok:not(.is-loading)>*:not(.video-detail):not(.setting-panel) { display: none !important; } .root.is-loading>.loading-now, .root.is-fail>.error-info, .root.is-ok>.video-detail { display: block; } .header { padding: 8px 8px 8px; font-size: 12px; } .upload-date { margin-right: 8px; } .counter span + span { margin-left: 8px; } .video-title { font-weight: bolder; font-size: 22px; margin-bottom: 4px; } .close-button { position: absolute; right: 0; top: 0; transition: 0.2s background ease, 0.2s border-color ease; cursor: pointer; width: 48px; height: 48px; font-size: 28px; line-height: 36px; text-align: center; user-select: none; border: 6px solid rgba(80, 80, 80, 0.5); border-color: transparent; border-radius: 0 16px 0 0; } .close-button:hover { background: #333; /*border-color: rgba(0, 0, 0, 0.9);*/ /*transform: translate(-50%, -50%) scale(2.5);*/ } .close-button:active { /*transform: translate(-50%, -50%) scale(2) rotate(360deg);*/ box-shadow: none; transition: none; } .is-setting .close-button { display: none; } .main { display: flex; background: rgba(0, 0, 0, 0.2); box-shadow: 0 0 4px rgba(0, 0, 0, 0.5) inset; } .main-left { width: 360px; padding: 8px; z-index: 100; } .video-thumbnail-container { position: relative; width: 360px; height: 270px; background: #000; /*box-shadow: 2px 2px 4px #000;*/ } .video-thumbnail-container ::slotted(img) { width: 360px !important; height: 270px !important; object-fit: contain; } .video-thumbnail-container .duration { position: absolute; display: inline-block; right: 0; bottom: 0; font-size: 14px; background: #000; color: #fff; padding: 2px 4px; } .video-thumbnail-container:hover .duration { display: none; } .main-right { position: relative; padding: 0; flex-grow: 1; font-size: 14px; } ::slotted(.owner-page-link) { display: inline-block; vertical-align: middle; } .owner-page-link img { border: 1px solid #333; border-radius: 3px; } .video-info { /*background: rgba(0, 0, 0, 0.2);*/ max-height: 282px; overflow-x: hidden; overflow-y: scroll; overscroll-behavior: contain; } *::-webkit-scrollbar, .video-info::-webkit-scrollbar { background: rgba(34, 34, 34, 0.5); } *::-webkit-scrollbar-thumb, .video-info::-webkit-scrollbar-thumb { border-radius: 0; background: #666; } *::-webkit-scrollbar-button, .video-info::-webkit-scrollbar-button { background: #666; display: none; } *::scrollbar, .video-info::scrollbar { background: #222; } *::scrollbar-thumb, .video-info::scrollbar-thumb { border-radius: 0; background: #666; } *::scrollbar-button, .video-info::scrollbar-button { background: #666; display: none; } .scrollable { overscroll-behavior: contain; } .owner-info { margin: 16px; display: table; } .owner-info * { vertical-align: middle; word-break: break-all; } .owner-info>* { display: table-cell !important; } .owner-name { display: inline-block; padding: 8px; font-size: 18px; } .owner-info.is-favorited { font-weight: bolder; color: orange; } .owner-info.is-ng { color: #888; text-decoration: line-through; } .is-channel .owner-name::before { content: 'CH'; margin: 0 4px; background: #999; color: #333; padding: 2px 4px; border: 1px solid; } .locale-owner-name::after { content: ' さん'; } .owner-info .add-ng-button, .owner-info .add-fav-button { visibility: hidden; pointer-events: none; } .is-ng-enable .owner-info:hover .add-ng-button, .is-ng-enable .owner-info:hover .add-fav-button { visibility: visible; pointer-events: auto; } .description { word-break: break-all; line-height: 1.5; padding: 0 16px 8px; } .description:first-letter { font-size: 24px; } .last-res-body { margin: 16px 16px 0; border: 1px solid #ccc; padding: 4px; border-radius: 4px; word-break: break-all; font-size: 12px; min-height: 24px; } .footer { padding: 8px; backface-visibility: hidden; } .pocket-button { cusror: pointer; } .pocket-button:active { } .video-tags { display: block; } .tag-container { display: inline-block; position: relative; padding: 4px 8px; border: 1px solid #888; border-radius: 4px; margin: 0 20px 4px 0; } .tag-container .tag { display: inline-block; font-size: 14px; color: #ccc; text-decoration: none; cursor: pointer; } .tag-container .tag.channel-search { margin-left: 8px; color: #ccc !important; padding: 0 8px; } .tag-container:hover .tag { color: #fff !important; } .tag-container.is-favorited .tag { font-weight: bolder; color: orange !important; } .tag-container.is-ng .tag { text-decoration: line-through; color: #888 !important; } .zenzaPlayerContainer .tagItemMenu { margin: 0 8px; } .tag-container .add-ng-button, .tag-container .add-fav-button { position: absolute !important; visibility: hidden; pointer-events: none; } .is-ng-enable .tag-container:hover .add-ng-button, .is-ng-enable .tag-container:hover .add-fav-button { visibility: visible; pointer-events: auto; width: 24px; height: 24px; line-height: 24px; font-size: 24px; vertical-align: bottom; display: inline-block; } .is-ng-enable .tag-container:hover .add-ng-button { right: -16px; } .is-ng-enable .tag-container:hover .add-fav-button { left: -16px; } .footer-menu { position: absolute; right: 0px; bottom: 0px; transform: translate3d(0, 120%, 0); opacity: 1; transition: 0.4s opacity ease 0.4s, 0.4s transform ease 0.4s; } .is-setting .video-detail .footer-menu { transform: translate3d(0, 0, 0); opacity: 0; } .footer-menu button { min-width: 70px; } .regular-menu { display: inline-block; background: rgba(0, 0, 0, 0.7); position: relative; border-radius: 8px; padding: 12px 16px; box-shadow: 0 0 16px #000; } .is-deflistUpdating .deflist-add { cursor: wait; opacity: 0.9; transform: scale(1.0); box-shadow: none; transition: none; } .is-deflistSuccess .deflist-add, .is-deflistFail .deflist-add { transform: scale(1.0); box-shadow: none; transition: none; } .is-deflistSuccess .deflist-add::after { content: attr(data-r###lt); background: #393; } .is-deflistFail .deflist-add::after { content: attr(data-r###lt); background: #933; } .is-deflistUpdating .deflist-add::after { content: '更新中'; background: #333; } .zenza-menu { display: none; } .is-zenzaReady .zenza-menu { display: inline-block; background: rgba(0, 0, 0, 0.7); margin-left: 32px; position: relative; border-radius: 8px; padding: 12px 16px; box-shadow: 0 0 16px #000; } .is-zenzaReady .zenza-menu::after { content: 'ZenzaWatch'; position: absolute; left: 50%; bottom: 10px; padding: 2px 8px; transform: translate(-50%, 100%); pointer-events: none; font-weith: bolder; background: rgba(0, 0, 0, 0.7); pointer-events: none; border-radius: 4px; white-space: nowrap; } .setting-menu { display: inline-block; background: rgba(0, 0, 0, 0.7); margin-left: 32px; position: relative; border-radius: 8px; padding: 12px 16px; box-shadow: 0 0 16px #000; } .toggle-setting-button { font-size: 32px; border-radius: 100%; border: 12px solid #333; cursor: pointer; background: rgba(32, 32, 32, 1); transition: 0.2s transform ease ; } .toggle-setting-button:hover { transform: scale(1.2); box-shadow: none; background: rgba(32, 32, 32, 1); background: transparent; } .toggle-setting-button:active { transform: scale(1.0); } .mylist-comment-link { cursor: pointer; } .setting-panel { opacity: 0; position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 8px 12px; z-index: 10000; background: rgba(50, 50, 64, 0.9); border-radius: 16px; color: #ccc; /*-webkit-user-select: none; user-select: none;*/ transform: rotateX(180deg); transition: 0.25s opacity ease 0.25s; } .is-setting .setting-panel { transition: 0.25s opacity ease; } .setting-panel-main { width: 100%; height: 100%; overflow-y: scroll; overflow-x: hidden; } .root:not(.is-setting) .setting-panel .footer-menu { transform: translate3d(0, 0, 0); opacity: 0; } .root.is-setting .setting-panel .footer-menu { right: -12px; bottom: -12px; transform: translate3d(0, 120%, 0); opacity: 1; transition: opacity 0.4s ease 0.4s, transform 0.4s ease 0.4s; } .close-setting-menu { display: inline-block; background: rgba(0, 0, 0, 0.7); margin-left: 32px; position: relative; border-radius: 8px; padding: 12px 16px; box-shadow: 0 0 16px #000; } .setting-label { display: inline-block; line-height: 24px; padding: 8px; } .setting-label:hover { text-shadow: 0 0 4px #996; } .setting-label * { cursor: pointer; } .setting-label input[type=checkbox] { transform: scale(2); margin: 8px; vertical-align: middle; } .setting-label input + span { font-size: 16px; } .setting-label input:checked + span { } .setting-fav, .setting-ng-textarea, .setting-fav-textarea { display: none; } .is-ng-enable .setting-fav { display: block; } .is-ng-enable .setting-ng-textarea, .is-ng-enable .setting-fav-textarea { display: flex; } .setting-ng-text-column, .setting-fav-text-column { flex: 1; position: relative; padding: 8px; } .setting-ng-text-column textarea, .setting-fav-text-column textarea { width: 100%; height: 150px; background: transparent; color: #ccc; } .setting-ng-label { display: none; } .is-ng-enable .setting-ng-label { display: inline-block; } .add-ng-button, .add-fav-button { display: none; } .is-ng-enable .add-ng-button, .is-ng-enable .add-fav-button { display: inline-block; position: relative; width: 32px; height: 32px; line-height: 32px; font-size: 28px; padding: 0; margin: 0; /*border-radius: 100%;*/ border: none; text-align: center; color: red; font-weight: bolder; cursor: pointer; background: transparent; box-shadow: none; transition: 0.2s transform ease, 0.2s text-shadow ease; } .is-ng-enable .add-fav-button { color: orange; } .is-ng-enable .add-ng-button:hover, .is-ng-enable .add-fav-button:hover { transform: scale(1.2); text-shadow: 2px 2px 4px black; } .is-ng-enable .add-ng-button:active, .is-ng-enable .add-fav-button:active { transform: scale(1.0); text-shadow: 0 0 2px black; } .is-ng-enable .add-ng-button:hover::after, .is-ng-enable .add-fav-button:hover::after { content: 'NG登録'; position: absolute; top: 0; left: 50%; transform: translate(-50%, -80%); font-size: 12px; line-height: 12px; white-space: nowrap; background: rgba(192, 192, 192, 0.8); color: #000; opacity: 0.9; padding: 2px 4px; text-shadow: none; font-weight: normal; pointer-evnets: none !important; } .is-ng-enable .is-ng .add-ng-button:hover::after, .is-ng-enable .is-ng .add-fav-button:hover::after { content: 'NG解除'; } .is-ng-enable .add-fav-button:hover::after { content: '強調登録'; } .is-ng-enable .is-favorited .add-fav-button:hover::after { content: '強調解除'; } .is-ng-enable .add-ng-button:active:hover::after, .is-ng-enable .add-fav-button:active:hover::after { display: none; } </style> <div class="popup root"> <div class="loading-now"> <div class="loading-inner"> <span class="spinner">⌛</span> </div> </div> <div class="error-info"> <slot name="error-description"></slot> </div> <div class="video-detail"> <div class="header"> <div class="video-title"><slot name="video-title"></slot></div> <span class="upload-date">投稿: <slot name="upload-date"/></span> <span class="counter"> <span class="view-counter">再生: <slot name="view-counter"/></span> <span class="comment-counter">コメント: <slot name="comment-counter"/></span> <span class="mylist-counter command2" data-command="mylist-comment-open">マイリスト: <span class="mylist-comment-link command" data-command="mylist-comment-open">❏</span> <slot name="mylist-counter"/> </span> </span> <div class="close-button command" data-command="close" tooltip="閉じる"> ✖ </div> </div> <div class="main"> <div class=" main-left"> <div class="video-thumbnail-container"> <slot name="video-thumbnail"></slot> <span class="duration"><slot name="duration"></slot></slot> </div> </div> <div class="video-info main-right scrollable"> <div class="owner-info"> <slot name="owner-page-link"></slot> <span class="owner-name"><slot name="locale-owner-name"></slot> <button class="add-fav-button command" data-command="toggle-fav-owner">★</button> <button class="add-ng-button command" data-command="toggle-ng-owner">✖</button> </span> </div> <div class="description"> <slot name="description"></slot> </div> <div class="last-res-body"> <slot name="last-res-body"></slot> </div> </div> </div> <div class="footer"> <div class="video-tags"> <slot name="tag"></slot> </div> </div> <div class="footer-menu scalingUI"> <div class="regular-menu"> <button class="mylistPocketButton deflist-add pocket-button command command-watch-id wwwZenzaOnly" data-command="deflist-add" tooltip="とりあえずマイリスト" >とり</button> <button class="pocket-button command command-watch-id" data-command="mylist-window" tooltip="マイリスト" >マイ</button> <button class="pocket-button command command-watch-id" data-command="open-mylist-open" tooltip="公開マイリスト" >公開</button> <button class="pocket-button command command-video-id" data-command="twitter-hash-open" tooltip="Twitterの反応" >#Twitter</button> </div> <div class="zenza-menu"> <button class="pocket-button command command-watch-id" data-command="zenza-open-now" tooltip="ZenzaWatchで開く" >Zen</button> <button class="pocket-button command command-watch-id" data-command="playlist-inert" tooltip="プレイリスト(次に再生)" >playlist</button> <button class="pocket-button command command-watch-id" data-command="playlist-queue" tooltip="プレイリスト(末尾に追加)" >▶</button> </div> <div class="setting-menu"> <button class="pocket-button command" data-command="toggle-setting" >設 定</button> </div> </div> </div> <div class="setting-panel"> <div class="setting-panel-main scrollable"> <h2>MylistPocket 設定</h2> <label class="setting-label"> <input type="checkbox" class="setting-form" data-config-name="openNewWindow" > <span>タグやリンクを新しいタブで開く (次回から反映)</span> </label> <label class="setting-label"> <input type="checkbox" class="setting-form" data-config-name="enableAutoComment" data-config-namespace="mylist" > <span>マイリストコメントに投稿者名を入れる</span> </label> <label class="setting-label"> <input type="checkbox" class="setting-form" data-config-name="responsive.matrix" data-config-namespace="" > <span>ランキングTOPのサムネイルを画面幅に合わせて小さくする</span> </label> <h2>NG設定(リロード後に反映)</h2> <label class="setting-label"> <input type="checkbox" class="setting-form" data-config-name="enable" data-config-namespace="ng" > <span>簡易NG&強調機能を使う</span> </label> <label class="setting-label"> <input type="checkbox" class="setting-form" data-config-name="hide" data-config-namespace="nicoad" > <span>検索結果やランキングのニコニ広告を消す</span> </label> <label class="setting-label wwwOnly wwwZenzaOnly setting-ng-label"> <input type="checkbox" class="setting-form" data-config-name="syncZenza" data-config-namespace="ng" > <span>NGタグ・投稿者をZenzaWatchにも反映する</span> </label> <div class="setting-ng-textarea setting-ng"> <div class="setting-ng-text-column"> 投稿者ID <textarea class="setting-form" data-config-name="owner" data-config-namespace="ng" ></textarea> </div> <div class="setting-ng-text-column"> タグ <textarea class="setting-form" data-config-name="tag" data-config-namespace="ng" ></textarea> </div> <div class="setting-ng-text-column"> タイトル・説明文 <textarea class="setting-form" data-config-name="word" data-config-namespace="ng" ></textarea> </div> </div> <h2 class="setting-fav">強調表示設定</h2> <div class="setting-fav-textarea setting-fav"> <div class="setting-fav-text-column"> 投稿者ID <textarea class="setting-form" data-config-name="owner" data-config-namespace="fav" ></textarea> </div> <div class="setting-fav-text-column"> タグ <textarea class="setting-form" data-config-name="tag" data-config-namespace="fav" ></textarea> </div> <div class="setting-fav-text-column"> タイトル・説明文 <textarea class="setting-form" data-config-name="word" data-config-namespace="fav" ></textarea> </div> </div> </div> <div class="footer-menu"> <div class="close-setting-menu"> <button class="pocket-button command" data-command="toggle-setting" >戻 る</button> </div> </div> </div> </div> </template> `).trim(); const __ng_css__ = ` /* .item_cell 将棋盤ランキング .item 従来のランキングと検索 */ .RankingMainVideo.is-ng-wait, .RankingBaseItem.is-ng-wait, .item_cell.is-ng-wait .item, .item.is-ng-wait { outline: 1px dotted rgba(192, 192, 192, 0.8); } .RankingMainVideo.is-ng-queue, .RankingBaseItem.is-ng-queue, .item_cell.is-ng-queue .item, .item.is-ng-queue { outline: 2px dotted rgba(192, 192, 192, 0.8); } .RankingMainVideo.is-ng-current, .RankingBaseItem.is-ng-current, .item_cell.is-ng-current .item, .item.is-ng-current { outline: 3px dotted rgba(128, 225, 128, 0.8); } .RankingMainVideo.is-ng-resolved, .RankingBaseItem.is-ng-resolved, .item_cell.is-ng-resolved .item, .item.is-ng-resolved { outline: 0px solid green; } .RankingMainVideo.is-ng-favorited, .RankingBaseItem.is-ng-favorited, .item_cell.is-fav-favorited .item, .item.is-fav-favorited { outline: 3px dotted orange; outline-offset: 3px; } .item.videoRanking.is-fav-favorited { outline-offset: -3px; } .RankingBaseItem.is-ng-rejected, .item_cell.is-ng-rejected { opacity: 0; pointer-events: none; visibility: hidden; } .VideoItem .VideoItem-postDate { line-height: 16px; vertical-align: top; font-size: 12px; color: #666; } .RankingMainVideo.is-ng-rejected, .item.is-ng-rejected { display: none; opacity: 0; pointer-events: none; } .NicorepoTimelineItem.is-ng-rejected { display: none; opacity: 0; pointer-events: none; } body.is-ng-disable .is-ng-rejected { outline: none; display: block !important; pointer-events: auto; opacity: 0.5; visibility: visible; } /* チャンネル検索 */ #search .item.is-ng-rejected { display: none; } `; // TODO: ライブラリ化 const util = MylistPocket.util = (() => { const util = {}; util.mixin = function(self, o) { Object.keys(o).forEach(f => { if (!_.isFunction(o[f])) { return; } if (_.isFunction(self[f])) { return; } self[f] = o[f].bind(o); }); }; util.attachShadowDom = function({host, tpl, mode = 'open'}) { const root = host.attachShadow ? host.attachShadow({mode}) : host.createShadowRoot(); const node = document.importNode(tpl.content, true); root.appendChild(node); return root; }; util.httpLink = function(html) { let links = {}, keyCount = 0; const getTmpKey = function() { return ` <!--${keyCount++}--> `; }; html = html.replace(/@([a-zA-Z0-9_]+)/g, (g, id) => { const tmpKey = getTmpKey(); links[tmpKey] = ` <a href="https://twitter.com/${id}" class="twitterLink" rel="noopener" target="_blank">@${id}</a> `; return tmpKey; }); html = html.replace(/(im)(\d+)/g, ' <a href="//seiga.nicovideo.jp/seiga/$1$2" class="seigaLink" rel="noopener" target="_blank">$1$2</a> '); html = html.replace(/(co)(\d+)/g, ' <a href="//com.nicovideo.jp/community/$1$2" class="communityLink" rel="noopener" target="_blank">$1$2</a> '); html = html.replace(/(watch|mylist|user)\/(\d+)/g, ' <a href="https://www.nicovideo.jp/$1/$2" rel="noopener" class="videoLink target-change">$1/$2</a> '); html = html.replace(/(sm|nm|so)(\d+)/g, ' <a href="https://www.nicovideo.jp/watch/$1$2" rel="noopener" class="videoLink target-change">$1$2</a> '); let linkmatch = /<a.*?<\/a>/, n; html = html.split('<br />').join(' <br /> '); while ((n = linkmatch.exec(html)) !== null) { let tmpKey = getTmpKey(); links[tmpKey] = n; html = html.replace(n, tmpKey); } html = html.replace(/\((https?:\/\/[\x21-\x3b\x3d-\x7e]+)\)/gi, '( $1 )'); html = html.replace(/(https?:\/\/[\x21-\x3b\x3d-\x7e]+)http/gi, '$1 http'); html = html.replace(/(https?:\/\/[\x21-\x3b\x3d-\x7e]+)/gi, '<a href="$1" rel="noopener" target="_blank" class="otherSite">$1</a>'); Object.keys(links).forEach(tmpKey => { html = html.replace(tmpKey, links[tmpKey]); }); html = html.split(' <br /> ').join('<br />'); return html; }; util.getSleepPromise = function(sleepTime, label = 'sleep') { return function(r###lt) { return new Promise(resolve => { window.setTimeout(() => { return resolve(r###lt); }, sleepTime); }); }; }; util.isFirefox = () => navigator.userAgent.toLowerCase().indexOf('firefox') >= 0; return util; })(); const bounce = { origin: Symbol('origin'), idle(func, time) { let reqId = null; let lastArgs = null; let promise = new PromiseHandler(); const [caller, canceller] = (time === undefined && self.requestIdleCallback) ? [self.requestIdleCallback, self.cancelIdleCallback] : [self.setTimeout, self.clearTimeout]; const callback = () => { const lastR###lt = func(...lastArgs); promise.resolve({lastR###lt, lastArgs}); reqId = lastArgs = null; promise = new PromiseHandler(); }; const r###lt = (...args) => { if (reqId) { reqId = canceller(reqId); } lastArgs = args; reqId = caller(callback, time); return promise; }; r###lt[this.origin] = func; return r###lt; }, time(func, time = 0) { return this.idle(func, time); } }; const throttle = (func, interval) => { let lastTime = 0; let timer; let promise = new PromiseHandler(); const r###lt = (...args) => { if (timer) { return promise; } const now = performance.now(); const timeDiff = now - lastTime; timer = setTimeout(() => { lastTime = performance.now(); timer = null; const lastR###lt = func(...args); promise.resolve({lastR###lt, lastArgs: args}); promise = new PromiseHandler(); }, Math.max(interval - timeDiff, 0)); return promise; }; r###lt.cancel = () => { if (timer) { timer = clearTimeout(timer); } promise.resolve({lastR###lt: null, lastArgs: null}); promise = new PromiseHandler(); }; return r###lt; }; throttle.time = (func, interval = 0) => throttle(func, interval); throttle.raf = function(func) { // let promise;// = new PromiseHandler(); let promise; let cancelled = false; const r###lt = (...args) => { if (promise) { return promise; } if (!this.req) { this.req = new Promise(res => requestAnimationFrame(res)).then(() => { this.req = null; }); } promise = this.req.then(() => { if (cancelled) { cancelled = false; return; } try { func(...args); } catch (e) { console.warn(e); } promise = null; }); return promise; }; r###lt.cancel = () => { cancelled = true; promise = null; }; return r###lt; }.bind({req: null, count: 0, id: 0}); throttle.idle = func => { let id; const request = (self.requestIdleCallback || self.setTimeout); const cancel = (self.cancelIdleCallback || self.clearTimeout); const r###lt = (...args) => { if (id) { return; } id = request(() => { id = null; func(...args); }, 0); }; r###lt.cancel = () => { if (id) { id = cancel(id); } }; return r###lt; }; const css = (() => { const setPropsTask = []; const applySetProps = throttle.raf( () => { const tasks = setPropsTask.concat(); setPropsTask.length = 0; for (const [element, prop, value] of tasks) { try { element.style.setProperty(prop, value); } catch (error) { console.warn('element.style.setProperty fail', {element, prop, value, error}); } } }); const css = { addStyle: (styles, option, document = window.document) => { const elm = Object.assign(document.createElement('style'), { type: 'text/css' }, typeof option === 'string' ? {id: option} : (option || {})); if (typeof option === 'string') { elm.id = option; } else if (option) { Object.assign(elm, option); } elm.classList.add(global.PRODUCT); elm.append(styles.toString()); (document.head || document.body || document.documentElement).append(elm); elm.disabled = option && option.disabled; elm.dataset.switch = elm.disabled ? 'off' : 'on'; return elm; }, registerProps(...args) { if (!CSS || !('registerProperty' in CSS)) { return; } for (const definition of args) { try { (definition.window || window).CSS.registerProperty(definition); } catch (err) { console.warn('CSS.registerProperty fail', definition, err); } } }, setProps(...tasks) { setPropsTask.push(...tasks); return setPropsTask.length ? applySetProps() : Promise.resolve(); }, addModule: async function(func, options = {}) { if (!CSS || !('paintWorklet' in CSS) || this.set.has(func)) { return; } this.set.add(func); const src = `(${func.toString()})( this, registerPaint, ${JSON.stringify(options.config || {}, null, 2)} );`; const blob = new Blob([src], {type: 'text/javascript'}); const url = URL.createObjectURL(blob); await CSS.paintWorklet.addModule(url).then(() => URL.revokeObjectURL(url)); return true; }.bind({set: new WeakSet}), escape: value => CSS.escape ? CSS.escape(value) : value.replace(/([\.#()[\]])/g, '\\$1'), number: value => CSS.number ? CSS.number(value) : value, s: value => CSS.s ? CSS.s(value) : `${value}s`, ms: value => CSS.ms ? CSS.ms(value) : `${value}ms`, pt: value => CSS.pt ? CSS.pt(value) : `${value}pt`, px: value => CSS.px ? CSS.px(value) : `${value}px`, percent: value => CSS.percent ? CSS.percent(value) : `${value}%`, vh: value => CSS.vh ? CSS.vh(value) : `${value}vh`, vw: value => CSS.vw ? CSS.vw(value) : `${value}vw`, trans: value => self.CSSStyleValue ? CSSStyleValue.parse('transform', value) : value, word: value => self.CSSKeywordValue ? new CSSKeywordValue(value) : value, image: value => self.CSSStyleValue ? CSSStyleValue.parse('background-image', value) : value, }; return css; })(); const cssUtil = css; Object.assign(util, css); Object.assign(util, workerUtil); const nicoUtil = { parseWatchQuery: query => { try { const r###lt = textUtil.parseQuery(query); const playlist = JSON.parse(textUtil.decodeBase64(r###lt.playlist) || '{}'); if (playlist.searchQuery) { const sq = playlist.searchQuery; if (sq.type === 'tag') { r###lt.playlist_type = 'tag'; r###lt.tag = sq.query; } else { r###lt.playlist_type = 'search'; r###lt.keyword = sq.query; } let [order, sort] = (sq.sort || '+f').split(''); r###lt.order = order === '-' ? 'a' : 'd'; r###lt.sort = sort; if (sq.fRange) { r###lt.f_range = sq.fRange; } if (sq.lRange) { r###lt.l_range = sq.lRange; } } else if (playlist.mylistId) { r###lt.playlist_type = 'mylist'; r###lt.group_id = playlist.mylistId; r###lt.order = document.querySelector('select[name="sort"]') ? document.querySelector('select[name="sort"]').value : '1'; } else if (playlist.id && playlist.id.includes('temporary_mylist')) { r###lt.playlist_type = 'deflist'; r###lt.group_id = 'deflist'; r###lt.order = document.querySelector('select[name="sort"]') ? document.querySelector('select[name="sort"]').value : '1'; } return r###lt; } catch(e) { return {}; } }, hasLargeThumbnail: videoId => { const threthold = 16371888; const cid = videoId.substr(0, 2); const fid = videoId.substr(2) * 1; if (cid === 'nm') { return false; } if (cid !== 'sm' && fid < 35000000) { return false; } if (fid < threthold) { return false; } return true; }, getThumbnailUrlByVideoId: videoId => { const videoIdReg = /^[a-z]{2}\d+$/; if (!videoIdReg.test(videoId)) { return null; } const fileId = parseInt(videoId.substr(2), 10); const large = nicoUtil.hasLargeThumbnail(videoId) ? '.L' : ''; return fileId >= 35374758 ? // このIDから先は新サーバー(おそらく) `https://nicovideo.cdn.nimg.jp/thumbnails/${fileId}/${fileId}.L` : `https://tn.smilevideo.jp/smile?i=${fileId}.${large}`; }, getWatchId: url => { let m; if (url && url.indexOf('nico.ms') >= 0) { m = /\/\/nico\.ms\/([a-z0-9]+)/.exec(url); } else { m = /\/?watch\/([a-z0-9]+)/.exec(url || location.pathname); } return m ? m[1] : null; }, getCommonHeader: () => { try { // hoge?.fuga... はGreasyforkの文法チェックで弾かれるのでまだ使えない return JSON.parse(document.querySelector('#CommonHeader[data-common-header]').dataset.commonHeader || '{}'); } catch (e) { return {initConfig: {}}; } }, isLegacyHeader: () => !document.querySelector('#CommonHeader[data-common-header]'), isPremiumLegacy: () => { const a = 'a[href^="https://account.nicovideo.jp/premium/register"]'; return !document.querySelector(`#topline ${a}, #CommonHeader ${a}`); }, isLoginLegacy: () => { const a = 'a[href^="https://account.nicovideo.jp/login"]'; return !document.querySelector(`#topline ${a}, #CommonHeader ${a}`); }, isPremium: () => nicoUtil.isLegacyHeader() ? nicoUtil.isPremiumLegacy() : !!nicoUtil.getCommonHeader().initConfig.user.isPremium, isLogin: () => nicoUtil.isLegacyHeader() ? nicoUtil.isLoginLegacy() : !!nicoUtil.getCommonHeader().initConfig.user.isLogin, getPageLanguage: () => { try { let h = document.getElementsByClassName('html')[0]; return h.lang || 'ja-JP'; } catch (e) { return 'ja-JP'; } }, openMylistWindow: watchId => { window.open( `//www.nicovideo.jp/mylist_add/video/${watchId}`, 'nicomylistadd', 'width=500, height=400, menubar=no, scrollbars=no'); }, openTweetWindow: ({watchId, duration, isChannel, title, videoId}) => { const nicomsUrl = `https://nico.ms/${watchId}`; const watchUrl = `https://www.nicovideo.jp/watch/${watchId}`; title = `${title}(${textUtil.secToTime(duration)})`.replace(/@/g, '@ '); const nicoch = isChannel ? ',+nicoch' : ''; const url = 'https://twitter.com/intent/tweet?' + 'url=' + encodeURIComponent(nicomsUrl) + '&text=' + encodeURIComponent(title) + '&hashtags=' + encodeURIComponent(videoId + nicoch) + '&original_referer=' + encodeURIComponent(watchUrl) + ''; window.open(url, '_blank', 'width=550, height=480, left=100, top50, personalbar=0, toolbar=0, scrollbars=1, sizable=1', 0); }, isGinzaWatchUrl: url => /^https?:\/\/www\.nicovideo\.jp\/watch\//.test(url || location.href), getPlayerVer: () => { if (document.getElementById('js-initial-watch-data')) { return 'html5'; } if (document.getElementById('watchAPIDataContainer')) { return 'flash'; } return 'unknown'; }, isZenzaPlayableVideo: () => { try { if (nicoUtil.getPlayerVer() === 'html5') { return true; } const watchApiData = JSON.parse(document.querySelector('#watchAPIDataContainer').textContent); const flvInfo = textUtil.parseQuery( decodeURIComponent(watchApiData.flashvars.flvInfo) ); const dmcInfo = JSON.parse( decodeURIComponent(watchApiData.flashvars.dmcInfo || '{}') ); const videoUrl = flvInfo.url ? flvInfo.url : ''; const isDmc = dmcInfo && dmcInfo.time; if (isDmc) { return true; } const isSwf = /\/smile\?s=/.test(videoUrl); const isRtmp = (videoUrl.indexOf('rtmp') === 0); return (isSwf || isRtmp) ? false : true; } catch (e) { return false; } }, getNicoHistory: window.decodeURIComponent(document.cookie.replace(/^.*(nicohistory[^;+]).*?/, '')), getMypageVer: () => document.querySelector('#js-initial-userpage-data') ? 'spa' : 'legacy' }; Object.assign(util, nicoUtil); const textUtil = { secToTime: sec => { return [ Math.floor(sec / 60).toString().padStart(2, '0'), (Math.floor(sec) % 60).toString().padStart(2, '0') ].join(':'); }, parseQuery: (query = '') => { query = query.startsWith('?') ? query.substr(1) : query; const r###lt = {}; query.split('&').forEach(item => { const sp = item.split('='); const key = decodeURIComponent(sp[0]); const val = decodeURIComponent(sp.slice(1).join('=')); r###lt[key] = val; }); return r###lt; }, parseUrl: url => { url = url || 'https://unknown.example.com/'; return Object.assign(document.createElement('a'), {href: url}); }, decodeBase64: str => { try { return decodeURIComponent( escape(atob( str.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(str.length / 4) * 4, '=') ))); } catch(e) { return ''; } }, encodeBase64: str => { try { return btoa(unescape(encodeURIComponent(str))); } catch(e) { return ''; } }, escapeHtml: text => { const map = { '&': '&', '\x27': ''', '"': '"', '<': '<', '>': '>' }; return text.replace(/[&"'<>]/g, char => map[char]); }, unescapeHtml: text => { const map = { '&': '&', ''': '\x27', '"': '"', '<': '<', '>': '>' }; return text.replace(/(&|'|"|<|>)/g, char => map[char]); }, escapeToZenkaku: text => { const map = { '&': '&', '\'': '’', '"': '”', '<': '<', '>': '>' }; return text.replace(/["'<>]/g, char => map[char]); }, escapeRegs: text => { const match = /[\\^$.*+?()[\]{}|]/g; return text.replace(match, '\\$&'); }, convertKansuEi: text => { let match = /[〇一二三四五六七八九零壱弐惨伍]/g; let map = { '〇': '0', '零': '0', '一': '1', '壱': '1', '二': '2', '弐': '2', '三': '3', '惨': '3', '四': '4', '五': '5', '伍': '5', '六': '6', '七': '7', '八': '8', '九': '9', }; text = text.replace(match, char => map[char]); text = text.replace(/([1-9]?)[十拾]([0-9]?)/g, (n, a, b) => (a && b) ? `${a}${b}` : (a ? a * 10 : 10 + b * 1)); return text; }, dateToString: date => { if (typeof date === 'string') { const origDate = date; date = date.replace(/\//g, '-'); const m = /^(\d+-\d+-\d+) (\d+):(\d+):(\d+)/.exec(date); if (m) { date = new Date(m[1]); date.setHours(m[2]); date.setMinutes(m[3]); date.setSeconds(m[4]); } else { const t = Date.parse(date); if (isNaN(t)) { return origDate; } date = new Date(t); } } else if (typeof date === 'number') { date = new Date(date); } if (!date || isNaN(date.getTime())) { return '1970/01/01 00:00:00'; } const [yy, mm, dd, h, m, s] = [ date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds() ].map(n => n.toString().padStart(2, '0')); return `${yy}/${mm}/${dd} ${h}:${m}:${s}`; }, isValidJson: data => { try { JSON.parse(data); return true; } catch (e) { return false; } }, toRgba: (c, alpha = 1) => `rgba(${parseInt(c.substr(1, 2), 16)}, ${parseInt(c.substr(3, 2), 16)}, ${parseInt(c.substr(5, 2), 16)}, ${alpha})`, snakeToCamel: snake => snake.replace(/-./g, s => s.charAt(1).toUpperCase()), camelToSnake: (camel, separator = '_') => camel.replace(/([A-Z])/g, s => separator + s.toLowerCase()) }; Object.assign(util, textUtil); const reg = (() => { const $ = Symbol('$'); const undef = Symbol.for('undefined'); const MAX_R###LT = 30; const smap = new WeakMap(); const self = {}; const reg = function(regex = undef, str = undef) { const {r###lts, last} = smap.has(this) ? smap.get(this) : {r###lts: [], last: {r###lt: null}}; smap.set(this, {r###lts, last}); if (regex === undef) { return last ? last.r###lt : null; } const regstr = regex.toString(); if (str !== undef) { const found = r###lts.find(r => regstr === r.regstr && str === r.str); return found ? found.r###lt : reg(regex).exec(str); } return { exec(str) { const r###lt = regex.exec(str); Array.isArray(r###lt) && r###lt.forEach((r, i) => r###lt['$' + i] = r); Object.assign(last, {str, regstr, r###lt}); r###lts.push(last); r###lts.length > MAX_R###LT && r###lts.shift(); this[$] = str[$] = regex[$] = r###lt; return r###lt; }, test(str) { return !!this.exec(str); } }; }; const scope = (scopeObj = {}) => reg.bind(scopeObj); return Object.assign(reg.bind(self), {$, scope}); })(); MylistPocket.emitter = util.emitter = new Emitter(); const ZenzaDetector = (function() { let isReady = false; let Zenza = null; const emitter = new Emitter(); const initialize = function() { const onZenzaReady = () => { isReady = true; Zenza = window.ZenzaWatch; Zenza.emitter.on('hideHover', () => { util.emitter.emit('hideHover'); }); Zenza.emitter.on('csrfToken', (token) => { util.emitter.emit('csrfToken', token); }); let popup = document.getElementById('mylistPocket-popup'); let defaultContainer = document.getElementById('mylistPocketDomContainer'); defaultContainer.classList.add('zen-family'); let zenzaContainer; Zenza.emitter.on('fullScreenStatusChange', isFull => { if (isFull) { if (!zenzaContainer) { zenzaContainer = document.querySelector('.zenzaPlayerContainer'); } zenzaContainer.appendChild(popup); } else { defaultContainer.appendChild(popup); } }); emitter.emit('ready', Zenza); }; if (window.ZenzaWatch && window.ZenzaWatch.ready) { window.console.log('ZenzaWatch is Ready'); onZenzaReady(); } else { document.body.addEventListener('ZenzaWatchInitialize', function() { window.console.log('ZenzaWatchInitialize MylistPocket'); onZenzaReady(); }); } }; const detect = function() { return new Promise(res => { if (isReady) { return res(Zenza); } emitter.on('ready', () => { res(Zenza); }); }); }; return { initialize: initialize, detect: detect }; })(); const objUtil = (() => { const isObject = e => e !== null && e instanceof Object; const PROPS = Symbol('PROPS'); const REVISION = Symbol('REVISION'); const CHANGED = Symbol('CHANGED'); const HAS = Symbol('HAS'); const SET = Symbol('SET'); const GET = Symbol('GET'); return { bridge: (self, target, keys = null) => { (keys || Object.getOwnPropertyNames(target.constructor.prototype)) .filter(key => typeof target[key] === 'function') .forEach(key => self[key] = target[key].bind(target)); }, isObject, toMap: (obj, mapper = Map) => { if (obj instanceof mapper) { return obj; } return new mapper(Object.entries(obj)); }, mapToObj: map => { if (!(map instanceof Map)) { return map; } const obj = {}; for (const [key, val] of map) { obj[key] = val; } return obj; }, }; })(); const StorageWriter = (() => { const func = function(self) { self.onmessage = ({command, params}) => { const {obj, replacer, space} = params; return JSON.stringify(obj, replacer || null, space || 0); }; }; let worker; const prototypePollution = window.Prototype && Array.prototype.hasOwnProperty('toJSON'); const toJson = async (obj, replacer = null, space = 0) => { if (!prototypePollution || obj === null || ['string', 'number', 'boolean'].includes(typeof obj)) { return JSON.stringify(obj, replacer, space); } worker = worker || workerUtil.createCrossMessageWorker(func, {name: 'ToJsonWorker'}); return worker.post({command: 'toJson', params: {obj, replacer, space}}); }; const writer = Symbol('StorageWriter'); const setItem = (storage, key, value) => { if (!prototypePollution || value === null || ['string', 'number', 'boolean'].includes(typeof value)) { storage.setItem(key, JSON.stringify(value)); } else { toJson(value).then(json => storage.setItem(key, json)); } }; localStorage[writer] = (key, value) => setItem(localStorage, key, value); sessionStorage[writer] = (key, value) => setItem(sessionStorage, key, value); return { writer, toJson }; })(); const Observable = (() => { const observableSymbol = Symbol.observable || Symbol('observable'); const nop = Handler.nop; class Subscription { constructor({observable, subscriber, unsubscribe, closed}) { this.callbacks = {unsubscribe, closed}; this.observable = observable; const next = subscriber.next.bind(subscriber); subscriber.next = args => { if (this.closed || (this._filterFunc && !this._filterFunc(args))) { return; } return this._mapFunc ? next(this._mapFunc(args)) : next(args); }; this._closed = false; } subscribe(subscriber, onError, onCompleted) { return this.observable.subscribe(subscriber, onError, onCompleted) .filter(this._filterFunc) .map(this._mapFunc); } unsubscribe() { this._closed = true; if (this.callbacks.unsubscribe) { this.callbacks.unsubscribe(); } return this; } dispose() { return this.unsubscribe(); } filter(func) { const _func = this._filterFunc; this._filterFunc = _func ? (arg => _func(arg) && func(arg)) : func; return this; } map(func) { const _func = this._mapFunc; this._mapFunc = _func ? arg => func(_func(arg)) : func; return this; } get closed() { if (this.callbacks.closed) { return this._closed || this.callbacks.closed(); } else { return this._closed; } } } class Subscriber { static create(onNext = null, onError = null, onCompleted = null) { if (typeof onNext === 'function') { return new this({ next: onNext, error: onError, complete: onCompleted }); } return new this(onNext || {}); } constructor({start, next, error, complete} = {start:nop, next:nop, error:nop, complete:nop}) { this.callbacks = {start, next, error, complete}; } start(arg) {this.callbacks.start(arg);} next(arg) {this.callbacks.next(arg);} error(arg) {this.callbacks.error(arg);} complete(arg) {this.callbacks.complete(arg);} get closed() { return this._callbacks.closed ? this._callbacks.closed() : false; } } Subscriber.nop = {start: nop, next: nop, error: nop, complete: nop, closed: nop}; const eleMap = new WeakMap(); class Observable { static of(...args) { return new this(o => { for (const arg of args) { o.next(arg); } o.complete(); return () => {}; }); } static from(arg) { if (arg[Symbol.iterator]) { return this.of(...arg); } else if (arg[Observable.observavle]) { return arg[Observable.observavle](); } } static fromEvent(element, eventName) { const em = eleMap.get(element) || {}; if (em && em[eventName]) { return em[eventName]; } eleMap.set(element, em); return em[eventName] = new this(o => { const onUpdate = e => o.next(e); element.addEventListener(eventName, onUpdate, {passive: true}); return () => element.removeEventListener(eventName, onUpdate); }); } static interval(ms) { return new this(function(o) { const timer = setInterval(() => o.next(this.i++), ms); return () => clearInterval(timer); }.bind({i: 0})); } constructor(subscriberFunction) { this._subscriberFunction = subscriberFunction; this._completed = false; this._cancelled = false; this._handlers = new Handler(); } _initSubscriber() { if (this._subscriber) { return; } const handlers = this._handlers; this._completed = this._cancelled = false; return this._subscriber = new Subscriber({ start: arg => handlers.execMethod('start', arg), next: arg => handlers.execMethod('next', arg), error: arg => handlers.execMethod('error', arg), complete: arg => { if (this._nextObservable) { this._nextObservable.subscribe(this._subscriber); this._nextObservable = this._nextObservable._nextObservable; } else { this._completed = true; handlers.execMethod('complete', arg); } }, closed: () => this.closed }); } get closed() { return this._completed || this._cancelled; } filter(func) { return this.subscribe().filter(func); } map(func) { return this.subscribe().map(func); } concat(arg) { const observable = Observable.from(arg); if (this._nextObservable) { this._nextObservable.concat(observable); } else { this._nextObservable = observable; } return this; } forEach(callback) { let p = new PromiseHandler(); callback(p); return this.subscribe({ next: arg => { const lp = p; p = new PromiseHandler(); lp.resolve(arg); callback(p); }, error: arg => { const lp = p; p = new PromiseHandler(); lp.reject(arg); callback(p); }}); } onStart(arg) { this._subscriber.start(arg); } onNext(arg) { this._subscriber.next(arg); } onError(arg) { this._subscriber.error(arg); } onComplete(arg) { this._subscriber.complete(arg);} disconnect() { if (!this._disconnectFunction) { return; } this._closed = true; this._disconnectFunction(); delete this._disconnectFunction; this._subscriber; this._handlers.clear(); } [observableSymbol]() { return this; } subscribe(onNext = null, onError = null, onCompleted = null) { this._initSubscriber(); const isNop = [onNext, onError, onCompleted].every(f => f === null); const subscriber = Subscriber.create(onNext, onError, onCompleted); return this._subscribe({subscriber, isNop}); } _subscribe({subscriber, isNop}) { if (!isNop && !this._disconnectFunction) { this._disconnectFunction = this._subscriberFunction(this._subscriber); } !isNop && this._handlers.add(subscriber); return new Subscription({ observable: this, subscriber, unsubscribe: () => { if (isNop) { return; } this._handlers.remove(subscriber); if (this._handlers.isEmpty) { this.disconnect(); } }, closed: () => this.closed }); } } Observable.observavle = observableSymbol; return Observable; })(); const WindowResizeObserver = Observable.fromEvent(window, 'resize') .map(o => { return {width: window.innerWidth, height: window.innerHeight}; }); // already required class DataStorage { static create(defaultData, options = {}) { return new DataStorage(defaultData, options); } static clone(dataStorage) { const options = { prefix: dataStorage.prefix, storage: dataStorage.storage, ignoreExportKeys: dataStorage.options.ignoreExportKeys, readonly: dataStorage.readonly }; return DataStorage.create(dataStorage.default, options); } constructor(defaultData, options = {}) { this.options = options; this.default = defaultData; this._data = Object.assign({}, defaultData); this.prefix = `${options.prefix || 'DATA'}_`; this.storage = options.storage || localStorage; this._ignoreExportKeys = options.ignoreExportKeys || []; this.readonly = options.readonly; this.silently = false; this._changed = new Map(); this._onChange = bounce.time(this._onChange.bind(this)); objUtil.bridge(this, new Emitter()); this.restore().then(() => { this.props = this._makeProps(defaultData); this.emitResolve('restore'); }); this.logger = (self || window).console; this.consol###bscriber = { next: (v, ...args) => this.logger.log('next', v, ...args), error: (e, ...args) => this.logger.warn('error', e, ...args), complete: (c, ...args) => this.logger.log('complete', c, ...args) }; } _makeProps(defaultData = {}, namespace = '') { namespace = namespace ? `${namespace}.` : ''; const self = this; const def = {}; const props = {}; Object.keys(defaultData).sort() .filter(key => key.includes(namespace)) .forEach(key => { const k = key.slice(namespace.length); if (k.includes('.')) { const ns = k.slice(0, k.indexOf('.')); props[ns] = this._makeProps(defaultData, `${namespace}${ns}`); } def[k] = { enumerable: !this._ignoreExportKeys.includes(key), get() { return self.getValue(key); }, set(v) { self.setValue(key, v); } }; }); Object.defineProperties(props, def); return props; } _onChange() { const changed = this._changed; this.emit('change', changed); for (const [key, val] of changed) { this.emitAsync('update', key, val); this.emitAsync(`update-${key}`, val); } this._changed.clear(); } onkey(key, callback) { this.on(`update-${key}`, callback); } offkey(key, callback) { this.off(`update-${key}`, callback); } async restore(storage) { storage = storage || this.storage; Object.keys(this.default).forEach(key => { const storageKey = this.getStorageKey(key); if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { try { this._data[key] = JSON.parse(storage[storageKey]); } catch (e) { console.error('config parse error key:"%s" value:"%s" ', key, storage[storageKey], e); delete storage[storageKey]; this._data[key] = this.default[key]; } } else { this._data[key] = this.default[key]; } }); } getNativeKey(key) { return key; } getStorageKey(key) { return `${this.prefix}${key}`; } async refresh(key, storage) { storage = storage || this.storage; key = this.getNativeKey(key); const storageKey = this.getStorageKey(key); if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { try { this._data[key] = JSON.parse(storage[storageKey]); } catch (e) { console.error('config parse error key:"%s" value:"%s" ', key, storage[storageKey], e); } } return this._data[key]; } getValue(key) { key = this.getNativeKey(key); return this._data[key]; } deleteValue(key) { key = this.getNativeKey(key); const storageKey = this.getStorageKey(key); this.storage.removeItem(storageKey); this._data[key] = this.default[key]; } setValue(key, value) { const _key = key; key = this.getNativeKey(key); if (this._data[key] === value || value === undefined) { return; } const storageKey = this.getStorageKey(key); const storage = this.storage; if (!this.readonly) { try { storage[storageKey] = JSON.stringify(value); } catch (e) { window.console.error(e); } } this._data[key] = value; if (!this.silently) { this._changed.set(_key, value); this._onChange(); } } setValueSilently(key, value) { const isSilent = this.silently; this.silently = true; this.setValue(key, value); this.silently = isSilent; } export(isAll = false) { const r###lt = {}; const _default = this.default; Object.keys(this.props) .filter(key => isAll || (_default[key] !== this._data[key])) .forEach(key => r###lt[key] = this.getValue(key)); return r###lt; } exportJson() { return JSON.stringify(this.export(), null, 2); } import(data) { Object.keys(this.props) .forEach(key => { const val = data.hasOwnProperty(key) ? data[key] : this.default[key]; console.log('import data: %s=%s', key, val); this.setValueSilently(key, val); }); } importJson(json) { this.import(JSON.parse(json)); } getKeys() { return Object.keys(this.props); } clearConfig() { this.silently = true; const storage = this.storage; Object.keys(this.default) .filter(key => !this._ignoreExportKeys.includes(key)).forEach(key => { const storageKey = this.getStorageKey(key); try { if (storage.hasOwnProperty(storageKey) || storage[storageKey] !== undefined) { console.nicoru('delete storage', storageKey, storage[storageKey]); delete storage[storageKey]; } this._data[key] = this.default[key]; } catch (e) {} }); this.silently = false; } namespace(name) { const namespace = name ? `${name}.` : ''; const origin = Symbol(`${namespace}`); const r###lt = { getValue: key => this.getValue(`${namespace}${key}`), setValue: (key, value) => this.setValue(`${namespace}${key}`, value), on: (key, func) => { if (key === 'update') { const onUpdate = (key, value) => { if (key.startsWith(namespace)) { func(key.slice(namespace.length + 1), value); } }; onUpdate[origin] = func; this.on('update', onUpdate); return r###lt; } return this.onkey(`${namespace}${key}`, func); }, off: (key, func) => { if (key === 'update') { func = func[origin] || func; this.off('update', func); return r###lt; } return this.offkey(`${namespace}${key}`, func); }, onkey: (key, func) => { this.on(`update-${namespace}${key}`, func); return r###lt; }, offkey: (key, func) => { this.off(`update-${namespace}${key}`, func); return r###lt; }, props: this.props[name], refresh: () => this.refresh(), subscribe: subscriber => { return this.subscribe(subscriber) .filter(changed => changed.keys().some(k => k.startsWith(namespace))) .map(changed => { const r###lt = new Map; for (const k of changed.keys()) { k.startsWith(namespace) && r###lt.set(k, changed.get(k)); } return r###lt; }); } }; return r###lt; } subscribe(subscriber) { subscriber = subscriber || this.consol###bscriber; const observable = new Observable(o => { const onChange = changed => o.next(changed); this.on('change', onChange); return () => this.off('change', onChange); }); return observable.subscribe(subscriber); } watch() { } unwatch() { this.consol###bscription && this.consol###bscription.unsubscribe(); this.consol###bscription = null; } } const config = (() => { const DEFAULT_CONFIG = { debug: false, 'videoInfo.openNewWindow': false, 'mylist.enableAutoComment': true, // マイリストコメントに投稿者を入れる 'responsive.matrix': false, 'nicoad.hide': false, 'ng.enable': false, 'ng.owner': '', 'ng.word': '', 'ng.tag': '', 'ng.syncZenza': false, 'fav.owner': '', 'fav.word': '', 'fav.tag': '' }; return new DataStorage( DEFAULT_CONFIG, { prefix: `${PRODUCT}_config`, ignoreExportKeys: [], readonly: !location || location.host !== 'www.nicovideo.jp', storage: localStorage } ); })(); MylistPocket.broadcast = (function(config) { if (!window.BroadcastChannel) { return; } const broadcastChannel = new window.BroadcastChannel(PRODUCT); const onBroadcastMessage = (e) => { const data = e.data; switch (data.type) { case 'config-update': config.refresh(true); break; } }; broadcastChannel.addEventListener('message', onBroadcastMessage); return { postMessage: (...args) => { broadcastChannel.postMessage(...args); } }; })(config); config.on('update', (key, value) => { if (!config.props.hasOwnProperty(key)) { return; } MylistPocket.broadcast.postMessage( {type: 'config-update', key, value, storage: 'local'} ); }); MylistPocket.config = config; const CacheStorage = (function() { let PREFIX = PRODUCT + '_cache_'; class CacheStorage { constructor(storage, gc = false) { this._storage = storage; this._memory = {}; if (gc) { this.gc(); } Object.keys(storage).forEach((key) => { if (key.indexOf(PREFIX) === 0) { this._memory[key] = storage[key]; } }); this.gc = bounce.time(this.gc.bind(this), 100); } gc(now = -1) { const storage = this._storage; now = now >= 0 ? now : Date.now(); Object.keys(storage).forEach((key, index) => { if (key.indexOf(PREFIX) === 0) { let item; try { item = JSON.parse(this._storage[key]); } catch(e) { storage.removeItem(key); } //console.info( // `${index}, key: ${key}, expiredAt: ${new Date(item.expiredAt).toLocaleString()}, now: ${new Date(now).toLocaleString()}`); if (item.expiredAt === '' || item.expiredAt > now) { //console.info('not expired: ', key); return; } //console.info('cache expired: ', key, item.expiredAt); storage.removeItem(key); } }); } setItem(key, data, expireTime) { key = PREFIX + key; const expiredAt = typeof expireTime === 'number' ? (Date.now() + expireTime) : ''; const cacheData = { data: data, type: typeof data, expiredAt: expiredAt }; this._memory[key] = cacheData; try { this._storage[key] = JSON.stringify(cacheData); this.gc(); } catch (e) { if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') { this.gc(0); } } } getItem(key) { key = PREFIX + key; if (!(this._storage.hasOwnProperty(key) || this._storage[key] !== undefined)) { return null; } let item = null; try { item = JSON.parse(this._storage[key]); } catch(e) { delete this._memory[key]; this._storage.removeItem(key); return null; } if (item.expiredAt === '' || item.expiredAt > Date.now()) { return item.data; } return null; } removeItem(key) { if (this._memory.hasOwnProperty(key)) { delete this._memory[key]; } key = PREFIX + key; if (this._storage.hasOwnProperty(key) || this._storage[key] !== undefined) { this._storage.removeItem(key); } } clear() { const storage = this._storage; this._memory = {}; Object.keys(storage).forEach((v) => { if (v.indexOf(PREFIX) === 0) { storage.removeItem(v); } }); } } return CacheStorage; })(); MylistPocket.debug.sessionCache = new CacheStorage(sessionStorage, true); MylistPocket.debug.localCache = new CacheStorage(localStorage, true); const WindowMessageEmitter = (function() { const emitter = new Emitter(); const knownSource = []; const onMessage = (event) => { if (_.indexOf(knownSource, event.source) < 0 //&& //event.origin !== location.protocol + '//ext.nicovideo.jp' ) { return; } try { let data = JSON.parse(event.data); if (data.id !== PRODUCT) { return; } emitter.emit('onMessage', data.body, data.type); } catch (e) { console.log( '%cMylistPocket.Error: window.onMessage - ', 'color: red; background: yellow', e, event ); console.log('%corigin: ', 'background: yellow;', event.origin); console.log('%cdata: ', 'background: yellow;', event.data); console.trace(); } }; emitter.addKnownSource = (win) => { knownSource.push(win); }; window.addEventListener('message', onMessage); return emitter; })(); class CrossDomainGate extends Emitter { static get hostReg() { return /^[a-z0-9]*\.nicovideo\.jp$/; } constructor(...args) { super(); this.initialize(...args); } initialize(params) { this._baseUrl = params.baseUrl; this._origin = params.origin || location.href; this._type = params.type; this._suffix = params.suffix || ''; this.name = params.name || params.type; this._sessions = {}; this._initializeStatus = 'none'; } _initializeFrame() { if (this._initializeStatus !== 'none') { return this.promise('initialize'); } this._initializeStatus = 'initializing'; const append = () => { if (!this.loaderFrame.parentNode) { console.warn('frame removed'); this.port = null; this._initializeCrossDomainGate(); } }; setTimeout(append, 5 * 1000); setTimeout(append, 10 * 1000); setTimeout(append, 20 * 1000); setTimeout(append, 30 * 1000); setTimeout(() => { if (this._initializeStatus === 'done') { return; } this.emitReject('initialize', { status: 'timeout', message: `CrossDomainGate初期化タイムアウト (type: ${this._type}, status: ${this._initializeStatus})` }); console.warn(`CrossDomainGate初期化タイムアウト (type: ${this._type}, status: ${this._initializeStatus})`); }, 60 * 1000); this._initializeCrossDomainGate(); return this.promise('initialize'); } _initializeCrossDomainGate() { window.console.time(`GATE OPEN: ${this.name} ${PRODUCT}`); const loaderFrame = this.loaderFrame = document.createElement('iframe'); loaderFrame.referrerPolicy = 'origin'; loaderFrame.sandbox = 'allow-scripts allow-same-origin'; loaderFrame.loading = 'eager'; loaderFrame.name = `${this._type}${PRODUCT}Loader${this._suffix ? `#${this._suffix}` : ''}`; loaderFrame.className = `xDomainLoaderFrame ${this._type}`; loaderFrame.style.cssText = ` position: fixed; left: -100vw; pointer-events: none;user-select: none; contain: strict;`; (document.body || document.documentElement).append(loaderFrame); this._loaderWindow = loaderFrame.contentWindow; const onInitialMessage = event => { if (event.source !== this._loaderWindow) { return; } window.removeEventListener('message', onInitialMessage); this._onMessage(event); }; window.addEventListener('message', onInitialMessage, {capture: true}); this._loaderWindow.location.replace(this._baseUrl + '#' + TOKEN); } _onMessage(event) { const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; const {id, type, token, sessionId, body} = data; if (id !== PRODUCT || type !== this._type || token !== TOKEN) { console.warn('invalid token:', {id, PRODUCT, type, _type: this._type, token, TOKEN}); return; } if (!this.port && body.command === 'initialized') { const port = this.port = event.ports[0]; port.addEventListener('message', this._onMessage.bind(this)); port.start(); port.postMessage({body: {command: 'ok'}, token: TOKEN}); } return this._onCommand(body, sessionId); } _onCommand({command, status, params}, sessionId = null) { switch (command) { case 'initialized': if (this._initializeStatus !== 'done') { this._initializeStatus = 'done'; const originalBody = params; window.console.timeEnd(`GATE OPEN: ${this.name} ${PRODUCT}`); const r###lt = this._onCommand(originalBody, sessionId); this.emitResolve('initialize', {status: 'ok'}); return r###lt; } break; case 'message': BroadcastEmitter.emitAsync('message', params, 'broadcast', sessionId); break; default: { const session = this._sessions[sessionId]; if (!session) { return; } if (status === 'ok') { session.resolve(params); } else { session.reject({message: status || 'fail'}); } delete this._sessions[sessionId]; } break; } } load(url, options) { return this._postMessage({command: 'loadUrl', params: {url, options}}); } videoCapture(src, sec) { return this._postMessage({command: 'videoCapture', params: {src, sec}}) .then(r###lt => Promise.resolve(r###lt.dataUrl)); } _fetch(url, options) { return this._postMessage({command: 'fetch', params: {url, options}}); } async fetch(url, options = {}) { const r###lt = await this._fetch(url, options); if (typeof r###lt === 'string' || !r###lt.buffer || !r###lt.init || !r###lt.headers) { return r###lt; } const {buffer, init, headers} = r###lt; const _headers = new Headers(); (headers || []).forEach(a => _headers.append(...a)); const _init = { status: init.status, statusText: init.statusText || '', headers: _headers }; if (options._format === 'arraybuffer') { return {buffer, init, headers}; } return new Response(buffer, _init); } async configBridge(config) { const keys = config.getKeys(); this._config = config; const configData = await this._postMessage({ command: 'dumpConfig', params: { keys, url: '', prefix: PRODUCT } }); for (const key of Object.keys(configData)) { config.props[key] = configData[key]; } if (!this.constructor.hostReg.test(location.host) && !config.props.allowOtherDomain) { return; } config.on('update', (key, value) => { if (key === 'autoCloseFullScreen') { return; } this._postMessage({command: 'saveConfig', params: {key, value, prefix: PRODUCT}}, false); }); } async _postMessage(body, usePromise = true, sessionId = '') { await this._initializeFrame(); sessionId = sessionId || (`gate:${Math.random()}`); const {params} = body; return this._sessions[sessionId] = new PromiseHandler((resolve, reject) => { try { this.port.postMessage({body, sessionId, token: TOKEN}, params.transfer); if (!usePromise) { delete this._sessions[sessionId]; resolve(); } } catch (error) { console.log('%cException!', 'background: red;', {error, body}); delete this._sessions[sessionId]; reject(error); } }); } postMessage(body, promise = true) { return this._postMessage(body, promise); } sendMessage(body, usePromise = false, sessionId = '') { return this._postMessage({command: 'message', params: body}, usePromise, sessionId); } pushHistory(path, title) { return this._postMessage({command: 'pushHistory', params: {path, title}}, false); } async bridgeDb({name, ver, stores}) { const worker = await this._postMessage( {command: 'bridge-db', params: {command: 'open', params: {name, ver, stores}}} ); const post = (command, data, storeName, transfer) => { const params = {data, storeName, transfer, name}; return this._postMessage({command: 'bridge-db', params: {command, params, transfer}}); }; const r###lt = {worker}; for (const meta of stores) { const storeName = meta.name; r###lt[storeName] = (storeName => { return { close: params => post('close', params, storeName), put: (record, transfer) => post('put', record, storeName, transfer), get: ({key, index, timeout}) => post('get', {key, index, timeout}, storeName), updateTime: ({key, index, timeout}) => post('updateTime', {key, index, timeout}, storeName), delete: ({key, index, timeout}) => post('delete', {key, index, timeout}, storeName), gc: (expireTime = 30 * 24 * 60 * 60 * 1000, index = 'updatedAt') => post('gc', {expireTime, index}, storeName) }; })(storeName); } return r###lt; } } const CsrfTokenLoader = (() => { const cacheStorage = new CacheStorage( location.host === 'www.nicovideo.jp' ? localStorage : sessionStorage); const TIMEOUT = 10 * 1000; const CACHE_EXPIRE_TIME = 60 * 30 * 1000; class CsrfTokenLoader { static load() { return new Promise((resolve, reject) => { const cache = cacheStorage.getItem('csrfToken'); if (cacheStorage.getItem('csrfToken')) { return resolve(cache); } let timeoutTimer = window.setTimeout(() => { reject('timeout'); }, TIMEOUT); return CsrfTokenLoader._getToken().then((token) => { window.clearTimeout(timeoutTimer); CsrfTokenLoader.saveToCache(token); resolve(token); }); }); } static saveToCache(token) { cacheStorage.setItem('csrfToken', token, CACHE_EXPIRE_TIME); } static _getToken() { const url = 'https://www.nicovideo.jp/mylist_add/video/sm9'; const tokenReg = /NicoAPI\.token *= *["']([a-z0-9-]+)["'];/; let m; return fetch(url, { credentials: 'include', _format: 'text'}) .then(res => res.text()) .then(r###lt => { if ((m = tokenReg.exec(r###lt))) { const token = m[1]; return Promise.resolve(token); } else { return Promise.reject('token parse error'); } }); } } util.emitter.on('csrfToken', (token) => { CsrfTokenLoader.saveToCache(token); }); return CsrfTokenLoader; })(); MylistPocket.debug.CsrfTokenLoader = CsrfTokenLoader; const ThumbInfoLoader = (() => { const BASE_URL = 'https://ext.nicovideo.jp/'; const MESSAGE_ORIGIN = 'https://ext.nicovideo.jp/'; const CACHE_EXPIRE_TIME = 60 * 60 * 1000; //const CACHE_EXPIRE_TIME = 60 * 1000; let gate = null; let cacheStorage = new CacheStorage(sessionStorage, true); let failedR###lt = {}; class ThumbInfoLoader { constructor() { this._emitter = new Emitter(); gate = new CrossDomainGate({ baseUrl: BASE_URL, origin: MESSAGE_ORIGIN, type: 'thumbInfo', messager: WindowMessageEmitter }); } _onMessage(data, type) { if (type !== 'videoInfoLoader') { return; } const info = data.message; this.emit('load', info, 'THUMB_WATCH'); } _parseXml(xmlText) { return parseThumbInfo(xmlText); } async load(watchId, options = {}) { const cacheKey = `thumbInfo_${watchId}`; const cache = cacheStorage.getItem(cacheKey); if (failedR###lt[`${watchId}`]) { return Promise.reject({data: failedR###lt[`${watchId}`], watchId}); } if (cache) { return cache; } const thumbInfo = await gate.fetch(`${BASE_URL}api/getthumbinfo/${watchId}`, options) .catch(e => { return {status: 'fail', message: e.message || `gate.fetch('${watchId}') failed` }; }); thumbInfo.fromCache = !!cache; if (thumbInfo.status !== 'ok') { failedR###lt[`${watchId}`] = thumbInfo; return Promise.reject(thumbInfo); } cacheStorage.setItem(cacheKey, thumbInfo, CACHE_EXPIRE_TIME); return thumbInfo; } } const loader = new ThumbInfoLoader(); return { load: watchId => loader.load(watchId), loadOwnerInfo: async watchId => { const info = await loader.load(watchId); const owner = info.owner; if (!owner) { return {}; } const lang = util.getPageLanguage(); const prefix = owner.type === 'user' ? '投稿者: ' : '提供: '; const suffix = (owner.type === 'user' && lang === 'ja-JP') ? ' さん' : ''; owner.linkId = owner.id ? (owner.type === 'user' ? `user/${owner.id}` : `ch${owner.id}`) : ''; owner.localeName = `${prefix}${owner.name}${suffix}`; return owner; } }; })(); MylistPocket.debug.ThumbInfoLoader = ThumbInfoLoader; const DeflistApiLoader = ((CsrfTokenLoader) => { const cacheStorage = new CacheStorage( location.host === 'www.nicovideo.jp' ? localStorage : sessionStorage); const TIMEOUT = 30000; const CACHE_EXPIRE_TIME = 60 * 3 * 1000; let isZenzaReady = false; class DeflistApiLoader { static getItems() { const url = 'https://www.nicovideo.jp/api/deflist/list'; const cacheKey = 'deflistItems'; return new Promise(function(resolve, reject) { const cache = cacheStorage.getItem(cacheKey); if (cache) { window.setTimeout(() => { resolve({items: cache.mylistitem, status: cache.status, from: 'cache'}); }, 0); return; } let timeoutTimer = window.setTimeout(() => { timeoutTimer = null; reject({status: 'fail', description: 'timeout'}); }, TIMEOUT); fetch(url, { credentials: 'include' }).then((res) => { return res.json(); }).then((json) => { if (json.status !== 'ok') { return reject(json); } if (timeoutTimer) { window.clearTimeout(timeoutTimer); } else { return; } cacheStorage.setItem(cacheKey, json, CACHE_EXPIRE_TIME); resolve({items: json.mylistitem, status: json.status, from: 'fetch'}); }); }); } static findItemByWatchId(watchId) { return DeflistApiLoader.getItems().then(({items}) => { for (let i = 0, len = items.length; i < len; i++) { let item = items[i], wid = item.id || item.item_data.watch_id; if (wid === watchId) { return Promise.resolve(item); } } return Promise.reject(); }); } static _removeItem({watchId, token}) { const cacheKey = 'deflistItems'; DeflistApiLoader.findItemByWatchId(watchId).then((item) => { const url = 'https://www.nicovideo.jp/api/deflist/delete'; const body = 'id_list[0][]=' + item.item_id + '&token=' + token; const req = { credentials: 'include', method: 'post', body, headers: {'Content-Type': 'application/x-www-form-urlencoded'} }; return fetch(url, req) .then(res => { return res.json(); }) .then((r###lt) => { if (r###lt.status !== 'ok') { return Promise.reject({ status: 'fail', r###lt: r###lt, code: r###lt.error.code, message: r###lt.error.description }); } cacheStorage.removeItem(cacheKey); util.emitter.emitAsync('deflistRemove', watchId); return Promise.resolve({ status: 'ok', r###lt: r###lt, message: 'とりあえずマイリストから削除' }); }, (err) => { return Promise.reject({ r###lt: err, message: 'とりあえずマイリストから削除失敗(2)' }); }); }, (err) => { return Promise.reject({ status: 'fail', r###lt: err, message: '動画が見つかりません' }); }); } static removeItem(watchId) { return CsrfTokenLoader.load().then((token) => { return DeflistApiLoader._removeItem({watchId, token}); }); } static __addItem({watchId, description, token, isRetry = false}) { const cacheKey = 'deflistItems'; const url = 'https://www.nicovideo.jp/api/deflist/add'; let body = 'item_id=' + watchId + '&token=' + token; if (description) { body += '&description='+ encodeURIComponent(description); } const req = { method: 'post', credentials: 'include', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }; return new Promise((resolve, reject) => { fetch(url, req) .then((res) => { return res.json(); }) .then((r###lt) => { if (r###lt.status && r###lt.status === 'ok') { cacheStorage.removeItem(cacheKey); //ZenzaWatch.emitter.emitAsync('deflistAdd', watchId, description); return resolve({ status: 'ok', r###lt: r###lt, message: 'とりあえずマイリスト登録' }); } if (!r###lt.status || !r###lt.error) { return reject({ status: 'fail', r###lt: r###lt, message: 'とりあえずマイリスト登録失敗(100)' }); } if (r###lt.error.code !== 'EXIST' || isRetry) { return reject({ status: 'fail', r###lt: r###lt, code: r###lt.error.code, message: r###lt.error.description }); } /** * すでに登録されている場合は、いったん削除して再度追加(先頭に移動) */ return DeflistApiLoader.removeItem(watchId) .then(util.getSleepPromise(1500, 'deflist remove')) .then(() => { return DeflistApiLoader._addItem(watchId, description, true) .then((r###lt) => { resolve({ status: 'ok', r###lt: r###lt, message: 'とりあえずマイリストの先頭に移動' }); }); }, (err) => { reject({ status: 'fail', r###lt: err.r###lt, code: err.code, message: 'とりあえずマイリスト登録失敗(101)' }); }); }, (err) => { reject({ status: 'fail', r###lt: err, message: 'とりあえずマイリスト登録失敗(200)' }); }); }); } static _addItem(watchId, description, isRetry = false) { return CsrfTokenLoader.load().then((token) => { return DeflistApiLoader.__addItem({watchId, description, isRetry, token}); }); } static addItem(watchId, description) { return DeflistApiLoader._addItem(watchId, description, false); } static addItemWithOwnerName(watchId) { return ThumbInfoLoader.loadOwnerInfo(watchId).then((owner) => { if (!owner.id) { return DeflistApiLoader.addItem(watchId); } const description = `${owner.localeName} ${owner.linkId}`; return DeflistApiLoader.addItem(watchId, description); }, () => DeflistApiLoader.addItem(watchId)); } static clearCache() { cacheStorage.removeItem('deflistItems'); } } ZenzaDetector.detect().then((ZenzaWatch) => { isZenzaReady = true; ZenzaWatch.emitter.on('deflistRemove', () => DeflistApiLoader.clearCache()); }); //DeflistApiLoader.clearCache(); return DeflistApiLoader; })(CsrfTokenLoader); MylistPocket.debug.DeflistApiLoader = DeflistApiLoader; class HoverMenu extends Emitter { constructor() { super(); this._init(); } _init() { this._view = document.querySelector('.mylistPocketHoverMenu'); this._view.addEventListener(location.host.includes('google') ? 'mouseup' : 'click', this._onClick.bind(this)); this._view.addEventListener('mousedown', this._onMousedown.bind(this)); this._view.addEventListener('contextmenu', this._onContextMenu.bind(this)); this._onHoverEnd = bounce.time(this._onHoverEnd.bind(this), 500); document.body.addEventListener( 'mouseover', this._onHover.bind(this), {passive: true}); document.body.addEventListener( 'mouseout', this._onMouseout.bind(this), {passive: true}); document.body.addEventListener( 'mouseover', this._onHoverEnd, {passive: true}); document.body.addEventListener( 'click', () => { this.hide(); }, {passive: true}); util.emitter.on('hideHover', () => this.hide()); this._x = this._y = 0; ZenzaDetector.detect().then(ZenzaWatch => { this._isZenzaReady = true; this.addClass('is-zenzaReady'); ZenzaWatch.emitter.on('DialogPlayerOpen', bounce.time(() => { this.hide(); }, 1000)); }); this.toggleClass('is-otherDomain', location.host !== 'www.nicovideo.jp'); this.toggleClass('is-guest', !util.isLogin()); this._deflistButton = this._view.querySelector('.mylistPocketButton.deflist-add'); MylistPocket.debug.hoverMenu = this._view; } toggleClass(className, v) { className.split(/ +/).forEach((c) => { this._view.classList.toggle(c, v); }); } addClass(className) { this.toggleClass(className, true); } removeClass(className) { this.toggleClass(className, false); } hide() { this.removeClass('is-show'); } show() { this.addClass('is-show'); } moveTo(x, y) { this._x = x; this._y = y; this._view.style.left = x + 'px'; this._view.style.top = y + 'px'; } _onClick(e) { e.preventDefault(); e.stopPropagation(); } _onContextMenu(e) { e.preventDefault(); e.stopPropagation(); } _onMousedown(e) { const watchId = this._watchId; const target = e.target.classList.contains('command') ? e.target : e.target.closest('.command'); const command = target.getAttribute('data-command'); e.preventDefault(); e.stopPropagation(); if (command === 'info') { this._videoInfo(watchId); this.hide(); } else if (command === 'playlist-queue') { this.emit('playlist-queue', watchId, this); } else { if (e.button !== 0 || e.shiftKey) { this._deflistRemove(watchId); } else { this._deflist(watchId); } } } _videoInfo(watchId) { this.emit('info', watchId || this._watchId, this); } _deflist(watchId) { this.emit('deflist-add', watchId || this._watchId, this); } _deflistRemove(watchId) { this.emit('deflist-remove', watchId || this._watchId, this); } _onHover(e) { const target = this._isTargetElement(e); if (!target) { return; } this._hoverElement = target; } _onHoverEnd(e) { const target = e.target.tagName === 'A' ? e.target : e.target.closest('a'); if (!target || this._hoverElement !== target) { return; } const href = target.getAttribute('data-href') || target.getAttribute('href'); const watchId = target.dataset.nicoVideoId || util.getWatchId(href); const offset = target.getBoundingClientRect(); //const bodyOffset = document.body.getBoundingClientRect(); const scrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0; const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft || 0; const left = offset.left + scrollLeft; const top = offset.top + scrollTop; const host = target.hostname; if (host !== 'www.nicovideo.jp' && host !== 'nico.ms' && host !== 'sp.nicovideo.jp') { return; } if (target.classList.contains('noHoverMenu')) { return; } if (!watchId || !watchId.match(/^[a-z0-9]+$/)) { return; } if (watchId.indexOf('lv') === 0) { return; } this._watchId = watchId; this.show(); this.moveTo( left + target.offsetWidth - this._view.offsetWidth / 2, top + target.offsetHeight / 2 - this._view.offsetHeight / 2 ); } _onMouseout(e) { const target = this._isTargetElement(e); if (!target) { return; } if (this._hoverElement === e.target) { this._hoverElement = null; } } _isTargetElement(e) { const target = e.target.tagName === 'A' ? e.target : e.target.closest('a'); if (!target) { return false; } const href = target.href || ''; if (!/(watch\/[a-z0-9]+|nico\.ms\/[a-z0-9]+)/.test(href)) { return false; } return target; } set isBusy(v) { this._isBusy = v; this.toggleClass('is-busy', v); } get isBusy() { return !!this._isBusy; } notifyBeginDeflistUpdate(/*watchId*/) { this.addClass('is-deflistUpdating'); } notifyEndDeflistUpdate(r###lt) { this.addClass('is-deflistSuccess'); window.setTimeout(() => { this.removeClass('is-deflistSuccess'); }, 3000); this._deflistButton.setAttribute('data-r###lt', r###lt.message || '登録しました'); this.removeClass('is-deflistUpdating'); } notifyFailDeflistUpdate(r###lt) { this.addClass('is-deflistFail'); window.setTimeout(() => { this.removeClass('is-deflistFail'); }, 3000); this._deflistButton.setAttribute('data-r###lt', r###lt.message || '登録失敗'); this.removeClass('is-deflistUpdating'); } } class VideoInfoView extends Emitter { constructor({host, tpl}) { super(); this._host = host; this._tpl = tpl; this._slot = {}; this._baseConfig = config; this._config = config.namespace('videoInfo'); this._mylistConfig = config.namespace('mylist'); const ngConfig = this._ngConfig = config.namespace('ng'); const favConfig = this._favConfig = config.namespace('fav'); this._nicoadConfig = config.namespace('nicoad'); const {ngChecker, favChecker} = initNgChecker({ngConfig, favConfig}); this._ngChecker = ngChecker; this._favChecker = favChecker; } _initialize() { if (this._isInitialized) { return; } const host = this._host; const tpl = this._tpl; this._shadowRoot = util.attachShadowDom({host, tpl}); Array.prototype.forEach.call(this._host.querySelectorAll('*'), (elm) => { //this._host.querySelectorAll('*').forEach((elm) => { const slot = elm.getAttribute('slot'); if (!slot) { return; } //const type = elm.getAttribute('data-type') || 'string'; this._slot[slot] = elm; }); this._rootDom = this._shadowRoot.querySelector('.root'); this._hostDom = this._host; this._rootDom.addEventListener('mousedown', e => { e.stopPropagation(); }); this._shadowRoot.addEventListener('mousedown', e => { e.stopPropagation(); }); this._rootDom.querySelector('.setting-panel-main').addEventListener('click', e => { e.stopPropagation(); }); this._initSettingPanel(); const updateNgEnable = v => { this.toggleClass('is-ng-enable', v); }; updateNgEnable(this._ngConfig.props.enable); this._ngConfig.onkey('enable', updateNgEnable); this._rootDom.addEventListener('click', this._onClick.bind(this)); this._boundOnBodyMouseDown = this._onBodyMouseDown.bind(this); MylistPocket.debug.view = this; util.emitter.on('hideHover', () => { this.hide(); }); const debUpdateFavNg = bounce.time(this._updateFavNg.bind(this), 100); this._ngConfig .on('update', debUpdateFavNg); this._favConfig .on('update', debUpdateFavNg); //this._mylistConfig.on('update', debUpdateFavNg); ZenzaDetector.detect().then(() => { this._isZenzaReady = true; this.addClass('is-zenzaReady'); window.ZenzaWatch.emitter.on('DialogPlayerOpen', bounce.time(() => { this.hide(); }, 1000)); }); this._videoInfoArea = this._rootDom.querySelector('.video-info'); this._deflistButton = this._rootDom.querySelector('.mylistPocketButton.deflist-add'); this.toggleClass('is-otherDomain', location.host !== 'www.nicovideo.jp'); this.toggleClass('is-firefox', util.isFirefox()); MylistPocket.external.observe({ query: 'a.videoLink', container: this._hostDom.querySelector('.description'), }); this._isInitialized = true; } _initSettingPanel() { const onSettingFormChange = this._onSettingFormChange.bind(this); const refresh = () => { Array.from(this._rootDom.querySelectorAll('.setting-form')).forEach(elm => { const name = elm.getAttribute('data-config-name'); if (!name) { return; } const namespace = elm.getAttribute('data-config-namespace') || ''; let config = this._config; switch (namespace) { case 'ng': config = this._ngConfig; break; case 'fav': config = this._favConfig; break; case 'mylist': config = this._mylistConfig; break; case 'nicoad': config = this._nicoadConfig; break; default: config = this._baseConfig; } const tagName = (elm.tagName.toLowerCase()).toLowerCase(); if (tagName === 'input') { const type = (elm.type || '').toLowerCase(); switch (type) { case 'checkbox': elm.checked = !!config.props[name]; break; default: elm.value = config.props[name]; break; } } else if (tagName === 'select' || tagName === 'textarea') { elm.value = config.props[name]; } elm.removeEventListener('change', onSettingFormChange); elm.addEventListener('change', onSettingFormChange); }); }; const onUpdate = bounce.time(refresh, 100); const syncZenza = bounce.time(() => { if (!this._ngConfig.props.syncZenza || !this._isZenzaReady) { return; } window.ZenzaWatch.config.setValue('videoTagFilter', this._ngConfig.props.tag); window.ZenzaWatch.config.setValue('videoOwnerFilter', this._ngConfig.props.owner); }, 1000); refresh(); this._config.on('update', onUpdate); this._favConfig.on('update', onUpdate); this._ngConfig.on('update', () => { onUpdate(); syncZenza(); }); } _onSettingFormChange(e) { const elm = e.target; const name = elm.getAttribute('data-config-name'); if (!name) { return; } const namespace = elm.getAttribute('data-config-namespace') || ''; let config = this._config; switch (namespace) { case 'ng': config = this._ngConfig; break; case 'fav': config = this._favConfig; break; case 'mylist': config = this._mylistConfig; break; case 'nicoad': config = this._nicoadConfig; break; default: config = this._baseConfig; } const tagName = (elm.tagName.toLowerCase()).toLowerCase(); if (tagName === 'input') { const type = (elm.type || '').toLowerCase(); switch (type) { case 'checkbox': config.props[name] = elm.checked; break; default: config.props[name] = elm.value; break; } } else if (tagName === 'select' || tagName === 'textarea') { config.props[name] = elm.value; } } toggleClass(className, v) { className.split(/ +/).forEach((c) => { this._rootDom.classList.toggle(c, v); this._hostDom.classList.toggle(c, v); }); } addClass(className) { this.toggleClass(className, true); } removeClass(className) { this.toggleClass(className, false); } bind(videoInfo) { this._videoInfo = videoInfo; if (videoInfo.status === 'ok') { this._bindSuccess(videoInfo); } else { this._bindFail(videoInfo); } window.setTimeout(() => { this.removeClass('is-loading'); }, 0); } _onClick(e) { const t = e.target; const elm = t.classList.contains('command') ? t : e.target.closest('.command'); if (!elm) { return; } // 簡易 throttle if (elm.classList.contains('is-active')) { return; } elm.classList.add('is-active'); window.setTimeout(() => { elm.classList.remove('is-active'); }, 500); e.preventDefault(); e.stopPropagation(); const command = elm.getAttribute('data-command'); const param = elm.getAttribute('data-param'); switch (command) { case 'toggle-setting': this.toggleSettingPanel(); break; case 'add-ng-tag': case 'add-fav-tag': case 'toggle-ng-tag': case 'toggle-fav-tag': { const tag = elm.getAttribute('data-tag') || ''; if (!tag) { break; } this.emit('command', command, { watchId: this._videoInfo.watchId, value: tag }, this); } break; case 'add-ng-owner': case 'add-fav-owner': case 'toggle-ng-owner': case 'toggle-fav-owner': { let owner = (this._videoInfo.isChannel ? 'ch' : '') + this._videoInfo.ownerId + '#' + this._videoInfo.ownerName; this.emit('command', command, { watchId: this._videoInfo.watchId, value: owner }, this); } break; case 'mylist-comment-open': this.emit('command', command, this._videoInfo.watchId); break; case 'close': this.hide(); break; default: this.emit('command', command, param, this); } } _updateFavNg() { if (!this._isInitialized) { return; } if (!this._videoInfo || this._videoInfo.status !== 'ok') { return; } const videoInfo = this._videoInfo; const ownerInfo = this._rootDom.querySelector('.owner-info'); ownerInfo.classList.toggle('is-favorited', this._favChecker.isMatchOwner(videoInfo.owner)); ownerInfo.classList.toggle('is-ng', this._ngChecker .isMatchOwner(videoInfo.owner)); Array.prototype.forEach.call( this._rootDom.querySelectorAll('.tag-container'), (elm) => { const tag = elm.getAttribute('data-tag'); elm.classList.toggle('is-favorited', this._favChecker.isMatchTag(tag)); elm.classList.toggle('is-ng', this._ngChecker.isMatchTag(tag)); }); } toggleSettingPanel() { this.toggleClass('is-setting'); } _onBodyMouseDown() { document.body.removeEventListener('mousedown', this._boundOnBodyMouseDown); this.hide(); } reset() { this._initialize(); window.setTimeout(() => { this._videoInfoArea.scrollTop = 0; }, 0); this.removeClass('noclip'); this.addClass('is-loading'); } show() { this.addClass('show'); document.body.addEventListener('mousedown', this._boundOnBodyMouseDown); } hide() { this._videoInfoArea.scrollTop = 0; this.removeClass('show is-ok is-fail noclip is-setting'); } _bindSuccess(videoInfo) { const toCamel = p => { return p.replace(/-./g, s => { return s.charAt(1).toUpperCase(); }); }; Object.keys(this._slot).forEach((key) => { const camelKey = toCamel(key); const data = videoInfo[camelKey]; const elm = this._slot[key]; const type = elm.getAttribute('data-type') || 'string'; switch (type) { case 'html': this._createDescription(elm, data); break; case 'int': { let i = parseInt(data, 10); i = i.toLocaleString ? i.toLocaleString() : i; elm.textContent = i; } break; case 'link': elm.href = data; break; case 'image': elm.src = data.replace('http:', 'https:'); break; case 'date': elm.textContent = data.toLocaleString(); break; default: elm.textContent = data; } }); const df = document.createDocumentFragment(); //Array.prototype.forEach.call(this._host.querySelectorAll('.tag'), t => { t.remove(); }); videoInfo.tags.forEach(tag => { df.appendChild((this._createTagSlot(tag, videoInfo))); }); const videoTags = this._rootDom.querySelector('.video-tags'); videoTags.innerHTML = ''; videoTags.appendChild(df); Array.prototype.forEach.call(this._rootDom.querySelectorAll('.command-watch-id'), elm => { elm.setAttribute('data-param', videoInfo.watchId); }); Array.prototype.forEach.call(this._rootDom.querySelectorAll('.command-video-id'), elm => { elm.setAttribute('data-param', videoInfo.videoId); }); const target = this._config.props.openNewWindow ? '_blank' : '_self'; Array.prototype.forEach.call( this._host.querySelectorAll('.target-change'), elm => { elm.target = target; elm.rel = 'noopener'; }); this._updateFavNg(); this.toggleClass('is-channel', videoInfo.isChannel); this.addClass('is-ok'); this.removeClass('is-fail'); window.setTimeout(() => { this.addClass('noclip'); }, 800); } _createDescription(elm, data) { elm.innerHTML = util.httpLink(data); const watchReg = /watch\/([a-z0-9]+)/; const isZenzaReady = this._isZenzaReady; //if (util.isFirefox()) { return; } Array.from(elm.querySelectorAll('.videoLink[href*=\'watch/\']')).forEach((link) => { const href = link.getAttribute('href'); if (!watchReg.test(href)) { return; } const watchId = RegExp.$1; if (isZenzaReady) { link.classList.add('noHoverMenu'); link.classList.add('command'); link.setAttribute('data-command', 'zenza-open'); link.setAttribute('data-param', watchId); } const label = document.createElement('span'); label.className = 'label'; label.textContent = link.textContent; link.textContent = ''; link.append(label); const btn = document.createElement('button'); btn.innerHTML = '?'; btn.className = 'command command-button noHoverMenu'; btn.setAttribute('slot', 'command-button'); btn.setAttribute('tooltip', '動画情報'); btn.setAttribute('data-command', 'info'); btn.setAttribute('data-param', watchId); link.appendChild(btn); const thumbnail = util.getThumbnailUrlByVideoId(watchId); const img = document.createElement('img'); img.className = 'videoThumbnail preview'; img.src = 'https://nicovideo.cdn.nimg.jp/uni/img/common/video_deleted.jpg';//(thumbnail || '').replace(/^http:/, ''); link.classList.add('popupThumbnail'); link.appendChild(img); link.dataset.videoId = watchId; link.classList.add('watch'); }); } _bindFail(videoInfo) { this._slot['error-description'].textContent = `動画情報の取得に失敗しました (${videoInfo.description})`; this.addClass('is-fail'); this.removeClass('is-ok'); } _createTagSlot(tag, {isChannel, owner}) { const text = util.escapeHtml(tag.text); const lock = tag.isLocked ? 'is-locked' : ''; const span = document.createElement('span'); const ownerId = owner ? owner.id : ''; const a = document.createElement('a'); const target = this._config.props.openNewWindow ? '_blank' : '_self'; a.textContent = tag.text; a.className = `tag ${lock}`; a.target = target; a.rel = 'noopener'; a.href = `https://www.nicovideo.jp/tag/${encodeURIComponent(text)}`; span.appendChild(a); if (isChannel) { const ch = document.createElement('a'); const target = this._config.props.openNewWindow ? '_blank' : '_self'; ch.textContent = '[ch]'; ch.className = `tag ${lock} channel-search`; ch.target = target; ch.rel = 'noopener'; ch.title = 'チャンネル検索'; //ch.href = `http://ch.nicovideo.jp/search/${encodeURIComponent(text)}?channel_id=ch${ownerId}&type=video&mode=t`; ch.href = `https://ch.nicovideo.jp/search/${encodeURIComponent(text)}?type=video&mode=t`; span.appendChild(ch); } const fav = document.createElement('button'); fav.className = 'add-fav-button command'; fav.setAttribute('data-command', 'toggle-fav-tag'); fav.setAttribute('data-tag', tag.text); fav.innerHTML = '★'; //'⃠'; // ✖ span.appendChild(fav); const bt = document.createElement('button'); bt.className = 'add-ng-button command'; bt.setAttribute('data-command', 'toggle-ng-tag'); bt.setAttribute('data-tag', tag.text); bt.innerHTML = '✖'; //'⃠'; // ✖ span.appendChild(bt); const menu = `<zenza-tag-item-menu class="tagItemMenu" data-text="${encodeURIComponent(text)}" data-has-nicodic="0" ></zenza-tag-item-menu>`; span.insertAdjacentHTML('afterbegin', menu); span.className = 'tag-container'; span.setAttribute('data-tag', tag.text); span.slot = 'tag'; return span; } notifyBeginDeflistUpdate(/*watchId*/) { this.addClass('is-deflistUpdating'); } notifyEndDeflistUpdate(r###lt) { this.addClass('is-deflistSuccess'); window.setTimeout(() => { this.removeClass('is-deflistSuccess'); }, 3000); this._deflistButton.setAttribute('data-r###lt', r###lt.message || '登録しました'); this.removeClass('is-deflistUpdating'); } notifyFailDeflistUpdate(r###lt) { this.addClass('is-deflistFail'); window.setTimeout(() => { this.removeClass('is-deflistFail'); }, 3000); this._deflistButton.setAttribute('data-r###lt', r###lt.message || '登録失敗'); this.removeClass('is-deflistUpdating'); } } class VideoInfo { static createByThumbInfo(thumbInfo) { let thumbnail = thumbInfo.thumbnail; if (util.hasLargeThumbnail(thumbInfo.videoId)) { thumbnail = thumbnail.replace(/\.[ML]$/) + '.L'; } const owner = thumbInfo.owner || {}; const isChannel = thumbInfo.isChannel; const rawData = { status: thumbInfo.status, videoId: thumbInfo.id, watchId: thumbInfo.v, videoTitle: thumbInfo.title, videoThumbnail: thumbnail, uploadDate: thumbInfo.postedAt, duration: textUtil.secToTime(thumbInfo.duration), viewCounter: thumbInfo.viewCount, mylistCounter: thumbInfo.mylistCount, commentCounter: thumbInfo.commentCount, description: thumbInfo.description, lastResBody: thumbInfo.lastResBody, isChannel, ownerId: owner.id, ownerName: owner.name, ownerIcon: owner.icon, tags: thumbInfo.tagList.map(tag => { return {text: tag.text, isLocked: tag.lock}; }) }; return new VideoInfo(rawData); } constructor(rawData) { this._rawData = rawData; } get status() { return this._rawData.status; } get videoId() { return this._rawData.videoId; } get watchId() { return this._rawData.watchId; } get originalVideoId() { return (!this.isChannel && this.videoId !== this.watchId) ? this.videoId : ''; } get videoTitle() { return this._rawData.videoTitle; } get videoThumbnail() { return this._rawData.videoThumbnail; } get description() { return this._rawData.description; } get duration() { return this._rawData.duration; } get owner() { return { type: this.isChannel ? 'channel' : 'user', id: this.ownerId, linkId: this.ownerId ? (this.isChannel ? `ch${this.ownerId}` : `user/${this.ownerId}`) : 'xx', name: this.ownerName, icon: this.ownerIcon }; } get ownerPageLink() { const ownerId = this.ownerId; if (this.isChannel) { return `${protocol}//ch.nicovideo.jp/ch${ownerId}`; } else { return `${protocol}//www.nicovideo.jp/user/${ownerId}`; } } get ownerIcon() { return this._rawData.ownerIcon; } get ownerName() { return this._rawData.ownerName; } get localeOwnerName() { if (this.isChannel) { return this.ownerName; } else { // TODO: 言語依存 return this.ownerName + ' さん'; } } get ownerId() { return this._rawData.ownerId; } get isChannel() { return this._rawData.isChannel; } get uploadDate() { return new Date(this._rawData.uploadDate); } get viewCounter() { return this._rawData.viewCounter; } get mylistCounter() { return this._rawData.mylistCounter; } get commentCounter() { return this._rawData.commentCounter; } get lastResBody() { return this._rawData.lastResBody; } get tags() { return this._rawData.tags; } } const deflistAdd = (watchId) => { const enableAutoComment = config.props.mylist.enableAutoComment; if (location.host === 'www.nicovideo.jp') { if (enableAutoComment) { return DeflistApiLoader.addItemWithOwnerName(watchId); } else { return DeflistApiLoader.addItem(watchId, ''); } } let zenza; let token; return ZenzaDetector.detect().then((z) => { zenza = z; }).then(() => { return CsrfTokenLoader.load().then((t) => { token = t; }, () => { return Promise.resolve(); }); }).then(() => { if (!enableAutoComment) { return {}; } return ThumbInfoLoader.loadOwnerInfo(watchId); }).then((owner) => { if (!owner.id) { return zenza.external.deflistAdd({watchId}); } const description = `${owner.localeName} ${owner.linkId}`; return zenza.external.deflistAdd({watchId, description, token}); }); }; const deflistRemove = (watchId) => { if (location.host === 'www.nicovideo.jp') { return DeflistApiLoader.removeItem(watchId); } let zenza; let token; return ZenzaDetector.detect().then((z) => { zenza = z; }).then(() => { return CsrfTokenLoader.load().then((t) => { token = t; }, () => { return Promise.resolve(); }); }).then(() => { return zenza.external.deflistRemove({watchId, token}); }); }; class MatchChecker { constructor({word = '', tag = '', owner = ''}) { this.init({word, tag, owner}); } init({word, tag, owner}) { this._tag = []; tag.split(/[\r\n]+/).forEach((t) => { if (t) { this._tag.push(t.trim()); } }); this._tag = _.uniq(this._tag); let wordTmp = []; this._word = null; word.split(/[\r\n]+/).forEach((w) => { if (w) { wordTmp.push(util.escapeRegs(w.trim())); } }); wordTmp = _.uniq(wordTmp); if (wordTmp.length > 0) { this._word = new RegExp('(' + wordTmp.join('|') + ')', 'i'); } this._userId = []; this._channelId = []; owner.split(/[\r\n]+/).forEach((o) => { if (typeof o === 'string') { const id = o.split('#')[0].trim(); if (id.startsWith('ch')) { this._channelId.push(parseInt(id.substring(2))); } else { this._userId.push(parseInt(id)); } } }); this._userId = _.uniq(this._userId); this._channelId = _.uniq(this._channelId); } isMatch(data) { if (this._isMatchTag(data.tagList)) { return true; } if (this._isMatchOwner(data.owner)) { return true; } if (this._isMatchWord({title: data.title, description: data.description})) { return true; } } _isMatchTag(tagList = []) { if (this._tag.length < 1) { return false; } const tagTmp = []; tagList.forEach(t => { if (t) { tagTmp.push(util.escapeRegs(t.trim ? t.trim() : t.text.trim())); } }); const tagReg = new RegExp(' (' + tagTmp.join('|') + ') ', 'i'); const _tag = ' ' + this._tag.join(' ') + ' '; return tagReg.test(_tag); } _isMatchOwner(owner) { const _id = owner.type === 'user' ? this._userId : this._channelId; return _id.includes(parseInt(owner.id, 10)); } _isMatchWord({title, description}) { if (!this._word) { return false; } return this._word.test(title) || this._word.test(description); } isMatchTag(tag) { return this._isMatchTag([tag]); } isMatchOwner(owner) { return this._isMatchOwner(owner); } } class NgChecker extends MatchChecker { isNg(data) { return super.isMatch(data); } } const initDom = () => { util.addStyle(__css__); const f = document.createElement('div'); f.id = 'mylistPocketDomContainer'; f.innerHTML = __tpl__; document.body.appendChild(f); }; const initZenzaBridge = () => { ZenzaDetector.initialize(); }; const createVideoInfoView = () => { const host = document.getElementById('mylistPocket-popup'); const tpl = document.getElementById('mylistPocket-popup-template'); const vv = new VideoInfoView({host, tpl}); return vv; }; const createVideoInfoLoader = vv => { const onVideoInfoLoad = thumbInfo => { const vi = VideoInfo.createByThumbInfo(thumbInfo); vv.bind(vi); }; const onVideoInfoFail = () => { vv.bind({status: 'fail', description: '通信失敗'}); return Promise.resolve(); }; return watchId => { vv.reset(); vv.show(); return ThumbInfoLoader.load(watchId, {expireTime: 60 * 60 * 1000}).then(onVideoInfoLoad, onVideoInfoFail); }; }; const createCommandDispatcher = ({infoView}) => { const info = createVideoInfoLoader(infoView); const ngConfig = config.namespace('ng'); const favConfig = config.namespace('fav'); const {ngChecker, favChecker} = initNgChecker({ngConfig, favConfig}); const toggleFavNg = (command, param) => { let [cmd, namespace, key] = command.split('-'); let _config = namespace === 'fav' ? favConfig : ngConfig; _config.refresh(); const value = param.value.trim(); let ngs = _config.props[key].trim().split(/[\r\n]/); const isContain = ngs.includes(value); if (isContain || cmd === 'remove') { ngs = ngs.filter((line) => { if (line === value) { window.console.info('%c-%s:%s', 'background: cyan', key, value); } return line !== value; }); cmd = 'remove'; } else if (!isContain || cmd === 'add') { ngs.push(value); window.console.info('%c+%s:%s', 'background: cyan', key, value); cmd = 'add'; } ngs = _.uniq(ngs); _config.props[key] = ngs.join('\n').trim(); const className = namespace === 'fav' ? 'is-fav-favorited' : 'is-ng-rejected'; Array.prototype.forEach.call( document.querySelectorAll(`*[data-watch-id=${param.watchId}]`), item => { item.classList.toggle(className, cmd === 'add'); }); }; return (command, param, src) => { switch(command) { case 'info': return info(param); case 'load': return QueueLoader.load(param); case 'fav-status': return QueueLoader.load(param).then((r###lt) => { if (!r###lt || r###lt.status === 'fail' || r###lt.code === 'DELETED') { return Promise.reject({status: 'unknown', r###lt}); } if (ngChecker.isMatch(r###lt)) { return {status: 'ng', r###lt}; } if (favChecker.isMatch(r###lt)) { return {status: 'favorite', r###lt}; } return {status: 'default', r###lt}; }); case 'mylist-window': window.open( protocol + '//www.nicovideo.jp/mylist_add/video/' + param, 'nicomylistadd', 'width=500, height=400, menubar=no, scrollbars=no'); break; case 'twitter-hash-open': window.open('https://twitter.com/hashtag/' + param + '?src=hash'); break; case 'open-mylist-open': window.open(protocol + '//www.nicovideo.jp/openlist/' + param); break; case 'mylist-comment-open': window.open(protocol + '//www.nicovideo.jp/mylistcomment/video/' + param); break; case 'zenza-open-now': if (window.ZenzaWatch.config && window.ZenzaWatch.config.getValue('enableSingleton')) { window.ZenzaWatch.external.sendOrExecCommand('openNow', param); } else { window.ZenzaWatch.external.execCommand('openNow', param); } break; case 'zenza-open': if (window.ZenzaWatch.config.getValue('enableSingleton')) { window.ZenzaWatch.external.sendOrOpen(param); } else { window.ZenzaWatch.external.open(param); } break; case 'playlist-inert': window.ZenzaWatch.external.playlist.insert(param); break; case 'playlist-queue': window.ZenzaWatch.external.playlist.add(param); break; case 'deflist-add': src.notifyBeginDeflistUpdate('is-deflistUpdating'); return deflistAdd(param) .then(util.getSleepPromise(1000, 'deflist-add')) .then((r###lt) => { src.notifyEndDeflistUpdate(r###lt); }, (err) => { console.error('deflist-add-r###lt', err); src.notifyFailDeflistUpdate(err); }); case 'deflist-remove': src.notifyBeginDeflistUpdate('is-deflistUpdating'); return deflistRemove(param) .then(util.getSleepPromise(1000, 'deflist-remove')) .then(() => { src.notifyEndDeflistUpdate({message: '削除しました'}); }, (err) => { console.error('deflist-remove-r###lt', err); src.notifyFailDeflistUpdate(err); }); case 'add-ng-word': case 'add-ng-tag': case 'add-ng-owner': case 'add-fav-word': case 'add-fav-tag': case 'add-fav-owner': case 'remove-ng-word': case 'remove-ng-tag': case 'remove-ng-owner': case 'remove-fav-word': case 'remove-fav-tag': case 'remove-fav-owner': case 'toggle-ng-word': case 'toggle-ng-tag': case 'toggle-ng-owner': case 'toggle-fav-word': case 'toggle-fav-tag': case 'toggle-fav-owner': toggleFavNg(command, param); break; } }; }; const initExternal = (dispatcher, hoverMenu, infoView) => { MylistPocket.external = { info: watchId => { return dispatcher('info', watchId); }, load: watchId => { return dispatcher('load', watchId, {expireTime: 60 * 60 * 1000}); }, getFavStatus: (watchId) => { return dispatcher('fav-status', watchId); }, observe: (params /*{query, container, closest}*/) => { initNg(params); }, hide: () => { hoverMenu.hide(); infoView.hide(); } }; MylistPocket.isReady = true; const ev = new CustomEvent('MylistPocketInitialized', { detail: { MylistPocket } }); document.body.dispatchEvent(ev); // 過去の互換用 if (window.jQuery) { window.jQuery('body').trigger('MylistPocketReady', MylistPocket); } }; const QueueLoader = (() => { let lastPromise = null; let count = 0; const MAX_LOAD = 6; const promises = []; const load = function(watchId, item) { count = (count + 1) % MAX_LOAD; lastPromise = promises[count]; const onLoad = info => { if (item) { watchId = info.watchId; item.setAttribute('data-watch-id', watchId); item.setAttribute('data-thumb-info', JSON.stringify(info)); } const sleepTime = info.fromCache ? 0 : 50; return (util.getSleepPromise(sleepTime, 'success-' + watchId))(info); }; const onFail = util.getSleepPromise(1000, 'fail-' + watchId); if (!lastPromise) { if (item) { item.classList.add('is-ng-current'); } lastPromise = ThumbInfoLoader.load(watchId).then(onLoad, onFail); } else { //lastPromise = Promise.all([lastPromise]).then(() => { lastPromise = Promise.race(promises).then(() => { if (item) { item.classList.add('is-ng-current'); } return ThumbInfoLoader.load(watchId).then(onLoad, onFail); }); } promises[count] = lastPromise; return lastPromise; }; return { load }; })(); const waitForDom = (query, timeout = 30000) => { const now = Date.now(); return new Promise(async (ok, ng) => { while (now + timeout > Date.now()) { const dom = document.querySelector(query); console.log('waitForDom', query, dom, now + timeout, Date.now()); if (dom) { return ok(dom); } await new Promise(wait => setTimeout(wait, 1000)); } ng('timeout'); }); }; const getNgEnv = async () => { if (location.host === 'www.nicovideo.jp' && (location.pathname.startsWith('/ranking') || location.pathname.startsWith('/tag') || location.pathname.startsWith('/search')) ) { if (document.querySelector('#MatrixRanking-app')) { await waitForDom('.RankingMatrixVideosRow'); } return { query: '.item[data-video-id]:not(.is-ng-wait), .item_cell[data-video-id]:not(.is-ng-wait), '+ '.VideoItem:not(.is-ng-wait), .RankingMainVideo[data-video-id]:not(.is-ng-wait)', container: Array.from( document.querySelectorAll( '.contentBody .list, .container.column####-0,'+ '.RankingMatrixVideosRow, '+ '.RankingMainContainer, .RankingVideoListContainer') ), subtree: false }; } if (location.host === 'www.nicovideo.jp' && document.querySelector('#MyPageNicorepoApp, #UserPageNicorepoApp')) { return { query: '.NicorepoTimelineItem:not(.is-ng-wait)', container: document.querySelector('#MyPageNicorepoApp, #UserPageNicorepoApp'), }; } if (location.host === 'ch.nicovideo.jp' && location.pathname.startsWith('/search')) { return { query: '.item:not(.is-ng-wait)', container: document.querySelector('.site_body') }; } if (location.host === 'search.nicovideo.jp') { return { query: '.video:not(.is-ng-wait)', container: document.querySelector('#row-r###lts') }; } return {query: null, container: null}; }; const initNgConfig = () => { const ngConfig = config.namespace('ng'); const updateEnable = v => { document.body.classList.toggle('is-ng-disable', !v); }; updateEnable(ngConfig.props.enable); if (!ngConfig.props.enable) { return {}; } ngConfig.onkey('enable', updateEnable); const favConfig = config.namespace('fav'); return {ngConfig, favConfig}; }; const initNgChecker = ({ngConfig, favConfig}) => { const ngChecker = new NgChecker({ word: ngConfig.props.word, tag: ngConfig.props.tag, owner: ngConfig.props.owner }); ngConfig.on('update', bounce.time(({key, value}) => { ngChecker.init({ word: ngConfig.props.word, tag: ngConfig.props.tag, owner: ngConfig.props.owner }); }, 100)); const favChecker = new MatchChecker({ word: favConfig.props.word, tag: favConfig.props.tag, owner: favConfig.props.owner }); favConfig.on('update', bounce.time(({key, value}) => { favChecker.init({ word: favConfig.props.word, tag: favConfig.props.tag, owner: favConfig.props.owner }); }, 100)); return {ngChecker, favChecker}; }; const initIntersectionObserver = onInview => { const onItemInview = item => { let watchId = item.getAttribute('data-id') || item.getAttribute('data-video-id') || item.getAttribute('data-watch-id'); const ignore = () => item.classList.add('is-ng-ignore'); if (!watchId) { const a = item.querySelector('a[href*=\'watch/\']'); let m; if (!a) { return ignore(); } if (a.hostname !== 'www.nicovideo.jp') { return ignore(); } if ((m = /^\/watch\/([a-z0-9]+)/.exec(a.pathname)) === null) { return ignore(); } watchId = m[1]; } if (!watchId) { item.classList.add('.no-watch-id'); return ignore(); } item.classList.add('is-ng-queue'); onInview(item, watchId); }; const intersectionObserver = new window.IntersectionObserver(entries => { entries.filter(entry => entry.isIntersecting).forEach(entry => { const item = entry.target; intersectionObserver.unobserve(item); onItemInview(item); }); }, { rootMargin: '400px'}); return intersectionObserver; }; const initNgDom = ({intersectionObserver, query, closest, container, subtree}) => { subtree = typeof subtree !== 'boolean' ? false : subtree; if (!container) { return; } util.addStyle(__ng_css__); const update = container => { let items = (container || document).querySelectorAll(query); if (!items || items.length < 1) { return; } if (closest) { let tmp = []; [...items].forEach(item => { const c = item.closest(closest); if (c && !tmp.includes(c)) { tmp.push(c); } }); items = tmp; } if (!items || items.length < 1) { return; } [...items].forEach(item => { //if (item.offsetLeft < 0) { return; } if (item.classList.contains('is-ng-ignore')) { return; } item.classList.add('is-ng-wait'); intersectionObserver.observe(item); }); }; update(); if (!container) { return; } const mutationObserver = new MutationObserver(mutations => { for (const record of mutations) { const container = record.target; if (record.addedNodes && record.addedNodes.length) { update(container); } } }); const containers = Array.isArray(container) ? container : [container]; containers.forEach(container => { container.dataset.isWatching = 1; mutationObserver.observe( container, {childList: true, characterData: false, attributes: false, subtree} ); }); }; const initNg = async params => { if (!window.IntersectionObserver) { return; } let {query, container, closest, subtree, callback} = params ? params : await getNgEnv(); if (!query) { return; } const {ngConfig, favConfig} = initNgConfig(); if (!ngConfig) { return; } const {ngChecker, favChecker} = initNgChecker({ngConfig, favConfig}); const onItemInview = (item, watchId) => { const loadLazy = () => { const lazyImage = item.querySelector('.jsLazyImage'); if (lazyImage) { const origImage = lazyImage.getAttribute('data-original'); if (origImage) { lazyImage.src = origImage; lazyImage.classList.remove('jsLazyImage'); } } }; QueueLoader.load(watchId, item).then( info => { item.classList.remove('is-ng-current'); if (!info || info.status === 'fail' || info.code === 'DELETED') { if (info && info.code !== 'COMMUNITY') { console.error('empty data', watchId, info, info ? info.code : 'unknown'); } item.classList.add('is-ng-failed', info ? info.code : 'is-no-data'); } else { if (callback) { return callback(item, {watchId, info, isNg: ngChecker.isNg(info), isFav: favChecker.isMatch(info)}); } item.classList.add( ngChecker.isNg(info) ? 'is-ng-rejected' : 'is-ng-resolved'); if (favChecker.isMatch(info)) { item.classList.add('is-fav-favorited'); } for (let img of item.querySelectorAll('img.videoThumbnail.preview')) { img.src = info.thumbnail; } let label = item.querySelector('.label'); item.dataset.title = info.title; // チャンネル動画のリンクを watch/so〜 に置き換える if (!(info.id || '').startsWith('so')) { return; } if (label && item.classList.contains('videoLink') ) { label.textContent = info.id; item.dataset.param = item.dataset.videoId = info.id; item.href = `https://www.nicovideo.jp/watch/${info.id}`; } for (let a of item.querySelectorAll(`a[href*="watch/${watchId}"]`)) { let href = a.getAttribute('href'); href = href.replace(/watch\/([0-9]+)/, `watch/${info.id}`); a.setAttribute('href', href.replace(/^http:/, 'https:')); } } loadLazy(); }, () => { item.classList.remove('is-ng-current'); item.classList.add('is-ng-failed'); loadLazy(); } ); }; const intersectionObserver = initIntersectionObserver(onItemInview); initNgDom({intersectionObserver, query, container, closest, subtree}); return intersectionObserver; }; const init = async () => { await config.promise('restore'); initDom(); initZenzaBridge(); const infoView = createVideoInfoView(); const dispatcher = createCommandDispatcher({infoView}); infoView.on('command', dispatcher); const hoverMenu = new HoverMenu(); hoverMenu.on('info', (watchId) => { hoverMenu.isBusy = true; dispatcher('info', watchId) .then(() => { hoverMenu.isBusy = false; }); }); hoverMenu.on('deflist-add', (watchId, src) => { dispatcher('deflist-add', watchId, src); }); hoverMenu.on('deflist-remove', (watchId, src) => { dispatcher('deflist-remove', watchId, src); }); hoverMenu.on('playlist-queue', (watchId, src) => { dispatcher('playlist-queue', watchId, src); }); MylistPocket.debug.hoverMenu = hoverMenu; initNg(); if (config.props.nicoad.hide) { util.addStyle(nicoadHideCss); } if (document.body.classList.contains('MatrixRanking-body') && config.props.responsive.matrix) { util.addStyle(responsiveCss); } initExternal(dispatcher, hoverMenu, infoView); }; init(); }; function EmitterInitFunc() { class Handler { //extends Array { constructor(...args) { this._list = args; } get length() { return this._list.length; } exec(...args) { if (!this._list.length) { return; } else if (this._list.length === 1) { this._list[0](...args); return; } for (let i = this._list.length - 1; i >= 0; i--) { this._list[i](...args); } } execMethod(name, ...args) { if (!this._list.length) { return; } else if (this._list.length === 1) { this._list[0][name](...args); return; } for (let i = this._list.length - 1; i >= 0; i--) { this._list[i][name](...args); } } add(member) { if (this._list.includes(member)) { return this; } this._list.unshift(member); return this; } remove(member) { this._list = this._list.filter(m => m !== member); return this; } clear() { this._list.length = 0; return this; } get isEmpty() { return this._list.length < 1; } *[Symbol.iterator]() { const list = this._list || []; for (const member of list) { yield member; } } next() { return this[Symbol.iterator](); } } Handler.nop = () => {/* ( ˘ω˘ ) スヤァ */}; const PromiseHandler = (() => { const id = function() { return `Promise${this.id++}`; }.bind({id: 0}); class PromiseHandler extends Promise { constructor(callback = () => {}) { const key = new Object({id: id(), callback, status: 'pending'}); const cb = function(res, rej) { const resolve = (...args) => { this.status = 'resolved'; this.value = args; res(...args); }; const reject = (...args) => { this.status = 'rejected'; this.value = args; rej(...args); }; if (this.r###lt) { return this.r###lt.then(resolve, reject); } Object.assign(this, {resolve, reject}); return callback(resolve, reject); }.bind(key); super(cb); this.resolve = this.resolve.bind(this); this.reject = this.reject.bind(this); this.key = key; } resolve(...args) { if (this.key.resolve) { this.key.resolve(...args); } else { this.key.r###lt = Promise.resolve(...args); } return this; } reject(...args) { if (this.key.reject) { this.key.reject(...args); } else { this.key.r###lt = Promise.reject(...args); } return this; } addCallback(callback) { Promise.resolve().then(() => callback(this.resolve, this.reject)); return this; } } return PromiseHandler; })(); const {Emitter} = (() => { let totalCount = 0; let warnings = []; class Emitter { on(name, callback) { if (!this._events) { Emitter.totalCount++; this._events = new Map(); } name = name.toLowerCase(); let e = this._events.get(name); if (!e) { e = this._events.set(name, new Handler(callback)); } else { e.add(callback); } if (e.length > 10) { !Emitter.warnings.includes(this) && Emitter.warnings.push(this); } return this; } off(name, callback) { if (!this._events) { return; } name = name.toLowerCase(); const e = this._events.get(name); if (!this._events.has(name)) { return; } else if (!callback) { this._events.delete(name); } else { e.remove(callback); if (e.isEmpty) { this._events.delete(name); } } if (this._events.size < 1) { delete this._events; } return this; } once(name, func) { const wrapper = (...args) => { func(...args); this.off(name, wrapper); wrapper._original = null; }; wrapper._original = func; return this.on(name, wrapper); } clear(name) { if (!this._events) { return; } if (name) { this._events.delete(name); } else { delete this._events; Emitter.totalCount--; } return this; } emit(name, ...args) { if (!this._events) { return; } name = name.toLowerCase(); const e = this._events.get(name); if (!e) { return; } e.exec(...args); return this; } emitAsync(...args) { if (!this._events) { return; } setTimeout(() => this.emit(...args), 0); return this; } promise(name, callback) { if (!this._promise) { this._promise = new Map; } const p = this._promise.get(name); if (p) { return callback ? p.addCallback(callback) : p; } this._promise.set(name, new PromiseHandler(callback)); return this._promise.get(name); } emitResolve(name, ...args) { if (!this._promise) { this._promise = new Map; } if (!this._promise.has(name)) { this._promise.set(name, new PromiseHandler()); } return this._promise.get(name).resolve(...args); } emitReject(name, ...args) { if (!this._promise) { this._promise = new Map; } if (!this._promise.has(name)) { this._promise.set(name, new PromiseHandler); } return this._promise.get(name).reject(...args); } resetPromise(name) { if (!this._promise) { return; } this._promise.delete(name); } hasPromise(name) { return this._promise && this._promise.has(name); } addEventListener(...args) { return this.on(...args); } removeEventListener(...args) { return this.off(...args);} } Emitter.totalCount = totalCount; Emitter.warnings = warnings; return {Emitter}; })(); return {Handler, PromiseHandler, Emitter}; } const {Handler, PromiseHandler, Emitter} = EmitterInitFunc(); function parseThumbInfo(xmlText) { if (typeof xmlText !== 'string' || xmlText.status === 'ok') { return xmlText; } const parser = new DOMParser(); const xml = parser.parseFromString(xmlText, 'text/xml'); const val = name => { const elms = xml.getElementsByTagName(name); if (elms.length < 1) { return null; } return elms[0].textContent; }; const dateToString = dateString => { const date = new Date(dateString); const [yy, mm, dd, h, m, s] = [ date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds() ].map(n => n.toString().padStart(2, '0')); return `${yy}/${mm}/${dd} ${h}:${m}:${s}`; }; const resp = xml.getElementsByTagName('nicovideo_thumb_response'); if (resp.length < 1 || resp[0].getAttribute('status') !== 'ok') { return { status: 'fail', code: val('code'), message: val('description') }; } const [min, sec] = val('length').split(':'); const duration = min * 60 + sec * 1; const watchId = val('watch_url').split('/').reverse()[0]; const postedAt = dateToString(new Date(val('first_retrieve'))); const tags = [...xml.getElementsByTagName('tag')].map(tag => { return { text: tag.textContent, category: tag.hasAttribute('category'), lock: tag.hasAttribute('lock') }; }); const videoId = val('video_id'); const isChannel = videoId.substring(0, 2) === 'so'; const r###lt = { status: 'ok', _format: 'thumbInfo', v: isChannel ? videoId : watchId, id: videoId, videoId, watchId: isChannel ? videoId : watchId, originalVideoId: (!isChannel && watchId !== videoId) ? videoId : '', isChannel, title: val('title'), description: val('description'), thumbnail: val('thumbnail_url').replace(/^http:/, 'https:'), movieType: val('movie_type'), lastResBody: val('last_res_body'), duration, postedAt, mylistCount: parseInt(val('mylist_counter'), 10), viewCount: parseInt(val('view_counter'), 10), commentCount: parseInt(val('comment_num'), 10), tagList: tags }; const userId = val('user_id'); if (userId !== null && userId !== '') { r###lt.owner = { type: 'user', id: userId, linkId: userId ? `user/${userId}` : '', name: val('user_nickname') || '(非公開ユーザー)', url: userId ? ('https://www.nicovideo.jp/user/' + userId) : '#', icon: val('user_icon_url') || 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg' }; } const channelId = val('ch_id'); if (channelId !== null && channelId !== '') { r###lt.owner = { type: 'channel', id: channelId, linkId: channelId ? `ch${channelId}` : '', name: val('ch_name') || '(非公開チャンネル)', url: 'https://ch.nicovideo.jp/ch' + channelId, icon: val('ch_icon_url') || 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg' }; } return r###lt; } const workerUtil = (() => { let config, TOKEN, PRODUCT = 'ZenzaWatch?', netUtil, CONSTANT, NAME = ''; let global = null, external = null; const isAvailable = !!(window.Blob && window.Worker && window.URL); const messageWrapper = function(self) { const _onmessage = self.onmessage || (() => {}); const promises = {}; const onMessage = async function(self, type, e) { const {body, sessionId, status} = e.data; const {command, params} = body; try { let r###lt; switch (command) { case 'commandR###lt': if (promises[sessionId]) { if (status === 'ok') { promises[sessionId].resolve(params.r###lt); } else { promises[sessionId].reject(params.r###lt); } delete promises[sessionId]; } return; case 'ping': r###lt = {now: Date.now(), NAME, PID, url: location.href}; break; case 'port': { const port = e.ports[0]; portMap[params.name] = port; port.addEventListener('message', onMessage.bind({}, port, params.name)); bindFunc(port, 'MessageChannel'); if (params.ping) { console.time('ping:' + sessionId); port.ping().then(r###lt => { console.timeEnd('ping:' + sessionId); console.log('ok %smec', Date.now() - params.now, params); }).catch(err => { console.timeEnd('ping:' + sessionId); console.warn('ping fail', {err, data: e.data}); }); } } return; case 'broadcast': { if (!BroadcastChannel) { return; } const channel = new BroadcastChannel(`${params.name}`); channel.addEventListener('message', onMessage.bind({}, channel, 'BroadcastChannel')); bindFunc(channel, 'BroadcastChannel'); bcast[params.basename] = channel; } return; case 'env': ({config, TOKEN, PRODUCT, CONSTANT} = params); return; default: r###lt = await _onmessage({command, params}, type, PID); break; } self.postMessage({body: {command: 'commandR###lt', params: {command, r###lt}}, sessionId, TYPE: type, PID, status: 'ok' }); } catch(err) { console.error('failed', {err, command, params, sessionId, TYPE: type, PID, data: e.data}); self.postMessage({body: {command: 'commandR###lt', params: {command, r###lt: err.message || null}}, sessionId, TYPE: type, PID, status: err.status || 'fail' }); } }; self.onmessage = onMessage.bind({}, self, self.name); self.onconnect = e => { const port = e.ports[0]; port.onmessage = self.onmessage; port.start(); }; const bindFunc = (self, type = 'Worker') => { const post = function(self, body, options = {}) { const sessionId = `recv:${NAME}:${type}:${this.sessionId++}`; return new Promise((resolve, reject) => { promises[sessionId] = {resolve, reject}; self.postMessage({body, sessionId, PID}, options.transfer); if (typeof options.timeout === 'number') { setTimeout(() => { reject({status: 'fail', message: 'timeout'}); delete promises[sessionId]; }, options.timeout); } }).finally(() => { delete promises[sessionId]; }); }; const emit = function(self, eventName, data = null) { self.post({command: 'emit', params: {eventName, data}}); }; const notify = function(self, message) { self.post({command: 'notify', params: {message}}); }; const alert = function(self, message) { self.post({command: 'alert', params: {message}}); }; const ping = async function(self, options = {}) { const timekey = `PING "${self.name}"`; console.log(timekey); let r###lt; options.timeout = options.timeout || 10000; try { console.time(timekey); r###lt = await self.post({command: 'ping', params: {now: Date.now(), NAME, PID, url: location.href}}, options); console.timeEnd(timekey); } catch (e) { console.timeEnd(timekey); console.warn('ping fail', e); } return r###lt; }; self.post = post.bind({sessionId: 0}, this.port || self); self.emit = emit.bind({}, self); self.notify = notify.bind({}, self); self.alert = alert.bind({}, self); self.ping = ping.bind({}, self); return self; }; bindFunc(self); self.xFetch = async (url, options = {}) => { options = {...options, ...{signal: null}}; // remove AbortController if (url.startsWith(location.origin)) { return fetch(url, options); } const r###lt = await self.post({command: 'fetch', params: {url, options}}); const {buffer, init, headers} = r###lt; const _headers = new Headers(); (headers || []).forEach(a => _headers.append(...a)); const _init = { status: init.status, statusText: init.statusText || '', headers: _headers }; return new Response(buffer, _init); }; }; const workerUtil = { isAvailable, js: (q, ...args) => { const strargs = args.map(a => typeof a === 'string' ? a : a.toString); return String.raw(q, ...strargs); }, env: params => { ({config, TOKEN, PRODUCT, netUtil, CONSTANT, global} = Object.assign({config, TOKEN, PRODUCT, netUtil, CONSTANT, global}, params)); if (global) { ({config, TOKEN, PRODUCT, CONSTANT} = global); } }, create: function(func, options = {}) { let cache = this.urlMap.get(func); const name = options.name || 'Worker'; if (!cache) { const src = ` const PID = '${window && window.name || 'self'}:${location.href}:${name}:${Date.now().toString(16).toUpperCase()}'; console.log('%cinit %s %s', 'font-weight: bold;', self.name || '', '${PRODUCT}', location.origin); (${func.toString()})(self); `; const blob = new Blob([src], {type: 'text/javascript'}); const url = URL.createObjectURL(blob); this.urlMap.set(func, url); cache = url; } if (options.type === 'SharedWorker') { const w = this.workerMap.get(func) || new SharedWorker(cache); this.workerMap.set(func, w); return w; } return new Worker(cache, options); }.bind({urlMap: new Map(), workerMap: new Map()}), createCrossMessageWorker: function(func, options = {}) { const promises = this.promises; const name = options.name || 'Worker'; const PID = `${window && window.name || 'self'}:${location.host}:${name}:${Date.now().toString(16).toUpperCase()}`; const _func = ` function (self) { let config = {}, PRODUCT, TOKEN, CONSTANT, NAME = decodeURI('${encodeURI(name)}'), bcast = {}, portMap = {}; const {Handler, PromiseHandler, Emitter} = (${EmitterInitFunc.toString()})(); (${func.toString()})(self); //=================================== (${messageWrapper.toString()})(self); } `; const worker = workerUtil.create(_func, options); const self = options.type === 'SharedWorker' ? worker.port : worker; self.name = name; const onMessage = async function(self, e) { const {body, sessionId, status} = e.data; const {command, params} = body; try { let r###lt = 'ok'; let transfer = null; switch (command) { case 'commandR###lt': if (promises[sessionId]) { if (status === 'ok') { promises[sessionId].resolve(params.r###lt); } else { promises[sessionId].reject(params.r###lt); } delete promises[sessionId]; } return; case 'ping': r###lt = {now: Date.now(), NAME, PID, url: location.href}; console.timeLog && console.timeLog(params.NAME, 'PONG'); break; case 'emit': global && global.emitter.emitAsync(params.eventName, params.data); break; case 'fetch': r###lt = await (netUtil || window).fetch(params.url, Object.assign({}, params.options || {}, {_format: 'arraybuffer'})); transfer = [r###lt.buffer]; break; case 'notify': global && global.notify(params.message); break; case 'alert': global && global.alert(params.message); break; default: self.oncommand && (r###lt = await self.oncommand({command, params})); break; } self.postMessage({body: {command: 'commandR###lt', params: {command, r###lt}}, sessionId, status: 'ok'}, transfer); } catch (err) { console.error('failed', {err, command, params, sessionId}); self.postMessage({body: {command: 'commandR###lt', params: {command, r###lt: err.message || null}}, sessionId, status: err.status || 'fail'}); } }; const bindFunc = (self, type = 'Worker') => { const post = function(self, body, options = {}) { const sessionId = `send:${name}:${type}:${this.sessionId++}`; return new Promise((resolve, reject) => { promises[sessionId] = {resolve, reject}; self.postMessage({body, sessionId, TYPE: type, PID}, options.transfer); if (typeof options.timeout === 'number') { setTimeout(() => { reject({status: 'fail', message: 'timeout'}); delete promises[sessionId]; }, options.timeout); } }).finally(() => { delete promises[sessionId]; }); }; const ping = async function(self, options = {}) { const timekey = `PING "${self.name}" total time`; window.console.log(`PING "${self.name}"...`); let r###lt; options.timeout = options.timeout || 10000; try { window.console.time(timekey); r###lt = await self.post({command: 'ping', params: {now: Date.now(), NAME: self.name, PID, url: location.href}}, options); window.console.timeEnd(timekey); } catch (e) { console.timeEnd(timekey); console.warn('ping fail', e); } return r###lt; }; self.post = post.bind({sessionId: 0}, self); self.ping = ping.bind({}, self); self.addEventListener('message', onMessage.bind({sessionId: 0}, self)); self.start && self.start(); }; bindFunc(self); if (config) { self.post({ command: 'env', params: {config: config.export(true), TOKEN, PRODUCT, CONSTANT} }); } self.addPort = (port, options = {}) => { const name = options.name || 'MessageChannel'; return self.post({command: 'port', params: {port, name}}, {transfer: [port]}); }; const channel = new MessageChannel(); self.addPort(channel.port2); bindFunc(channel.port1, {name: 'MessageChannel'}); self.bridge = async (worker, options = {}) => { const name = options.name || 'MessageChannelBridge'; const channel = new MessageChannel(); await self.addPort(channel.port1, {name: worker.name || name}); await worker.addPort(channel.port2, {name: self.name || name}); console.log('ping self -> other', await channel.port1.ping()); console.log('ping other -> self', await channel.port2.ping()); }; self.BroadcastChannel = basename => { const name = `${basename || 'Broadcast'}${TOKEN || Date.now().toString(16)}`; self.post({command: 'broadcast', params: {basename, name}}); const channel = new BroadcastChannel(name); channel.addEventListener('message', onMessage.bind({}, channel, 'BroadcastChannel')); bindFunc(channel, 'BroadcastChannel'); return name; }; self.ping() .catch(r###lt => console.warn('FAIL', r###lt)); return self; }.bind({ sessionId: 0, promises: {} }) }; return workerUtil; })(); const IndexedDbStorage = (() => { const workerFunc = function(self) { const db = {}; const controller = { async init({name, ver, stores}) { if (db[name]) { return Promise.resolve(db[name]); } return new Promise((resolve, reject) => { const req = indexedDB.open(name, ver); req.onupgradeneeded = e => { const _db = e.target.r###lt; for (const meta of stores) { if(_db.objectStoreNames.contains(meta.name)) { _db.deleteObjectStore(meta.name); } const store = _db.createObjectStore(meta.name, meta.definition); const indexes = meta.indexes || []; for (const idx of indexes) { store.createIndex(idx.name, idx.keyPath, idx.params); } store.transaction.oncomplete = () => { console.log('store.transaction.complete', JSON.stringify({name, ver, store: meta})); }; } }; req.onsuccess = e => { db[name] = e.target.r###lt; resolve(db[name]); }; req.onerror = reject; }); }, close({name}) { if (!db[name]) { return; } db[name].close(); db[name] = null; }, async getStore({name, storeName, mode = 'readonly'}) { const db = await this.init({name}); return new Promise(async (resolve, reject) => { const tx = db.transaction(storeName, mode); tx.onerror = reject; return resolve({ store: tx.objectStore(storeName), transaction: tx }); }); }, async put({name, storeName, data}) { const {store, transaction} = await this.getStore({name, storeName, mode: 'readwrite'}); return new Promise((resolve, reject) => { const req = store.put(data); req.onsuccess = e => { transaction.commit && transaction.commit(); resolve(e.target.r###lt); }; req.onerror = reject; }); }, async get({name, storeName, data: {key, index, timeout}}) { const {store} = await this.getStore({name, storeName}); return new Promise((resolve, reject) => { const req = index ? store.index(index).get(key) : store.get(key); req.onsuccess = e => resolve(e.target.r###lt); req.onerror = reject; if (timeout) { setTimeout(() => { reject(`timeout: key${key}`); }, timeout); } }); }, async updateTime({name, storeName, data: {key, index, timeout}}) { const record = await this.get({name, storeName, data: {key, index, timeout}}); if (!record) { return null; } record.updatedAt = Date.now(); this.put({name, storeName, data: record}); return record; }, async delete({name, storeName, data: {key, index}}) { const {store, transaction} = await this.getStore({name, storeName, mode: 'readwrite'}); return new Promise((resolve, reject) => { let remove = 0; let range = IDBKeyRange.only(key); let req = index ? store.index(index).openCursor(range) : store.openCursor(range); req.onsuccess = e => { const r###lt = e.target.r###lt; if (!r###lt) { transaction.commit && transaction.commit(); return resolve(remove > 0); } r###lt.delete(); remove++; r###lt.continue(); }; req.onerror = reject; }); }, async clear({name, storeName}) { const {store} = await this.getStore({name, storeName, mode: 'readwrite'}); return new Promise((resolve, reject) => { const req = store.clear(); req.onsuccess = e => { console.timeEnd('storage clear'); resolve(); }; req.onerror = e => { console.timeEnd('storage clear'); reject(e); }; }); }, async gc({name, storeName, data: {expireTime, index}}) { index = index || 'updatedAt'; const {store, transaction} = await this.getStore({name, storeName, mode: 'readwrite'}); const now = Date.now(), ptime = performance.now(); const expiresAt = (index !== 'expiresAt') ? (now - expireTime) : now; const expireDateTime = new Date(expiresAt).toLocaleString(); const timekey = `GC [DELETE FROM ${name}.${storeName} WHERE ${index} < '${expireDateTime}'] `; console.time(timekey); let count = 0; return new Promise((resolve, reject) => { const range = IDBKeyRange.upperBound(expiresAt); const idx = store.index(index); const req = idx.openCursor(range); req.onsuccess = e => { const cursor = e.target.r###lt; if (cursor) { count++; cursor.delete(); return cursor.continue(); } console.timeEnd(timekey); resolve({status: 'ok', count, time: performance.now() - ptime}); count && console.log('deleted %s records.', count); }; req.onerror = reject; }).catch(e => { console.error('gc fail', {name, storeName, data: {expireTime, index}, timekey}, e); store.clear(); }); } }; self.onmessage = async ({command, params}) => { try { switch (command) { case 'init': await controller[command](params); return 'ok'; case 'put': return controller.put(params); case 'updateTime': case 'get': return controller[command](params); default: return controller[command](params) || 'ok'; } } catch (err) { console.warn('command failed: ', {command, params}); throw err; } }; return controller; }; const workers = new Map; const open = async ({name, ver, stores}, func) => { let worker; if (func) { let _func = workerFunc; if (func) { _func = ` (() => { const controller = (${workerFunc.toString()})(self); (${func.toString()})(self) }) `; } worker = workers.get(func) || workerUtil.createCrossMessageWorker(_func, {name: `IndexedDb[${name}]`}); workers.set(func, worker); } else { worker = workers.get(workerFunc) || workerUtil.createCrossMessageWorker(workerFunc, {name: 'IndexedDb'}); workers.set(workerFunc, worker); } worker.post({command: 'init', params: {name, ver, stores}}); const post = (command, data, storeName, transfer) => { const params = {data, name, storeName, transfer}; return worker.post({command, params}, transfer); }; const r###lt = {worker}; for (const meta of stores) { const storeName = meta.name; r###lt[storeName] = (storeName => { return { close: params => post('close', params, storeName), put: (record, transfer) => post('put', record, storeName, transfer), get: ({key, index, timeout}) => post('get', {key, index, timeout}, storeName), updateTime: ({key, index, timeout}) => post('updateTime', {key, index, timeout}, storeName), delete: ({key, index, timeout}) => post('delete', {key, index, timeout}, storeName), gc: (expireTime = 30 * 24 * 60 * 60 * 1000, index = 'updatedAt') => post('gc', {expireTime, index}, storeName) }; })(storeName); } return r###lt; }; return {open}; })(); const ThumbInfoCacheDb = (() => { const THUMB_INFO = { name: 'thumb-info', ver: 1, stores: [ { name: 'cache', indexes: [ {name: 'postedAt', keyPath: 'postedAt', params: {unique: false}}, {name: 'updatedAt', keyPath: 'updatedAt', params: {unique: false}} ], definition: {keyPath: 'watchId', autoIncrement: false} } ] }; let db; const open = async () => { db = db || await IndexedDbStorage.open(THUMB_INFO); const cacheDb = db['cache']; cacheDb.gc(90 * 24 * 60 * 60 * 1000); return { put: (xml, thumbInfo = null) => { thumbInfo = thumbInfo || parseThumbInfo(xml); if (thumbInfo.status !== 'ok') { return; } const watchId = thumbInfo.v; const videoId = thumbInfo.id; const postedAt = new Date(thumbInfo.postedAt).getTime(); const updatedAt = Date.now(); const record = { watchId, videoId, postedAt, updatedAt, xml, thumbInfo }; cacheDb.put(record); return {watchId, updatedAt}; }, get: watchId => cacheDb.updateTime({key: watchId}), delete: watchId => cacheDb.delete({key: watchId}), close: () => cacheDb.close() }; }; return {open}; })(); window.MylistPocketLib = { workerUtil }; const thumbInfoApi = async function() { const gate = () => { const post = function(body, {type, token, sessionId, origin} = {}) { sessionId = sessionId || ''; origin = origin || ''; this.origin = origin = origin || this.origin || document.referrer; this.token = token = token || this.token; this.type = type = type || this.type; if (!this.channel) { this.channel = new MessageChannel; } const url = location.href; const id = PRODUCT; try { const msg = {id, type, token, url, sessionId, body}; if (!this.port) { msg.body = {command: 'initialized', params: msg.body}; parent.postMessage(msg, origin, [this.channel.port2]); this.port = this.channel.port1; this.port.start(); } else { this.port.postMessage(msg); } } catch (e) { console.error('%cError: parent.postMessage - ', 'color: red; background: yellow', e); } return this.port; }.bind({channel: null, port: null, origin: null, token: null, type: null}); const parseUrl = url => { url = url || 'https://unknown.example.com/'; const a = document.createElement('a'); a.href = url; return a; }; const isNicoServiceHost = url => { const host = parseUrl(url).hostname; return /(^[a-z0-9.-]*\.nicovideo\.jp$|^[a-z0-9.-]*\.nico(|:[0-9]+)$)/.test(host); }; const isWhiteHost = url => { const u = parseUrl(url); const host = u.hostname; if (['account.nicovideo.jp', 'point.nicovideo.jp'].includes(host)) { return false; } if (isNicoServiceHost(url)) { return true; } if (['localhost', '127.0.0.1'].includes(host)) { return true; } if (localStorage.ZenzaWatch_whiteHost) { if (localStorage.ZenzaWatch_whiteHost.split(',').includes(host)) { return true; } } if (u.protocol !== 'https:') { return false; } return [ 'google.com', 'www.google.com', 'www.google.co.jp', 'www.bing.com', 'twitter.com', 'friends.nico', 'feedly.com', 'www.youtube.com', ].includes(host) || host.endsWith('.slack.com'); }; const uFetch = params => { const {url, options}= params; if (!isWhiteHost(url) || !isNicoServiceHost(url)) { return Promise.reject({status: 'fail', message: 'network error'}); } const racers = []; let timer; const timeout = (typeof params.timeout === 'number' && !isNaN(params.timeout)) ? params.timeout : 30 * 1000; if (timeout > 0) { racers.push(new Promise((resolve, reject) => timer = setTimeout(() => timer ? reject({name: 'timeout', message: 'timeout'}) : resolve(), timeout)) ); } const controller = AbortController ? (new AbortController()) : null; if (controller) { params.signal = controller.signal; } racers.push(fetch(url, options)); return Promise.race(racers) .catch(err => { let message = 'uFetch fail'; if (err && err.name === 'timeout') { if (controller) { console.warn('request timeout'); controller.abort(); } message = 'timeout'; } return Promise.reject({status: 'fail', message}); }).finally(() => { timer && clearTimeout(timer); }); }; const xFetch = (params, sessionId = null) => { const command = 'fetch'; return uFetch(params).then(async resp => { const buffer = await resp.arrayBuffer(); const init = ['type', 'url', 'redirected', 'status', 'ok', 'statusText'] .reduce((map, key) => {map[key] = resp[key]; return map;}, {}); const headers = [...resp.headers.entries()]; return Promise.resolve({buffer, init, headers}); }).then(({buffer, init, headers}) => { const r###lt = {status: 'ok', command, params: {buffer, init, headers}}; post(r###lt, {sessionId}); return r###lt; }).catch(({status, message}) => { post({status, message, command}, {sessionId}); }); }; const init = ({prefix, type}) => { if (!window.name.startsWith(prefix)) { throw new Error(`unknown name "${window.name}"`); } const PID = `${window && window.name || 'self'}:${location.host}:${name}:${Date.now().toString(16).toUpperCase()}`; type = type || window.name.replace(new RegExp(`/(${PRODUCT}|)Loader$/`), ''); const origin = document.referrer || window.name.split('#')[1]; console.log('%cCrossDomainPort: host:%s window:%s', 'background: lightgreen;', location.host, window.name.split('#')[0]); if (!isWhiteHost(origin)) { throw new Error(`disable bridge "${origin}"`); } const TOKEN = location.hash ? location.hash.substring(1) : null; window.history.replaceState(null, null, location.pathname); const port = post({status: 'ok', command: 'initialized'}, {type, token: TOKEN, origin}); workerUtil && workerUtil.env({TOKEN, PRODUCT}); return {port, TOKEN, origin, type, PID}; }; return {post, parseUrl, isNicoServiceHost, isWhiteHost, uFetch, xFetch, init}; }; const {post, parseUrl, uFetch, init} = gate(); const {port, TOKEN} = init({prefix: `thumbInfo${PRODUCT}`, type: 'thumbInfo'}); const db = await ThumbInfoCacheDb.open(); port.addEventListener('message', async e => { const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; const {body, sessionId, token} = data; const {command, params} = body; if (command !== 'fetch') { return; } const p = parseUrl(params.url); if (TOKEN !== token || p.hostname !== location.host || !p.pathname.startsWith('/api/getthumbinfo/')) { console.log('invalid msg: ', {origin: e.origin, TOKEN, token, body}); return; } params.options = params.options || {}; const watchId = params.url.split('/').reverse()[0]; const expiresAt = Date.now() - (params.options.expireTime || 0); const cache = await db.get(watchId); if (cache && cache.thumbInfo.status === 'ok' && cache.updatedAt > expiresAt) { return post({status: 'ok', command, params: cache.thumbInfo}, {sessionId}); } delete params.options.credentials; return uFetch(params, sessionId) .then(res => res.text()) .then(async xmlText => { let thumbInfo = parseThumbInfo(xmlText); if (thumbInfo.status === 'ok') { db.put(xmlText, thumbInfo); } else if (cache && cache.thumbInfo.status === 'ok') { thumbInfo = cache.thumbInfo; } const r###lt = {status: 'ok', command, params: thumbInfo}; post(r###lt, {sessionId}); }).catch(({status, message}) => { if (cache && cache.thumbInfo.status === 'ok') { return post({status: 'ok', command, params: cache.thumbInfo}, {sessionId}); } return post({status, message, command}, {sessionId}); }); }); }; const loadGm = () => { const script = document.createElement('script'); script.id = `${PRODUCT}Loader`; script.setAttribute('type', 'text/javascript'); script.setAttribute('charset', 'UTF-8'); script.append(` (() => { const {Handler, PromiseHandler, Emitter} = (${EmitterInitFunc.toString()})(); ${parseThumbInfo.toString()} (${monkey.toString()})("${PRODUCT}"); })();`); (document.head || document.documentElement).append(script); }; const host = window.location.host || ''; if (host === 'ext.nicovideo.jp' && window.name.indexOf(`thumbInfo${PRODUCT}Loader`) >= 0) { thumbInfoApi(); } else if (window === top) { loadGm(); } });