mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user