'use strict'; // If the server says the passkey session is gone, go back to the sign-in page. const _fetch = window.fetch.bind(window); window.fetch = async (...a) => { const r = await _fetch(...a); if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login'); return r; }; const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const store = { get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, del(k) { try { localStorage.removeItem(k); } catch {} }, }; const ICONS = { home: '', mail: '', files: '', doc: '', cog: '', chev: '', back: '', edit: '', up: '', newdir: '', file: '', dl: '', audio: '', copy: '', music: '', play: '', pause: '', next: '', prev: '', shuffle: '', more: '', cal: '', search: '', trash: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; // ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail. const MAIL = [ { id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' }, { id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' }, { id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' }, { id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' }, ]; // Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox. const mailHints = () => store.get('mail_hints', {}); function mailUrl(m, kind) { const base = m[kind], hint = (mailHints()[m.id] || '').trim(); if (!hint) return base; const u = new URL(base), q = encodeURIComponent(hint); if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); } if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); } return base; // iCloud has no sign-in hint } let cfg = { audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; // ---- Seafile client (via same-origin proxy) const sf = { token: () => store.get('sf_token'), async req(path, opts = {}) { const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) }; const r = await fetch('/sf' + path, { ...opts, headers }); if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); } if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')'); const t = await r.text(); try { return JSON.parse(t); } catch { return t; } }, async login(username, password, otp) { const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (otp) headers['X-SEAFILE-OTP'] = otp; const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) }); const j = await r.json().catch(() => ({})); if (!r.ok) { if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true }); throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' ')); } store.set('sf_token', j.token); }, }; const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); }; const ext = n => (n.split('.').pop() || '').toLowerCase(); const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']); const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']); const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB'; const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']]; const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More" const phone = matchMedia('(max-width: 767px)'); let activeTab = 'home'; function renderNav(active = activeTab) { activeTab = active; const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}`; const brand = `
NE Hub
`; if (phone.matches) { const more = !PRIMARY.includes(active); $('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') + `${icon('more')}More`; $('#more').onclick = e => { e.preventDefault(); openMore(); }; } else $('#nav').innerHTML = brand + TABS.map(tab).join(''); } phone.addEventListener('change', () => renderNav()); function openMore() { const el = openSheet('More', '', `
${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => ` ${icon(TAB_ICON[id])}${label}${icon('chev', 'chev')}`).join('')}
`); el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); } const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; let editor; function route() { const [, id = 'home', ...rest] = location.hash.split('/'); const name = routes[id] ? id : 'home'; try { editor?.destroyEditor(); } catch {} editor = null; document.body.classList.toggle('editing', name === 'edit'); document.body.classList.remove('inedit', 'onaudio'); const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame renderNav(name === 'edit' ? 'files' : name); routes[name](rest.map(decodeURIComponent)); $('#view').scrollTop = 0; } function tile(href, color, ic, title, sub, external) { return ` ${icon(ic)}${esc(title)}${esc(sub)}`; } // ---- Views function viewHome() { const h = new Date().getHours(); const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; const recents = store.get('recents', []).slice(0, 5); const t = typeof cur === 'function' ? cur() : null; $('#view').innerHTML = `

${greet}

${icon('search')}Search files, music, libraries ${t ? `

Now playing

` : ''}
${recents.length ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''}

Apps

${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')} ${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')} ${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')} ${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')} ${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')} ${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}

Mail

${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
`; const np = $('#hnp'); if (np) np.onclick = openNow; $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); 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

`; const q = $('#q'); q.focus(); let timer, seq = 0; q.oninput = () => { clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); }; async function runSearch(term, mySeq) { const res = $('#res'); if (term.length < 2) { res.innerHTML = ''; return; } res.innerHTML = '

Searching…

'; const enc = encodeURIComponent; const [files, libs, music] = await Promise.allSettled([ sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')), sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')), jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))), ]); if (mySeq !== seq || !location.hash.startsWith('#/search')) return; const sec = (title, html) => html ? `

${title}

${html}
` : ''; 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('') : ''; 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)}` : ``; }).join('') : ''; let musicHtml = ''; if (music.status === 'fulfilled') { for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) { if (m.type === 'artist') musicHtml += `${esc(m.title)}Artist`; else if (m.type === 'album') musicHtml += `${esc(m.title)}Album · ${esc(m.parentTitle || '')}`; else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += ``; } } } 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); }); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); } } // ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server) const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; async function loadRange(start, days) { const end = new Date(start); end.setDate(end.getDate() + days); const last = new Date(end.getTime() - 1); const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) }); const { events, errors } = await jfetch('/api/cal/events?' + q); const byDay = new Map(); const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); }; for (const e of events) { if (e.allDay) { // spans start..end-1 (end exclusive) for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); } } else put(ymd(new Date(e.start)), e); } for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start))); return { byDay, errors, start }; } const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); }; const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const evRow = e => `
${esc(e.title)} ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`; const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1); return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); }; async function renderAgenda(el, mode) { // Home card (today only) if (!el) return; try { const cals = await jfetch('/api/cal/list'); if (!cals.length) return; const { byDay } = await loadAgenda(1); const list = byDay.get(ymd(new Date())) || []; if (!document.body.contains(el)) return; el.innerHTML = `

Today

${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`; } catch {} } const now0 = new Date(); const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) }; async function viewCalendar() { const mode = store.get('cal_view', 'month'); const v = $('#view'); v.innerHTML = `

Calendar

${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `${l}`).join('')}

Loading…

`; v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); $('#mc').onclick = manageCalendars; try { const cals = await jfetch('/api/cal/list'); if (!cals.length) { $('#cbody').innerHTML = '

No calendars yet. Add a private calendar link to see your events here.

'; $('#ac').onclick = addCalendar; return; } if (mode === 'month') await renderMonth(); else await renderAgendaList(); } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `

${esc(e.message)}

`; } } async function renderAgendaList() { const range = store.get('cal_range', 7); const { byDay, errors } = await loadAgenda(range); if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; const keys = [...byDay.keys()].sort(); $('#cbody').innerHTML = `
${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `${l}`).join('')}
` + (keys.map(k => `

${esc(dayLabel(k))}

${byDay.get(k).map(evRow).join('')}
`).join('') || '

Nothing coming up

') + errors.map(e => `

${esc(e)}

`).join(''); $('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); }); } async function renderMonth() { const { y, m } = calState; const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st const { byDay, errors } = await loadRange(gridStart, 42); if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; const today = ymd(new Date()); if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first); const cells = []; for (let i = 0; i < 42; i++) { const d = new Date(gridStart); d.setDate(d.getDate() + i); const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m; cells.push(``); } $('#cbody').innerHTML = `

${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}

${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d}`).join('')}
${cells.join('')}
${errors.map(e => `

${esc(e)}

`).join('')}`; const showDay = () => { const k = calState.sel, evs = byDay.get(k) || []; $('#dayev').innerHTML = `

${esc(dayLabel(k))}

${evs.length ? evs.map(evRow).join('') : '
No events
'}
`; $('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k)); }; showDay(); $('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); }); const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); }; $('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1); $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); }; } function addCalendar() { const el = openSheet('Add a calendar', '', `

Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. iCloud: calendar's Share → Public Calendar → copy link. Anyone with the link can read that calendar, so keep it private.

`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('form').onsubmit = async e => { e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = ''; try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); } catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; } }; } async function manageCalendars() { const cals = await jfetch('/api/cal/list'); const el = openSheet('Calendars', '', `
${cals.map(c => `
${esc(c.name)}
`).join('') || '
No calendars
'}
`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar; el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); }); } function viewMail() { $('#view').innerHTML = `

Mail

Opens each provider's own webmail in a new tab. Nothing is stored here.

Inboxes

Compose

`; } const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join(''); function viewOffice(args = []) { if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); $('#view').innerHTML = `

Office

Create, open and edit documents without leaving this tab. Everything is saved in Seafile.

${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}

Browse all files${sf.token() ? '' : 'Sign in'}
`; $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l)); if (!sf.token()) return; (async () => { try { const loc = await officeLocation(false), el = $('#olib'); if (!el) return; if (!loc) { el.textContent = 'Choose where your documents are saved in Seafile.'; $('#odocs').innerHTML = '
'; $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); return; } el.innerHTML = `Saved in ${esc(locLabel(loc))} · change`; $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); }; const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`); const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60); if (!location.hash.startsWith('#/office') || !$('#odocs')) return; $('#odocs').innerHTML = docs.length ? `

Documents

${docs.map((f, i) => { const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b'; return ``; }).join('')}
` : '

No documents here yet. Create one above.

'; $('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => { const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name; addRecent(loc.repo, path, f.name); if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); }); } catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)}`; } })(); } const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / ')); // The saved Office location, if it still exists (the library may have been deleted). async function officeLocation(ask) { const loc = store.get('office_loc'); if (loc) { let repos; try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice const r = repos.find(x => x.id === loc.repo); if (r) { try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; } catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; } } store.del('office_loc'); // library or folder is really gone } if (!ask) return null; const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l; } // Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled. function chooseFolder(title, start) { return new Promise(resolve => { let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false; const el = openSheet(title, '', '
'); el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); const finish = v => { done = true; closeSheet(); resolve(v); }; const draw = async () => { const box = el.querySelector('#cf'); box.innerHTML = '

Loading…

'; try { if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `

Choose a library

${repos.map(r => ``).join('') || '

No libraries. Create one in Files.

'}
`; box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); }); } else { const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir').sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `

${esc(locLabel({ repoName, dir }))}

${subs.map(x => ``).join('')}
`; box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); }); box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); }; box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir }); } box.querySelector('#cx').onclick = () => finish(null); } catch (e) { box.innerHTML = `

${esc(e.message)}

`; } }; draw(); }); } // Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor). async function createFromTemplate(repo, dir, name, x) { const enc = encodeURIComponent; if (!['docx', 'xlsx', 'pptx'].includes(x)) { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) }); return name; } const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing'); const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`); const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name); const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd }); if (!r.ok) throw new Error('Could not create ' + name); return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken } async function newOfficeDoc(x, label) { if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; } let loc; try { loc = await officeLocation(true); } catch (e) { return toast(e.message); } if (!loc) return; const el = openSheet('New ' + label, '', `
`); const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); }; showLoc(); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#nl').onclick = async () => { // Sheets don't stack, so close this one, choose, then reopen with the new location. const picked = await chooseFolder('Save in…', loc); if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label); }; setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50); el.querySelector('form').onsubmit = async e => { e.preventDefault(); const n = el.querySelector('#nn').value.trim(); if (!n) return; const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; try { const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name); addRecent(loc.repo, path, name); closeSheet(); if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); } catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; } }; } // ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap, // because a page can't type into another site's login form. const abCreds = () => store.get('ab_creds', { u: '', p: '' }); function viewAudio() { const url = cfg.audiobooks, c = abCreds(); $('#view').innerHTML = `

Audio

${esc(new URL(url).host)}

Open in new tab ${c.u ? `` : ''} ${c.p ? `` : ''} ${c.u || c.p ? '' : 'Save sign-in'}
`; // Create the frame once and keep it: it is only shown/hidden, never rebuilt, so playback carries on across tabs. const wrap = $('#audiowrap'); if (!wrap.firstChild) { const f = document.createElement('iframe'); f.src = url; f.title = 'Audiobooks'; f.allow = 'autoplay; fullscreen; picture-in-picture; clipboard-write'; wrap.appendChild(f); } wrap.className = 'on'; document.body.classList.add('onaudio'); const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } }; const cu = $('#cu'), cp = $('#cp'); if (cu) cu.onclick = () => copy(c.u, 'Username'); if (cp) cp.onclick = () => copy(c.p, 'Password'); } // ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here) const jfetch = async (path, opts) => { const r = await fetch(path, opts); const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status }); return j; }; const px = { secs: null, async get(path) { const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } }); if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true }); if (!r.ok) throw new Error('Plex error ' + r.status); return (await r.json()).MediaContainer || {}; }, art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', // All music libraries on the server; the chosen one is remembered per server. async sections() { if (px.secs) return px.secs; const d = (await px.get('/library/sections')).Directory || []; px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title })); if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); } return px.secs; }, async section() { const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey]; return (secs.find(x => x.key === want) || secs[0]).key; }, serverKey: '', }; const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }; const mapTrack = (t, mc) => { const part = t.Media?.[0]?.Part?.[0]; return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '', album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 }; }; async function viewMusic(args) { const v = $('#view'); v.innerHTML = '

Music

Loading…

'; try { const st = await jfetch('/api/plex/status'); if (!location.hash.startsWith('#/music')) return; if (!st.signedIn) return plexSignIn(); if (!st.server) return plexPickServer(); await musicRoute(args, st); } catch (e) { if (e.auth) return plexSignIn(); v.innerHTML = `

Music

${esc(e.message)}

`; $('#rs').onclick = plexPickServer; } } function plexSignIn(msg = '') { $('#view').innerHTML = `

Music

Play your Plex music library here.

You'll sign in on plex.tv in a new tab. This app never sees your Plex password.

${esc(msg)}
`; $('#pl').onclick = async () => { const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it try { const pin = await jfetch('/api/plex/pin', { method: 'POST' }); if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `Tap to open Plex sign-in`; for (let i = 0; i < 150; i++) { await new Promise(r => setTimeout(r, 2000)); if (!location.hash.startsWith('#/music')) return; const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`); if (r.ok) { try { w?.close(); } catch {} return route(); } } $('#pe').textContent = 'Timed out waiting for Plex. Try again.'; } catch (e) { $('#pe').textContent = e.message; } }; } async function plexPickServer() { const v = $('#view'); v.innerHTML = '

Music

Finding your Plex servers…

'; try { const servers = await jfetch('/api/plex/servers'); if (!servers.length) { v.innerHTML = '

Music

No Plex servers found on this account.

'; return; } v.innerHTML = `

Music

Choose a server

${servers.map(s => ` `).join('')}
`; v.querySelectorAll('button.row').forEach(b => b.onclick = async () => { b.disabled = true; $('#pe').textContent = ''; try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); } catch (e) { b.disabled = false; $('#pe').textContent = e.message; // Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails. $('#pm').innerHTML = `

Know the server's address? Enter it (for example http://192.168.1.10:32400).

`; $('#pf2').onsubmit = async ev => { ev.preventDefault(); $('#pe').textContent = ''; try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); } catch (e2) { $('#pe').textContent = e2.message; } }; } }); } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `

Music

${esc(e.message)}

`; } } async function musicRoute([kind = 'albums', key], st) { px.serverKey = st.server; const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0]; 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)}`; const listing = (items, html) => { v.innerHTML = `

Music

${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾` : ''}

${seg}
${html || '

Nothing here

'}
`; const pl2 = $('#plib'); if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music', run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); }; $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); }; }; const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort'; 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'); return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); } if (kind === 'artist') { const mc = await px.get(`/library/metadata/${key}/children`); v.innerHTML = `

${esc(mc.title2 || mc.parentTitle || 'Artist')}

Albums

${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`; return; } if (kind === 'album' || kind === 'playlist') { const album = kind === 'album'; const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`), px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]); const info = (meta.Metadata || [])[0] || {}; const tracks = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks'; const thumb = info.thumb || info.composite || mc.thumb; v.innerHTML = `

${esc(title)}

${esc(sub)}

${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(); return; } location.hash = '#/music/albums'; } // ---- Player: one