fix(desktop): release video elements so macOS media keys go back to Music

**Problem:** Playing a video in a Buzz chat made Buzz the macOS "Now
Playing" app, and nothing ever gave that status back. WKWebView registers
the session on the first audible `<video>` and keeps it until the element
itself is torn down — unmounting the row only detaches the element, which
holds the claim until it happens to be garbage collected. From then on the
hardware play/pause key (and AirPods taps) reached Buzz instead of Music,
sometimes audibly resuming a video the user had scrolled past.

**Solution:** `useReleasingVideoRef` is a ref callback that pauses, detaches
the source, and clears the Now Playing metadata when React drops a
`<video>`. It is wired into the inline player, the review overlay, and the
composer attachment preview. Detaching the source is what makes WebKit
destroy the media session immediately instead of at GC time; holding the
keys while a video is on screen stays unchanged, since that is how resume
is supposed to work.

Teardown cannot live in an effect cleanup: React nulls host refs during the
mutation phase, so on unmount the existing cleanup only ever saw a null ref
and the element escaped untouched — the inline position save moves into the
ref cleanup for the same reason. StrictMode re-attaches a ref without
re-applying props, so the release remembers the source it detached and
restores it, or every video would render empty in development.

Players now also publish `MediaMetadata` (filename/title plus channel)
while playing so Control Center shows the video instead of a bare entry,
and clear it on release. Clearing is scoped to the element that claimed the
session, so an off-screen timeline row unmounting cannot wipe the metadata
of the review overlay that is actually playing.

The playback speed control moves to `VideoPlaybackSpeedControl.tsx`
unchanged, keeping `VideoPlayer.tsx` under the file-size ratchet.

## Validation

- `pnpm check`, `pnpm typecheck`, `pnpm test` (4958 desktop unit tests,
  including new `mediaSession` coverage that fails without the release and
  without the StrictMode restore)
- `pnpm test:e2e:smoke` — 1050 passed, 5 failed. Two (`huddle-transcription`
  agent voices, `message-feedback-snapshots` profile hover) reproduce with
  these changes stashed; the other three pass on re-run with the changes
  applied.
- Not covered: MediaRemote routing itself is invisible to both Playwright
  and jsdom, so the Control Center hand-back still needs a manual pass on
  macOS (play Music → play a chat video → switch channels → confirm the key
  controls Music again), including the review overlay and AirPods, plus the
  note's open question about notification sounds.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-17 15:54:52 +10:00
parent f956e6fe06
commit 3d94c0eb6b
5 changed files with 461 additions and 98 deletions
@@ -31,6 +31,7 @@ import {
AttachmentMedia,
AttachmentTitle,
} from "@/shared/ui/attachment";
import { useReleasingVideoRef } from "@/shared/ui/mediaSession";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import { Progress } from "@/shared/ui/progress";
import { Toggle } from "@/shared/ui/toggle";
@@ -263,6 +264,10 @@ const MediaAttachmentItem = React.forwardRef<
) {
const [open, setOpen] = React.useState(false);
const [mode, setMode] = React.useState<"view" | "edit">("view");
// Closing the lightbox unmounts the preview; without an explicit release it
// would keep the macOS Now Playing session (and the hardware play/pause key)
// until the detached element is collected.
const attachPreviewVideo = useReleasingVideoRef();
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
@@ -412,6 +417,7 @@ const MediaAttachmentItem = React.forwardRef<
) : isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
ref={attachPreviewVideo}
src={rewriteRelayUrl(attachment.url)}
controls
className={cn(
@@ -0,0 +1,93 @@
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
export const DEFAULT_PLAYBACK_SPEED = 1;
export const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
export function formatPlaybackSpeed(speed: number): string {
return `${speed}x`;
}
export function isPlaybackSpeedOption(speed: number): boolean {
return PLAYBACK_SPEEDS.some((option) => option === speed);
}
/** Speed menu shared by the inline player and the review overlay. */
export function PlaybackSpeedControl({
playbackSpeed,
onPlaybackSpeedChange,
size = "inline",
testId,
}: {
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
size?: "inline" | "review";
testId: string;
}) {
const [open, setOpen] = React.useState(false);
const label = formatPlaybackSpeed(playbackSpeed);
const triggerSizeClass =
size === "review"
? "h-8 min-w-11 rounded-lg px-2 text-xs"
: "h-7 min-w-10 rounded-md px-1.5 text-2xs";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
aria-label={`Playback speed: ${label}`}
className={cn(
"flex shrink-0 items-center justify-center font-semibold tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
triggerSizeClass,
open && "bg-white/15",
)}
data-testid={testId}
type="button"
onClick={(event) => event.stopPropagation()}
>
{label}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-28 border-white/10 bg-black/85 p-1 text-white backdrop-blur-xl"
data-testid={`${testId}-menu`}
side="top"
sideOffset={8}
onClick={(event) => event.stopPropagation()}
>
<div className="px-2 pb-1 pt-1 text-2xs font-medium text-white/55">
Speed
</div>
<div className="grid gap-0.5">
{PLAYBACK_SPEEDS.map((speed) => {
const speedLabel = formatPlaybackSpeed(speed);
const selected = speed === playbackSpeed;
return (
<button
aria-pressed={selected}
className={cn(
"flex h-8 w-full items-center justify-between rounded-lg px-2 text-left text-xs font-medium tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
selected && "bg-white/15",
)}
key={speed}
type="button"
onClick={(event) => {
event.stopPropagation();
onPlaybackSpeedChange(speed);
setOpen(false);
}}
>
<span>{speedLabel}</span>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
+70 -98
View File
@@ -21,11 +21,21 @@ import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
claimMediaSession,
markMediaSessionPaused,
type NowPlayingMetadata,
useReleasingVideoRef,
} from "./mediaSession";
import { Spinner } from "./spinner";
import {
DEFAULT_PLAYBACK_SPEED,
isPlaybackSpeedOption,
PlaybackSpeedControl,
} from "./VideoPlaybackSpeedControl";
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
import { useVideoContextMenu } from "./useVideoContextMenu";
import { useRegisterVideoReview } from "./VideoReviewNavigation";
@@ -117,9 +127,7 @@ type TimecodedComment = {
};
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
const DEFAULT_PLAYBACK_SPEED = 1;
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
/**
* Frosted-glass backing layer for floating media controls. The parent must
@@ -181,14 +189,6 @@ function formatCommentTimecode(seconds: number): string {
});
}
function formatPlaybackSpeed(speed: number): string {
return `${speed}x`;
}
function isPlaybackSpeedOption(speed: number): boolean {
return PLAYBACK_SPEEDS.some((option) => option === speed);
}
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
const parsed = parseVideoReviewTimecode(comment.body);
return parsed
@@ -600,82 +600,6 @@ function VolumeControl({
);
}
function PlaybackSpeedControl({
playbackSpeed,
onPlaybackSpeedChange,
size = "inline",
testId,
}: {
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
size?: "inline" | "review";
testId: string;
}) {
const [open, setOpen] = React.useState(false);
const label = formatPlaybackSpeed(playbackSpeed);
const triggerSizeClass =
size === "review"
? "h-8 min-w-11 rounded-lg px-2 text-xs"
: "h-7 min-w-10 rounded-md px-1.5 text-2xs";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
aria-label={`Playback speed: ${label}`}
className={cn(
"flex shrink-0 items-center justify-center font-semibold tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
triggerSizeClass,
open && "bg-white/15",
)}
data-testid={testId}
type="button"
onClick={(event) => event.stopPropagation()}
>
{label}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-28 border-white/10 bg-black/85 p-1 text-white backdrop-blur-xl"
data-testid={`${testId}-menu`}
side="top"
sideOffset={8}
onClick={(event) => event.stopPropagation()}
>
<div className="px-2 pb-1 pt-1 text-2xs font-medium text-white/55">
Speed
</div>
<div className="grid gap-0.5">
{PLAYBACK_SPEEDS.map((speed) => {
const speedLabel = formatPlaybackSpeed(speed);
const selected = speed === playbackSpeed;
return (
<button
aria-pressed={selected}
className={cn(
"flex h-8 w-full items-center justify-between rounded-lg px-2 text-left text-xs font-medium tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
selected && "bg-white/15",
)}
key={speed}
type="button"
onClick={(event) => {
event.stopPropagation();
onPlaybackSpeedChange(speed);
setOpen(false);
}}
>
<span>{speedLabel}</span>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
export function VideoPlayer({
src,
poster,
@@ -750,19 +674,47 @@ export function VideoPlayer({
);
}, [persistedReviewKey]);
React.useEffect(() => {
return () => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.currentTime)) {
const savePositionFor = React.useCallback(
(video: HTMLVideoElement, key: string) => {
if (!Number.isFinite(video.currentTime)) {
return;
}
saveInlinePlaybackPosition(
persistedReviewKey,
key,
Math.max(video.currentTime, currentTimeRef.current),
{ ignoreResetToZero: true },
);
},
[],
);
// Covers the key swapping under a still-mounted player (optimistic row
// replaced by its acked event); the unmount path saves from the ref cleanup
// below, where the element is still reachable.
React.useEffect(() => {
return () => {
const video = videoRef.current;
if (!video) {
return;
}
savePositionFor(video, persistedReviewKey);
};
}, [persistedReviewKey]);
}, [persistedReviewKey, savePositionFor]);
const mediaSessionMetadata = React.useMemo<NowPlayingMetadata>(
() => ({
artist: reviewContext?.channelName,
title: reviewContext?.title ?? filename ?? fileNameFromUrl(src),
}),
[filename, reviewContext?.channelName, reviewContext?.title, src],
);
// Saves the position, then hands the macOS media keys back: an unmounted
// player must not keep answering the hardware play/pause key.
const attachInlineVideo = useReleasingVideoRef({
onRelease: (video) => savePositionFor(video, persistedReviewKey),
videoRef,
});
const setCurrentTime = React.useCallback(
(seconds: number) => {
@@ -998,7 +950,7 @@ export function VideoPlayer({
frame. */}
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
<video
ref={videoRef}
ref={attachInlineVideo}
className="h-full w-full object-cover"
playsInline
poster={poster}
@@ -1008,7 +960,10 @@ export function VideoPlayer({
onDurationChange={(event) =>
handleMediaDuration(event.currentTarget.duration)
}
onEnded={() => setIsPlaying(false)}
onEnded={(event) => {
markMediaSessionPaused(event.currentTarget);
setIsPlaying(false);
}}
onError={() => {
if (started) {
setHasError(true);
@@ -1042,11 +997,13 @@ export function VideoPlayer({
}
learnNaturalAspectRatio(videoWidth, videoHeight);
}}
onPause={() => {
onPause={(event) => {
markMediaSessionPaused(event.currentTarget);
setIsPlaying(false);
setIsBuffering(false);
}}
onPlay={() => {
onPlay={(event) => {
claimMediaSession(event.currentTarget, mediaSessionMetadata);
setStarted(true);
setIsPlaying(true);
}}
@@ -1408,6 +1365,15 @@ function VideoReviewDialog({
const reviewSeek = useThrottledVideoSeek(videoRef);
const mediaSessionMetadata = React.useMemo<NowPlayingMetadata>(
() => ({ artist: reviewContext?.channelName, title }),
[reviewContext?.channelName, title],
);
// Closing the overlay unmounts this element; releasing it is what returns
// the macOS media keys to whatever owned them before the video played.
const attachReviewVideo = useReleasingVideoRef({ videoRef });
const setVideoTime = React.useCallback(
(seconds: number) => {
const boundedSeconds = boundSeconds(seconds);
@@ -1704,7 +1670,7 @@ function VideoReviewDialog({
<div className="relative z-10 h-full w-full overflow-hidden rounded-lg">
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
<video
ref={videoRef}
ref={attachReviewVideo}
className="h-full w-full min-h-0 object-contain"
playsInline
poster={poster}
@@ -1715,6 +1681,7 @@ function VideoReviewDialog({
onDurationChange(event.currentTarget.duration)
}
onEnded={(event) => {
markMediaSessionPaused(event.currentTarget);
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(false);
}}
@@ -1735,10 +1702,15 @@ function VideoReviewDialog({
}}
onLoadedData={() => setHasVisibleFrame(true)}
onPause={(event) => {
markMediaSessionPaused(event.currentTarget);
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(false);
}}
onPlay={(event) => {
claimMediaSession(
event.currentTarget,
mediaSessionMetadata,
);
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(true);
}}
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
// macOS gives the hardware play/pause key to the app holding the Now Playing
// session. WKWebView claims one for Buzz on the first audible <video> and
// keeps it until the element itself is torn down — dropping the element from
// the DOM is not enough. These tests pin the teardown contract that hands the
// key back: pause, detach the source, clear the metadata we published.
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const paused = [];
const loaded = [];
let mediaSession;
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
// jsdom leaves the playback methods unimplemented; record the calls instead.
dom.window.HTMLMediaElement.prototype.pause = function pause() {
paused.push(this);
};
dom.window.HTMLMediaElement.prototype.load = function load() {
loaded.push(this);
};
globalThis.MediaMetadata = class MediaMetadata {
constructor(init) {
Object.assign(this, init);
}
};
// Neither node's built-in `navigator` nor jsdom's implements the Media
// Session API, so graft a stub onto jsdom's and make it the global.
Object.defineProperty(dom.window.navigator, "mediaSession", {
configurable: true,
value: { metadata: null, playbackState: "none" },
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
mediaSession = globalThis.navigator.mediaSession;
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
paused.length = 0;
loaded.length = 0;
mediaSession.metadata = null;
mediaSession.playbackState = "none";
});
after(() => dom.window.close());
async function renderVideo({ strict = false } = {}) {
const React = await import("react");
const { act, render } = await import("@testing-library/react");
const { useReleasingVideoRef } = await import("./mediaSession.ts");
const released = [];
function Player() {
const attach = useReleasingVideoRef({
onRelease: (video) => released.push(video.currentTime),
});
return React.createElement("video", {
ref: attach,
src: "https://relay.example/media/clip.mp4",
});
}
const tree = React.createElement(Player);
const result = render(
strict ? React.createElement(React.StrictMode, null, tree) : tree,
);
await act(async () => {});
return {
act,
released,
unmount: result.unmount,
video: result.container.querySelector("video"),
};
}
test("unmounting a player releases the media keys it claimed", async () => {
const { claimMediaSession } = await import("./mediaSession.ts");
const { act, video, unmount } = await renderVideo();
claimMediaSession(video, { artist: "engineering", title: "clip.mp4" });
assert.equal(mediaSession.metadata.title, "clip.mp4");
assert.equal(mediaSession.playbackState, "playing");
await act(async () => unmount());
// Pause alone leaves the session alive — WebKit only destroys it once the
// source is detached, which is what returns the key to Music.
assert.ok(paused.includes(video), "the element was paused");
assert.equal(video.getAttribute("src"), null, "the source was detached");
assert.ok(loaded.includes(video), "load() reset the element");
assert.equal(mediaSession.metadata, null);
assert.equal(mediaSession.playbackState, "none");
});
test("release runs before the source is detached, so the position is still readable", async () => {
const { released, act, unmount } = await renderVideo();
await act(async () => unmount());
assert.deepEqual(released, [0]);
});
test("a bystander player's teardown leaves the playing video's session alone", async () => {
const { claimMediaSession, releaseVideoElement } = await import(
"./mediaSession.ts"
);
const playing = dom.window.document.createElement("video");
const bystander = dom.window.document.createElement("video");
claimMediaSession(playing, { title: "review.mp4" });
// An off-screen timeline row unmounting while the review overlay plays.
releaseVideoElement(bystander);
assert.equal(mediaSession.metadata.title, "review.mp4");
assert.equal(mediaSession.playbackState, "playing");
});
test("StrictMode's simulated remount leaves the source attached", async () => {
const { video } = await renderVideo({ strict: true });
// StrictMode runs the ref cleanup and re-attaches the same element without
// re-applying props; a release that did not restore the source would blank
// every video in development.
assert.equal(
video.getAttribute("src"),
"https://relay.example/media/clip.mp4",
);
});
+148
View File
@@ -0,0 +1,148 @@
import * as React from "react";
/**
* macOS routes the hardware play/pause key (keyboard media keys, AirPods taps)
* to whichever app most recently registered a Now Playing session. WKWebView
* registers one for Buzz as soon as an audible `<video>` starts, and the claim
* outlives removal from the DOM: a detached media element keeps its session
* until it is garbage collected, so the key can keep reaching a player the
* user scrolled past or closed minutes ago instead of going back to Music.
*
* Holding the keys while a video is on screen is correct — that is how resume
* works in every macOS app. Handing them back when the video goes away is what
* this module exists for: pause, detach the source (the only thing that makes
* WebKit destroy the session immediately rather than at some later GC), and
* clear the metadata this player published.
*/
export type NowPlayingMetadata = {
/** Channel the video was posted in, shown as the Now Playing subtitle. */
artist?: string;
title: string;
};
/**
* Element the current Now Playing metadata describes. Clearing is scoped to
* the owner so an off-screen timeline row unmounting cannot wipe the metadata
* of the review overlay the user is actually watching.
*/
let sessionOwner: HTMLVideoElement | null = null;
/** Sources detached by {@link releaseVideoElement}, for the StrictMode
* re-attach path in {@link useReleasingVideoRef}. Weak so a released element
* stays collectable. */
const detachedSources = new WeakMap<HTMLVideoElement, string>();
function getMediaSession(): MediaSession | null {
// Feature-detected: WebKit has shipped the Media Session API since Safari
// 15, but the unit-test environment's `navigator` has no `mediaSession`.
if (typeof navigator === "undefined") {
return null;
}
return navigator.mediaSession ?? null;
}
/** Publish what is playing so Control Center shows the video instead of a
* bare "Buzz" entry, and record this element as the session's owner. */
export function claimMediaSession(
video: HTMLVideoElement,
metadata: NowPlayingMetadata,
): void {
sessionOwner = video;
const session = getMediaSession();
if (!session) {
return;
}
if (typeof MediaMetadata === "function") {
session.metadata = new MediaMetadata({
artist: metadata.artist ?? "",
title: metadata.title,
});
}
session.playbackState = "playing";
}
export function markMediaSessionPaused(video: HTMLVideoElement): void {
const session = getMediaSession();
if (!session || sessionOwner !== video) {
return;
}
session.playbackState = "paused";
}
export function releaseMediaSession(video: HTMLVideoElement): void {
if (sessionOwner !== video) {
return;
}
sessionOwner = null;
const session = getMediaSession();
if (!session) {
return;
}
session.metadata = null;
session.playbackState = "none";
}
/**
* Tear a media element down deterministically: stop playback, drop this
* player's Now Playing metadata, and detach the source so WebKit destroys the
* element's media session now instead of holding the media keys until the
* detached element happens to be collected.
*/
export function releaseVideoElement(video: HTMLVideoElement): void {
releaseMediaSession(video);
video.pause();
const src = video.getAttribute("src");
if (src !== null) {
detachedSources.set(video, src);
video.removeAttribute("src");
}
video.load();
}
/**
* Ref callback that releases its `<video>` when React drops it.
*
* Teardown cannot live in an effect cleanup: React detaches host refs during
* the mutation phase, so on unmount a passive cleanup only ever sees a null
* ref and the element escapes untouched. A ref cleanup still holds the
* element, and runs before the node leaves the document.
*
* `onRelease` runs before the source is detached — that is the last moment a
* caller can read `currentTime` off the element. Both options are re-read on
* every render, so callers do not have to memoize them.
*/
export function useReleasingVideoRef(options?: {
onRelease?: (video: HTMLVideoElement) => void;
videoRef?: React.RefObject<HTMLVideoElement | null>;
}): (video: HTMLVideoElement) => () => void {
const optionsRef = React.useRef(options);
optionsRef.current = options;
return React.useCallback((video: HTMLVideoElement) => {
// StrictMode simulates a remount by running this cleanup and re-attaching
// the same element — without re-applying props. Restore a source the
// release detached, or every video renders empty in development.
const detachedSrc = detachedSources.get(video);
if (detachedSrc !== undefined) {
detachedSources.delete(video);
if (!video.hasAttribute("src")) {
video.setAttribute("src", detachedSrc);
video.load();
}
}
const attachedRef = optionsRef.current?.videoRef;
if (attachedRef) {
attachedRef.current = video;
}
return () => {
const current = optionsRef.current;
current?.onRelease?.(video);
if (current?.videoRef) {
current.videoRef.current = null;
}
releaseVideoElement(video);
};
}, []);
}