summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js339
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css35
12 files changed, 372 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a9559c6..198bc78 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1004,6 +1004,15 @@ const CREDIT_KEEPALIVE_MS = 20000;
// 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;
const PIPELINE_WINDOW = 8;
@@ -2673,6 +2682,43 @@ function _mseSupported(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 browser 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.
+ */
+function readResumePosition(fileId) {
+ try {
+ const raw = localStorage.getItem(`mb:pos:${fileId}`);
+ 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 {
+ if (!Number.isFinite(at) || at < RESUME_MIN_S
+ || (duration && at > duration * RESUME_MAX_FRACTION)) {
+ localStorage.removeItem(`mb:pos:${fileId}`);
+ return;
+ }
+ localStorage.setItem(`mb:pos:${fileId}`, String(Math.floor(at)));
+ } catch { /* nothing to be done, and nothing worth failing over */ }
+}
+
function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
@@ -2692,6 +2738,43 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// 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);
+
+ /**
+ * 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.
@@ -2705,7 +2788,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const v = videoRef.current;
if (!sb || !v || sb.updating || !sb.buffered.length) return false;
const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
- const start = sb.buffered.start(0);
+ // 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);
@@ -2713,19 +2801,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
} catch {
return false;
}
- }, []);
+ }, [currentRange]);
/** Seconds of film held past the playhead. */
const bufferedAhead = useCallback(() => {
- const sb = sbRef.current;
const v = videoRef.current;
- if (!sb || !v || !sb.buffered.length) return 0;
- try {
- return sb.buffered.end(sb.buffered.length - 1) - v.currentTime;
- } catch {
- return 0;
- }
- }, []);
+ const range = currentRange();
+ if (!v || !range) return 0;
+ return Math.max(0, range[1] - v.currentTime);
+ }, [currentRange]);
const flushQueue = useCallback(() => {
const sb = sbRef.current;
@@ -2819,6 +2903,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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'));
@@ -2829,14 +2923,152 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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');
+ 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) {
+ 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;
+ setPhase('streaming');
+ pump();
+ };
+
const onSeeking = () => {
const v = videoRef.current;
- if (!v || !v.buffered.length) return;
+ if (!v || cancelled) return;
const target = v.currentTime;
- const end = v.buffered.end(v.buffered.length - 1);
- const start = v.buffered.start(0);
- if (target > end) v.currentTime = Math.max(end - 0.5, start);
- else if (target < start) v.currentTime = start;
+ // Inside what is buffered, the browser handles it and the node need not
+ // hear about it at all.
+ 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 () => {
@@ -2861,6 +3093,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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') {
+ reinitAt(msg.start || 0).catch(() => {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ });
+ return;
+ }
+
const ms = new MediaSource();
msRef.current = ms;
const url = URL.createObjectURL(ms);
@@ -2873,7 +3118,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
}
const sb = ms.addSourceBuffer(mime);
sbRef.current = sb;
- sb.mode = 'sequence';
+ // '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()`
@@ -2881,6 +3137,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// evictions. What may be in flight is decided from the buffer, in
// pump(), and nowhere else.
appendingRef.current = false;
+ landPlayhead();
pump();
});
setPhase('streaming');
@@ -2902,16 +3159,27 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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);
+ // 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;
- // One of the in-flight segments landed, whatever becomes of it: the
- // window has room again. Counted before the decrypt so that a segment
- // we fail to read still frees its slot rather than shrinking the
- // window by one for the rest of the film.
- outstandingRef.current = Math.max(0, outstandingRef.current - 1);
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
@@ -2928,6 +3196,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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;
flushQueue();
};
@@ -2937,7 +3208,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// 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;
- transport.requestStream(entry.id, 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
@@ -2973,6 +3246,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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++) {
@@ -3004,6 +3282,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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);
+ }
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
if (videoRef.current) {
@@ -3080,6 +3365,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
${(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>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index f045243..15401bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -121,6 +121,8 @@ export default {
// Video player
'video.loading': '{name} wird geladen …',
'video.buffering': 'Wird gepuffert …',
+ 'video.resumed_at': "Fortgesetzt bei {time}",
+ 'video.from_start': "Von vorn beginnen",
'video.close': 'Schließen (Esc)',
'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es '
+ 'stattdessen herunter — entschlüsselt wurde es ohnehin hier.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 25c1f6c..a98eb1a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -120,6 +120,8 @@ export default {
// Video player
'video.loading': 'Loading {name}...',
'video.buffering': 'Buffering...',
+ 'video.resumed_at': "Resumed at {time}",
+ 'video.from_start': "Start from the beginning",
'video.close': 'Close (Esc)',
'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.',
'group.upload_indexing': 'indexing…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 0799ec5..596d952 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -119,6 +119,8 @@ export default {
// Video player
'video.loading': 'Cargando {name}...',
'video.buffering': 'Almacenando en búfer...',
+ 'video.resumed_at': "Reanudado en {time}",
+ 'video.from_start': "Empezar desde el principio",
'video.close': 'Cerrar (Esc)',
'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo '
+ 'en su lugar — en cualquier caso se descifró aquí.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 32d0461..61dc99e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -120,6 +120,8 @@ export default {
// Video player
'video.loading': 'Chargement de {name}...',
'video.buffering': 'Mise en mémoire tampon...',
+ 'video.resumed_at': "Reprise à {time}",
+ 'video.from_start': "Reprendre depuis le début",
'video.close': 'Fermer (Échap)',
'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. '
+ 'Téléchargez-le plutôt — il a été déchiffré ici dans les deux cas.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 32d7922..6693115 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -120,6 +120,8 @@ export default {
// Video player
'video.loading': 'Caricamento di {name}...',
'video.buffering': 'Buffering in corso...',
+ 'video.resumed_at': "Ripreso da {time}",
+ 'video.from_start': "Riparti dall'inizio",
'video.close': 'Chiudi (Esc)',
'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi '
+ 'invece — in ogni caso è stato decifrato qui.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 0adc51c..8e18a19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -117,6 +117,8 @@ export default {
// Video player
'video.loading': '{name} を読み込んでいます…',
'video.buffering': 'バッファリング中…',
+ 'video.resumed_at': "{time} から再開しました",
+ 'video.from_start': "最初から再生する",
'video.close': '閉じる(Esc)',
'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。'
+ 'ダウンロードしてご覧ください。いずれにせよ復号はここで行われています。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 947724f..6fd9220 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -121,6 +121,8 @@ export default {
// Video player
'video.loading': '{name} wordt geladen...',
'video.buffering': 'Bezig met bufferen...',
+ 'video.resumed_at': "Hervat op {time}",
+ 'video.from_start': "Vanaf het begin afspelen",
'video.close': 'Sluiten (Esc)',
'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download '
+ 'hem in plaats daarvan — ontsleuteld werd hij hoe dan ook hier.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 687b93b..81f0139 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -126,6 +126,8 @@ export default {
// Video player
'video.loading': 'Wczytywanie {name}...',
'video.buffering': 'Buforowanie...',
+ 'video.resumed_at': "Wznowiono od {time}",
+ 'video.from_start': "Odtwórz od początku",
'video.close': 'Zamknij (Esc)',
'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę '
+ 'go pobrać — i tak został odszyfrowany tutaj.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 56bfbdd..a2f41af 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -121,6 +121,8 @@ export default {
// Video player
'video.loading': 'Carregando {name}...',
'video.buffering': 'Armazenando em buffer...',
+ 'video.resumed_at': "Retomado em {time}",
+ 'video.from_start': "Começar do início",
'video.close': 'Fechar (Esc)',
'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe '
+ 'o arquivo — de todo modo ele foi descriptografado aqui.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 40a40a4..86c7580 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -115,6 +115,8 @@ export default {
// Video player
'video.loading': '正在加载 {name}…',
'video.buffering': '正在缓冲…',
+ 'video.resumed_at': "已从 {time} 继续播放",
+ 'video.from_start': "从头开始播放",
'video.close': '关闭(Esc)',
'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。',
'group.upload_indexing': '建立索引中…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index a041f66..5987d1c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1027,6 +1027,41 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
outline: none;
}
+/* Where the film was picked up again. Sits over the picture rather than
+ pushing it down, and clears itself once the viewer is watching. */
+.video-container { position: relative; }
+
+.video-resumed {
+ position: absolute;
+ left: 12px;
+ top: 12px;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: rgba(15, 23, 42, 0.82);
+ color: #e2e8f0;
+ font-size: 0.85rem;
+ pointer-events: auto;
+ animation: video-resumed-fade 6s forwards;
+}
+
+@keyframes video-resumed-fade {
+ 0%, 70% { opacity: 1; }
+ 100% { opacity: 0; visibility: hidden; }
+}
+
+.video-resumed .linklike {
+ background: none;
+ border: 0;
+ padding: 0;
+ color: #7dd3fc;
+ cursor: pointer;
+ font: inherit;
+ text-decoration: underline;
+}
+
.video-loading {
text-align: center;
color: #94a3b8;