Add search, calendar, music queue/resume, file actions, More menu
build-container / build (push) Canceled after 0s
build-container / build (push) Canceled after 0s
- Calendar agenda from private ICS feeds (recurrence, EXDATE, overrides, time zones, SSRF guard) - Search across libraries, files (recursive fallback) and Plex - Home dashboard: now playing, today's agenda, recent files - Files: rename, move, share link, preview, create new documents - Music: recently added, play next / add to queue, queue sheet, resume on launch - Phone tab bar shows four tabs plus a More menu Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55e0c39171
commit
cf23472ad6
2 files changed
+71
-9
No files matched your search
+66
-8
@@ -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 = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Files, libraries, artists, albums, tracks" autocomplete="off" autocapitalize="off">
|
||||
@@ -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 =>
|
||||
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).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 ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
|
||||
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
|
||||
@@ -202,7 +223,7 @@ function viewSearch() {
|
||||
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
|
||||
}
|
||||
}
|
||||
const note = files.status === 'rejected' && sf.token() ? '<p class="sub">File search is not available on your Seafile server (it needs Seafile\'s search index). Libraries and music still work.</p>' : '';
|
||||
const note = !found && sf.token() ? '<p class="sub">Could not search Seafile files right now.</p>' : '';
|
||||
res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || '<p class="empty">No results</p>';
|
||||
if (!libHtml && !fileHtml && !musicHtml) res.innerHTML = '<p class="empty">No results</p>' + 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 = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
|
||||
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
|
||||
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
|
||||
@@ -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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
||||
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button></div>
|
||||
<div class="list">${tracks.map((t, i) => `<button class="row" data-i="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
v.querySelectorAll('button.row').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.i));
|
||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
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', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
|
||||
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
|
||||
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
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() {
|
||||
<p class="sub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div></div>`);
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button></div></div>`);
|
||||
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(() => {});
|
||||
})();
|
||||
Reference in new issue
Block a user