| 1 | import { XIcon } from "lucide-react"; |
| 2 | import { |
| 3 | createContext, |
| 4 | useContext, |
| 5 | useLayoutEffect, |
| 6 | useRef, |
| 7 | useState, |
| 8 | useSyncExternalStore, |
| 9 | type ReactNode, |
| 10 | } from "react"; |
| 11 | import { createPortal } from "react-dom"; |
| 12 | |
| 13 | import { Button } from "../ui/button"; |
| 14 | |
| 15 | interface PlaybackEntry { |
| 16 | readonly identity: string; |
| 17 | readonly container: HTMLSpanElement; |
| 18 | readonly key: number; |
| 19 | slot: HTMLSpanElement | null; |
| 20 | children: ReactNode; |
| 21 | } |
| 22 | |
| 23 | function 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 | |
| 32 | function hasRetainedPlayback(entry: PlaybackEntry) { |
| 33 | const video = entry.container.querySelector("video"); |
| 34 | return video && ((!video.paused && !video.ended) || document.pictureInPictureElement === video); |
| 35 | } |
| 36 | |
| 37 | function 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 | |
| 156 | const PlaybackContext = createContext<ReturnType<typeof createPlaybackHost> | null>(null); |
| 157 | |
| 158 | export 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 | |
| 221 | function 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 | |
| 252 | export 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 | } |