'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 = '') => ``;
// ---- 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', '', ``);
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 = ``;
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('')}
`;
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', '', ``);
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.'}
`;
$('#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)}
`;
// 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 = '';
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 = ``; }
}
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 = ``;
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 = `
${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