kaledonya clips: a Discord-friendly clip site on top of Zipline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
e6f8905377
34 files changed
+2917
No files matched your search
@@ -0,0 +1,526 @@
|
||||
'use strict';
|
||||
// Streamable-style library, watch page and uploads in a 2003 Steam skin.
|
||||
// Talks to intake's /_site/api; uploads go straight to intake's UPLOAD_URL (cfg.uploadUrl).
|
||||
const app = document.getElementById('app');
|
||||
const dialogs = document.getElementById('dialogs');
|
||||
const BLOCK = 12; // one 4px gap + one 8px block, like Steam's VGUI ProgressBar
|
||||
let cfg = { uploadUrl: '', publicUrl: '', uploadHint: '' };
|
||||
// SITE_NAME and SERVER_NAME, filled into index.html by intake
|
||||
const SITE = document.title, BRAND = SITE.split(' ')[0], SERVER = document.documentElement.dataset.server;
|
||||
let me = null;
|
||||
const state = { files: [], page: 0, pages: 1, total: null };
|
||||
|
||||
function h(tag, attrs = {}, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'style') el.style.cssText = v;
|
||||
else if (k === 'value') el.value = v;
|
||||
else if (v !== false && v != null) el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : String(kid));
|
||||
return el;
|
||||
}
|
||||
|
||||
class LoggedOut extends Error {}
|
||||
async function api(path, opts = {}) {
|
||||
// no-store: answers differ per user, so never reuse one from the browser cache
|
||||
const init = { method: opts.method || 'GET', credentials: 'same-origin', cache: 'no-store', headers: {} };
|
||||
if (opts.body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(opts.body); }
|
||||
let r;
|
||||
try { r = await fetch('/_site/api' + path, init); } catch { throw new Error(`Lost connection to ${SERVER}.`); }
|
||||
let data = {};
|
||||
try { data = await r.json(); } catch {}
|
||||
if (r.status === 401 && path !== '/login') throw new LoggedOut();
|
||||
if (!r.ok) throw new Error(data.error || 'Error ' + r.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
const fmtSize = (b) => { const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return (i ? b.toFixed(1) : b) + ' ' + u[i]; };
|
||||
const fmtDate = (s) => new Date(s).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
const daysLeft = (s) => (s ? Math.max(0, Math.ceil((new Date(s) - Date.now()) / 86400000)) : null);
|
||||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
||||
const media = (path) => cfg.publicUrl + path;
|
||||
// the link to paste in Discord: /v/<name> gives Discord a player (and a small copy for big clips)
|
||||
const isVideo = (name) => /\.(mp4|mov|m4v|webm)$/i.test(name);
|
||||
const shareLink = (name, raw) => isVideo(name) ? cfg.publicUrl + '/v/' + encodeURIComponent(name) : raw;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// the old Steam block bar: whole blocks only, added one at a time until they catch up with the real progress
|
||||
function sbar() {
|
||||
const bar = h('div', { class: 'sbar' }, h('div', { class: 'fill' }));
|
||||
bar.shown = 0; bar.target = 0; bar.tick = null;
|
||||
return bar;
|
||||
}
|
||||
function setBar(bar, frac) {
|
||||
const total = Math.max(1, Math.floor(bar.clientWidth / BLOCK));
|
||||
bar.target = Math.floor(total * Math.min(1, Math.max(0, frac)));
|
||||
if (bar.target < bar.shown) bar.shown = bar.target; // a new file starts again from empty
|
||||
bar.firstChild.style.width = bar.shown * BLOCK + 'px';
|
||||
if (!bar.tick) bar.tick = setInterval(() => {
|
||||
if (bar.shown < bar.target) bar.shown++;
|
||||
else { clearInterval(bar.tick); bar.tick = null; }
|
||||
bar.firstChild.style.width = bar.shown * BLOCK + 'px';
|
||||
}, 30);
|
||||
}
|
||||
const barFull = (bar) => new Promise((r) => { const t = setInterval(() => { if (!bar.tick) { clearInterval(t); r(); } }, 30); });
|
||||
|
||||
function dialog(title, body, { clear = false } = {}) {
|
||||
const box = h('div', { class: 'dlg raised' }, h('div', { class: 'title' }, title), h('div', { class: 'body' }, body));
|
||||
const ov = h('div', { class: 'overlay' + (clear ? ' clear' : '') }, box);
|
||||
dialogs.append(ov);
|
||||
return { box, close: () => ov.remove() };
|
||||
}
|
||||
function shake(box) { box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake'); }
|
||||
function alertBox(title, msg) {
|
||||
return new Promise((res) => {
|
||||
const ok = h('button', { class: 'btn y', onclick: () => { d.close(); res(); } }, 'OK');
|
||||
const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, ok)]);
|
||||
ok.focus();
|
||||
});
|
||||
}
|
||||
function confirmBox(title, msg, yes) {
|
||||
return new Promise((res) => {
|
||||
const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' },
|
||||
h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'),
|
||||
h('button', { class: 'btn y', onclick: () => { d.close(); res(true); } }, yes))]);
|
||||
});
|
||||
}
|
||||
// check(value) resolves to an error message, or null to close the box
|
||||
function promptBox(title, label, value, check) {
|
||||
return new Promise((res) => {
|
||||
const input = h('input', { class: 'field', value });
|
||||
const err = h('div', { class: 'err' });
|
||||
const go = async () => {
|
||||
const e = await check(input.value.trim());
|
||||
if (e) { err.textContent = e; shake(d.box); } else { d.close(); res(true); }
|
||||
};
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') go();
|
||||
if (e.key === 'Escape') { d.close(); res(false); }
|
||||
});
|
||||
const d = dialog(title, [h('label', {}, label), input, err, h('div', { class: 'row' },
|
||||
h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'),
|
||||
h('button', { class: 'btn y', onclick: go }, 'OK'))]);
|
||||
input.focus(); input.select();
|
||||
});
|
||||
}
|
||||
function toast(msg) { const t = h('div', { class: 'toast raised' }, msg); document.body.append(t); setTimeout(() => t.remove(), 1600); }
|
||||
// Discord plays a big clip only from its small copy and caches whatever it sees first: on the clip page, a link
|
||||
// whose copy is still being made shows "preparing for Discord... 64%" instead, with Copy off
|
||||
async function holdForDiscord(name, el, show) {
|
||||
if (!isVideo(name)) return show(true);
|
||||
show(false, 'checking Discord...');
|
||||
for (let i = 0; i < 3600; i++) {
|
||||
let s = { state: 'small' };
|
||||
try { s = await api('/discord/' + encodeURIComponent(name)); } catch {}
|
||||
if (s.state !== 'making') return show(true, s.state === 'none' ? "Too long for Discord's player: the link opens the clip, but won't play in the chat." : '');
|
||||
show(false, 'preparing for Discord... ' + discordNote(s));
|
||||
await wait(1000);
|
||||
if (!el.isConnected) return; // left the page
|
||||
}
|
||||
show(true);
|
||||
}
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); toast('Link copied'); }
|
||||
catch { await promptBox('Copy link', 'Copy this link:', text, async () => null); }
|
||||
}
|
||||
|
||||
// static markup only (h() can't make SVG elements)
|
||||
function svg(markup) { const t = document.createElement('template'); t.innerHTML = markup; return t.content.firstChild; }
|
||||
|
||||
// full-screen login; the window buttons are only for the look
|
||||
function loginDialog() {
|
||||
return new Promise((res) => {
|
||||
const user = h('input', { class: 'field', id: 'lu', autocomplete: 'username' });
|
||||
const pass = h('input', { class: 'field', id: 'lp', type: 'password', autocomplete: 'current-password' });
|
||||
const err = h('div', { class: 'err' });
|
||||
const bar = sbar();
|
||||
const conn = h('div', { style: 'display:none' }, h('div', {}, `Connecting to ${SERVER}...`), bar);
|
||||
const form = h('form', { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
err.textContent = '';
|
||||
form.style.display = 'none'; conn.style.display = '';
|
||||
bar.shown = 0; setBar(bar, 0.6);
|
||||
try {
|
||||
await api('/login', { method: 'POST', body: { username: user.value.trim(), password: pass.value } });
|
||||
setBar(bar, 1); await barFull(bar);
|
||||
screen.remove(); res();
|
||||
} catch (x) {
|
||||
conn.style.display = 'none'; form.style.display = '';
|
||||
err.textContent = x.message; shake(win); pass.select();
|
||||
}
|
||||
} }, h('label', { for: 'lu' }, 'Account name'), user, h('label', { for: 'lp' }, 'Password'), pass, err,
|
||||
h('div', { class: 'row' }, h('button', { class: 'btn y', type: 'submit' }, 'Log in')));
|
||||
const win = h('div', { class: 'win' },
|
||||
h('div', { class: 'title' }, h('i', {}, BRAND + ' · Login'), h('u', {}, '_'), h('u', {}, '□'), h('u', {}, '×')),
|
||||
h('div', { class: 'body' },
|
||||
h('div', { class: 'brand' },
|
||||
svg('<svg viewBox="0 0 34 34" aria-hidden="true"><rect width="34" height="34" fill="#c4b550"/><path d="M12 8 L27 17 L12 26Z" fill="#2b3125"/></svg>'),
|
||||
h('strong', {}, SITE)),
|
||||
form, conn));
|
||||
const screen = h('div', { class: 'login' },
|
||||
h('header', {}, h('b', {}, BRAND), h('div', { class: 'sp' }),
|
||||
h('span', {}, `Connected to ${SERVER}`), h('span', { class: 'hide' }, 'Invite only'), h('span', {}, 'v1.0')),
|
||||
h('div', { class: 'mark-word', 'aria-hidden': 'true' }, BRAND),
|
||||
svg('<svg class="mark" viewBox="0 0 100 100" aria-hidden="true"><rect x="4" y="4" width="92" height="92"/><path d="M38 28 L74 50 L38 72Z"/></svg>'),
|
||||
h('main', {}, win));
|
||||
dialogs.append(screen);
|
||||
user.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function shell(content, sideOn) {
|
||||
if (!me) {
|
||||
// a visitor from a shared link: the clip, the look, and a way in for account holders
|
||||
const top = h('div', { class: 'topbar' },
|
||||
h('a', { class: 'logo', href: '/' }, h('i'), SITE),
|
||||
h('div', { class: 'user' }, h('button', { class: 'btn', onclick: async () => { await loginDialog(); boot(); } }, 'Log in')));
|
||||
app.replaceChildren(h('div', { class: 'shell' }, top,
|
||||
h('div', { class: 'main' }, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))),
|
||||
h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, 'guest'))));
|
||||
return;
|
||||
}
|
||||
const side = h('div', { class: 'side' },
|
||||
h('div', { class: 'item' + (sideOn === 'all' ? ' on' : ''), onclick: () => go('/') },
|
||||
'▣ All videos', state.total != null ? ` (${state.total})` : ''),
|
||||
h('div', { class: 'lbl' }, 'STORAGE'),
|
||||
h('div', { class: 'small' }, fmtSize(me.storageUsed || 0) + ' used'));
|
||||
const menu = h('div', { class: 'menu raised', style: 'display:none' }, h('div', { onclick: logout }, 'Log out'));
|
||||
const top = h('div', { class: 'topbar' },
|
||||
h('button', { class: 'btn burger', onclick: () => side.classList.toggle('open') }, '☰'),
|
||||
h('a', { class: 'logo', href: '/', onclick: (e) => { e.preventDefault(); go('/'); } }, h('i'), SITE),
|
||||
h('div', { class: 'user' }, h('button', { class: 'btn', onclick: () => { menu.style.display = menu.style.display === 'none' ? '' : 'none'; } }, me.username + ' ▾'), menu));
|
||||
app.replaceChildren(h('div', { class: 'shell' }, top,
|
||||
h('div', { class: 'main' }, side, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))),
|
||||
h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, me.username))));
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
const d = await api('/files?page=' + (state.page + 1));
|
||||
state.page++; state.pages = d.pages || 1; state.total = d.total;
|
||||
state.files.push(...d.files);
|
||||
}
|
||||
function card(f) {
|
||||
const left = daysLeft(f.deletesAt);
|
||||
return h('div', { class: 'card raised', onclick: () => go('/v/' + encodeURIComponent(f.name)) },
|
||||
h('div', { class: 'thumb sunken', style: f.thumbnail ? `background-image:url("${media(f.thumbnail)}")` : '' }, f.thumbnail ? '' : '▶'),
|
||||
h('div', { class: 't', title: f.name }, f.name),
|
||||
h('div', { class: 'm' }, [fmtDate(f.createdAt), plural(f.views, 'view'), left != null ? left + 'd left' : null].filter(Boolean).join(' · ')));
|
||||
}
|
||||
function preload(files, progress) {
|
||||
const urls = files.map((f) => f.thumbnail).filter(Boolean).slice(0, 12);
|
||||
if (!urls.length) { progress(1); return Promise.resolve(); }
|
||||
let n = 0;
|
||||
const one = (u) => new Promise((r) => {
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => { progress(++n / urls.length); r(); };
|
||||
img.src = media(u);
|
||||
});
|
||||
return Promise.race([Promise.all(urls.map(one)), wait(4000)]);
|
||||
}
|
||||
async function libraryView(progress) {
|
||||
state.files = []; state.page = 0;
|
||||
await loadPage();
|
||||
if (progress) await preload(state.files, progress);
|
||||
renderLibrary();
|
||||
}
|
||||
function renderLibrary() {
|
||||
const picker = h('input', { type: 'file', multiple: true, style: 'display:none', onchange: () => { const f = [...picker.files]; picker.value = ''; uploadFiles(f); } });
|
||||
const more = state.page < state.pages ? h('div', { class: 'more' }, h('button', { class: 'btn', onclick: async (e) => {
|
||||
e.target.disabled = true;
|
||||
try { await loadPage(); renderLibrary(); } catch (x) { handleError(x); }
|
||||
} }, 'Load more')) : null;
|
||||
const body = state.files.length
|
||||
? [h('div', { class: 'grid' }, state.files.map(card)), more]
|
||||
: h('div', { class: 'empty raised', onclick: () => picker.click() },
|
||||
h('div', { class: 'icon sunken' }, '⬆'), h('div', { class: 'big' }, 'Select videos to upload'), h('div', { class: 'dim' }, 'Or drag and drop video files'));
|
||||
shell([picker,
|
||||
h('div', { class: 'uploadbar raised' }, h('button', { class: 'btn y', onclick: () => picker.click() }, '⬆ Upload video'),
|
||||
h('div', { class: 'hint sunken' }, 'or drop files anywhere · max 5 GB · .mkv becomes .mp4')),
|
||||
h('h2', { class: 'count' }, 'All videos ', h('span', {}, `(${state.total ?? 0})`)), body], 'all');
|
||||
}
|
||||
|
||||
async function watchView(name) {
|
||||
let f = null;
|
||||
if (me) {
|
||||
try { f = await api('/files/' + encodeURIComponent(name)); }
|
||||
catch (e) { if (e instanceof LoggedOut || !/doesn't exist/.test(e.message)) throw e; }
|
||||
}
|
||||
// not logged in, or someone else's clip: everyone may watch a shared link
|
||||
if (!f) return publicWatchView(await api('/public/' + encodeURIComponent(name)));
|
||||
if (state.total == null) { try { state.total = (await api('/files?page=1')).total; } catch {} }
|
||||
const left = daysLeft(f.deletesAt);
|
||||
const link = media(f.url);
|
||||
const share = shareLink(f.name, link);
|
||||
const copyBtn = h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link');
|
||||
const shown = h('div', { class: 'link sunken' }, share), note = h('div', { class: 'small' });
|
||||
const player = (f.type || '').startsWith('image/')
|
||||
? h('img', { class: 'sunken', src: link, alt: f.name })
|
||||
: h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false });
|
||||
shell([
|
||||
h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos'),
|
||||
h('div', { class: 'watch' }, player,
|
||||
h('h1', {}, f.name),
|
||||
h('div', { class: 'info' }, [fmtDate(f.createdAt), plural(f.views, 'view'), fmtSize(f.size),
|
||||
left != null ? `deletes in ${plural(left, 'day')}` : null].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'actions' },
|
||||
copyBtn,
|
||||
h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download'),
|
||||
h('button', { class: 'btn', onclick: () => rename(f) }, '✎ Rename'),
|
||||
h('button', { class: 'btn right', onclick: () => remove(f) }, '✖ Delete')),
|
||||
shown, note)], null);
|
||||
holdForDiscord(f.name, shown, (ready, msg = '') => {
|
||||
copyBtn.disabled = !ready; shown.textContent = ready ? share : msg; note.textContent = ready ? msg : '';
|
||||
});
|
||||
}
|
||||
function publicWatchView(f) {
|
||||
const link = media(f.url);
|
||||
const share = shareLink(f.name, link);
|
||||
const player = (f.type || '').startsWith('image/')
|
||||
? h('img', { class: 'sunken', src: link, alt: f.name })
|
||||
: h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false });
|
||||
shell([
|
||||
me ? h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos') : null,
|
||||
h('div', { class: 'watch' }, player,
|
||||
h('h1', {}, f.name),
|
||||
h('div', { class: 'info' }, fmtSize(f.size)),
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'),
|
||||
h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download')),
|
||||
h('div', { class: 'link sunken' }, share))], null);
|
||||
}
|
||||
async function rename(f) {
|
||||
const ext = (f.name.match(/\.[^.]+$/) || [''])[0];
|
||||
let renamed = null;
|
||||
const ok = await promptBox('Rename', 'New name', f.name, async (v) => {
|
||||
if (!v) return 'Type a name.';
|
||||
if (ext && !v.toLowerCase().endsWith(ext.toLowerCase())) v += ext;
|
||||
if (v === f.name) return null;
|
||||
try { renamed = (await api('/files/' + encodeURIComponent(f.id), { method: 'PATCH', body: { name: v } })).name; return null; }
|
||||
catch (e) { if (e instanceof LoggedOut) { location.reload(); return null; } return e.message; }
|
||||
});
|
||||
if (ok && renamed) go('/v/' + encodeURIComponent(renamed), true);
|
||||
}
|
||||
async function remove(f) {
|
||||
if (!(await confirmBox('Delete clip', `Delete ${f.name}? The link stops working.`, 'Delete'))) return;
|
||||
try { await api('/files/' + encodeURIComponent(f.id), { method: 'DELETE' }); toast('Deleted'); go('/'); }
|
||||
catch (e) { handleError(e); }
|
||||
}
|
||||
|
||||
function send(file, ticket, onProgress, onSent) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
const t0 = Date.now();
|
||||
let sentAny = false;
|
||||
xhr.open('POST', ticket.uploadUrl + '/upload');
|
||||
xhr.setRequestHeader('x-upload-ticket', ticket.ticket);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
sentAny = sentAny || e.loaded > 0;
|
||||
const rate = e.loaded / Math.max((Date.now() - t0) / 1000, 0.001);
|
||||
const left = rate > 0 ? Math.round((e.total - e.loaded) / rate) : 0;
|
||||
onProgress(xhr, e.loaded / e.total, `Sending to ${SERVER} · ${fmtSize(e.loaded)} / ${fmtSize(e.total)} · ${fmtSize(rate)}/s · ${left}s left`);
|
||||
};
|
||||
xhr.upload.onload = onSent;
|
||||
xhr.onload = () => {
|
||||
let data = {};
|
||||
try { data = JSON.parse(xhr.responseText); } catch {}
|
||||
if (xhr.status === 200) resolve(data); else reject(new Error(data.error || `Upload failed (${xhr.status})`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error(sentAny ? `Lost connection to ${SERVER}.` : cfg.uploadHint));
|
||||
xhr.onabort = () => reject(new Error('Cancelled'));
|
||||
onProgress(xhr, 0, `Connecting to ${SERVER}...`);
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
// what /discord says about a clip, worded for the dialog
|
||||
function discordNote(s) {
|
||||
if (s.state === 'small') return 'plays as is';
|
||||
if (s.state === 'ready') return 'ready';
|
||||
if (s.state === 'none') return "too long for Discord's player";
|
||||
if (s.queued) return 'waiting for another clip...';
|
||||
return s.progress >= 0.99 ? 'finishing...' : Math.round(s.progress * 100) + '%';
|
||||
}
|
||||
const fmtLeft = (s) => s < 60 ? `~${Math.max(1, Math.round(s))} s` : `~${Math.round(s / 60)} min`;
|
||||
const linkName = (r) => r.name + (r.remuxed ? ' (converted to .mp4)'
|
||||
: /\.mkv$/i.test(r.name) ? " (couldn't convert to .mp4, kept as .mkv: Discord may not play it inline)" : '');
|
||||
|
||||
// the upload dialog walks through three steps, like Steam installing a game:
|
||||
// send each file to the server, wait for the small Discord copies, then hand out the links.
|
||||
// Discord caches whatever it sees first, so a link is only shown once its clip will play there.
|
||||
async function uploadFiles(files) {
|
||||
if (!files.length) return;
|
||||
const bar = sbar(), line = h('div', { class: 'small' }), sub = h('div', { class: 'small' }), extra = h('div');
|
||||
const steps = ['Send to ' + SERVER, 'Prepare for Discord', 'Link ready'].map((label) => {
|
||||
const mark = h('i', {}, '·'), right = h('span', { class: 'r' });
|
||||
const row = h('div', {}, mark, h('span', {}, label), right);
|
||||
row.set = (state, text = '') => { row.className = state; mark.textContent = { done: '✓', now: '▸' }[state] || '·'; right.textContent = text; };
|
||||
return row;
|
||||
});
|
||||
let xhr = null, cancelled = false, failure = null;
|
||||
const btn = h('button', { class: 'btn', onclick: () => { cancelled = true; if (xhr) xhr.abort(); } }, 'Cancel');
|
||||
const d = dialog('Uploading', [h('div', { class: 'steps' }, steps), line, bar, sub, extra, h('div', { class: 'row' }, btn)]);
|
||||
d.box.classList.add('install');
|
||||
const title = d.box.querySelector('.title');
|
||||
const done = [];
|
||||
|
||||
// step 1: send the files, one at a time (keeps the server's CPU and upload link calm)
|
||||
for (let i = 0; i < files.length && !cancelled; i++) {
|
||||
const file = files[i];
|
||||
title.textContent = 'Uploading · ' + file.name;
|
||||
steps[0].set('now', files.length > 1 ? `${i + 1} of ${files.length}` : fmtSize(file.size));
|
||||
setBar(bar, 0);
|
||||
sub.textContent = /\.mkv$/i.test(file.name) ? 'then: converting .mkv to .mp4' : '';
|
||||
btn.disabled = false;
|
||||
try {
|
||||
const ticket = await api('/upload-ticket', { method: 'POST' });
|
||||
done.push(await send(file, ticket,
|
||||
(x, frac, text) => { xhr = x; setBar(bar, frac); line.textContent = text; },
|
||||
() => { setBar(bar, 1); line.textContent = `Processing on ${SERVER}...`; btn.disabled = true; }));
|
||||
} catch (e) {
|
||||
if (!cancelled) failure = `${file.name}: ${e instanceof LoggedOut ? 'You were logged out. Log in again.' : e.message}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!done.length) {
|
||||
d.close();
|
||||
if (failure) await alertBox('Upload failed', failure);
|
||||
return go(location.pathname, true);
|
||||
}
|
||||
steps[0].set('done', done.length > 1 ? plural(done.length, 'clip') : fmtSize(files[0].size));
|
||||
|
||||
// step 2: wait until Discord can play every clip (closing early is fine: the copies get made anyway)
|
||||
await barFull(bar);
|
||||
title.textContent = 'Preparing for Discord';
|
||||
steps[1].set('now');
|
||||
sub.textContent = '';
|
||||
setBar(bar, 0);
|
||||
line.textContent = 'Discord only waits ~8 s for a video, so big clips get a small 720p copy just for it. Your original stays full quality.';
|
||||
let closed = false;
|
||||
btn.textContent = 'Close'; btn.disabled = false;
|
||||
btn.onclick = () => { closed = true; d.close(); };
|
||||
const perClip = done.map((r) => h('div', { class: 'clip' }, h('span', {}, r.name), h('span', { class: 'r' })));
|
||||
if (done.length > 1) extra.replaceChildren(h('div', { class: 'clips sunken' }, perClip));
|
||||
const status = done.map(() => ({ state: 'making', progress: 0, queued: true }));
|
||||
let first = null;
|
||||
for (;;) {
|
||||
await Promise.all(done.map(async (r, i) => {
|
||||
if (status[i].state !== 'making') return;
|
||||
if (!isVideo(r.name)) { status[i] = { state: 'small', progress: 1, queued: false }; return; }
|
||||
try { status[i] = await api('/discord/' + encodeURIComponent(r.name)); }
|
||||
catch { status[i] = { state: 'small', progress: 1, queued: false }; } // can't ask: don't hold the link forever
|
||||
}));
|
||||
if (closed) return go(location.pathname, true);
|
||||
const making = status.filter((s) => s.state === 'making');
|
||||
const frac = status.reduce((a, s) => a + (s.state === 'making' ? s.progress : 1), 0) / status.length;
|
||||
status.forEach((s, i) => { perClip[i].lastChild.textContent = discordNote(s); });
|
||||
setBar(bar, frac);
|
||||
if (!making.length) break;
|
||||
if (making.every((s) => s.queued)) steps[1].set('now', 'waiting for another clip...');
|
||||
else {
|
||||
if (!first && frac > 0) first = { t: Date.now(), frac };
|
||||
const rate = first && frac > first.frac ? (frac - first.frac) / (Date.now() - first.t) : 0;
|
||||
steps[1].set('now', Math.round(frac * 100) + '%' + (rate ? ' · ' + fmtLeft((1 - frac) / rate / 1000) : ''));
|
||||
}
|
||||
await wait(1000);
|
||||
if (closed) return go(location.pathname, true);
|
||||
}
|
||||
const allSmall = status.every((s) => s.state === 'small');
|
||||
steps[1].set('done', allSmall ? 'plays as is' : '100%');
|
||||
await barFull(bar);
|
||||
|
||||
// step 3: the links, Copy lit up
|
||||
steps[2].set('done');
|
||||
title.textContent = done.length > 1 ? `Ready · ${plural(done.length, 'clip')}` : 'Ready · ' + done[0].name;
|
||||
line.textContent = done.length > 1 ? 'Paste these in Discord: they play right in the chat.' : 'Paste this in Discord: it plays right in the chat.';
|
||||
bar.remove();
|
||||
extra.replaceChildren(...done.map((r, i) => {
|
||||
const share = shareLink(r.name, r.url);
|
||||
return h('div', { class: 'linkrow' },
|
||||
done.length > 1 || r.remuxed || /\.mkv$/i.test(r.name) ? h('div', { class: 'small' }, linkName(r)) : null,
|
||||
h('div', { style: 'display:flex;gap:6px' },
|
||||
h('input', { class: 'field', value: share, readonly: true, onclick: (e) => e.target.select() }),
|
||||
h('button', { class: 'btn y', onclick: () => copy(share) }, 'Copy')),
|
||||
status[i].state === 'none' ? h('div', { class: 'small' }, "Too long for Discord's player: the link opens the clip, but won't play in the chat.") : null);
|
||||
}));
|
||||
if (status.some((s) => s.state === 'none') && done.length === 1) line.textContent = 'Your link:';
|
||||
d.box.classList.add('ready');
|
||||
await new Promise((res) => { btn.onclick = () => { d.close(); res(); }; });
|
||||
if (failure) await alertBox('Upload failed', failure);
|
||||
go(location.pathname, true);
|
||||
}
|
||||
|
||||
function go(path, replace = false) {
|
||||
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState']({}, '', path);
|
||||
route().catch(handleError);
|
||||
}
|
||||
async function route(progress) {
|
||||
const m = location.pathname.match(/^\/v\/(.+)$/);
|
||||
if (m) {
|
||||
let name;
|
||||
try { name = decodeURIComponent(m[1]); } catch { throw new Error("This clip doesn't exist (or isn't yours)."); }
|
||||
await watchView(name);
|
||||
if (progress) progress(1);
|
||||
}
|
||||
else await libraryView(progress);
|
||||
}
|
||||
async function handleError(e) {
|
||||
if (e instanceof LoggedOut) {
|
||||
await loginDialog();
|
||||
me = await api('/me');
|
||||
return route().catch(handleError);
|
||||
}
|
||||
await alertBox('Error', e.message);
|
||||
if (location.pathname !== '/') go('/', true);
|
||||
}
|
||||
async function logout() { await api('/logout', { method: 'POST' }).catch(() => {}); location.reload(); }
|
||||
|
||||
// the loading dialog moves on real steps: config+session, library list, thumbnails
|
||||
async function boot() {
|
||||
const bar = sbar(), status = h('div', { class: 'small' }, `Connecting to ${SERVER}...`);
|
||||
const d = dialog(SITE, [h('div', { class: 'small' }, 'Loading...'), bar, status], { clear: true });
|
||||
const step = (frac, text) => { setBar(bar, frac); status.textContent = text; };
|
||||
try {
|
||||
step(0.1, 'Checking your session...');
|
||||
cfg = await api('/config');
|
||||
try { me = await api('/me'); }
|
||||
catch (e) { if (!(e instanceof LoggedOut) || !location.pathname.startsWith('/v/')) throw e; me = null; }
|
||||
step(0.4, 'Loading your videos...');
|
||||
await route((frac) => step(0.5 + frac * 0.5, 'Loading thumbnails...'));
|
||||
step(1, 'Done');
|
||||
await barFull(bar);
|
||||
await wait(250);
|
||||
d.close();
|
||||
} catch (e) {
|
||||
d.close();
|
||||
if (e instanceof LoggedOut) { await loginDialog(); return boot(); }
|
||||
if (location.pathname !== '/') {
|
||||
// a clip that's gone (or someone else's): say so, then show the library
|
||||
await alertBox(SITE, e.message);
|
||||
history.replaceState({}, '', '/');
|
||||
return boot();
|
||||
}
|
||||
dialog(SITE, [h('div', { class: 'msg' }, e.message),
|
||||
h('div', { class: 'row' }, h('button', { class: 'btn y', onclick: () => location.reload() }, 'Retry'))], { clear: true });
|
||||
}
|
||||
}
|
||||
|
||||
let dragDepth = 0;
|
||||
const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||||
document.addEventListener('dragenter', (e) => { if (!me || !hasFiles(e)) return; e.preventDefault(); dragDepth++; document.body.classList.add('dropping'); });
|
||||
document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove('dropping'); } });
|
||||
document.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); dragDepth = 0; document.body.classList.remove('dropping');
|
||||
if (me && e.dataTransfer.files.length) uploadFiles([...e.dataTransfer.files]);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.menu').forEach((m) => { if (!m.parentElement.contains(e.target)) m.style.display = 'none'; });
|
||||
});
|
||||
window.addEventListener('popstate', () => route().catch(handleError));
|
||||
boot();
|
||||
Reference in new issue
Block a user