gh-14843: Allow multiple media controllers (gh-14844)

This commit is contained in:
mr. m
2026-08-04 19:55:53 +02:00
committed by GitHub
parent 36c71c5c2b
commit b3bd688ca4
12 changed files with 854 additions and 969 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -8,48 +8,41 @@
mode="icons"
fullscreentoolbar="true"
hidden="true">
<toolbaritem>
<vbox id="zen-media-main-vbox">
<vbox>
<hbox id="zen-media-info-container" class="show-on-hover">
<vbox id="zen-media-info-vbox">
<label id="zen-media-title" fadein="true"/>
<label id="zen-media-artist" fadein="true"/>
</vbox>
<hbox id="zen-media-buttons-hbox">
<toolbarbutton id="zen-media-pip-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-close-button"
class="toolbarbutton-1" />
<html:template id="zen-media-card-template">
<toolbaritem class="zen-media-card">
<vbox class="zen-media-main-vbox">
<vbox>
<hbox class="zen-media-info-container show-on-hover">
<vbox class="zen-media-info-vbox">
<label class="zen-media-title" fadein="true"/>
<label class="zen-media-artist" fadein="true"/>
</vbox>
<hbox class="zen-media-buttons-hbox">
<toolbarbutton class="zen-media-pip-button toolbarbutton-1" />
<toolbarbutton class="zen-media-close-button toolbarbutton-1" />
</hbox>
</hbox>
<hbox class="zen-media-progress-hbox show-on-hover">
<label class="zen-media-current-time">0:00</label>
<html:input type="range" class="zen-media-progress-bar"
value="0" min="0" max="100" step="0.1"/>
<label class="zen-media-duration">0:00</label>
</hbox>
</vbox>
<hbox class="zen-media-controls-hbox">
<toolbarbutton class="zen-media-focus-button toolbarbutton-1" />
<hbox class="zen-media-playback-buttons">
<toolbarbutton class="zen-media-previoustrack-button toolbarbutton-1" />
<toolbarbutton class="zen-media-playpause-button toolbarbutton-1" />
<toolbarbutton class="zen-media-nexttrack-button toolbarbutton-1" />
</hbox>
<toolbarbutton class="zen-media-mute-button toolbarbutton-1" />
<hbox class="media-device-buttons">
<toolbarbutton class="zen-media-mute-mic-button toolbarbutton-1" />
<toolbarbutton class="zen-media-mute-camera-button toolbarbutton-1" />
</hbox>
</hbox>
<hbox id="zen-media-progress-hbox" class="show-on-hover">
<label id="zen-media-current-time">0:00</label>
<html:input type="range" id="zen-media-progress-bar"
value="0" min="0" max="100" step="0.1"/>
<label id="zen-media-duration">0:00</label>
</hbox>
</vbox>
<hbox id="zen-media-controls-hbox">
<toolbarbutton id="zen-media-focus-button"
class="toolbarbutton-1" />
<hbox id="zen-media-playback-buttons">
<toolbarbutton id="zen-media-previoustrack-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-playpause-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-nexttrack-button"
class="toolbarbutton-1" />
</hbox>
<toolbarbutton id="zen-media-mute-button"
class="toolbarbutton-1" />
<hbox id="media-device-buttons">
<toolbarbutton id="zen-media-mute-mic-button"
class="toolbarbutton-1" />
<toolbarbutton id="zen-media-mute-camera-button"
class="toolbarbutton-1" />
</hbox>
</hbox>
</vbox>
</toolbaritem>
</toolbaritem>
</html:template>
</toolbar>

View File

@@ -14,3 +14,5 @@ support-files = [
["browser_media_next_track.js"]
["browser_media_shows_on_tab_switch.js"]
["browser_media_stack.js"]

View File

@@ -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",

View File

@@ -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();

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}
});

View File

@@ -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 }));
}

View File

@@ -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 ||