diff --git a/README.md b/README.md index 3fa3fbc..505b7dd 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,11 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke The server proxies `/sf/*` to Seafile because Seafile sends no CORS headers. Token lives in the browser only. Office files open in Seafile's own OnlyOffice integration. - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. -- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs. +- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). +- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation. +- **Calendar**: agenda from private ICS links (Google secret address, Outlook/M365 published link, iCloud public calendar). Links are stored on the server in `/data`. +- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. +- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered. - **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose). diff --git a/public/app.js b/public/app.js index 04bac56..f64cde3 100644 --- a/public/app.js +++ b/public/app.js @@ -167,6 +167,24 @@ function viewHome() { if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today'); } +// Name search by listing each library recursively (cached for the session). +const treeCache = new Map(); +async function walkSearch(repos, term) { + const out = []; + for (const r of repos.filter(x => !x.encrypted).slice(0, 12)) { + try { + let t = treeCache.get(r.id); + if (!t || Date.now() - t.at > 120e3) { t = { at: Date.now(), items: await sf.req(`/api2/repos/${encodeURIComponent(r.id)}/dir/?p=/&recursive=1`) }; treeCache.set(r.id, t); } + for (const it of t.items) if (it.name.toLowerCase().includes(term)) { + const parent = (it.parent_dir || '/').replace(/\/?$/, '/'); + out.push({ repo_id: r.id, repo_name: r.name, name: it.name, fullpath: parent + it.name, is_dir: it.type === 'dir', size: it.size }); + if (out.length >= 40) return out; + } + } catch {} + } + return out; +} + // ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others) function viewSearch() { $('#view').innerHTML = `

Search

@@ -189,7 +207,10 @@ function viewSearch() { const lower = term.toLowerCase(); const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r => `${icon('files')}${esc(r.name)}Library`).join('') : ''; - const fileHtml = files.status === 'fulfilled' ? (files.value.results || []).map(f => { + let found = files.status === 'fulfilled' ? files.value.results || [] : null; + if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead + if (mySeq !== seq || !location.hash.startsWith('#/search')) return; + const fileHtml = found ? found.map(f => { const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/'; return f.is_dir ? `${icon('files')}${esc(f.name)}${esc(f.repo_name || '')} ${esc(f.fullpath)}` : ``; @@ -202,7 +223,7 @@ function viewSearch() { else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += ``; } } } - const note = files.status === 'rejected' && sf.token() ? '

File search is not available on your Seafile server (it needs Seafile\'s search index). Libraries and music still work.

' : ''; + const note = !found && sf.token() ? '

Could not search Seafile files right now.

' : ''; res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || '

No results

'; if (!libHtml && !fileHtml && !musicHtml) res.innerHTML = '

No results

' + note; res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); }); @@ -410,7 +431,7 @@ async function plexPickServer() { async function musicRoute([kind = 'albums', key], st) { const v = $('#view'), sec = await px.section(); - const tabs = ['albums', 'artists', 'playlists']; + const tabs = ['albums', 'artists', 'playlists', 'recent']; const seg = `
${tabs.map(t => `${t[0].toUpperCase() + t.slice(1)}`).join('')}
`; const card = (href, thumb, title, sub, round) => ` ${esc(title)}${esc(sub)}`; @@ -423,6 +444,8 @@ async function musicRoute([kind = 'albums', key], st) { if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`); return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); } + if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`); + return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); } if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`); return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); } if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio'); @@ -445,9 +468,11 @@ async function musicRoute([kind = 'albums', key], st) { v.innerHTML = `

${esc(title)}

${esc(sub)}

-
${tracks.map((t, i) => ``).join('') || '

No playable tracks

'}
`; - v.querySelectorAll('button.row').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.i)); +
${tracks.map((t, i) => `
+
`).join('') || '

No playable tracks

'}
`; + v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p)); + v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m])); $('#pa').onclick = () => tracks.length && playQueue(tracks, 0); $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0); markPlaying(); @@ -465,12 +490,42 @@ function loadCur() { const t = cur(); if (!t) return; pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start')); if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] }); - renderMini(); syncUI(); markPlaying(); + renderMini(); syncUI(); markPlaying(); saveState(); +} +function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); } +const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); toast('Playing next'); }; +const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); toast('Added to queue'); }; +function trackSheet(t) { + actionSheet(t.title, t.artist, [ + { label: 'Play next', icon: 'next', run: () => playNextTrack(t) }, + { label: 'Add to queue', icon: 'files', run: () => queueTrack(t) }, + { label: 'Play now', icon: 'play', run: () => playQueue([t], 0) }, + ]); +} +// Restore the last queue and position on launch (paused, so nothing starts by itself). +function restorePlayer() { + const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return; + pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src; + pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true }); + renderMini(); syncUI(); +} +function openQueue() { + const upcoming = pl.q.map((t, i) => ({ t, i })); + const el = openSheet('Up next', pl.q.length + ' tracks', `
${upcoming.map(({ t, i }) => ` +
+ ${i === pl.i ? '' : ``}
`).join('')}
+
`); + el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; + el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(); closeSheet(); }); + el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); }); } const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } }; const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } }; const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause()); pl.a.addEventListener('ended', next); +let lastSave = 0; +pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } }); +addEventListener('pagehide', saveState); ['play', 'pause', 'timeupdate', 'loadedmetadata'].forEach(e => pl.a.addEventListener(e, () => syncUI())); pl.a.addEventListener('error', () => toast('Could not play this track')); if ('mediaSession' in navigator) { @@ -506,10 +561,12 @@ function openNow() {

${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}

0:000:00
-
`); + +
`); const sk = el.querySelector('#npk'); sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); }; sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; + el.querySelector('#npq').onclick = openQueue; el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle; syncUI(); } @@ -835,6 +892,7 @@ function dirSheet(repo, path, name, dir) { (async () => { try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {} addEventListener('hashchange', route); + restorePlayer(); route(); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); })();