自動記憶[臨時時間戳]+自定義3個書籤
// ==UserScript== // @name YT時間軸書籤bookmarks // @namespace https://greasyfork.org/zh-TW/users/4839-leadra // @version 1.1.2 // @description 自動記憶[臨時時間戳]+自定義3個書籤 // @description:en youtube timeline for Automatic memory [temporary timestamp] + custom 3 bookmarks // @author puzzle // @match https://www.youtube.com/watch* // @match https://www.youtube.com/live/* // @icon https://www.youtube.com/favicon.ico // @run-at document-start // @grant none // @license MIT // ==/UserScript== //原作者https://greasyfork.org/users/115438-puzzle //localStorage改成sessionStorage,關閉網頁就刪除紀錄 (async function() { 'use strict'; const __helper = { $: (sel, parent = document) => parent.querySelector(sel), $$: (sel, parent = document) => Array.from(parent.querySelectorAll(sel)), async waitUntilExist(selector) { return new Promise((resolve, reject) => { let timer = setInterval(function (e) { const el = document.querySelector(selector); if (el) { clearInterval(timer); resolve(el); } }, 100); }); } } const {$, $$, waitUntilExist} = __helper; const progressBar = { elem: await waitUntilExist('.ytp-progress-bar'), get ariaValueMin() { return this.elem.ariaValueMin; }, get ariaValueNow() { return this.elem.ariaValueNow; }, get ariaValueMax() { return this.elem.ariaValueMax; }, mouseDown: false, }; const video = { elem: await waitUntilExist('video'), get offset() { return Math.max(video.elem.currentTime - this.ytCurrentTime, 0); }, get ytCurrentTime() { return progressBar.ariaValueNow - progressBar.ariaValueMin; }, get ytDuration() { return progressBar.ariaValueMax - progressBar.ariaValueMin; }, get currentTime() { return this.ytCurrentTime - this.offset; }, set currentTime(value) { console.log(`set currentTime: ${value}`); video.elem.currentTime = Math.max(value + this.offset, 0); }, get duration() { return video.elem.duration; } }; let isLiveStream = !!$('.ytp-time-display.ytp-live'); let hasChapters = !!$('.ytp-chapters-container')?.children?.length; progressBar.elem.insertAdjacentHTML('beforeEnd', ` <style> #userscript-bookmarks { position: absolute; width: 100%; height: 100%; z-index: 50; top: 0; & .bookmark:not([style]), & .bookmark[data-description='']::before{ display: none; } & .bookmark { position: absolute; transform: translate(-50%,-50%); border: clamp(10px,2.5vh, 18px) solid transparent; border-top: clamp(10px,2.5vh, 18px) solid orange; } & .bookmark:hover::before { content: attr(data-description); position: absolute; font-size: clamp(12px, 2.5vh, 16px); background: black; padding: 5px; border-radius: 5px; white-space: nowrap; } & .bookmark::after { content: attr(data-num); position: absolute; transform: translate(-50%, -100%); color: black; font-size: clamp(10px, 2.5vh, 14px); } } #userscript-recent-positions { position: absolute; width: 100%; height: 100%; z-index: 50; top: 0; & .position { position: absolute; width: 3px; height: 1vh; transform: translate(-50%); } } </style> <div id='userscript-bookmarks'> <span class='bookmark' data-num='1' data-description=''></span> <span class='bookmark' data-num='2' data-description=''></span> <span class='bookmark' data-num='3' data-description=''></span> </div> <div id='userscript-recent-positions'> <span class='position' data-num='1'></span> <span class='position' data-num='2'></span> </div> `); const positions = { state: { prev: 0, current: 0, }, elems: { container: $('#userscript-recent-positions'), get list() { return [...this.container.children] }, }, toggle() { [this.state.prev, this.state.current] = [this.state.current, this.state.prev]; console.log(`toggle(): this.state.prev, this.state.current = ${this.state.prev}, ${this.state.current}`); video.currentTime = this.state.current; }, reset() { positions.state.prev = 0; positions.state.current = 0; }, markers: { async set(num, time, type) { isLiveStream || await videoLoaded(); const elem = positions.elems.list[num-1]; switch (type) { case 'current': elem.style.background = 'lime'; break; case 'prev': elem.style.background = 'snow'; break; } const offset = (time || video.ytCurrentTime) * 100 / video.ytDuration; elem.style.left = `${offset}%`; } }, }; const bookmarks = { state: [], elems: { container: $('#userscript-bookmarks'), get list() { return [...this.container.children] } }, _resetState(num = null) { if (num) { this.state[num-1] = null; } else { this.state = [] } }, set(num, time, description = '') { this._markers._set(num, time + video.offset); this.descriptions.set(num, description); time = time || video.ytCurrentTime; this.state[num-1] = time; }, reset(num = null) { this._markers._remove(num); this._resetState(num); this.descriptions._reset(num); }, call(num) { video.currentTime = this.state[num-1]; }, _markers: { async _set(num, time) { isLiveStream || await videoLoaded(); const elem = bookmarks.elems.list[num-1]; const offset = (time || video.ytCurrentTime) * 100 / video.ytDuration; elem.style.left = `${offset}%`; }, _remove(num = null) { if (num) { bookmarks.elems.list[num-1].removeAttribute('style'); } else { bookmarks.elems.list.forEach( bookmark => { bookmark.removeAttribute('style'); }) } }, }, descriptions: { set(num, description = '') { bookmarks.elems.list[num-1].dataset.description = description; }, _reset(num = null) { if (num) { bookmarks.elems.list[num-1].dataset.description = ''; } else { bookmarks.elems.list.forEach( bookmark => { bookmark.dataset.description = ''; }) } }, }, sessionStorage: { save(num,time,description = '') { const videoID = new URLSearchParams(location.search).get('v'); const storedBookmarks = (sessionStorage[videoID] && JSON.parse(sessionStorage[videoID])) || []; time = time || video.ytCurrentTime; storedBookmarks[num-1] = {num, time, description}; sessionStorage[videoID] = JSON.stringify(storedBookmarks); }, restore() { const videoID = new URLSearchParams(location.search).get('v'); if (!sessionStorage[videoID]) return; const storedBookmarks = JSON.parse(sessionStorage[videoID]); storedBookmarks.forEach(bookmark => { bookmark && bookmarks.set(bookmark.num, bookmark.time, bookmark.description); }) }, remove(num = null) { const videoID = new URLSearchParams(location.search).get('v'); if (num) { const storedBookmarks = JSON.parse(sessionStorage[videoID]); if (storedBookmarks.length > 1) { storedBookmarks[num-1] = null; sessionStorage[videoID] = JSON.stringify(storedBookmarks); return; } } delete sessionStorage[videoID]; }, }, }; async function videoLoaded() { return new Promise( (res, rej) => { setTimeout(function loop() { if (video.elem.duration) { return res(); } setTimeout(loop, 100); },0) }) } //滑鼠事件ctrl=跳;shift=刪除;0=click document.addEventListener('mousedown', function(e) { if (e.target.classList.contains('bookmark')) { const bookmarkDataset = e.target.dataset; e.preventDefault(); e.stopPropagation(); if (e.button === 0 & e.ctrlKey) { bookmarks.call(bookmarkDataset.num); return; }else if (e.button === 0 & e.shiftKey) { bookmarks.reset(bookmarkDataset.num); bookmarks.sessionStorage.remove(bookmarkDataset.num); return; } } }, true) document.addEventListener('keydown', e => { if (e.target.isContentEditable || e.target.tagName === 'INPUT') return; //快速鍵 const hotkeys = { switchRecentPositions: 'F1', bookmark1: 'Digit1', bookmark2: 'Digit2', bookmark3: 'Digit3', resetBookmarks: 'Digit0', resetBookmarks2: 'Digit4', modifierCall: 'ctrlKey', modifierDelete: 'shiftKey', //modifierDescription: 'altKey', }; if (!Object.values(hotkeys).some( key => key === e.code)) return; e.preventDefault(); e.stopPropagation(); const processBookmark = (num) => { const modifierCall = e[hotkeys.modifierCall], modifierDelete = e[hotkeys.modifierDelete], modifierDescription = e[hotkeys.modifierDescription]; if (modifierCall) { bookmarks.call(num); } else if (modifierDelete) { bookmarks.reset(num); bookmarks.sessionStorage.remove(num); } else if (modifierDescription) { const description = prompt('Bookmark description', bookmarks.elems.list[num-1].dataset.description) || ''; bookmarks.descriptions.set(num, description); bookmarks.sessionStorage.save(num, bookmarks.state[num-1], description); } else { bookmarks.set(num); bookmarks.sessionStorage.save(num); } }; if (e.code === hotkeys.switchRecentPositions) { positions.toggle(); } else if (e.code === hotkeys.bookmark1) { processBookmark(1); } else if (e.code === hotkeys.bookmark2) { processBookmark(2); } else if (e.code === hotkeys.bookmark3) { processBookmark(3); } else if (e.code === hotkeys.resetBookmarks||hotkeys.resetBookmarks2) { bookmarks.reset(); bookmarks.sessionStorage.remove(); } }, true) //跳20秒以上,更新記憶點(不分滑鼠、快速鍵) video.elem.addEventListener('timeupdate', function() { //if (progressBar.mouseDown && Math.abs(video.ytCurrentTime - positions.state.current) > 20) { if (Math.abs(video.ytCurrentTime - positions.state.current) > 20) { positions.state.prev = positions.state.current; } positions.state.current = video.ytCurrentTime; positions.markers.set(1, positions.state.prev, 'prev'); positions.markers.set(2, positions.state.current, 'current'); progressBar.mouseDown = false; }) progressBar.elem.addEventListener('mousedown', function() { progressBar.mouseDown = true; }, true) document.addEventListener('yt-navigate-finish', e => { isLiveStream = !!$('.ytp-time-display.ytp-live'); hasChapters = !!$('.ytp-chapters-container')?.children?.length; positions.reset(); bookmarks.reset(); bookmarks.sessionStorage.restore(); }) })();