feat: add passkey-style online profiles for cross-device sync and show build time in About

This commit is contained in:
Jonathan Sykes
2026-07-03 05:16:05 +08:00
parent 048817bcf4
commit 94140a5433
5 changed files with 379 additions and 8 deletions

View File

@@ -87,12 +87,14 @@ async function opfsDownload(videoId, { mux = false } = {}) {
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
// so a big save never touches the main thread (no UI jank, no audio
// stutter). Falls back to the legacy main-thread streaming below only when
// the worker path is unsupported.
// stutter). ANY worker failure — unsupported API or a mid-download error —
// falls back to the legacy main-thread streaming below; the worker's error
// is kept so it can be reported if the fallback fails too.
let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
const w = await window.OPFS.downloadVideo(videoId, url);
if (w.ok) return { ok: true, cached: true };
if (!w.fallback) return { ok: false, error: w.error || 'download failed' };
workerError = w.error || null;
}
try {
@@ -107,7 +109,7 @@ async function opfsDownload(videoId, { mux = false } = {}) {
await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true };
} catch (err) {
return { ok: false, error: err.message };
return { ok: false, error: workerError || err.message };
}
}
@@ -204,7 +206,7 @@ const DEFAULT_SETTINGS = {
autoBackupEnabled: false,
autoBackupIntervalDays: 7,
};
let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0 };
let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
let channelData = { name: '', url: '', key: '', results: [], loading: false };
@@ -284,6 +286,178 @@ const els = {
function persist() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
scheduleProfilePush();
}
// ---------- Online profile sync (WEB mode) ----------
// The profile NAME acts as the passkey: any device that knows it can load
// and update the same server-side copy of playlists/settings/etc. Sync is
// last-write-wins: every local change pushes (debounced); every app launch
// pulls when the server copy is newer than what this device last synced.
function profilePayload() {
return {
playlists: data.playlists,
history: data.history,
settings: data.settings,
resumePositions: data.resumePositions,
playCount: data.playCount,
abMarkers: data.abMarkers,
};
}
let profilePushTimer = null;
function scheduleProfilePush() {
if (!WEB || !data.profile || !data.profile.name) return;
clearTimeout(profilePushTimer);
profilePushTimer = setTimeout(pushProfile, 1500);
}
async function pushProfile() {
if (!WEB || !data.profile || !data.profile.name) return;
try {
const res = await fetch('/api/profile/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: data.profile.name, data: profilePayload() }),
});
const j = await res.json().catch(() => null);
if (j && j.ok) {
data.profile.syncedAt = j.updatedAt || 0;
// Record syncedAt directly — going through persist() would re-schedule
// another push forever.
API.saveData(data).catch(() => {});
}
} catch { /* offline — the next persist() retries */ }
}
// Replace the synced slice of local state with a profile's server copy.
function applyProfileData(name, payload, updatedAt) {
payload = payload || {};
if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
if (Array.isArray(payload.history)) data.history = payload.history;
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
data.profile = { name, syncedAt: updatedAt || 0 };
API.saveData(data).catch(() => {});
}
// On launch: adopt the server copy when it's newer than this device's last
// sync (another device pushed since); otherwise push local state up.
async function pullProfileIfNewer() {
if (!WEB || !data.profile || !data.profile.name) return;
try {
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`);
if (res.status === 404) return; // profile gone server-side; keep local data
const j = await res.json().catch(() => null);
if (!j || !j.ok) return;
if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) {
applyProfileData(j.name, j.data, j.updatedAt);
} else {
scheduleProfilePush();
}
} catch { /* offline — stay on local data */ }
}
// Refresh the Settings row without a full re-render (no-op on other views).
function updateProfileStatus() {
const el = document.getElementById('profileStatus');
if (el) el.textContent = (data.profile && data.profile.name) || 'Not linked';
const unlink = document.getElementById('profileUnlinkBtn');
if (unlink) unlink.style.display = data.profile && data.profile.name ? '' : 'none';
}
function createProfileFlow() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Pick a name (340 characters: letters, digits, - or _).
<strong>The name is the key</strong> — anyone who knows it can load this
profile on their device, so use something hard to guess or go random.
</p>
<input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
showModal(' Create online profile', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: '🎲 Random name', onClick: () => requestCreateProfile(null) },
{ label: 'Create', primary: true, onClick: () => {
const name = ($('profileNameInput').value || '').trim();
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) {
toast('⚠ Name must be 340 characters: letters, digits, - or _');
return; // keep the modal open for another attempt
}
requestCreateProfile(name);
} },
]);
$('profileNameInput').focus();
}
// name === null → let the server generate a unique random one.
async function requestCreateProfile(name) {
try {
const res = await fetch('/api/profile/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data: profilePayload() }),
});
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
toast('⚠ ' + ((j && j.error) || 'Could not create profile'));
return; // modal stays open — user can adjust the name
}
closeModal();
data.profile = { name: j.name, syncedAt: j.updatedAt || 0 };
persist();
updateProfileStatus();
toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 });
} catch {
toast('⚠ Network error — try again');
}
}
function loadProfileFlow() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Enter a profile name to pull its playlists, history and settings onto
this device. The synced data on this device is replaced, and future
changes here sync back to that profile.
</p>
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
showModal('⬇ Load online profile', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Load', primary: true, onClick: async () => {
const name = ($('profileNameInput').value || '').trim();
if (!name) return;
try {
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
toast('⚠ ' + ((j && j.error) || 'Profile not found'));
return; // keep the modal open
}
closeModal();
applyProfileData(j.name, j.data, j.updatedAt);
// Re-apply everything the loaded data drives.
applyAppearance();
updateLoopRepeatButtons();
updateQueueBadge();
els.volume.value = String(data.settings.volume ?? 1);
els.quality.value = data.settings.quality || 'auto';
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
renderSidebar();
renderSmartSidebar();
render();
updateProfileStatus();
data.playlists.forEach(preloadPlaylist);
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
} catch {
toast('⚠ Network error — try again');
}
} },
]);
$('profileNameInput').focus();
}
// ---------- Helpers ----------
@@ -1822,6 +1996,22 @@ async function renderSettings() {
</div>
</div>
<div class="set-group">
<div class="set-group-title">Online profile — sync between devices</div>
<div class="set-row">
<span>
Linked profile
<small>Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.</small>
</span>
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
</div>
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
<button id="profileCreateBtn" class="btn">Create profile</button>
<button id="profileLoadBtn" class="btn">Load profile</button>
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
</div>
</div>
<div class="set-group">
<div class="set-group-title">About</div>
<div class="set-row">
@@ -1833,6 +2023,13 @@ async function renderSettings() {
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
</div>
</div>
<div class="set-row">
<span>
Build time
<small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
</span>
<span id="aboutBuildTime" class="about-version">…</span>
</div>
</div>`;
c.appendChild(wrap);
@@ -1943,7 +2140,17 @@ async function renderSettings() {
$('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
$('backupFileInput').addEventListener('change', importBackup);
// ---- About: append server build tag (WEB mode only) ----
// ---- Online profile ----
$('profileCreateBtn').addEventListener('click', createProfileFlow);
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
$('profileUnlinkBtn').addEventListener('click', () => {
data.profile = null;
persist();
updateProfileStatus();
toast('Profile unlinked — this device stops syncing (server copy is kept)');
});
// ---- About: append server build tag + build time (WEB mode only) ----
if (WEB) {
try {
const res = await fetch('/api/version', { cache: 'no-store' });
@@ -1951,6 +2158,11 @@ async function renderSettings() {
const v = await res.json();
const el = $('aboutVersion');
if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
const bt = $('aboutBuildTime');
if (bt) {
const t = v.buildTime ? new Date(v.buildTime) : null;
bt.textContent = t && !isNaN(t) ? t.toLocaleString() : 'unknown';
}
}
} catch { /* offline — leave the static version in place */ }
}
@@ -3266,11 +3478,15 @@ async function boot() {
abMarkers: loaded.abMarkers || {},
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
profile: loaded.profile || null,
};
}
} catch {
// first run / bridge not ready — start with defaults
}
// Linked online profile: adopt the server copy if another device pushed a
// newer one. Runs before any rendering so no re-render pass is needed.
await pullProfileIfNewer();
applyAppearance();
updateLoopRepeatButtons();
updateQueueBadge();