From b3bd688ca4ec3f7b2cb28efb71ebe15ee34db300 Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:55:53 +0200 Subject: [PATCH] gh-14843: Allow multiple media controllers (gh-14844) --- src/browser/themes/shared/zen-icons/icons.css | 30 +- src/zen/media/ZenMediaController.mjs | 1271 ++++++++--------- src/zen/media/zen-media-controls.css | 249 ++-- src/zen/media/zen-media-controls.inc.xhtml | 75 +- src/zen/tests/media/browser.toml | 2 + src/zen/tests/media/browser_media_metadata.js | 16 +- src/zen/tests/media/browser_media_mute.js | 26 +- .../tests/media/browser_media_next_track.js | 21 +- .../browser_media_shows_on_tab_switch.js | 24 +- src/zen/tests/media/browser_media_stack.js | 61 + src/zen/tests/media/head.js | 21 +- .../actors/ZenWindowDragChild.sys.mjs | 27 +- 12 files changed, 854 insertions(+), 969 deletions(-) create mode 100644 src/zen/tests/media/browser_media_stack.js diff --git a/src/browser/themes/shared/zen-icons/icons.css b/src/browser/themes/shared/zen-icons/icons.css index 33297f51e..a42d0021c 100644 --- a/src/browser/themes/shared/zen-icons/icons.css +++ b/src/browser/themes/shared/zen-icons/icons.css @@ -816,61 +816,57 @@ display: none !important; } -#zen-media-playpause-button { +.zen-media-playpause-button { list-style-image: url("media-play.svg"); } -#zen-media-controls-toolbar.playing #zen-media-playpause-button { +.zen-media-card.playing .zen-media-playpause-button { list-style-image: url("media-pause.svg"); } -#zen-media-nexttrack-button { +.zen-media-nexttrack-button { list-style-image: url("media-next.svg"); } -#zen-media-previoustrack-button { +.zen-media-previoustrack-button { list-style-image: url("media-previous.svg"); } -#zen-media-controls-toolbar[muted] #zen-media-mute-button { +.zen-media-card[muted] .zen-media-mute-button { list-style-image: url("media-mute.svg"); } -#zen-media-mute-button { +.zen-media-mute-button { list-style-image: url("media-unmute.svg"); } -#zen-media-close-button { +.zen-media-close-button { list-style-image: url("close.svg"); } -#zen-media-focus-button:hover { +.zen-media-focus-button:hover { list-style-image: url("screen.svg"); } -#zen-media-close-button { - list-style-image: url("close.svg"); -} - -#zen-media-mute-mic-button { +.zen-media-mute-mic-button { list-style-image: url("microphone-fill.svg"); } -#zen-media-controls-toolbar[mic-muted] #zen-media-mute-mic-button { +.zen-media-card[mic-muted] .zen-media-mute-mic-button { list-style-image: url("microphone-blocked-fill.svg"); fill: rgb(224, 41, 29); } -#zen-media-mute-camera-button { +.zen-media-mute-camera-button { list-style-image: url("video-fill.svg"); } -#zen-media-controls-toolbar[camera-muted] #zen-media-mute-camera-button { +.zen-media-card[camera-muted] .zen-media-mute-camera-button { list-style-image: url("video-blocked-fill.svg"); fill: rgb(224, 41, 29); } -#zen-media-pip-button { +.zen-media-pip-button { list-style-image: url("chrome://global/skin/media/picture-in-picture-open.svg"); } diff --git a/src/zen/media/ZenMediaController.mjs b/src/zen/media/ZenMediaController.mjs index bb91a97a9..95ea84e72 100644 --- a/src/zen/media/ZenMediaController.mjs +++ b/src/zen/media/ZenMediaController.mjs @@ -10,643 +10,255 @@ XPCOMUtils.defineLazyPreferenceGetter( true ); +// Maximum number of cards shown in the stack; extra cards stay hidden until +// a slot frees up. +const MAX_STACKED_CARDS = 5; +// How many background cards visually peek out at the top of the stack. +// Deeper cards hide perfectly behind the last peeking one, so the stack +// doesn't take more room with every extra card. +const MAX_PEEK_LEVELS = 2; + /** - * Zen Media Controller, handles the small media control bar UI and interactions - * located at the bottom of the sidebar. + * A single card in the sidebar media stack: the full control UI for one + * media controller, or for a WebRTC sharing session when `controller` is + * null. */ -class nsZenMediaController { - _currentMediaController = null; - _currentBrowser = null; - _mediaUpdateInterval = null; +class ZenMediaCard { + #updateInterval = null; + #tabTimeout = null; + #controllerListeners = null; - mediaTitle = null; - mediaArtist = null; - mediaControlBar = null; - mediaProgressBar = null; - mediaCurrentTime = null; - mediaDuration = null; - mediaFocusButton = null; - mediaProgressBarContainer = null; + static supportedKeys = ["playpause", "previoustrack", "nexttrack"]; - supportedKeys = ["playpause", "previoustrack", "nexttrack"]; - mediaControllersMap = new Map(); + constructor(manager, element, browser, controller = null) { + this.manager = manager; + this.element = element; + this.browser = browser; + this.controller = controller; - _tabTimeout = null; - _controllerSwitchTimeout = null; + this.titleEl = element.querySelector(".zen-media-title"); + this.artistEl = element.querySelector(".zen-media-artist"); + this.progressBar = element.querySelector(".zen-media-progress-bar"); + this.currentTimeEl = element.querySelector(".zen-media-current-time"); + this.durationEl = element.querySelector(".zen-media-duration"); + this.focusButton = element.querySelector(".zen-media-focus-button"); - #isSeeking = false; + this.#initListeners(); - init() { - if (!Services.prefs.getBoolPref("zen.mediacontrols.enabled", true)) { - return; + if (controller) { + this.#controllerListeners = { + positionstatechange: this.#onPositionstateChange.bind(this), + playbackstatechange: this.#onPlaybackstateChange.bind(this), + supportedkeyschange: this.#onSupportedKeysChange.bind(this), + metadatachange: this.#onMetadataChange.bind(this), + deactivated: this.#onDeactivated.bind(this), + pictureinpicturemodechange: this.#onPipModeChange.bind(this), + }; + for (const [event, listener] of Object.entries( + this.#controllerListeners + )) { + controller.addEventListener(event, listener); + } + + this.element.classList.toggle("playing", controller.isPlaying); + this.updateMetadata(); + this.updatePositionState(controller.getPositionState()); + this.updateSupportedKeys(); + } else { + this.element.setAttribute("media-sharing", ""); + this.element.setAttribute("media-position-hidden", "true"); + this.titleEl.textContent = + window.gBrowser.getTabForBrowser(browser)?.label || ""; + this.artistEl.textContent = ""; + this.updateIcon(); } - this.mediaTitle = document.querySelector("#zen-media-title"); - this.mediaArtist = document.querySelector("#zen-media-artist"); - this.mediaControlBar = document.querySelector( - "#zen-media-controls-toolbar" - ); - this.mediaProgressBar = document.querySelector("#zen-media-progress-bar"); - this.mediaCurrentTime = document.querySelector("#zen-media-current-time"); - this.mediaDuration = document.querySelector("#zen-media-duration"); - this.mediaFocusButton = document.querySelector("#zen-media-focus-button"); - this.mediaProgressBarContainer = document.querySelector( - "#zen-media-progress-hbox" - ); - - this.onPositionstateChange = this._onPositionstateChange.bind(this); - this.onPlaybackstateChange = this._onPlaybackstateChange.bind(this); - this.onSupportedKeysChange = this._onSupportedKeysChange.bind(this); - this.onMetadataChange = this._onMetadataChange.bind(this); - this.onDeactivated = this._onDeactivated.bind(this); - this.onPipModeChange = this._onPictureInPictureModeChange.bind(this); - - this.#initEventListeners(); + this.updatePipButton(); + this.updateMuteState(); } - #initEventListeners() { - this.mediaControlBar.addEventListener("mousedown", event => { - if (event.target.closest(":is(toolbarbutton,#zen-media-progress-hbox)")) { + get isSharing() { + return !this.controller; + } + + get shouldBeVisible() { + if (this.isSharing) { + return true; + } + if (this.controller.isBeingUsedInPIPModeOrFullscreen) { + return false; + } + return gBrowser.selectedBrowser.browserId !== this.browser.browserId; + } + + #initListeners() { + this.element.addEventListener("mousedown", event => { + if (event.target.closest(":is(toolbarbutton,.zen-media-progress-hbox)")) { return; } - this.onMediaFocus(); + this.onFocus(); }); - this.mediaControlBar.addEventListener("command", event => { + this.element.addEventListener("command", event => { const button = event.target.closest("toolbarbutton"); if (!button) { return; } - switch (button.id) { - case "zen-media-pip-button": - this.onMediaPip(); - break; - case "zen-media-close-button": - this.onControllerClose(); - break; - case "zen-media-focus-button": - this.onMediaFocus(); - break; - case "zen-media-mute-button": - this.onMediaMute(); - break; - case "zen-media-previoustrack-button": - this.onMediaPlayPrev(); - break; - case "zen-media-nexttrack-button": - this.onMediaPlayNext(); - break; - case "zen-media-playpause-button": - this.onMediaToggle(); - break; - case "zen-media-mute-mic-button": - this.onMicrophoneMuteToggle(); - break; - case "zen-media-mute-camera-button": - this.onCameraMuteToggle(); - break; + if (button.matches(".zen-media-pip-button")) { + this.onPip(); + } else if (button.matches(".zen-media-close-button")) { + this.onClose(); + } else if (button.matches(".zen-media-focus-button")) { + this.onFocus(); + } else if (button.matches(".zen-media-mute-button")) { + this.onMute(); + } else if (button.matches(".zen-media-previoustrack-button")) { + this.onPlayPrev(); + } else if (button.matches(".zen-media-nexttrack-button")) { + this.onPlayNext(); + } else if (button.matches(".zen-media-playpause-button")) { + this.onToggle(); + } else if (button.matches(".zen-media-mute-mic-button")) { + this.onMicrophoneMuteToggle(); + } else if (button.matches(".zen-media-mute-camera-button")) { + this.onCameraMuteToggle(); } }); - this.mediaProgressBar.addEventListener( - "input", - this.onMediaSeekDrag.bind(this) - ); - this.mediaProgressBar.addEventListener( - "change", - this.onMediaSeekComplete.bind(this) - ); - - window.addEventListener("TabSelect", event => { - if (this.isSharing) { - return; - } - - const linkedBrowser = event.target.linkedBrowser; - this.switchController(); - - 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); - } - } - }); - - const onTabDiscardedOrClosed = this.onTabDiscardedOrClosed.bind(this); - - window.addEventListener("TabClose", onTabDiscardedOrClosed); - window.addEventListener("TabBrowserDiscarded", onTabDiscardedOrClosed); - - 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 - ); - }); - - window.addEventListener("DOMAudioPlaybackStopped", () => - this.updateMuteState() - ); + this.progressBar.addEventListener("input", this.onSeekDrag.bind(this)); + this.progressBar.addEventListener("change", this.onSeekComplete.bind(this)); } - onTabDiscardedOrClosed(event) { - const { linkedBrowser } = event.target; - const isCurrentBrowser = - linkedBrowser?.browserId === this._currentBrowser?.browserId; - - if (isCurrentBrowser) { - this.isSharing = false; - this.hideMediaControls(); - } - - if (linkedBrowser?.browsingContext?.mediaController) { - this.deinitMediaController( - linkedBrowser.browsingContext.mediaController, - true, - isCurrentBrowser, - true - ); - } + #onPositionstateChange(event) { + this.updatePositionState(event); } - async deinitMediaController( - mediaController, - shouldForget = true, - shouldOverride = true, - shouldHide = true - ) { - if (shouldForget && mediaController) { - mediaController.removeEventListener( - "pictureinpicturemodechange", - this.onPipModeChange - ); - 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); - } - - 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"); - } + #onPlaybackstateChange() { + this.element.classList.toggle("playing", this.controller.isPlaying); + this.updatePosition(); } - get isSharing() { - return this.mediaControlBar.hasAttribute("media-sharing"); + #onSupportedKeysChange() { + this.updateSupportedKeys(); } - set isSharing(value) { - if (this._currentBrowser?.browsingContext && !value) { - const webRTC = - this._currentBrowser.browsingContext.currentWindowGlobal.getActor( - "WebRTC" - ); - webRTC.sendAsyncMessage("webrtc:UnmuteMicrophone"); - webRTC.sendAsyncMessage("webrtc:UnmuteCamera"); - } - - if (!value) { - this.mediaControlBar.removeAttribute("mic-muted"); - this.mediaControlBar.removeAttribute("camera-muted"); - } else { - this.mediaControlBar.setAttribute("media-position-hidden", ""); - this.mediaControlBar.setAttribute("media-sharing", ""); - } - } - - hideMediaControls() { - if (this.mediaControlBar.hasAttribute("hidden")) { - return; - } - - gZenUIManager.motion - .animate( - this.mediaControlBar, - { - opacity: [1, 0], - y: [0, 10], - }, - { - duration: 0.1, - } - ) - .then(() => { - this.mediaControlBar.setAttribute("hidden", "true"); - this.mediaControlBar.removeAttribute("media-sharing"); - gZenUIManager.updateTabsToolbar(); - }); - } - - showMediaControls() { - if (!this.mediaControlBar.hasAttribute("hidden")) { - return; - } - - if (!this.isSharing) { - if (!this._currentMediaController) { - return; - } - if (this._currentMediaController.isBeingUsedInPIPModeOrFullscreen) { - this.hideMediaControls(); - return; - } - - this.updatePipButton(); - } - - const mediaInfoElements = [this.mediaTitle, this.mediaArtist]; - for (const element of mediaInfoElements) { - element.removeAttribute("overflow"); // So we can properly recalculate the overflow - } - - 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], - }, - {} - ); - this.addLabelOverflows(mediaInfoElements); - }); - } - - addLabelOverflows(elements) { - for (const element of elements) { - // eslint-disable-next-line no-shadow - const parent = element.parentElement; - if (element.scrollWidth > parent.clientWidth) { - element.setAttribute("overflow", ""); - } else { - element.removeAttribute("overflow"); - } - } - } - - setupMediaController(mediaController, browser) { - this._currentMediaController = mediaController; - this._currentBrowser = browser; - + #onMetadataChange() { + this.updateMetadata(); this.updatePipButton(); } - setupMediaControlUI(metadata, positionState) { - this.updatePipButton(); + #onDeactivated() { + this.destroy(); + } - if ( - !this.mediaControlBar.classList.contains("playing") && - this._currentMediaController.isPlaying - ) { - this.mediaControlBar.classList.add("playing"); + #onPipModeChange() { + this.element.toggleAttribute( + "pip", + this.controller.isBeingUsedInPIPModeOrFullscreen + ); + this.refreshVisibility(); + } + + refreshVisibility() { + this.setHidden(!this.shouldBeVisible); + } + + setHidden(hidden) { + if (this.element.hidden === hidden) { + return; + } + this.element.hidden = hidden; + this.manager.onCardVisibilityChanged(); + } + + // Mirrors the original bar behavior: hide instantly when the card's own + // tab is selected, reappear on a small delay when switching away. + onTabSelect() { + if (this.#tabTimeout) { + clearTimeout(this.#tabTimeout); + this.#tabTimeout = null; } + if (!this.shouldBeVisible) { + this.setHidden(true); + } else if (this.element.hidden) { + this.#tabTimeout = setTimeout(() => { + this.#tabTimeout = null; + this.refreshVisibility(); + }, 500); + } + } + + updateMetadata() { + const metadata = this.controller.getMetadata(); + this.titleEl.textContent = metadata.title || ""; + this.artistEl.textContent = metadata.artist || ""; + this.updateIcon(); + this.updateLabelOverflows(); + } + + updateIcon() { 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 || ""; - - gZenUIManager.updateTabsToolbar(); - - this._currentPosition = positionState.position; - this._currentDuration = positionState.duration; - this._currentPlaybackRate = positionState.playbackRate; - - this.updateMediaPosition(); - - for (const key of this.supportedKeys) { - const button = this.mediaControlBar.querySelector( - `#zen-media-${key}-button` - ); - button.disabled = - !this._currentMediaController.supportedKeys.includes(key); - } + this.browser.mIconURL || `page-icon:${this.browser.currentURI.spec}`; + this.focusButton.style.listStyleImage = `url(${iconURL})`; } - activateMediaControls(mediaController, browser) { - this.updateMuteState(); - this.switchController(); - - 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, - playbackRate: positionState.playbackRate, - lastUpdated: Date.now(), - }); - - if (!this._currentBrowser && !this.isSharing) { - this.setupMediaController(mediaController, browser); - this.setupMediaControlUI(metadata, positionState); - } - - mediaController.addEventListener( - "pictureinpicturemodechange", - this.onPipModeChange - ); - mediaController.addEventListener( - "positionstatechange", - this.onPositionstateChange - ); - mediaController.addEventListener( - "playbackstatechange", - this.onPlaybackstateChange - ); - mediaController.addEventListener( - "supportedkeyschange", - this.onSupportedKeysChange - ); - mediaController.addEventListener("metadatachange", this.onMetadataChange); - mediaController.addEventListener("deactivated", this.onDeactivated); - } - - activateMediaDeviceControls(browser) { - if ( - browser?.browsingContext.currentWindowGlobal.hasActivePeerConnections() - ) { - this.mediaControlBar.removeAttribute("can-pip"); - this._currentBrowser = browser; - - const tab = window.gBrowser.getTabForBrowser(browser); - const iconURL = - browser.mIconURL || `page-icon:${browser.currentURI.spec}`; - - this.isSharing = true; - - this.mediaFocusButton.style.listStyleImage = `url(${iconURL})`; - this.mediaTitle.textContent = tab.label; - this.mediaArtist.textContent = ""; - - this.showMediaControls(); - } - } - - updateMediaSharing(data) { - const { windowId, showCameraIndicator, showMicrophoneIndicator } = data; - - for (const browser of window.gBrowser.browsers) { - const isMatch = browser.innerWindowID === windowId; - const isCurrentBrowser = - this._currentBrowser?.browserId === browser.browserId; - const shouldShow = showCameraIndicator || showMicrophoneIndicator; - - if (!isMatch) { - continue; + updateLabelOverflows() { + for (const label of [this.titleEl, this.artistEl]) { + label.removeAttribute("overflow"); // So we can properly recalculate the overflow + if (label.scrollWidth > label.parentElement.clientWidth) { + label.setAttribute("overflow", ""); } - if (shouldShow && !(isCurrentBrowser && this.isSharing)) { - const webRTC = - browser.browsingContext.currentWindowGlobal.getActor("WebRTC"); - webRTC.sendAsyncMessage("webrtc:UnmuteMicrophone"); - webRTC.sendAsyncMessage("webrtc:UnmuteCamera"); + } + } - if (this._currentBrowser) { - this.isSharing = false; + updateSupportedKeys() { + for (const key of ZenMediaCard.supportedKeys) { + const button = this.element.querySelector(`.zen-media-${key}-button`); + button.disabled = !this.controller.supportedKeys.includes(key); + } + } + + updatePositionState(positionState) { + this.position = positionState.position; + this.duration = positionState.duration; + this.playbackRate = positionState.playbackRate; + this.updatePosition(); + } + + updatePosition() { + if (this.#updateInterval) { + clearInterval(this.#updateInterval); + this.#updateInterval = null; + } + + if (this.duration >= 900_000) { + this.element.setAttribute("media-position-hidden", "true"); + return; + } + this.element.removeAttribute("media-position-hidden"); + + if (!this.duration) { + return; + } + + this.currentTimeEl.textContent = this.formatSecondsToTime(this.position); + this.durationEl.textContent = this.formatSecondsToTime(this.duration); + this.progressBar.value = (this.position / this.duration) * 100; + + this.#updateInterval = setInterval(() => { + if (this.controller?.isPlaying) { + this.position += 1 * this.playbackRate; + if (this.position > this.duration) { + this.position = this.duration; } - if (this._currentMediaController) { - this._currentMediaController.pause(); - this.deinitMediaController( - this._currentMediaController, - true, - true - ).then(() => this.activateMediaDeviceControls(browser)); - } else { - this.activateMediaDeviceControls(browser); - } - } else if (!shouldShow && isCurrentBrowser && this.isSharing) { - this.isSharing = false; - this._currentBrowser = null; - this.hideMediaControls(); - } - - break; - } - } - - _onDeactivated(event) { - this.deinitMediaController( - event.target, - true, - event.target.id === this._currentMediaController.id, - true - ); - this.switchController(); - } - - _onPlaybackstateChange() { - if (this._currentMediaController?.isPlaying) { - this.mediaControlBar.classList.add("playing"); - } else { - this.switchController(); - this.mediaControlBar.classList.remove("playing"); - } - } - - _onSupportedKeysChange(event) { - if (event.target.id !== this._currentMediaController?.id) { - return; - } - for (const key of this.supportedKeys) { - const button = this.mediaControlBar.querySelector( - `#zen-media-${key}-button` - ); - button.disabled = !event.target.supportedKeys.includes(key); - } - } - - _onPositionstateChange(event) { - const mediaController = this.mediaControllersMap.get(event.target.id); - this.mediaControllersMap.set(event.target.id, { - ...mediaController, - position: event.position, - duration: event.duration, - playbackRate: event.playbackRate, - lastUpdated: Date.now(), - }); - - if (event.target.id !== this._currentMediaController?.id) { - return; - } - - this._currentPosition = event.position; - this._currentDuration = event.duration; - this._currentPlaybackRate = event.playbackRate; - - this.updateMediaPosition(); - } - - switchController(force = false) { - let timeout = 3000; - - if (this.isSharing) { - return; - } - if (this.#isSeeking) { - return; - } - - 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 - ).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, - playbackRate: nextController.playbackRate, - }); - - this.showMediaControls(); - }); - } - } - - this._controllerSwitchTimeout = null; - }, timeout); - } - - updateMediaPosition() { - if (this._mediaUpdateInterval) { - clearInterval(this._mediaUpdateInterval); - this._mediaUpdateInterval = null; - } - - if (this._currentDuration >= 900_000) { - this.mediaControlBar.setAttribute("media-position-hidden", "true"); - return; - } - this.mediaControlBar.removeAttribute("media-position-hidden"); - - if (!this._currentDuration) { - return; - } - - this.mediaCurrentTime.textContent = this.formatSecondsToTime( - this._currentPosition - ); - this.mediaDuration.textContent = this.formatSecondsToTime( - this._currentDuration - ); - this.mediaProgressBar.value = - (this._currentPosition / this._currentDuration) * 100; - - this._mediaUpdateInterval = setInterval(() => { - if (this._currentMediaController?.isPlaying) { - this._currentPosition += 1 * this._currentPlaybackRate; - if (this._currentPosition > this._currentDuration) { - this._currentPosition = this._currentDuration; - } - this.mediaCurrentTime.textContent = this.formatSecondsToTime( - this._currentPosition + this.currentTimeEl.textContent = this.formatSecondsToTime( + this.position ); - this.mediaProgressBar.value = - (this._currentPosition / this._currentDuration) * 100; + this.progressBar.value = (this.position / this.duration) * 100; } else { - clearInterval(this._mediaUpdateInterval); - this._mediaUpdateInterval = null; + clearInterval(this.#updateInterval); + this.#updateInterval = null; } }, 1000); } @@ -668,171 +280,404 @@ class nsZenMediaController { return `${minutes}:${secs.padStart(2, "0")}`; } - _onMetadataChange(event) { - if (event.target.id !== this._currentMediaController?.id) { - return; - } - this.updatePipButton(); - - const metadata = event.target.getMetadata(); - this.mediaTitle.textContent = metadata.title || ""; - this.mediaArtist.textContent = metadata.artist || ""; - - const mediaInfoElements = [this.mediaTitle, this.mediaArtist]; - for (const element of mediaInfoElements) { - element.removeAttribute("overflow"); - } - - this.addLabelOverflows(mediaInfoElements); + updateMuteState() { + this.element.toggleAttribute("muted", this.browser.audioMuted); } - _onPictureInPictureModeChange(event) { - if (event.target.id !== this._currentMediaController?.id) { + updatePipButton() { + if (this.isSharing) { + this.element.removeAttribute("can-pip"); return; } - if (event.target.isBeingUsedInPIPModeOrFullscreen) { - this.hideMediaControls(); - this.mediaControlBar.setAttribute("pip", ""); + + const { totalPipCount, totalPipDisabled } = + PictureInPicture.getEligiblePipVideoCount(this.browser); + const canPip = + totalPipCount === 1 || + (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED); + + this.element.toggleAttribute("can-pip", canPip); + } + + onFocus() { + if (this.controller) { + this.controller.focus(); } else { - const { selectedBrowser } = gBrowser; - if (selectedBrowser.browserId !== this._currentBrowser.browserId) { - this.showMediaControls(); - } - - this.mediaControlBar.removeAttribute("pip"); - } - } - - onMediaPlayPrev() { - if (this._currentMediaController?.supportedKeys.includes("previoustrack")) { - this._currentMediaController.prevTrack(); - } - } - - onMediaPlayNext() { - if (this._currentMediaController?.supportedKeys.includes("nexttrack")) { - this._currentMediaController.nextTrack(); - } - } - - onMediaSeekDrag(event) { - this.#isSeeking = true; - - this._currentMediaController?.pause(); - const newTime = (event.target.value / 100) * this._currentDuration; - this.mediaCurrentTime.textContent = this.formatSecondsToTime(newTime); - } - - onMediaSeekComplete(event) { - const newPosition = (event.target.value / 100) * this._currentDuration; - if (this._currentMediaController?.supportedKeys.includes("seekto")) { - this._currentMediaController.seekTo(newPosition); - this._currentMediaController.play(); - } - - this.#isSeeking = false; - } - - onMediaFocus() { - if (!this._currentBrowser) { - return; - } - - if (this._currentMediaController) { - this._currentMediaController.focus(); - } else if (this._currentBrowser) { - const tab = window.gBrowser.getTabForBrowser(this._currentBrowser); + const tab = window.gBrowser.getTabForBrowser(this.browser); if (tab) { window.gZenWorkspaces.switchTabIfNeeded(tab); } } } - onMediaMute() { - const tab = window.gBrowser.getTabForBrowser(this._currentBrowser); + onMute() { + const tab = window.gBrowser.getTabForBrowser(this.browser); if (tab) { tab.toggleMuteAudio(); this.updateMuteState(); } } - onMediaToggle() { - if (this.mediaControlBar.classList.contains("playing")) { - this._currentMediaController?.pause(); + onToggle() { + if (this.controller?.isPlaying) { + this.controller.pause(); } else { - this._currentMediaController?.play(); + this.controller?.play(); } } - onControllerClose() { - if (this._currentMediaController) { - this._currentMediaController.pause(); - this.deinitMediaController(this._currentMediaController); - } else if (this.isSharing) { - this.isSharing = false; + onPlayPrev() { + if (this.controller?.supportedKeys.includes("previoustrack")) { + this.controller.prevTrack(); } - - this.hideMediaControls(); - this.switchController(true); } - onMediaPip() { - this._currentBrowser.browsingContext.currentWindowGlobal + onPlayNext() { + if (this.controller?.supportedKeys.includes("nexttrack")) { + this.controller.nextTrack(); + } + } + + onSeekDrag(event) { + this.controller?.pause(); + const newTime = (event.target.value / 100) * this.duration; + this.currentTimeEl.textContent = this.formatSecondsToTime(newTime); + } + + onSeekComplete(event) { + const newPosition = (event.target.value / 100) * this.duration; + if (this.controller?.supportedKeys.includes("seekto")) { + this.controller.seekTo(newPosition); + this.controller.play(); + } + } + + onPip() { + this.browser.browsingContext.currentWindowGlobal .getActor("PictureInPictureLauncher") .sendAsyncMessage("PictureInPicture:KeyToggle"); } onMicrophoneMuteToggle() { - if (this._currentBrowser) { - const shouldMute = this.mediaControlBar.hasAttribute("mic-muted") - ? "webrtc:UnmuteMicrophone" - : "webrtc:MuteMicrophone"; + const shouldMute = this.element.hasAttribute("mic-muted") + ? "webrtc:UnmuteMicrophone" + : "webrtc:MuteMicrophone"; - this._currentBrowser.browsingContext.currentWindowGlobal - .getActor("WebRTC") - .sendAsyncMessage(shouldMute); - this.mediaControlBar.toggleAttribute("mic-muted"); - } + this.browser.browsingContext.currentWindowGlobal + .getActor("WebRTC") + .sendAsyncMessage(shouldMute); + this.element.toggleAttribute("mic-muted"); } onCameraMuteToggle() { - if (this._currentBrowser) { - const shouldMute = this.mediaControlBar.hasAttribute("camera-muted") - ? "webrtc:UnmuteCamera" - : "webrtc:MuteCamera"; + const shouldMute = this.element.hasAttribute("camera-muted") + ? "webrtc:UnmuteCamera" + : "webrtc:MuteCamera"; - this._currentBrowser.browsingContext.currentWindowGlobal - .getActor("WebRTC") - .sendAsyncMessage(shouldMute); - this.mediaControlBar.toggleAttribute("camera-muted"); - } + this.browser.browsingContext.currentWindowGlobal + .getActor("WebRTC") + .sendAsyncMessage(shouldMute); + this.element.toggleAttribute("camera-muted"); } - updateMuteState() { - if (!this._currentBrowser) { + onClose() { + if (this.controller) { + this.controller.pause(); + } else { + const webRTC = + this.browser.browsingContext.currentWindowGlobal.getActor("WebRTC"); + webRTC.sendAsyncMessage("webrtc:UnmuteMicrophone"); + webRTC.sendAsyncMessage("webrtc:UnmuteCamera"); + } + + this.destroy(); + } + + destroy() { + if (this.controller && this.#controllerListeners) { + for (const [event, listener] of Object.entries( + this.#controllerListeners + )) { + this.controller.removeEventListener(event, listener); + } + this.#controllerListeners = null; + } + + if (this.#updateInterval) { + clearInterval(this.#updateInterval); + this.#updateInterval = null; + } + if (this.#tabTimeout) { + clearTimeout(this.#tabTimeout); + this.#tabTimeout = null; + } + + this.element.remove(); + this.manager.onCardDestroyed(this); + } +} + +/** + * Zen Media Controller, handles the stack of media control cards located at + * the bottom of the sidebar. Each active media controller (and WebRTC + * sharing session) gets its own card; cards stack behind each other and + * expand upwards when hovering the container. + */ +class nsZenMediaController { + #cards = new Map(); + #cardTemplate = null; + + mediaControlBar = null; + + init() { + if (!Services.prefs.getBoolPref("zen.mediacontrols.enabled", true)) { return; } - this.mediaControlBar.toggleAttribute( - "muted", - this._currentBrowser.audioMuted + + this.mediaControlBar = document.querySelector( + "#zen-media-controls-toolbar" + ); + this.#cardTemplate = document.querySelector("#zen-media-card-template"); + + window.addEventListener("TabSelect", () => { + for (const card of this.#cards.values()) { + card.onTabSelect(); + } + }); + + const onTabDiscardedOrClosed = this.onTabDiscardedOrClosed.bind(this); + + window.addEventListener("TabClose", onTabDiscardedOrClosed); + window.addEventListener("TabBrowserDiscarded", onTabDiscardedOrClosed); + + window.addEventListener("DOMAudioPlaybackStarted", event => { + this.activateMediaControls( + event.target.browsingContext.mediaController, + event.target + ); + }); + + window.addEventListener("DOMAudioPlaybackStopped", () => { + for (const card of this.#cards.values()) { + card.updateMuteState(); + } + }); + } + + get frontCard() { + return this.#orderedCards.find(card => !card.element.hidden) ?? null; + } + + get #orderedCards() { + // Newest media sits at the front of the stack. + return Array.from(this.#cards.values()).reverse(); + } + + get #hasVisibleCards() { + return Array.from(this.#cards.values()).some(card => !card.element.hidden); + } + + get #sharingCard() { + return ( + Array.from(this.#cards.values()).find(card => card.isSharing) ?? null ); } - updatePipButton() { - if (!this._currentBrowser) { - return; - } - if (this.isSharing) { + activateMediaControls(mediaController, browser) { + if (!mediaController?.isActive || this.#cards.has(mediaController.id)) { return; } - const { totalPipCount, totalPipDisabled } = - PictureInPicture.getEligiblePipVideoCount(this._currentBrowser); - const canPip = - totalPipCount === 1 || - (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED); + this.#addCard(mediaController.id, browser, mediaController); + } - this.mediaControlBar.toggleAttribute("can-pip", canPip); + activateMediaDeviceControls(browser) { + if ( + !browser?.browsingContext.currentWindowGlobal.hasActivePeerConnections() + ) { + return; + } + + this.#sharingCard?.destroy(); + this.#addCard(`sharing-${browser.browserId}`, browser); + } + + updateMediaSharing(data) { + if (!this.mediaControlBar) { + return; + } + + const { windowId, showCameraIndicator, showMicrophoneIndicator } = data; + const shouldShow = showCameraIndicator || showMicrophoneIndicator; + + for (const browser of window.gBrowser.browsers) { + if (browser.innerWindowID !== windowId) { + continue; + } + + const sharingCard = this.#sharingCard; + const isSharingBrowser = + sharingCard?.browser.browserId === browser.browserId; + + if (shouldShow && !isSharingBrowser) { + const webRTC = + browser.browsingContext.currentWindowGlobal.getActor("WebRTC"); + webRTC.sendAsyncMessage("webrtc:UnmuteMicrophone"); + webRTC.sendAsyncMessage("webrtc:UnmuteCamera"); + + this.activateMediaDeviceControls(browser); + } else if (!shouldShow && isSharingBrowser) { + sharingCard.destroy(); + } + + break; + } + } + + onTabDiscardedOrClosed(event) { + const { linkedBrowser } = event.target; + if (!linkedBrowser) { + return; + } + + for (const card of Array.from(this.#cards.values())) { + if (card.browser.browserId === linkedBrowser.browserId) { + card.destroy(); + } + } + } + + closeAllCards() { + for (const card of Array.from(this.#cards.values())) { + card.destroy(); + } + } + + onCardVisibilityChanged() { + this.#updateStack(); + this.#refreshToolbarVisibility(); + } + + onCardDestroyed(card) { + for (const [key, existing] of this.#cards) { + if (existing === card) { + this.#cards.delete(key); + break; + } + } + this.onCardVisibilityChanged(); + } + + #addCard(key, browser, controller = null) { + const element = this.#createCardElement(); + const card = new ZenMediaCard(this, element, browser, controller); + this.#cards.set(key, card); + card.refreshVisibility(); + this.onCardVisibilityChanged(); + return card; + } + + #createCardElement() { + const fragment = this.#cardTemplate.content.cloneNode(true); + const element = fragment.firstElementChild; + this.mediaControlBar.appendChild(fragment); + return element; + } + + #updateStack() { + const visibleCards = this.#orderedCards.filter( + card => !card.element.hidden + ); + + visibleCards.forEach((card, index) => { + card.element.toggleAttribute( + "stack-overflow", + index >= MAX_STACKED_CARDS + ); + card.element.toggleAttribute("stacked-behind", index > 0); + card.element.style.setProperty("--zen-media-card-index", index); + card.element.style.setProperty( + "--zen-media-card-peek-level", + Math.min(index, MAX_PEEK_LEVELS) + ); + card.element.style.zIndex = 100 - index; + }); + + const stackedCount = Math.min(visibleCards.length, MAX_STACKED_CARDS); + this.mediaControlBar.style.setProperty( + "--zen-media-stack-behind", + Math.max(Math.min(stackedCount - 1, MAX_PEEK_LEVELS), 0) + ); + + gZenUIManager.updateTabsToolbar(); + } + + #refreshToolbarVisibility() { + if (this.#hasVisibleCards) { + this.showMediaControls(); + } else { + this.hideMediaControls(); + } + } + + hideMediaControls() { + if (this.mediaControlBar.hasAttribute("hidden")) { + return; + } + + gZenUIManager.motion + .animate( + this.mediaControlBar, + { + opacity: [1, 0], + y: [0, 10], + }, + { + duration: 0.1, + } + ) + .then(() => { + if (this.#hasVisibleCards) { + // A card re-appeared while the hide animation was running. + return; + } + this.mediaControlBar.setAttribute("hidden", "true"); + gZenUIManager.updateTabsToolbar(); + }); + } + + showMediaControls() { + if (!this.mediaControlBar.hasAttribute("hidden")) { + return; + } + + this.mediaControlBar.removeAttribute("hidden"); + window.requestAnimationFrame(() => { + const front = this.frontCard; + if (!front) { + this.mediaControlBar.setAttribute("hidden", "true"); + return; + } + + this.mediaControlBar.style.setProperty( + "--zen-media-collapsed-height", + front.element.getBoundingClientRect().height + "px" + ); + this.mediaControlBar.style.opacity = 0; + gZenUIManager.updateTabsToolbar(); + gZenUIManager.motion.animate( + this.mediaControlBar, + { + opacity: [0, 1], + y: [10, 0], + }, + {} + ); + + for (const card of this.#cards.values()) { + card.updateLabelOverflows(); + } + }); } } diff --git a/src/zen/media/zen-media-controls.css b/src/zen/media/zen-media-controls.css index 2c5adbf94..910f0f506 100644 --- a/src/zen/media/zen-media-controls.css +++ b/src/zen/media/zen-media-controls.css @@ -7,6 +7,8 @@ #zen-media-controls-toolbar { --progress-height: 4px; --button-spacing: 2px; + --zen-media-stack-peek: 8px; + --zen-media-stack-gap: 6px; display: flex; min-width: 0; @@ -18,50 +20,23 @@ border-radius: 5px; } - #zen-media-buttons-hbox { + .zen-media-buttons-hbox { align-items: start; margin-top: -4px; --toolbarbutton-padding-outer: 2px; } - &:not([media-sharing]) { - #media-device-buttons { - display: none; - } - } - - #media-device-buttons { + .media-device-buttons { gap: 2px; } - &[media-sharing] :is(#zen-media-playback-buttons, #zen-media-mute-button) { - display: none; - } - - &:not([can-pip]) { - #zen-media-info-vbox { - width: calc(100% - 26px); - flex-shrink: 0; - } - - #zen-media-pip-button { - display: none; - } - } - - #zen-media-prev-button, - #zen-media-play-pause-button, - #zen-media-next-button { - margin: 0; - } - image.toolbarbutton-icon { padding: 5px; width: 26px; height: 26px; } - #zen-media-progress-bar { + .zen-media-progress-bar { appearance: none; width: 100%; height: var(--progress-height); @@ -109,56 +84,29 @@ pointer-events: auto; } - & #zen-media-info-vbox label[overflow] { + & .zen-media-info-vbox label[overflow] { animation: zen-back-and-forth-text 10s infinite ease-in-out; } - } - & #zen-media-focus-button::after { - content: ""; - position: absolute; - background: url("chrome://browser/content/zen-images/note-indicator.svg") no-repeat; - top: -70%; - left: 0; - right: 0; - bottom: 0; - z-index: 0; - pointer-events: none; - transition: opacity 0.8s ease; - opacity: 1; - will-change: opacity; - } - - &:is(:not(.playing:not([muted])), :hover) #zen-media-focus-button::after { - opacity: 0; - } - - #zen-media-focus-button { - align-self: center; - transition: - opacity 0.2s ease, - transform 0.2s ease; - position: relative; - - & image { - &:-moz-broken { - content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3C/svg%3E") !important; - background: color-mix(in srgb, var(--zen-primary-color) 70%, transparent 30%); - } + & .zen-media-card { + transform: translateY( + calc(var(--zen-media-card-index, 0) * (-100% - var(--zen-media-stack-gap))) + ); + opacity: 1; } - } - & > toolbaritem { - flex-grow: 1; - transition: padding 0.3s ease-out; - position: absolute; - left: 0; - bottom: 0; - padding: 4px 6px; - border-radius: var(--border-radius-medium); - box-shadow: var(--zen-sidebar-notification-shadow); - background-color: var(--zen-sidebar-notification-bg); - width: 100%; + & .zen-media-focus-button::after { + display: none; + } + + & .zen-media-card::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: calc(var(--zen-media-stack-gap) + var(--zen-media-stack-peek)); + } } .show-on-hover { @@ -174,8 +122,8 @@ padding 0.2s ease; } - #zen-media-current-time, - #zen-media-duration { + .zen-media-current-time, + .zen-media-duration { margin: 0 0 0 1px; font-size: x-small; opacity: 0.7; @@ -184,6 +132,101 @@ } } +.zen-media-card { + flex-grow: 1; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + padding: 4px 6px; + border-radius: var(--border-radius-medium); + box-shadow: var(--zen-sidebar-notification-shadow); + background-color: var(--zen-sidebar-notification-bg); + transform-origin: top center; + /* Collapsed stack: cards behind the front card peek out at the top, + fading the deeper they sit */ + transform: translateY(calc(-1 * var(--zen-media-stack-peek) * var(--zen-media-card-peek-level, 0))) + scale(calc(1 - 0.04 * var(--zen-media-card-peek-level, 0))); + opacity: calc(1 - 0.2 * var(--zen-media-card-peek-level, 0)); + transition: + transform 0.1s ease-out, + opacity 0.2s ease-out, + padding 0.3s ease-out; + + &[stack-overflow] { + display: none; + } + + &:not([media-sharing]) .media-device-buttons { + display: none; + } + + &[media-sharing] :is(.zen-media-playback-buttons, .zen-media-mute-button) { + display: none; + } + + &:not([can-pip]) { + .zen-media-info-vbox { + width: calc(100% - 26px); + flex-shrink: 0; + } + + .zen-media-pip-button { + display: none; + } + } + + &[can-pip] { + .zen-media-info-vbox { + flex-shrink: 1; + } + + .zen-media-pip-button { + display: flex; + } + } + + & .zen-media-focus-button::after { + content: ""; + position: absolute; + background: url("chrome://browser/content/zen-images/note-indicator.svg") no-repeat; + top: -70%; + left: 0; + right: 0; + bottom: 0; + z-index: 0; + pointer-events: none; + transition: opacity 0.8s ease; + opacity: 1; + will-change: opacity; + } + + &:not(.playing:not([muted])) .zen-media-focus-button::after { + opacity: 0; + } + + /* Background cards never show the floating notes — only their top sliver + is visible and the notes would poke through it */ + &[stacked-behind] .zen-media-focus-button::after { + display: none; + } + + .zen-media-focus-button { + align-self: center; + transition: + opacity 0.2s ease, + transform 0.2s ease; + position: relative; + + & image { + &:-moz-broken { + content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3C/svg%3E") !important; + background: color-mix(in srgb, var(--zen-primary-color) 70%, transparent 30%); + } + } + } +} + #zen-media-controls-toolbar { display: none; animation: none; @@ -191,19 +234,25 @@ &:not([hidden]) { display: flex; - height: 2.5rem; + /* The collapsed height includes the room for the peeking stacked + cards, so the stack never shifts the surrounding UI */ + height: calc( + var(--zen-media-collapsed-height, 2.5rem) + var(--zen-media-stack-peek) * + var(--zen-media-stack-behind, 0) + ); overflow: visible; position: relative; z-index: 2; + transition: height 0.2s ease-out; } } -#zen-media-title, -#zen-media-artist { +.zen-media-title, +.zen-media-artist { align-self: start; } -#zen-media-artist { +.zen-media-artist { opacity: 0.7; font-size: smaller; @@ -212,19 +261,19 @@ } } -#zen-media-title { +.zen-media-title { height: var(--size-item-small); font-size: math; } -#zen-media-main-vbox, -#zen-media-info-vbox, -#zen-media-progress-hbox { +.zen-media-main-vbox, +.zen-media-info-vbox, +.zen-media-progress-hbox { width: 100%; } -#zen-media-info-vbox { - #zen-media-controls-toolbar:not([media-position-hidden]) & { +.zen-media-info-vbox { + .zen-media-card:not([media-position-hidden]) & { transition-delay: 0.01s !important; } overflow-x: hidden; @@ -254,23 +303,23 @@ } } -#zen-media-main-vbox { +.zen-media-main-vbox { height: 100%; justify-content: space-between; } -#zen-media-progress-hbox { +.zen-media-progress-hbox { flex-grow: 1; height: var(--size-item-large); align-items: center; padding-top: 0 !important; - #zen-media-controls-toolbar[media-position-hidden] & { + .zen-media-card[media-position-hidden] & { display: none; } } -#zen-media-controls-hbox { +.zen-media-controls-hbox { align-items: flex-end; justify-content: space-between; max-width: 100%; @@ -279,33 +328,23 @@ gap: 0.7rem; } -#zen-media-playback-buttons { +.zen-media-playback-buttons { justify-content: space-between; max-width: 10em; width: 100%; } -/* Hide #zen-media-focus-button if it doesn't fit in the toolbar */ +/* Hide .zen-media-focus-button if it doesn't fit in the toolbar */ @container media-controls (max-width: 165px) { - #zen-media-focus-button { + .zen-media-focus-button { display: none; } } -#zen-media-info-container { +.zen-media-info-container { padding-right: 0 !important; } -#zen-media-controls-toolbar[can-pip] { - #zen-media-info-vbox { - flex-shrink: 1; - } - - #zen-media-pip-button { - display: flex; - } -} - :root:not([zen-sidebar-expanded="true"]) { #zen-media-controls-toolbar { display: none; diff --git a/src/zen/media/zen-media-controls.inc.xhtml b/src/zen/media/zen-media-controls.inc.xhtml index 43d5cf0d7..0fa0236da 100644 --- a/src/zen/media/zen-media-controls.inc.xhtml +++ b/src/zen/media/zen-media-controls.inc.xhtml @@ -8,48 +8,41 @@ mode="icons" fullscreentoolbar="true" hidden="true"> - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/zen/tests/media/browser.toml b/src/zen/tests/media/browser.toml index 52752f239..5c7b1860b 100644 --- a/src/zen/tests/media/browser.toml +++ b/src/zen/tests/media/browser.toml @@ -14,3 +14,5 @@ support-files = [ ["browser_media_next_track.js"] ["browser_media_shows_on_tab_switch.js"] + +["browser_media_stack.js"] diff --git a/src/zen/tests/media/browser_media_metadata.js b/src/zen/tests/media/browser_media_metadata.js index a048b8cac..281ec151b 100644 --- a/src/zen/tests/media/browser_media_metadata.js +++ b/src/zen/tests/media/browser_media_metadata.js @@ -3,17 +3,6 @@ "use strict"; -// User flow: -// 1. A page (think Spotify, YouTube Music) plays media and publishes -// title/artist via navigator.mediaSession.metadata. -// 2. User switches off that tab, media bar appears. -// 3. The title and artist labels in the bar show what the page published. -// 4. The page then updates the metadata mid-playback (next song starts). -// 5. The bar updates live, without the user having to switch tabs again. -// -// This is what makes the bar feel connected to the playing page instead of -// a generic "something is playing" indicator. - add_task(async function test_media_bar_shows_metadata_from_page() { const originalTab = gBrowser.selectedTab; const mediaTab = await addMediaTab(); @@ -28,8 +17,9 @@ add_task(async function test_media_bar_shows_metadata_from_page() { await BrowserTestUtils.switchTab(gBrowser, originalTab); await waitForMediaBarVisible(); - const titleEl = document.getElementById("zen-media-title"); - const artistEl = document.getElementById("zen-media-artist"); + const card = frontMediaCard().element; + const titleEl = card.querySelector(".zen-media-title"); + const artistEl = card.querySelector(".zen-media-artist"); await BrowserTestUtils.waitForCondition( () => titleEl.textContent === "Sandstorm", diff --git a/src/zen/tests/media/browser_media_mute.js b/src/zen/tests/media/browser_media_mute.js index 766059fd0..68e0dda02 100644 --- a/src/zen/tests/media/browser_media_mute.js +++ b/src/zen/tests/media/browser_media_mute.js @@ -3,18 +3,6 @@ "use strict"; -// User flow: -// 1. User plays a video, switches tabs, media bar appears. -// 2. User clicks the mute button on the Zen media bar. -// 3. The underlying tab actually goes silent (browser.audioMuted flips). -// 4. The media bar reflects that with the `muted` attribute so the icon -// changes. -// 5. Clicking again unmutes. -// -// If this breaks, the user sees a mute button that looks toggled but the -// audio keeps playing — or worse, the tab is muted but the button still -// says "unmuted". - add_task(async function test_mute_from_media_bar() { const originalTab = gBrowser.selectedTab; const mediaTab = await addMediaTab(); @@ -29,9 +17,10 @@ add_task(async function test_mute_from_media_bar() { !mediaTab.linkedBrowser.audioMuted, "precondition: playing tab starts unmuted" ); + const card = frontMediaCard().element; ok( - !mediaBar().hasAttribute("muted"), - "precondition: media bar has no muted attribute" + !card.hasAttribute("muted"), + "precondition: media card has no muted attribute" ); clickMediaButton("zen-media-mute-button"); @@ -40,8 +29,8 @@ add_task(async function test_mute_from_media_bar() { "tab becomes muted after clicking the media bar mute button" ); ok( - mediaBar().hasAttribute("muted"), - "media bar reflects the muted state in its attribute" + card.hasAttribute("muted"), + "media card reflects the muted state in its attribute" ); clickMediaButton("zen-media-mute-button"); @@ -49,10 +38,7 @@ add_task(async function test_mute_from_media_bar() { () => !mediaTab.linkedBrowser.audioMuted, "clicking again unmutes the tab" ); - ok( - !mediaBar().hasAttribute("muted"), - "media bar drops the muted attribute" - ); + ok(!card.hasAttribute("muted"), "media card drops the muted attribute"); } finally { if (mediaTab.linkedBrowser.audioMuted) { mediaTab.toggleMuteAudio(); diff --git a/src/zen/tests/media/browser_media_next_track.js b/src/zen/tests/media/browser_media_next_track.js index f4797e7e0..dd6804d15 100644 --- a/src/zen/tests/media/browser_media_next_track.js +++ b/src/zen/tests/media/browser_media_next_track.js @@ -3,19 +3,6 @@ "use strict"; -// User flow: -// 1. A music page registers a "nexttrack" action handler (like most -// streaming sites do). -// 2. User is on another tab, media bar is showing with the next-track -// button enabled. -// 3. User clicks next-track. -// 4. The action fires inside the page — the page is responsible for -// loading the next song. Zen's job here is to relay the click. -// -// Also guards the button-enablement logic: if the page does NOT register a -// handler, the next-track button must be disabled. Otherwise clicks go -// nowhere and users think the bar is broken. - add_task(async function test_next_track_relays_to_page() { const originalTab = gBrowser.selectedTab; const mediaTab = await addMediaTab(); @@ -28,7 +15,9 @@ add_task(async function test_next_track_relays_to_page() { await BrowserTestUtils.switchTab(gBrowser, originalTab); await waitForMediaBarVisible(); - const nextButton = document.getElementById("zen-media-nexttrack-button"); + const nextButton = frontMediaCard().element.querySelector( + ".zen-media-nexttrack-button" + ); // supportedkeyschange propagates asynchronously; wait for the bar's // next-track button to become enabled before clicking. @@ -60,7 +49,9 @@ add_task(async function test_next_track_button_disabled_without_handler() { await BrowserTestUtils.switchTab(gBrowser, originalTab); await waitForMediaBarVisible(); - const nextButton = document.getElementById("zen-media-nexttrack-button"); + const nextButton = frontMediaCard().element.querySelector( + ".zen-media-nexttrack-button" + ); Assert.equal( nextButton.disabled, true, diff --git a/src/zen/tests/media/browser_media_shows_on_tab_switch.js b/src/zen/tests/media/browser_media_shows_on_tab_switch.js index 783a88af1..d6f5a92ee 100644 --- a/src/zen/tests/media/browser_media_shows_on_tab_switch.js +++ b/src/zen/tests/media/browser_media_shows_on_tab_switch.js @@ -3,26 +3,8 @@ "use strict"; -// User flow: -// 1. User opens a page with audio and hits play. -// 2. User switches to a different tab. -// 3. The Zen media control bar should appear (so the user can still -// pause/skip without going back to the noisy tab). -// 4. User switches back to the audio tab. -// 5. The media bar should hide again — it's redundant next to the real -// page controls. -// -// This covers the real contract users see: the DOMAudioPlaybackStarted → -// TabSelect → showMediaControls chain in nsZenMediaController, plus the -// inverse path on selecting the playing tab. A regression anywhere in that -// chain (event wiring, the 500ms tab-switch debounce, the hidden attribute -// flip) surfaces as a bar that either never shows or never hides. - -// note: We keep setting timeouts because media player takes a bit to -// get removed (after the animation, more specifically) - add_task(async function test_media_bar_shows_when_switching_off_playing_tab() { - gZenMediaController.onControllerClose(); + gZenMediaController.closeAllCards(); await BrowserTestUtils.waitForCondition( () => !isMediaBarVisible(), "media bar hides again once the playing tab regains focus" @@ -55,9 +37,9 @@ add_task(async function test_media_bar_shows_when_switching_off_playing_tab() { ); Assert.equal( - gZenMediaController._currentBrowser?.browserId, + frontMediaCard()?.browser.browserId, mediaTab.linkedBrowser.browserId, - "media controller is bound to the media tab's browser, not the selected tab" + "media card is bound to the media tab's browser, not the selected tab" ); await BrowserTestUtils.switchTab(gBrowser, mediaTab); diff --git a/src/zen/tests/media/browser_media_stack.js b/src/zen/tests/media/browser_media_stack.js new file mode 100644 index 000000000..07d3b0cf0 --- /dev/null +++ b/src/zen/tests/media/browser_media_stack.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_media_stack_shows_multiple_cards() { + const originalTab = gBrowser.selectedTab; + const tabA = await addMediaTab(); + const tabB = await addMediaTab(); + + try { + await BrowserTestUtils.switchTab(gBrowser, tabA); + await setMediaSessionMetadata(tabA, { title: "Song A", artist: "A" }); + await playVideoIn(tabA); + + await BrowserTestUtils.switchTab(gBrowser, tabB); + await setMediaSessionMetadata(tabB, { title: "Song B", artist: "B" }); + await playVideoIn(tabB); + + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await waitForMediaBarVisible(); + + // Cards reappear on the 500ms tab-switch debounce; wait for both. + await BrowserTestUtils.waitForCondition( + () => visibleMediaCards().length === 2, + "both playing tabs get their own card in the stack" + ); + + Assert.equal( + frontMediaCard().browser.browserId, + tabB.linkedBrowser.browserId, + "front card belongs to the most recently started media" + ); + + await BrowserTestUtils.waitForCondition(() => { + const titles = visibleMediaCards().map( + card => card.querySelector(".zen-media-title").textContent + ); + return titles.includes("Song A") && titles.includes("Song B"); + }, "each card shows its own session's metadata"); + + // Closing the front card only removes that session. + clickMediaButton("zen-media-close-button"); + await BrowserTestUtils.waitForCondition( + () => visibleMediaCards().length === 1, + "closing the front card only removes that card" + ); + ok(isMediaBarVisible(), "media bar stays visible with the remaining card"); + Assert.equal( + frontMediaCard().browser.browserId, + tabA.linkedBrowser.browserId, + "remaining card takes the front slot" + ); + } finally { + await pauseVideoIn(tabA); + await pauseVideoIn(tabB); + BrowserTestUtils.removeTab(tabA); + BrowserTestUtils.removeTab(tabB); + gBrowser.selectedTab = originalTab; + } +}); diff --git a/src/zen/tests/media/head.js b/src/zen/tests/media/head.js index 82de52c6c..de7674a1c 100644 --- a/src/zen/tests/media/head.js +++ b/src/zen/tests/media/head.js @@ -63,12 +63,21 @@ async function waitForMediaBarVisible() { ); } -// Click a toolbarbutton on the media bar. We dispatch a "command" event -// directly because that's what the controller listens for and it sidesteps -// the flakiness of synthesizing a mouse click on a small toolbar button. -function clickMediaButton(id) { - const button = document.getElementById(id); - ok(button, `media bar button ${id} exists`); +function frontMediaCard() { + return gZenMediaController.frontCard; +} + +function visibleMediaCards() { + return [...mediaBar().querySelectorAll(".zen-media-card")].filter( + card => !card.hidden + ); +} + +function clickMediaButton(className) { + const card = frontMediaCard(); + ok(card, "front media card exists"); + const button = card.element.querySelector(`.${className}`); + ok(button, `media bar button ${className} exists`); button.dispatchEvent(new Event("command", { bubbles: true })); } diff --git a/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs index 672f11ea4..d20c47454 100644 --- a/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs +++ b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs @@ -24,7 +24,7 @@ XPCOMUtils.defineLazyServiceGetter( // clicks commonly slide a few pixels (especially on trackpads), and once // the native move starts the OS swallows the mouseup — so keep this // comfortably above click jitter or clicks in the region get lost. -const DRAG_START_THRESHOLD_PX = 10; +const DRAG_START_THRESHOLD_PX = 4; // Content that drives its own mouse interaction without being // interactive HTML content in the spec sense. @@ -256,8 +256,13 @@ export class ZenWindowDragChild extends JSWindowActorChild { if (event.originalTarget?.isNativeAnonymous) { return true; } - let target = event.composedTarget; + let target = event.explicitOriginalTarget; if (target?.nodeType === Node.TEXT_NODE) { + // The hit-test landed on rendered text; a drag here should select + // it instead, unless it isn't selectable. + if (this.#isSelectableText(target)) { + return true; + } target = target.parentElement; } if (!target || target.nodeType !== Node.ELEMENT_NODE) { @@ -271,24 +276,10 @@ export class ZenWindowDragChild extends JSWindowActorChild { return true; } } - return ( - this.#hasInteractiveCursor(target) || this.#isOverSelectableText(event) - ); + return this.#hasInteractiveCursor(target); } - /** - * A drag starting over selectable text should select it, not move the - * window. rangeParent is the caret position Gecko computed for the - * event, so this also covers empty space on the same line, where - * dragging extends a selection. - * - * @param {MouseEvent} event - */ - #isOverSelectableText(event) { - const node = event.rangeParent; - if (node?.nodeType !== Node.TEXT_NODE) { - return false; - } + #isSelectableText(node) { const parent = node.parentElement; return ( !parent ||