1import { XIcon } from "lucide-react";
2import {
3 createContext,
4 useContext,
5 useLayoutEffect,
6 useRef,
7 useState,
8 useSyncExternalStore,
9 type ReactNode,
10} from "react";
11import { createPortal } from "react-dom";
12
13import { Button } from "../ui/button";
14
15interface PlaybackEntry {
16 readonly identity: string;
17 readonly container: HTMLSpanElement;
18 readonly key: number;
19 slot: HTMLSpanElement | null;
20 children: ReactNode;
21}
22
23function moveContainer(entry: PlaybackEntry, destination: HTMLSpanElement) {
24 // A state-preserving DOM move keeps the decoder and native picture-in-picture alive.
25 if (entry.container.isConnected && destination.isConnected && destination.moveBefore) {
26 destination.moveBefore(entry.container, null);
27 } else {
28 destination.append(entry.container);
29 }
30}
31
32function hasRetainedPlayback(entry: PlaybackEntry) {
33 const video = entry.container.querySelector("video");
34 return video && ((!video.paused && !video.ended) || document.pictureInPictureElement === video);
35}
36
37function createPlaybackHost() {
38 let entries: PlaybackEntry[] = [];
39 let nextKey = 0;
40 let mounted = false;
41 let lastPlayed: PlaybackEntry | null = null;
42 let floatingHost: HTMLSpanElement | null = null;
43 const listeners = new Set<() => void>();
44
45 const publish = () => {
46 entries = [...entries];
47 listeners.forEach((listener) => listener());
48 };
49
50 const release = (entry: PlaybackEntry, videos = entry.container.querySelectorAll("video")) => {
51 if (!entries.includes(entry)) return;
52
53 entries = entries.filter((candidate) => candidate !== entry);
54 if (lastPlayed === entry) lastPlayed = null;
55 for (const video of videos) {
56 if (document.pictureInPictureElement === video) {
57 void document.exitPictureInPicture?.().catch(() => undefined);
58 }
59 video.pause();
60 video.removeAttribute("src");
61 video.load();
62 }
63 entry.container.remove();
64 publish();
65 };
66
67 return {
68 subscribe(listener: () => void) {
69 listeners.add(listener);
70 return () => listeners.delete(listener);
71 },
72 getSnapshot: () => entries,
73 setFloatingHost(element: HTMLSpanElement | null) {
74 floatingHost = element;
75 },
76 mount() {
77 mounted = true;
78 return () => {
79 mounted = false;
80 const snapshots = entries.map((entry) => ({
81 entry,
82 videos: entry.container.querySelectorAll("video"),
83 }));
84 // StrictMode reconnects effects before this microtask can dispose the host.
85 queueMicrotask(() => {
86 if (!mounted) snapshots.forEach(({ entry, videos }) => release(entry, videos));
87 });
88 };
89 },
90 attach(identity: string, slot: HTMLSpanElement) {
91 let entry = entries.find((candidate) => candidate.identity === identity && !candidate.slot);
92 if (!entry) {
93 const container = document.createElement("span");
94 container.className = "contents";
95 entry = { identity, container, key: nextKey++, slot, children: null };
96 const createdEntry = entry;
97 container.addEventListener(
98 "play",
99 () => {
100 lastPlayed = createdEntry;
101 },
102 true,
103 );
104 container.addEventListener(
105 "ended",
106 () => {
107 if (!createdEntry.slot) release(createdEntry);
108 },
109 true,
110 );
111 entries = [...entries, entry];
112 }
113
114 entry.slot = slot;
115 moveContainer(entry, slot);
116 publish();
117 return entry;
118 },
119 update(entry: PlaybackEntry, children: ReactNode) {
120 if (entry.children === children || !entries.includes(entry)) return;
121 entry.children = children;
122 publish();
123 },
124 detach(entry: PlaybackEntry, slot: HTMLSpanElement) {
125 if (entry.slot !== slot || !entries.includes(entry)) return;
126 entry.slot = null;
127 const keepPlaying = hasRetainedPlayback(entry);
128
129 if (keepPlaying) {
130 if (
131 lastPlayed &&
132 lastPlayed !== entry &&
133 !lastPlayed.slot &&
134 hasRetainedPlayback(lastPlayed)
135 ) {
136 release(entry);
137 return;
138 }
139 entries
140 .filter((candidate) => candidate !== entry && !candidate.slot)
141 .forEach((candidate) => release(candidate));
142 }
143 if (floatingHost) moveContainer(entry, floatingHost);
144 publish();
145
146 if (!keepPlaying) {
147 queueMicrotask(() => {
148 if (!entry.slot) release(entry);
149 });
150 }
151 },
152 release,
153 };
154}
155
156const PlaybackContext = createContext<ReturnType<typeof createPlaybackHost> | null>(null);
157
158export function MediaPlaybackProvider({ children }: { readonly children: ReactNode }) {
159 const [host] = useState(createPlaybackHost);
160 const floatingRegionRef = useRef<HTMLElement>(null);
161 const entries = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
162 const floating = entries.find((entry) => !entry.slot);
163 const label = floating?.container.querySelector("video")?.getAttribute("aria-label") || "Video";
164
165 useLayoutEffect(() => host.mount(), [host]);
166 useLayoutEffect(() => {
167 const region = floatingRegionRef.current;
168 if (!region) return;
169 const closeOnEscape = (event: KeyboardEvent) => {
170 if (event.key !== "Escape" || region.contains(document.fullscreenElement)) return;
171 const entry = host.getSnapshot().find((candidate) => !candidate.slot);
172 if (!entry) return;
173 event.preventDefault();
174 event.stopPropagation();
175 host.release(entry);
176 };
177 // Portal controls bubble through React's owner tree, not this DOM region.
178 region.addEventListener("keydown", closeOnEscape, true);
179 return () => region.removeEventListener("keydown", closeOnEscape, true);
180 }, [host]);
181
182 return (
183 <PlaybackContext value={host}>
184 {children}
185 {entries.map((entry) => createPortal(entry.children, entry.container, String(entry.key)))}
186 <section
187 ref={floatingRegionRef}
188 role="region"
189 aria-label={`Picture in picture: ${label}`}
190 hidden={!floating}
191 data-media-floating-player
192 className="fixed right-4 z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
193 style={{
194 width: "min(22rem, calc(100vw - 2rem))",
195 bottom: "clamp(1rem, 24dvh, 12rem)",
196 maxHeight: "calc(100dvh - 2rem)",
197 }}
198 >
199 <div className="flex items-center gap-2 py-1 pr-1 pl-3">
200 <span className="min-w-0 flex-1 truncate text-xs">{label}</span>
201 <Button
202 variant="ghost"
203 size="icon-xs"
204 aria-label="Close picture in picture"
205 onClick={() => {
206 if (floating) host.release(floating);
207 }}
208 >
209 <XIcon aria-hidden />
210 </Button>
211 </div>
212 <span
213 ref={(element) => host.setFloatingHost(element)}
214 className="block [&_[data-media-player]]:block! [&_[data-media-player]]:w-full! [&_[data-media-player]]:max-w-full! [&_video]:max-h-[min(40dvh,20rem)]! [&_video]:w-full! [&_video]:object-contain!"
215 />
216 </section>
217 </PlaybackContext>
218 );
219}
220
221function HostedPlaybackSlot({
222 identity,
223 children,
224 host,
225}: {
226 readonly identity: string;
227 readonly children: ReactNode;
228 readonly host: ReturnType<typeof createPlaybackHost>;
229}) {
230 const slotRef = useRef<HTMLSpanElement>(null);
231 const entryRef = useRef<PlaybackEntry | null>(null);
232
233 useLayoutEffect(() => {
234 const slot = slotRef.current;
235 if (!slot) return;
236 const entry = host.attach(identity, slot);
237 entryRef.current = entry;
238 return () => {
239 entryRef.current = null;
240 host.detach(entry, slot);
241 };
242 }, [host, identity]);
243
244 useLayoutEffect(() => {
245 const entry = entryRef.current;
246 if (entry?.identity === identity) host.update(entry, children);
247 }, [host, identity, children]);
248
249 return <span ref={slotRef} className="contents" data-media-playback-slot={identity} />;
250}
251
252export function MediaPlaybackSlot(props: {
253 readonly identity: string;
254 readonly children: ReactNode;
255}) {
256 const host = useContext(PlaybackContext);
257 return host ? <HostedPlaybackSlot {...props} host={host} /> : props.children;
258}