diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/video-player.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 992 |
1 files changed, 992 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js new file mode 100644 index 0000000..42842d2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -0,0 +1,992 @@ +import { + html, useState, useEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize } from './file-utils.js'; +import { loadAuth } from './hub-client.js'; +import * as platform from './platform.js'; + +// Seconds of already-watched video kept in the SourceBuffer, and the queue depth +// past which we start making room before being forced to. +const BUFFER_BEHIND_S = 60; +// How far past the playhead we are willing to pull. The browser caps a video +// SourceBuffer at a few hundred megabytes and refuses the append that goes +// past, so "as fast as the network allows" is not a strategy for a film: the +// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a +// ten-megabit second fills the ceiling in the first minute. Buffering by time +// rather than by bytes keeps a two-hour film and a two-minute clip alike. +const BUFFER_AHEAD_S = 90; +// While we deliberately hold credit back, the node must still hear from us: its +// own stall timeout is two minutes, and a paused film is not a gone viewer. +const CREDIT_KEEPALIVE_MS = 20000; +// Segments allowed in flight while there is room to put them. This is a window, +// topped up as segments land, and not a debt released in one go: accumulating a +// credit per append and handing the lot over when the buffer finally had room +// sent 6 MB in a burst, overshot the target by a minute of film, and then said +// nothing for the next forty-six seconds. Measured in Chrome against real +// fragmented MP4. A stream that arrives in gulps has no margin for a network +// that hesitates, and looks like a hang while it is quiet. +const STREAM_WINDOW = 8; +// Dragging the scrubber fires `seeking` continuously, and every seek we act on +// kills an ffmpeg and spawns another. Only where the finger stops is worth a +// restart. +const SEEK_DEBOUNCE_MS = 350; +// A position is remembered per file, in this browser. Below the first threshold +// there is nothing to resume; above the second the film is finished and +// offering to resume thirty seconds before the credits is a nuisance. +const RESUME_MIN_S = 30; +const RESUME_MAX_FRACTION = 0.97; +const QUEUE_HIGH_WATER = 12; + +function _mseSupported(codec) { + if (!window.MediaSource) return false; + const mime = `video/mp4; codecs="${codec}"`; + return MediaSource.isTypeSupported(mime); +} + +/** Seconds as h:mm:ss, or m:ss under an hour. */ +function formatClock(seconds) { + const s = Math.max(0, Math.floor(seconds || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = String(s % 60).padStart(2, '0'); + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`; +} + +/** + * Where *this account on this device* last left off in a given file. + * + * localStorage rather than the node: it needs no protocol, no storage anyone + * else has to keep, and nothing new learns what you watch. The cost is that + * the position does not follow you from the laptop to the phone. + * + * The account has to be in the key. Without it the position is per *device* — + * so a second person signing in on the same machine was offered "resume where + * you left off" in a film they had never opened, which is both wrong and a + * small disclosure of what someone else watches. Found by signing in with a + * fresh account and being offered a resume point. + */ +function resumeKey(fileId) { + const auth = loadAuth(); + return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null; +} + +function readResumePosition(fileId) { + try { + const key = resumeKey(fileId); + if (!key) return 0; + const raw = localStorage.getItem(key); + const at = raw ? parseFloat(raw) : 0; + return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0; + } catch { + return 0; // private browsing, or storage disabled + } +} + +function writeResumePosition(fileId, at, duration) { + try { + const key = resumeKey(fileId); + if (!key) return; + if (!Number.isFinite(at) || at < RESUME_MIN_S + || (duration && at > duration * RESUME_MAX_FRACTION)) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, String(Math.floor(at))); + } catch { /* nothing to be done, and nothing worth failing over */ } +} + +/** + * Drop the positions written before they were scoped to an account. + * + * Re-keying them is not possible — there is no record of whose they were, and + * guessing would hand them to whoever signs in next, which is the bug. They go. + */ +function purgeUnscopedResumePositions() { + try { + const stale = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current. + if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) { + stale.push(key); + } + } + stale.forEach((key) => localStorage.removeItem(key)); + } catch { /* storage disabled: nothing was written either */ } +} + +purgeUnscopedResumePositions(); + +function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { + const [dlBusy, setDlBusy] = useState(false); + const [phase, setPhase] = useState('loading'); + const [error, setError] = useState(''); + const videoRef = useRef(null); + const msRef = useRef(null); + const sbRef = useRef(null); + const blobUrlRef = useRef(null); + const queueRef = useRef([]); + const appendingRef = useRef(false); + const endedRef = useRef(false); + const durationRef = useRef(0); + // Segments the node is allowed to have in flight but has not sent yet, and + // when we last said anything to it at all. + const outstandingRef = useRef(0); + const lastPokeRef = useRef(0); + // Diagnostics reported to the node: how many appends the browser refused for + // want of room, and whether the element itself says it is starved. + const quotaRef = useRef(0); + const stalledRef = useRef(false); + // Seeking. `awaitingInit` is true from the moment we ask the node to restart + // somewhere else until its new `stream_init` arrives: the channel is ordered, + // so everything in between belongs to the stream we just abandoned and would + // otherwise be appended on top of the new one. `seekTarget` is where to put + // the playhead once the buffer actually covers it. + const awaitingInitRef = useRef(false); + const seekTargetRef = useRef(null); + const seekTimerRef = useRef(null); + // The seek is built inside the effect, where the transport and `cancelled` + // live; the render needs to reach it for "start from the beginning". + const requestSeekRef = useRef(null); + const [resumedFrom, setResumedFrom] = useState(0); + const [castActive, setCastActive] = useState(false); + const [castUrl, setCastUrl] = useState(null); + const [castPickerOpen, setCastPickerOpen] = useState(false); + const [castDevices, setCastDevices] = useState([]); + const [castScanning, setCastScanning] = useState(false); + const [castDeviceName, setCastDeviceName] = useState(null); + const castActiveRef = useRef(false); + const castCodecRef = useRef(null); + const initSegmentRef = useRef(null); + const castRestartPendingRef = useRef(false); + const castDeviceRef = useRef(null); + const castRestartGenRef = useRef(0); + const landingPlayheadRef = useRef(false); + + /** + * The buffered range the playhead is actually in, or null. + * + * Seeking makes the buffer discontinuous, and "the last range" stops meaning + * "the one being watched" the moment there is more than one: measuring the + * read-ahead against a range on the far side of a gap reports a full buffer + * while the player starves. + */ + const currentRange = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v) return null; + try { + const t = v.currentTime; + for (let i = 0; i < sb.buffered.length; i++) { + // Half a second of slack: the playhead sits exactly on a boundary + // often enough, and a strict test there reports nothing buffered. + if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) { + return [sb.buffered.start(i), sb.buffered.end(i)]; + } + } + } catch { /* the SourceBuffer went away under us */ } + return null; + }, []); + + /** + * Drop what has already been watched. + * + * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes + * and refuse the append that goes past. Keeping a minute behind the playhead + * is enough for a small seek backwards and bounded for a three-hour film. + */ + const evictBehind = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v || sb.updating || !sb.buffered.length) return false; + const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S); + // The range being watched, not the first one: after a seek backwards the + // first range is somewhere else entirely, and removing from its start to + // just behind the playhead would take out everything in between — + // including what is playing. + const range = currentRange(); + const start = range ? range[0] : sb.buffered.start(0); + if (keepFrom - start < 10) return false; + try { + sb.remove(start, keepFrom); + return true; + } catch { + return false; + } + }, [currentRange]); + + /** Seconds of film held past the playhead. */ + const bufferedAhead = useCallback(() => { + const v = videoRef.current; + const range = currentRange(); + if (!v || !range) return 0; + return Math.max(0, range[1] - v.currentTime); + }, [currentRange]); + + const flushQueue = useCallback(() => { + const sb = sbRef.current; + if (!sb || appendingRef.current || sb.updating) return; + if (queueRef.current.length === 0) { + if (endedRef.current && msRef.current?.readyState === 'open') { + try { msRef.current.endOfStream(); } catch {} + } + return; + } + appendingRef.current = true; + const chunk = queueRef.current[0]; + try { + sb.appendBuffer(chunk); + queueRef.current.shift(); + } catch (e) { + appendingRef.current = false; + if (e.name === 'QuotaExceededError') { + quotaRef.current += 1; + // The segment stays at the head of the queue and is tried again once + // there is room. Dropping it — which is what this used to do — leaves a + // hole in the middle of the film and no error anywhere. + if (!evictBehind()) { + console.warn('[MSE] buffer full and nothing to evict yet'); + } + return; + } + queueRef.current.shift(); + console.error('[MSE] appendBuffer error:', e); + } + }, [evictBehind]); + + /** + * Decide whether the node may send more, and keep the pipeline moving. + * + * This is the only place credit is granted, and the only thing that can + * restart a pipeline the buffer ceiling has stopped. That second job is why + * it exists: an append refused for quota fires no `updateend`, so it grants + * no credit, so the node sends nothing, so no segment arrives to call + * `flushQueue` again. Every wakeup the append path had was downstream of the + * append that just failed — the player deadlocked against itself and sat on + * "buffering" for good, which is what a 500 MB film did at around 100 MB. + * + * So the clock drives this, not the data. + */ + const pump = useCallback(() => { + if (awaitingInitRef.current) return; + const transport = transportRef.current; + evictBehind(); + flushQueue(); + if (endedRef.current && queueRef.current.length === 0) return; + if (!transport || !transport.connected) return; + + if (bufferedAhead() > BUFFER_AHEAD_S + || queueRef.current.length > QUEUE_HIGH_WATER) { + // Far enough ahead. Grant nothing, but do not go silent: two minutes of + // silence is how the node decides nobody is watching, and pausing a film + // for two minutes is an ordinary thing to do. + const now = Date.now(); + if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) { + lastPokeRef.current = now; + transport.grantStreamCredit(0); + } + return; + } + + // Top the window back up to what is allowed in flight, rather than paying + // off everything owed at once. Called on every arriving segment as well as + // on the clock, so credit trickles out as room appears instead of being + // released in one gulp when the buffer finally drains. + const room = STREAM_WINDOW - outstandingRef.current; + if (room > 0) { + outstandingRef.current += room; + lastPokeRef.current = Date.now(); + transport.grantStreamCredit(room); + } + }, [evictBehind, flushQueue, bufferedAhead]); + + useEffect(() => { + let cancelled = false; + // Reset here, not in the teardown of the run before: switching video while + // an append was in flight left `appendingRef` true, and flushQueue bails + // out on it. The new SourceBuffer then never appended anything, so no + // `updateend` ever cleared the flag, no credit went back to the node, and + // the player sat on "buffering" for good. `endedRef` surviving is the same + // shape of bug — the next stream would call endOfStream() the first time + // its queue ran dry and truncate the film. + appendingRef.current = false; + endedRef.current = false; + queueRef.current = []; + outstandingRef.current = 0; + lastPokeRef.current = Date.now(); + quotaRef.current = 0; + stalledRef.current = false; + // The same shape again, and the seek refs are worse than the others. + // Switching film while a seek was in flight leaves `awaitingInit` true, + // and only reinitAt() ever lowers it — which the next film does not go + // through, because it builds a new SourceBuffer. Every segment of the new + // film is then dropped as though it belonged to the one we left, for good. + // A stale `seekTarget` is milder: the new film jumps to a position from + // the old one the moment that much is buffered. + awaitingInitRef.current = false; + seekTargetRef.current = null; + clearTimeout(seekTimerRef.current); + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } + + const onStarved = () => { stalledRef.current = true; pump(); }; + const onFed = () => { stalledRef.current = false; }; + + /** The buffered ranges, short enough for a log line. */ + const describeRanges = () => { + const sb = sbRef.current; + if (!sb) return '(no buffer)'; + try { + let s = ''; + for (let i = 0; i < sb.buffered.length; i++) { + s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; + } + return s.trim() || '(empty)'; + } catch { + return '?'; + } + }; + + /** + * Ask the node to restart the film somewhere else. + * + * Debounced, because dragging the scrubber fires `seeking` continuously and + * each request kills an ffmpeg and spawns another. Only the position the + * finger stops on is worth acting on. + */ + const requestSeek = (target) => { + clearTimeout(seekTimerRef.current); + seekTimerRef.current = setTimeout(() => { + const t = transportRef.current; + if (cancelled || !t || !t.connected) return; + // Everything arriving from here until the new `stream_init` belongs to + // the stream being abandoned. The channel is ordered, so this flag is + // enough to tell them apart without a sequence number in the protocol. + // Rare enough to report every time, and the node logs it at INFO. A + // seek nobody asked for is the kind of thing only this line can show: + // from the node's side it is indistinguishable from a viewer dragging + // the scrubber. + t.sendStreamDiag({ + event: 'seek', target: +target.toFixed(1), + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ready: videoRef.current ? videoRef.current.readyState : null, + offset: sbRef.current ? sbRef.current.timestampOffset : null, + ranges: describeRanges(), + }); + awaitingInitRef.current = true; + seekTargetRef.current = target; + outstandingRef.current = STREAM_WINDOW; + setPhase('loading'); + console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW); + t.requestStream(entry.id, STREAM_WINDOW, target); + }, SEEK_DEBOUNCE_MS); + }; + requestSeekRef.current = requestSeek; + + /** + * Move the playhead onto a seek once the data for it has arrived. + * + * Setting `currentTime` into a region that is not buffered yet leaves the + * element waiting with nothing to show, and on a seek backwards it would + * be overwritten by the playhead the browser restores. So the position is + * remembered and applied on the first append that actually covers it. + */ + const landPlayhead = () => { + const target = seekTargetRef.current; + const v = videoRef.current, sb = sbRef.current; + if (target === null || !v || !sb) return; + try { + for (let i = 0; i < sb.buffered.length; i++) { + const a = sb.buffered.start(i), b = sb.buffered.end(i); + if (target >= a - 1 && target < b) { + seekTargetRef.current = null; + // ffmpeg lands on the keyframe at or before what we asked for, so + // the range can begin slightly later than the target; never seek + // behind what is actually there. + if (Math.abs(v.currentTime - target) > 0.5) { + landingPlayheadRef.current = true; + v.currentTime = Math.max(target, a); + } + v.play().catch(() => {}); + return; + } + } + } catch { /* the SourceBuffer went away */ } + }; + + /** Wait for whatever the SourceBuffer is doing to finish. */ + const settled = (sb) => new Promise((resolve) => { + if (!sb.updating) return resolve(); + sb.addEventListener('updateend', resolve, { once: true }); + }); + + /** + * Put the SourceBuffer back to an empty state that starts at `start`. + * + * Everything buffered is dropped rather than kept alongside the new + * material. A discontinuous buffer is legal and every piece of code that + * reads `buffered` then has to reason about which range it means — the + * eviction, the read-ahead, the seek test — for the sake of a few + * megabytes of film the viewer has just navigated away from. + * + * `abort()` first: ffmpeg was killed mid-fragment, so the parser is + * holding half of one, and appending the next stream's header on top of + * that is a decode error. + */ + const reinitAt = async (start) => { + const sb = sbRef.current; + if (!sb) return; + try { sb.abort(); } catch { /* not in a state that needs it */ } + await settled(sb); + try { + sb.remove(0, Infinity); + await settled(sb); + } catch { /* nothing buffered */ } + // ffmpeg restarts its timestamps at zero however far in we asked it to + // seek, so this is what puts the fragments back on the film's timeline. + try { sb.timestampOffset = start; } catch { /* older browsers */ } + const tr = transportRef.current; + if (tr) { + tr.sendStreamDiag({ + event: 'reinit', target: start, offset: sb.timestampOffset, + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ranges: describeRanges(), + }); + } + queueRef.current = []; + appendingRef.current = false; + endedRef.current = false; + quotaRef.current = 0; + awaitingInitRef.current = false; + seekTargetRef.current = start; + console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); + setPhase('streaming'); + pump(); + }; + + const onSeeking = () => { + if (landingPlayheadRef.current) { + landingPlayheadRef.current = false; + return; + } + const v = videoRef.current; + if (!v || cancelled) return; + const target = v.currentTime; + // Inside what is buffered, the browser handles it and the node need not + // hear about it at all — unless a cast is active, because the relay + // cannot seek within its HTTP stream and must be restarted. + if (!castActiveRef.current) { + const sb = sbRef.current; + if (sb) { + try { + for (let i = 0; i < sb.buffered.length; i++) { + if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) { + return; + } + } + } catch { /* fall through and ask the node */ } + } + } + requestSeek(target); + }; + + const startStream = async () => { + transport.onStreamError = (msg) => { + if (cancelled) return; + // Say what the node said. Sitting on "buffering" with the reason + // already delivered is the worst of both. + setError(msg.detail || t('video.err_transport')); + setPhase('error'); + }; + + transport.onStreamInit = (msg) => { + if (cancelled) return; + if (msg.file_id && msg.file_id !== entry.id) return; + const mime = `video/mp4; codecs="${msg.codec}"`; + castCodecRef.current = msg.codec; + + if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { + setError(t('video.err_mse', { codec: msg.codec })); + setPhase('error'); + return; + } + + durationRef.current = msg.duration || 0; + + // A second init on a live SourceBuffer is a seek landing, not a new + // film. Reuse what is there: rebuilding the MediaSource would reset the + // element's src, blank the picture and throw away the duration the + // scrubber is drawn from. + if (sbRef.current && msRef.current + && msRef.current.readyState === 'open') { + console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current); + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } + reinitAt(msg.start || 0).catch(() => { + setError(t('video.err_transport')); + setPhase('error'); + }); + return; + } + + // If we reach here during a seek (readyState was 'ended' after the + // previous stream finished), the seek-landing path above could not run. + // A fresh MediaSource is needed, but the seek state must still be reset + // or awaitingInit stays true and every segment is dropped forever. + awaitingInitRef.current = false; + endedRef.current = false; + appendingRef.current = false; + queueRef.current = []; + sbRef.current = null; + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } + + const ms = new MediaSource(); + msRef.current = ms; + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + const url = URL.createObjectURL(ms); + blobUrlRef.current = url; + + ms.addEventListener('sourceopen', () => { + if (cancelled) return; + if (durationRef.current > 0) { + ms.duration = durationRef.current; + } + const sb = ms.addSourceBuffer(mime); + sbRef.current = sb; + // 'segments', not 'sequence': the fragments must land where they + // belong on the film's timeline rather than one after another, or a + // stream that started at 40 minutes would be buffered at zero and + // the scrubber would lie about everything. + sb.mode = 'segments'; + try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ } + if (msg.start) seekTargetRef.current = msg.start; + transport.sendStreamDiag({ + event: 'first-init', target: msg.start || 0, + offset: sb.timestampOffset, duration: durationRef.current, + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + }); + sb.addEventListener('updateend', () => { + // No credit is granted here, deliberately. Appending is not the + // same question as having room, and tying the two meant `remove()` + // — which fires this event too — paid the node for the player's own + // evictions. What may be in flight is decided from the buffer, in + // pump(), and nowhere else. + appendingRef.current = false; + landPlayhead(); + pump(); + }); + setPhase('streaming'); + flushQueue(); + }); + + if (videoRef.current) { + videoRef.current.src = url; + videoRef.current.addEventListener('seeking', onSeeking); + videoRef.current.addEventListener('timeupdate', pump); + // The element's own verdict. "buffering" on screen is this, and it + // is the one thing the node cannot infer from a stream it is feeding. + videoRef.current.addEventListener('waiting', onStarved); + videoRef.current.addEventListener('stalled', onStarved); + videoRef.current.addEventListener('playing', onFed); + videoRef.current.addEventListener('canplay', onFed); + } + }; + + transport.onStreamData = async (msg) => { + if (cancelled) return; + // A segment arrived, so it is no longer in flight — whatever we go on + // to do with it. This has to come before every early return below, and + // it did not: skipping the count for segments we discard leaks a slot + // out of the window each time, and the window never grows back. + // + // `reinitAt` is asynchronous — it waits for two `updateend` events — + // and a seek's first segments arrive during that gap and are dropped + // by the flag below. Lose all eight and the player believes a full + // window is in flight, grants nothing ever again, and the node waits + // for credit that cannot come. A race, which is why the same seek + // worked twice and hung on the third. + outstandingRef.current = Math.max(0, outstandingRef.current - 1); + if (awaitingInitRef.current) { + console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); + } + // Between asking for a seek and its `stream_init`, everything on the + // channel is the film we just left. Same file, so `file_id` cannot + // tell them apart — ordering can. + if (awaitingInitRef.current) return; + // Late segments from the stream we just left. The DataChannel is + // ordered, so they arrive before the new stream's first segment and + // would otherwise be decrypted against the wrong file — which fails, + // loudly, in the console, for something that is simply not ours. + if (msg.file_id && msg.file_id !== entry.id) return; + try { + const plaintext = await window.MeshBayCrypto.decryptChunkBin( + gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); + if (initSegmentRef.current === null) { + initSegmentRef.current = plaintext; + if (castRestartPendingRef.current && castActiveRef.current + && platform.cast.available) { + castRestartPendingRef.current = false; + const gen = ++castRestartGenRef.current; + const device = castDeviceRef.current; + platform.cast.start({ + codec: castCodecRef.current, + initSegment: plaintext, + }).then(async (result) => { + if (castRestartGenRef.current !== gen) return; + if (!result) return; + setCastUrl(result.url); + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: result.url }); + } else if (device) { + await platform.cast.chromecastConnect({ + deviceId: device.id, mediaUrl: result.url, + }); + setCastDeviceName(device.name); + } else { + navigator.clipboard.writeText(result.url).catch(() => {}); + } + }).catch((err) => { + if (castRestartGenRef.current !== gen) return; + console.error('[cast] restart failed:', err); + platform.cast.stop().catch(() => {}); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); setCastDeviceName(null); + }); + } + } + if (castActiveRef.current && !castRestartPendingRef.current + && platform.cast.available) { + platform.cast.push(plaintext).catch(() => {}); + } + queueRef.current.push(plaintext); + // pump(), not flushQueue(): arriving data is the moment to top the + // window back up, and that is what keeps the stream continuous. + pump(); + } catch (e) { + console.error('[MSE] decrypt error:', e); + } + }; + + transport.onStreamEnd = (msg) => { + if (cancelled) return; + // The end of the previous film is not the end of this one. + if (msg && msg.file_id && msg.file_id !== entry.id) return; + // Nor is the end of the stream we abandoned by seeking: taking it + // would call endOfStream() and truncate the film at the seek point. + if (awaitingInitRef.current) return; + endedRef.current = true; + if (castActiveRef.current && platform.cast.available) { + platform.cast.finish().catch(() => {}); + } + flushQueue(); + }; + + // The opening window, and the count that tracks it. Asking for more here + // than pump() maintains would leave the node holding credit this side + // does not know about, which is the whole window's worth of overshoot on + // the very first breath of the stream. + outstandingRef.current = STREAM_WINDOW; + const resumeAt = readResumePosition(entry.id); + if (resumeAt) setResumedFrom(resumeAt); + transport.requestStream(entry.id, STREAM_WINDOW, resumeAt); + }; + + // Closing the tab, or backgrounding it on a phone, never runs a React + // cleanup — so the node hears nothing and keeps transcoding. `pagehide` + // fires in both cases and is the one event mobile browsers honour on the + // way out; `visibilitychange` covers switching apps. The node stops the + // stream by itself when the connection drops, but that costs a round of + // detection, and this message is a single datagram already in flight. + const leave = (why) => { + const t = transportRef.current; + console.log('[MeshBay] stopStream:', why); + if (t && t.connected) t.stopStream(); + }; + const onPageHide = () => leave('pagehide'); + // NOT wired to stopStream. Android fires visibilitychange when a video goes + // fullscreen, so cutting the stream here killed the film the moment it was + // watched properly. Logged only, until that is confirmed or ruled out. + const onVisibility = () => { + console.log('[MeshBay] visibilitychange:', document.visibilityState); + }; + window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibility); + + // `timeupdate` is silent while the film is paused, and the append path + // cannot wake itself once the ceiling has refused a segment. This is the + // clock that guarantees something is still driving the pipeline. + const pumpTimer = setInterval(pump, 1000); + + // What the player sees, into the node's log. A hang on a phone shows the + // node feeding a stream quite happily; the half that says otherwise is in + // here, and there is no console to read it from. + const diagTimer = setInterval(() => { + const v = videoRef.current, sb = sbRef.current; + const t = transportRef.current; + if (!t || !v) return; + // Cheap, and the only thing that makes "resume where I stopped" work + // when the tab is closed rather than the player. + if (!v.paused) { + writeResumePosition(entry.id, v.currentTime, durationRef.current); + } + let ranges = ''; + try { + for (let i = 0; sb && i < sb.buffered.length; i++) { + ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; + } + } catch { ranges = '?'; } + t.sendStreamDiag({ + t: +v.currentTime.toFixed(1), + ahead: +bufferedAhead().toFixed(1), + ranges: ranges.trim(), + ready: v.readyState, // 0 = nothing, 4 = enough to play through + paused: v.paused, + stalled: stalledRef.current, + q: queueRef.current.length, + inflight: outstandingRef.current, + appending: appendingRef.current, + updating: sb ? sb.updating : null, + quota: quotaRef.current, + ms: msRef.current ? msRef.current.readyState : null, + err: v.error ? `${v.error.code}:${v.error.message}` : null, + }); + }, 5000); + + startStream().catch(err => { + if (!cancelled) { setError(err.message); setPhase('error'); } + }); + + return () => { + cancelled = true; + clearInterval(pumpTimer); + clearInterval(diagTimer); + clearTimeout(seekTimerRef.current); + // Closing the player is the commonest way to stop watching, so this is + // the write that matters most. + if (videoRef.current) { + writeResumePosition(entry.id, videoRef.current.currentTime, + durationRef.current); + } + if (castActiveRef.current && platform.cast.available) { + platform.cast.chromecastDisconnect().catch(() => {}); + platform.cast.stop().catch(() => {}); + castActiveRef.current = false; + } + window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibility); + if (videoRef.current) { + videoRef.current.removeEventListener('seeking', onSeeking); + videoRef.current.removeEventListener('timeupdate', pump); + videoRef.current.removeEventListener('waiting', onStarved); + videoRef.current.removeEventListener('stalled', onStarved); + videoRef.current.removeEventListener('playing', onFed); + videoRef.current.removeEventListener('canplay', onFed); + } + if (transport) { + // Tell the node first: dropping the handlers only makes us deaf, and a + // stream nobody is listening to still occupies a transcode slot. + transport.stopStream(); + transport.onStreamInit = null; + transport.onStreamData = null; + transport.onStreamEnd = null; + transport.onStreamError = null; + } + // The queue can hold several megabytes of decrypted video. + queueRef.current = []; + const ms = msRef.current; + if (ms && ms.readyState === 'open') { + try { ms.endOfStream(); } catch { /* already ended */ } + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + sbRef.current = null; + msRef.current = null; + }; + }, [entry, flushQueue, pump]); + + useEffect(() => { + if (phase === 'streaming' && videoRef.current) { + videoRef.current.play().catch(() => {}); + } + }, [phase]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${platform.capabilities.lanCast && html` + <div class="cast-wrapper" style="position:relative"> + <button class="video-close ${castActive ? 'cast-active' : ''}" + onClick=${async () => { + if (castActive) { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + } else if (castCodecRef.current && initSegmentRef.current) { + if (castPickerOpen) { + setCastPickerOpen(false); + } else { + setCastPickerOpen(true); + setCastScanning(true); + setCastDevices([]); + platform.cast.discover().then((devices) => { + setCastDevices(devices || []); + setCastScanning(false); + }).catch(() => setCastScanning(false)); + } + } + }} + title="${castActive ? t('cast.stop') : t('cast.start')}"> + <${Icon} name="cast" /></button> + ${castPickerOpen && html` + <div class="cast-picker"> + ${castScanning && html` + <div class="cast-picker-item cast-picker-scanning"> + <span class="spinner" style="width:14px;height:14px"></span> + ${t('cast.scanning')} + </div> + `} + ${castDevices.map(d => html` + <button class="cast-picker-item" onClick=${() => { + setCastPickerOpen(false); + setCastDeviceName(d.name); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + castDeviceRef.current = d; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="cast" /> ${d.name} + </button> + `)} + ${!castScanning && castDevices.length === 0 && html` + <div class="cast-picker-item cast-picker-empty"> + ${t('cast.no_devices')} + </div> + `} + <div class="cast-picker-sep"></div> + <button class="cast-picker-item" onClick=${async () => { + setCastPickerOpen(false); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="clip" /> ${t('cast.copy_url')} + </button> + </div> + `} + </div> + `} + ${castUrl && html` + <span class="cast-status-label"> + ${castDeviceName + ? castDeviceName + : html`<input class="cast-url-input" readOnly value=${castUrl} + onClick=${(e) => { + e.target.select(); + navigator.clipboard.writeText(castUrl).catch(() => {}); + }} + title="${t('cast.copy_url')}" />` + } + </span> + `} + ${onDownload && html` + <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} + onClick=${() => { + if (!dlBusy) { + setDlBusy(true); + onDownload(); + setTimeout(() => setDlBusy(false), 1500); + } + }} + title="${t('group.download')}"> + ${dlBusy + ? html`<span class="spinner"></span>` + : html`<${Icon} name="download" />`}</button> + `} + <button class="video-close" onClick=${onClose} title="${t('video.close')}"> + <${Icon} name="close" /></button> + </div> + + ${phase === 'loading' && html` + <div class="video-loading"> + <div class="video-loading-label"> + <span class="spinner"></span>${' '}${t('video.buffering')} + </div> + </div> + `} + + ${(phase === 'streaming' || phase === 'loading') && html` + <div class="video-container"> + <video ref=${videoRef} controls autoplay /> + ${resumedFrom > 0 && html` + <div class="video-resumed"> + ${t('video.resumed_at', { time: formatClock(resumedFrom) })} + <button class="linklike" onClick=${() => { + setResumedFrom(0); + writeResumePosition(entry.id, 0, durationRef.current); + if (requestSeekRef.current) requestSeekRef.current(0); + }}>${t('video.from_start')}</button> + </div> + `} + </div> + `} + + ${phase === 'error' && html` + <div class="video-error">${error}</div> + `} + </div> + `; +} + +// ── Search Page (cross-group file search) ─────────────────────────────────── + +/** + * "3 hours ago", in the reader's language. + * + * The search page needs it because its results come from a cache: a file that + * was deleted an hour ago is still listed until the group is opened again, and + * the honest thing is to say how old the answer is rather than to imply it is + * live. + */ + +export { VideoPlayer }; |