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,
|
AttachmentMedia,
|
||||||
AttachmentTitle,
|
AttachmentTitle,
|
||||||
} from "@/shared/ui/attachment";
|
} from "@/shared/ui/attachment";
|
||||||
|
import { useReleasingVideoRef } from "@/shared/ui/mediaSession";
|
||||||
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
||||||
import { Progress } from "@/shared/ui/progress";
|
import { Progress } from "@/shared/ui/progress";
|
||||||
import { Toggle } from "@/shared/ui/toggle";
|
import { Toggle } from "@/shared/ui/toggle";
|
||||||
@@ -263,6 +264,10 @@ const MediaAttachmentItem = React.forwardRef<
|
|||||||
) {
|
) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [mode, setMode] = React.useState<"view" | "edit">("view");
|
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 hash = shortHash(attachment.sha256);
|
||||||
const isVideo = attachment.type.startsWith("video/");
|
const isVideo = attachment.type.startsWith("video/");
|
||||||
@@ -412,6 +417,7 @@ const MediaAttachmentItem = React.forwardRef<
|
|||||||
) : isVideo ? (
|
) : isVideo ? (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
|
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
|
||||||
<video
|
<video
|
||||||
|
ref={attachPreviewVideo}
|
||||||
src={rewriteRelayUrl(attachment.url)}
|
src={rewriteRelayUrl(attachment.url)}
|
||||||
controls
|
controls
|
||||||
className={cn(
|
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 { Button } from "@/shared/ui/button";
|
||||||
import { Checkbox } from "@/shared/ui/checkbox";
|
import { Checkbox } from "@/shared/ui/checkbox";
|
||||||
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
|
||||||
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
|
import {
|
||||||
|
claimMediaSession,
|
||||||
|
markMediaSessionPaused,
|
||||||
|
type NowPlayingMetadata,
|
||||||
|
useReleasingVideoRef,
|
||||||
|
} from "./mediaSession";
|
||||||
import { Spinner } from "./spinner";
|
import { Spinner } from "./spinner";
|
||||||
|
import {
|
||||||
|
DEFAULT_PLAYBACK_SPEED,
|
||||||
|
isPlaybackSpeedOption,
|
||||||
|
PlaybackSpeedControl,
|
||||||
|
} from "./VideoPlaybackSpeedControl";
|
||||||
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
|
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
|
||||||
import { useVideoContextMenu } from "./useVideoContextMenu";
|
import { useVideoContextMenu } from "./useVideoContextMenu";
|
||||||
import { useRegisterVideoReview } from "./VideoReviewNavigation";
|
import { useRegisterVideoReview } from "./VideoReviewNavigation";
|
||||||
@@ -117,9 +127,7 @@ type TimecodedComment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
|
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
|
||||||
const DEFAULT_PLAYBACK_SPEED = 1;
|
|
||||||
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
|
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
|
* 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 {
|
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
|
||||||
const parsed = parseVideoReviewTimecode(comment.body);
|
const parsed = parseVideoReviewTimecode(comment.body);
|
||||||
return parsed
|
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({
|
export function VideoPlayer({
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
@@ -750,19 +674,47 @@ export function VideoPlayer({
|
|||||||
);
|
);
|
||||||
}, [persistedReviewKey]);
|
}, [persistedReviewKey]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const savePositionFor = React.useCallback(
|
||||||
return () => {
|
(video: HTMLVideoElement, key: string) => {
|
||||||
const video = videoRef.current;
|
if (!Number.isFinite(video.currentTime)) {
|
||||||
if (!video || !Number.isFinite(video.currentTime)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveInlinePlaybackPosition(
|
saveInlinePlaybackPosition(
|
||||||
persistedReviewKey,
|
key,
|
||||||
Math.max(video.currentTime, currentTimeRef.current),
|
Math.max(video.currentTime, currentTimeRef.current),
|
||||||
{ ignoreResetToZero: true },
|
{ 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(
|
const setCurrentTime = React.useCallback(
|
||||||
(seconds: number) => {
|
(seconds: number) => {
|
||||||
@@ -998,7 +950,7 @@ export function VideoPlayer({
|
|||||||
frame. */}
|
frame. */}
|
||||||
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={attachInlineVideo}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
playsInline
|
playsInline
|
||||||
poster={poster}
|
poster={poster}
|
||||||
@@ -1008,7 +960,10 @@ export function VideoPlayer({
|
|||||||
onDurationChange={(event) =>
|
onDurationChange={(event) =>
|
||||||
handleMediaDuration(event.currentTarget.duration)
|
handleMediaDuration(event.currentTarget.duration)
|
||||||
}
|
}
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={(event) => {
|
||||||
|
markMediaSessionPaused(event.currentTarget);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
if (started) {
|
if (started) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
@@ -1042,11 +997,13 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
learnNaturalAspectRatio(videoWidth, videoHeight);
|
learnNaturalAspectRatio(videoWidth, videoHeight);
|
||||||
}}
|
}}
|
||||||
onPause={() => {
|
onPause={(event) => {
|
||||||
|
markMediaSessionPaused(event.currentTarget);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}}
|
}}
|
||||||
onPlay={() => {
|
onPlay={(event) => {
|
||||||
|
claimMediaSession(event.currentTarget, mediaSessionMetadata);
|
||||||
setStarted(true);
|
setStarted(true);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}}
|
}}
|
||||||
@@ -1408,6 +1365,15 @@ function VideoReviewDialog({
|
|||||||
|
|
||||||
const reviewSeek = useThrottledVideoSeek(videoRef);
|
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(
|
const setVideoTime = React.useCallback(
|
||||||
(seconds: number) => {
|
(seconds: number) => {
|
||||||
const boundedSeconds = boundSeconds(seconds);
|
const boundedSeconds = boundSeconds(seconds);
|
||||||
@@ -1704,7 +1670,7 @@ function VideoReviewDialog({
|
|||||||
<div className="relative z-10 h-full w-full overflow-hidden rounded-lg">
|
<div className="relative z-10 h-full w-full overflow-hidden rounded-lg">
|
||||||
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={attachReviewVideo}
|
||||||
className="h-full w-full min-h-0 object-contain"
|
className="h-full w-full min-h-0 object-contain"
|
||||||
playsInline
|
playsInline
|
||||||
poster={poster}
|
poster={poster}
|
||||||
@@ -1715,6 +1681,7 @@ function VideoReviewDialog({
|
|||||||
onDurationChange(event.currentTarget.duration)
|
onDurationChange(event.currentTarget.duration)
|
||||||
}
|
}
|
||||||
onEnded={(event) => {
|
onEnded={(event) => {
|
||||||
|
markMediaSessionPaused(event.currentTarget);
|
||||||
syncCurrentTime(event.currentTarget.currentTime);
|
syncCurrentTime(event.currentTarget.currentTime);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}}
|
}}
|
||||||
@@ -1735,10 +1702,15 @@ function VideoReviewDialog({
|
|||||||
}}
|
}}
|
||||||
onLoadedData={() => setHasVisibleFrame(true)}
|
onLoadedData={() => setHasVisibleFrame(true)}
|
||||||
onPause={(event) => {
|
onPause={(event) => {
|
||||||
|
markMediaSessionPaused(event.currentTarget);
|
||||||
syncCurrentTime(event.currentTarget.currentTime);
|
syncCurrentTime(event.currentTarget.currentTime);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}}
|
}}
|
||||||
onPlay={(event) => {
|
onPlay={(event) => {
|
||||||
|
claimMediaSession(
|
||||||
|
event.currentTarget,
|
||||||
|
mediaSessionMetadata,
|
||||||
|
);
|
||||||
syncCurrentTime(event.currentTarget.currentTime);
|
syncCurrentTime(event.currentTarget.currentTime);
|
||||||
setIsPlaying(true);
|
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