Greasy Fork is available in English.

YT時間軸書籤bookmarks

自動記憶[臨時時間戳]+自定義3個書籤

// ==UserScript==
// @name         YT時間軸書籤bookmarks
// @namespace    https://greasyfork.org/zh-TW/users/4839-leadra
// @version      1.1.1
// @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

(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 = '';
                    })
                }
            },
        },

        localStorage: {
            save(num,time,description = '') {
                const videoID = new URLSearchParams(location.search).get('v');
                const storedBookmarks = (localStorage[videoID] && JSON.parse(localStorage[videoID])) || [];
                time = time || video.ytCurrentTime;
                storedBookmarks[num-1] = {num, time, description};
                localStorage[videoID] = JSON.stringify(storedBookmarks);
            },

            restore() {
                const videoID = new URLSearchParams(location.search).get('v');
                if (!localStorage[videoID]) return;
                const storedBookmarks = JSON.parse(localStorage[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(localStorage[videoID]);
                    if (storedBookmarks.length > 1) {
                        storedBookmarks[num-1] = null;
                        localStorage[videoID] = JSON.stringify(storedBookmarks);
                        return;
                    }
                }
                delete localStorage[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.localStorage.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.localStorage.remove(num);
            } else if (modifierDescription) {
                const description = prompt('Bookmark description', bookmarks.elems.list[num-1].dataset.description) || '';
                bookmarks.descriptions.set(num, description);
                bookmarks.localStorage.save(num, bookmarks.state[num-1], description);
            } else {
                bookmarks.set(num);
                bookmarks.localStorage.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.localStorage.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.localStorage.restore();
    })

})();