From 0af85f8c03a22e6cc1bff1bdc0754f62c4bb3bae Mon Sep 17 00:00:00 2001 From: Slowlife01 Date: Sun, 16 Mar 2025 21:04:08 +0700 Subject: [PATCH 1/4] New features for media control --- .../base/content/zen-media-player.inc.xhtml | 18 +- .../content/zen-styles/zen-media-controls.css | 26 ++ .../zen-components/ZenMediaController.mjs | 297 ++++++++++++++---- src/browser/themes/shared/zen-icons/icons.css | 8 + .../PictureInPicture-sys-mjs.patch | 12 +- 5 files changed, 286 insertions(+), 75 deletions(-) diff --git a/src/browser/base/content/zen-media-player.inc.xhtml b/src/browser/base/content/zen-media-player.inc.xhtml index e2bc804d5..20e42d254 100644 --- a/src/browser/base/content/zen-media-player.inc.xhtml +++ b/src/browser/base/content/zen-media-player.inc.xhtml @@ -6,10 +6,20 @@ - - + + + + + + + + { - if (this._currentBrowser) { - if (event.target.linkedBrowser.browserId === this._currentBrowser.browserId) { - gZenUIManager.motion - .animate( - this.mediaControlBar, - { - opacity: [1, 0], - y: [0, 10], - }, - { - duration: 0.1, - } - ) - .then(() => { - this.mediaControlBar.setAttribute('hidden', 'true'); - }); - } else if (this.mediaControlBar.hasAttribute('hidden')) { - this.mediaControlBar.removeAttribute('hidden'); - window.requestAnimationFrame(() => { - this.mediaControlBar.style.height = - this.mediaControlBar.querySelector('toolbaritem').getBoundingClientRect().height + 'px'; - this.mediaControlBar.style.opacity = 0; - gZenUIManager.motion.animate( - this.mediaControlBar, - { - opacity: [0, 1], - y: [10, 0], - }, - {} - ); - }); - } + const linkedBrowser = event.target.linkedBrowser; + this.switchController(); - gZenUIManager.updateTabsToolbar(); + if (this._currentBrowser) { + if (linkedBrowser.browserId === this._currentBrowser.browserId) { + if (this._tabTimeout) { + clearTimeout(this._tabTimeout); + this._tabTimeout = null; + } + + this.hideMediaControls(); + } else { + this._tabTimeout = setTimeout(() => { + if (!this.mediaControlBar.hasAttribute('pip')) this.showMediaControls(); + else this._tabTimeout = null; + }, 500); + } } }); window.addEventListener('TabClose', (event) => { - if (this._currentBrowser) { - if (event.target.linkedBrowser.browserId === this._currentBrowser.browserId) { - this.deinitMediaController(this._currentMediaController); - } - } + const linkedBrowser = event.target.linkedBrowser; + this.deinitMediaController( + linkedBrowser.browsingContext.mediaController, + true, + linkedBrowser.browserId === this._currentBrowser?.browserId, + true + ); }); window.addEventListener('DOMAudioPlaybackStarted', (event) => { @@ -87,24 +78,46 @@ class ZenMediaController { * Deinitializes a media controller, removing all event listeners and resetting state. * @param {Object} mediaController - The media controller to deinitialize. */ - deinitMediaController(mediaController) { + async deinitMediaController(mediaController, shouldForget = true, shouldOverride = true, shouldHide = true) { if (!mediaController) return; - mediaController.removeEventListener('positionstatechange', this.onPositionstateChange); - mediaController.removeEventListener('playbackstatechange', this.onPlaybackstateChange); - mediaController.removeEventListener('supportedkeyschange', this.onSupportedKeysChange); - mediaController.removeEventListener('metadatachange', this.onMetadataChange); - mediaController.removeEventListener('deactivated', this.onDeactivated); + const retrievedMediaController = this.mediaControllersMap.get(mediaController.id); - this._currentMediaController = null; - this._currentBrowser = null; - - if (this._mediaUpdateInterval) { - clearInterval(this._mediaUpdateInterval); - this._mediaUpdateInterval = null; + if (this.tabObserver && shouldOverride) { + this.tabObserver.disconnect(); + this.tabObserver = null; } - gZenUIManager.motion + if (shouldForget) { + mediaController.removeEventListener('positionstatechange', this.onPositionstateChange); + mediaController.removeEventListener('playbackstatechange', this.onPlaybackstateChange); + mediaController.removeEventListener('supportedkeyschange', this.onSupportedKeysChange); + mediaController.removeEventListener('metadatachange', this.onMetadataChange); + mediaController.removeEventListener('deactivated', this.onDeactivated); + + this.mediaControllersMap.delete(mediaController.id); + this.pipEligibilityMap.delete(retrievedMediaController.browser.browserId); + } + + if (shouldOverride) { + this._currentMediaController = null; + this._currentBrowser = null; + + if (this._mediaUpdateInterval) { + clearInterval(this._mediaUpdateInterval); + this._mediaUpdateInterval = null; + } + + if (shouldHide) await this.hideMediaControls(); + this.mediaControlBar.removeAttribute('muted'); + this.mediaControlBar.classList.remove('playing'); + } + } + + hideMediaControls() { + if (this.mediaControlBar.hasAttribute('hidden')) return; + + return gZenUIManager.motion .animate( this.mediaControlBar, { @@ -119,8 +132,67 @@ class ZenMediaController { this.mediaControlBar.setAttribute('hidden', 'true'); gZenUIManager.updateTabsToolbar(); }); - this.mediaControlBar.removeAttribute('muted'); - this.mediaControlBar.classList.remove('playing'); + } + + showMediaControls() { + const tab = gBrowser.getTabForBrowser(this._currentBrowser); + if (tab.hasAttribute('pictureinpicture')) return this.hideMediaControls(); + + if (!this.mediaControlBar.hasAttribute('hidden')) return; + this.updatePipButton(); + + this.mediaControlBar.removeAttribute('hidden'); + window.requestAnimationFrame(() => { + this.mediaControlBar.style.height = + this.mediaControlBar.querySelector('toolbaritem').getBoundingClientRect().height + 'px'; + this.mediaControlBar.style.opacity = 0; + gZenUIManager.updateTabsToolbar(); + gZenUIManager.motion.animate( + this.mediaControlBar, + { + opacity: [0, 1], + y: [10, 0], + }, + {} + ); + }); + } + + setupMediaController(mediaController, browser) { + this._currentMediaController = mediaController; + this._currentBrowser = browser; + + this.updatePipButton(); + + this.tabObserver = new MutationObserver((entries) => { + for (const entry of entries) { + if (entry.target.hasAttribute('pictureinpicture')) { + this.hideMediaControls(); + this.mediaControlBar.setAttribute('pip', ''); + } else { + const { selectedBrowser } = gBrowser; + if (selectedBrowser.browserId !== this._currentBrowser.browserId) { + this.showMediaControls(); + } + + this.mediaControlBar.removeAttribute('pip'); + } + } + }); + + this.tabObserver.observe(gBrowser.getTabForBrowser(browser), { + attributes: true, + attributeFilter: ['pictureinpicture'], + }); + + const positionState = mediaController.getPositionState(); + this.mediaControllersMap.set(mediaController.id, { + controller: mediaController, + browser, + position: positionState.position, + duration: positionState.duration, + lastUpdated: Date.now(), + }); } /** @@ -128,12 +200,15 @@ class ZenMediaController { * @param {Object} metadata - The media metadata (title, artist, etc.). * @param {Object} positionState - The position state (position, duration). */ - setupMediaControl(metadata, positionState) { - if (!this.mediaControlBar.classList.contains('playing')) { + setupMediaControlUI(metadata, positionState) { + this.updatePipButton(); + + if (!this.mediaControlBar.classList.contains('playing') && this._currentMediaController.isPlaying) { this.mediaControlBar.classList.add('playing'); } - this.mediaFocusButton.style.listStyleImage = `url(${this._currentBrowser.mIconURL})`; + const iconURL = this._currentBrowser.mIconURL || `page-icon:${this._currentBrowser.currentURI.spec}`; + this.mediaFocusButton.style.listStyleImage = `url(${iconURL})`; this.mediaTitle.textContent = metadata.title || ''; this.mediaArtist.textContent = metadata.artist || ''; @@ -158,10 +233,21 @@ class ZenMediaController { this.updateMuteState(); if (!mediaController.isActive || this._currentBrowser?.browserId === browser.browserId) return; + + const metadata = mediaController.getMetadata(); + const positionState = mediaController.getPositionState(); + this.mediaControllersMap.set(mediaController.id, { + controller: mediaController, + browser, + position: positionState.position, + duration: positionState.duration, + lastUpdated: Date.now(), + }); + + if (this._currentBrowser) this.switchController(); else { - this.deinitMediaController(this._currentMediaController); - this._currentMediaController = mediaController; - this._currentBrowser = browser; + this.setupMediaController(mediaController, browser); + this.setupMediaControlUI(metadata, positionState); } mediaController.addEventListener('positionstatechange', this.onPositionstateChange); @@ -169,28 +255,31 @@ class ZenMediaController { mediaController.addEventListener('supportedkeyschange', this.onSupportedKeysChange); mediaController.addEventListener('metadatachange', this.onMetadataChange); mediaController.addEventListener('deactivated', this.onDeactivated); + } - const metadata = mediaController.getMetadata(); - const positionState = mediaController.getPositionState(); - - this.setupMediaControl(metadata, positionState); + updatePipEligibility(browser, isEligible) { + this.pipEligibilityMap.set(browser.browserId, isEligible); } /** * @param {Event} event - The deactivation event. */ _onDeactivated(event) { - if (event.target === this._currentMediaController) { - this.deinitMediaController(event.target); - } + this.deinitMediaController(event.target, true, event.target.id === this._currentMediaController.id, true); + this.switchController(); } /** * Updates playback state and UI based on changes. * @param {Event} event - The playback state change event. */ - _onPlaybackstateChange(event) { - this.mediaControlBar.classList.toggle('playing', event.target.isPlaying); + _onPlaybackstateChange() { + if (this._currentMediaController?.isPlaying) { + this.mediaControlBar.classList.add('playing'); + } else { + this.switchController(); + this.mediaControlBar.classList.remove('playing'); + } } /** @@ -198,6 +287,7 @@ class ZenMediaController { * @param {Event} event - The supported keys change event. */ _onSupportedKeysChange(event) { + if (event.target !== this._currentMediaController) return; for (const key of this.supportedKeys) { const button = this.mediaControlBar.querySelector(`#zen-media-${key}-button`); button.disabled = !event.target.supportedKeys.includes(key); @@ -209,13 +299,61 @@ class ZenMediaController { * @param {Event} event - The position state change event. */ _onPositionstateChange(event) { - if (event.target !== this._currentMediaController) return; + if (event.target !== this._currentMediaController) { + const mediaController = this.mediaControllersMap.get(event.target.id); + this.mediaControllersMap.set(event.target.id, { + ...mediaController, + position: event.position, + duration: event.duration, + lastUpdated: Date.now(), + }); + } this._currentPosition = event.position; this._currentDuration = event.duration; this.updateMediaPosition(); } + switchController(force = false) { + let timeout = 3000; + + if (this._controllerSwitchTimeout) { + clearTimeout(this._controllerSwitchTimeout); + this._controllerSwitchTimeout = null; + } + + if (this.mediaControllersMap.size === 1) timeout = 0; + this._controllerSwitchTimeout = setTimeout(() => { + if (!this._currentMediaController?.isPlaying || force) { + const nextController = Array.from(this.mediaControllersMap.values()) + .filter( + (ctrl) => + ctrl.controller.isPlaying && + gBrowser.selectedBrowser.browserId !== ctrl.browser.browserId && + ctrl.controller.id !== this._currentMediaController?.id + ) + .sort((a, b) => b.lastUpdated - a.lastUpdated) + .shift(); + + if (nextController) { + this.deinitMediaController(this._currentMediaController, false, true, false).then(() => { + this.setupMediaController(nextController.controller, nextController.browser); + const elapsedTime = Math.floor((Date.now() - nextController.lastUpdated) / 1000); + + this.setupMediaControlUI(nextController.controller.getMetadata(), { + position: nextController.position + (nextController.controller.isPlaying ? elapsedTime : 0), + duration: nextController.duration, + }); + + this.showMediaControls(); + }); + } + } + + this._controllerSwitchTimeout = null; + }, timeout); + } + /** * Updates the media progress bar and time display. */ @@ -274,6 +412,9 @@ class ZenMediaController { * @param {Event} event - The metadata change event. */ _onMetadataChange(event) { + if (event.target !== this._currentMediaController) return; + this.updatePipButton(); + const metadata = event.target.getMetadata(); this.mediaTitle.textContent = metadata.title || ''; this.mediaArtist.textContent = metadata.artist || ''; @@ -327,6 +468,18 @@ class ZenMediaController { } } + onControllerClose() { + this._currentMediaController?.pause(); + this.switchController(true); + this.deinitMediaController(this._currentMediaController, true, true, true); + } + + onMediaPip() { + this._currentBrowser.browsingContext.currentWindowGlobal + .getActor('PictureInPictureLauncher') + .sendAsyncMessage('PictureInPicture:KeyToggle'); + } + updateMuteState() { if (!this._currentBrowser) return; if (this._currentBrowser._audioMuted) { @@ -335,6 +488,12 @@ class ZenMediaController { this.mediaControlBar.removeAttribute('muted'); } } + + updatePipButton() { + const isPipEligible = this.pipEligibilityMap.get(this._currentBrowser.browserId); + if (isPipEligible) this.mediaControlBar.setAttribute('can-pip', ''); + else this.mediaControlBar.removeAttribute('can-pip'); + } } window.gZenMediaController = new ZenMediaController(); diff --git a/src/browser/themes/shared/zen-icons/icons.css b/src/browser/themes/shared/zen-icons/icons.css index b6a47e67d..cf000af85 100644 --- a/src/browser/themes/shared/zen-icons/icons.css +++ b/src/browser/themes/shared/zen-icons/icons.css @@ -1182,3 +1182,11 @@ menupopup > menuitem:is([type='checkbox']) .menu-iconic-left { #zen-media-focus-button:hover { list-style-image: url('screen.svg') !important; } + +#zen-media-close-button { + list-style-image: url('close.svg') !important; +} + +#zen-media-pip-button { + list-style-image: url('chrome://global/skin/media/picture-in-picture-open.svg') !important; +} diff --git a/src/toolkit/components/pictureinpicture/PictureInPicture-sys-mjs.patch b/src/toolkit/components/pictureinpicture/PictureInPicture-sys-mjs.patch index a6be52467..feaef745e 100644 --- a/src/toolkit/components/pictureinpicture/PictureInPicture-sys-mjs.patch +++ b/src/toolkit/components/pictureinpicture/PictureInPicture-sys-mjs.patch @@ -1,5 +1,5 @@ diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs -index 5da0404b2672ba8cce7bcf808bf2373474776654..c3d58941b66c54f9d506698d015e294f8c8a5ceb 100644 +index 5da0404b2672ba8cce7bcf808bf2373474776654..44b62bd752294c2af96dd5b5d08c90ddf3dc513f 100644 --- a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs +++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs @@ -488,13 +488,13 @@ export var PictureInPicture = { @@ -19,7 +19,15 @@ index 5da0404b2672ba8cce7bcf808bf2373474776654..c3d58941b66c54f9d506698d015e294f await this.closeSinglePipWindow({ reason: "Unpip", actorRef: pipActor }); }, -@@ -877,7 +877,7 @@ export var PictureInPicture = { +@@ -623,6 +623,7 @@ export var PictureInPicture = { + pipToggle.hidden = true; + } + ++ win.gZenMediaController.updatePipEligibility(browser, !pipToggle.hidden); + let browserHasPip = !!this.browserWeakMap.get(browser); + if (browserHasPip) { + this.setUrlbarPipIconActive(browser.ownerGlobal); +@@ -877,7 +878,7 @@ export var PictureInPicture = { win.setIsMutedState(videoData.isMuted); // set attribute which shows pip icon in tab From 2530740066e60906b2194eb414243f55be33de5a Mon Sep 17 00:00:00 2001 From: Slowlife01 Date: Sun, 16 Mar 2025 21:13:01 +0700 Subject: [PATCH 2/4] remove unneeded params --- src/browser/base/zen-components/ZenMediaController.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/base/zen-components/ZenMediaController.mjs b/src/browser/base/zen-components/ZenMediaController.mjs index 276c087f0..0087dbcbc 100644 --- a/src/browser/base/zen-components/ZenMediaController.mjs +++ b/src/browser/base/zen-components/ZenMediaController.mjs @@ -471,7 +471,7 @@ class ZenMediaController { onControllerClose() { this._currentMediaController?.pause(); this.switchController(true); - this.deinitMediaController(this._currentMediaController, true, true, true); + this.deinitMediaController(this._currentMediaController); } onMediaPip() { From 36cf513431b6707abb097d9212f4c5f7c53fdd12 Mon Sep 17 00:00:00 2001 From: Slowlife01 Date: Sun, 16 Mar 2025 21:25:04 +0700 Subject: [PATCH 3/4] adjust timeout --- src/browser/base/zen-components/ZenMediaController.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/base/zen-components/ZenMediaController.mjs b/src/browser/base/zen-components/ZenMediaController.mjs index 0087dbcbc..e64e57342 100644 --- a/src/browser/base/zen-components/ZenMediaController.mjs +++ b/src/browser/base/zen-components/ZenMediaController.mjs @@ -323,6 +323,8 @@ class ZenMediaController { } if (this.mediaControllersMap.size === 1) timeout = 0; + if (this.mediaControlBar.hasAttribute('hidden')) timeout = 0; + this._controllerSwitchTimeout = setTimeout(() => { if (!this._currentMediaController?.isPlaying || force) { const nextController = Array.from(this.mediaControllersMap.values()) From d88e3ec982136c382cf3ff47a7b8057c199fb3f2 Mon Sep 17 00:00:00 2001 From: Slowlife01 Date: Sun, 16 Mar 2025 22:05:03 +0700 Subject: [PATCH 4/4] add a timeout just in case the control is still hiding.. --- .../zen-components/ZenMediaController.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/browser/base/zen-components/ZenMediaController.mjs b/src/browser/base/zen-components/ZenMediaController.mjs index e64e57342..d8db89036 100644 --- a/src/browser/base/zen-components/ZenMediaController.mjs +++ b/src/browser/base/zen-components/ZenMediaController.mjs @@ -68,6 +68,19 @@ class ZenMediaController { }); window.addEventListener('DOMAudioPlaybackStarted', (event) => { + setTimeout(() => { + if ( + this._currentMediaController?.isPlaying && + this.mediaControlBar.hasAttribute('hidden') && + !this.mediaControlBar.hasAttribute('pip') + ) { + const { selectedBrowser } = gBrowser; + if (selectedBrowser.browserId !== this._currentBrowser.browserId) { + this.showMediaControls(); + } + } + }, 1000); + this.activateMediaControls(event.target.browsingContext.mediaController, event.target); }); @@ -231,6 +244,7 @@ class ZenMediaController { */ activateMediaControls(mediaController, browser) { this.updateMuteState(); + this.switchController(); if (!mediaController.isActive || this._currentBrowser?.browserId === browser.browserId) return; @@ -244,8 +258,7 @@ class ZenMediaController { lastUpdated: Date.now(), }); - if (this._currentBrowser) this.switchController(); - else { + if (!this._currentBrowser) { this.setupMediaController(mediaController, browser); this.setupMediaControlUI(metadata, positionState); } @@ -323,8 +336,6 @@ class ZenMediaController { } if (this.mediaControllersMap.size === 1) timeout = 0; - if (this.mediaControlBar.hasAttribute('hidden')) timeout = 0; - this._controllerSwitchTimeout = setTimeout(() => { if (!this._currentMediaController?.isPlaying || force) { const nextController = Array.from(this.mediaControllersMap.values())