aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js119
1 files changed, 109 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index c8fb694..18ce83a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -941,6 +941,10 @@ function canPreview(e) {
}
const CHUNK_SIZE = 1024 * 1024;
+// 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;
+const QUEUE_HIGH_WATER = 12;
const PIPELINE_WINDOW = 8;
/**
@@ -950,7 +954,8 @@ const PIPELINE_WINDOW = 8;
* it and hand the browser a blob", or false for "the person dismissed the
* dialog", which is not an error and must not start a transfer.
*/
-async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) {
+async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
+ swSize = size) {
try {
const target = await downloads.openTarget(filename);
if (target) return target;
@@ -958,12 +963,16 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) {
console.warn('[MeshBay] download folder unusable:', err.message);
}
- // No granted folder. Saving automatically means not putting a dialog in the
- // way, so anything that fits in memory goes to the browser's own download
- // folder — which is what "automatic" meant to whoever chose the setting.
- // Past that a blob would take the tab down with it, and one dialog is the
- // lesser evil; Settings is where to stop it happening again.
- if (downloads.getMode() === 'auto' && size < downloads.BLOB_LIMIT) return null;
+ // No granted folder. A service worker can still hand the browser a stream to
+ // write, which is how this works at all in Firefox: the alternative there is
+ // to collect gigabytes in a tab. It goes to the browser's own download
+ // folder, without a dialog, which is what "save automatically" meant.
+ if (downloads.getMode() === 'auto') {
+ const streamed = await downloads.openStreamedDownload(filename, swSize);
+ if (streamed) return streamed;
+ // Nothing to stream to: small enough for memory, and no dialog.
+ if (size < downloads.BLOB_LIMIT) return null;
+ }
if (!window.showSaveFilePicker) return null;
try {
@@ -1331,10 +1340,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
const suggested = (dir.split('/').pop() || 'files') + '.zip';
+ // totalBytes decides how this is delivered, but it is not the archive's
+ // size — headers and the central directory come on top — so it is not
+ // announced as a Content-Length that the download would then miss.
const target = await _openDownloadTarget(suggested, totalBytes, {
types: [{ description: 'ZIP archive',
accept: { 'application/zip': ['.zip'] } }],
- });
+ }, 0);
if (target === false) return;
if (!target && !confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
@@ -1970,6 +1982,39 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
}
}, [pairCode, transportRef, userId]);
+ const [removing, setRemoving] = useState('');
+
+ /**
+ * Take someone out of this group: both halves, in the order that fails safe.
+ *
+ * The node first, because that is the half that stops the group key being
+ * wrapped for them; if the hub removal then fails, they are a member on paper
+ * with no key. The other order would leave them able to reach a node that
+ * still serves them.
+ */
+ const removeMember = useCallback(async (member) => {
+ const transport = transportRef && transportRef.current;
+ setError('');
+ setRemoving(member.user_id);
+ try {
+ if (transport && transport.connected && operatorPaired) {
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.revokeMember(member.user_id, signFn);
+ }
+ await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, {
+ method: 'DELETE', token,
+ });
+ loadMembers();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setRemoving('');
+ }
+ }, [groupId, token, transportRef, operatorPaired]);
+
const loadMembers = useCallback(() => {
setLoading(true);
hubFetch(`/v1/groups/${groupId}/members`, { token })
@@ -2067,6 +2112,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
<tr>
<th>${t('admin.col_username')}</th>
<th>${t('members.group_role')}</th>
+ <th></th>
</tr>
</thead>
<tbody>
@@ -2079,10 +2125,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
: html`<span class="badge">${t('members.member')}</span>`
}
</td>
+ <td class="admin-actions">
+ ${isAdmin && m.user_id !== adminId && html`
+ <button class="admin-btn danger" disabled=${removing === m.user_id}
+ onClick=${() => {
+ if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
+ removeMember(m);
+ }}>
+ ${removing === m.user_id ? '...' : t('members.remove')}
+ </button>
+ `}
+ </td>
</tr>
`)}
</tbody>
</table>
+ ${isAdmin && members.length > 1 && html`
+ <p class="settings-hint">${t('members.remove_hint')}</p>
+ `}
${isNodeAdmin && !operatorPaired && html`
<form class="invite-form" onSubmit=${doPair}>
<h4>${t('members.pair_title')}</h4>
@@ -2379,6 +2439,28 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const endedRef = useRef(false);
const durationRef = useRef(0);
+ /**
+ * 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);
+ const start = sb.buffered.start(0);
+ if (keepFrom - start < 10) return false;
+ try {
+ sb.remove(start, keepFrom);
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
const flushQueue = useCallback(() => {
const sb = sbRef.current;
if (!sb || appendingRef.current || sb.updating) return;
@@ -2389,14 +2471,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
return;
}
appendingRef.current = true;
- const chunk = queueRef.current.shift();
+ const chunk = queueRef.current[0];
try {
sb.appendBuffer(chunk);
+ queueRef.current.shift();
} catch (e) {
appendingRef.current = false;
+ if (e.name === 'QuotaExceededError') {
+ // 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]);
useEffect(() => {
let cancelled = false;
@@ -2445,6 +2538,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
sb.mode = 'sequence';
sb.addEventListener('updateend', () => {
appendingRef.current = false;
+ // One segment consumed, so the node may send one more. The credit
+ // is granted here, after the append, because this is the point at
+ // which the memory is genuinely free again.
+ const transport = transportRef.current;
+ if (transport && transport.connected) transport.grantStreamCredit(1);
+ if (queueRef.current.length > QUEUE_HIGH_WATER) evictBehind();
flushQueue();
});
setPhase('streaming');