Compare commits
4
Commits
92cf86bf5c
...
3166a7b1ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3166a7b1ab | ||
|
|
1981e66b8a | ||
|
|
2c8afdef79 | ||
|
|
93ae8f8840 |
No files matched your search
@@ -329,4 +329,14 @@ async function handle(req, res) {
|
|||||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
||||||
|
async function newest() {
|
||||||
|
const out = [];
|
||||||
|
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** For the admin health page: can each connected account be reached right now? */
|
||||||
|
async function health() {
|
||||||
|
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
||||||
|
}
|
||||||
|
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||||
@@ -271,6 +271,7 @@ function homeApps() {
|
|||||||
|
|
||||||
function viewHome() {
|
function viewHome() {
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
|
const evening = h >= 17 || h < 5; // after work the media comes first; during the day the agenda and tasks do
|
||||||
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
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 recents = store.get('recents', []).slice(0, 5);
|
||||||
const t = typeof cur === 'function' ? cur() : null;
|
const t = typeof cur === 'function' ? cur() : null;
|
||||||
@@ -280,12 +281,15 @@ function viewHome() {
|
|||||||
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
||||||
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
||||||
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
||||||
|
${evening ? '<div id="resume"></div>' : ''}
|
||||||
<div class="homecols">
|
<div class="homecols">
|
||||||
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></div>
|
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></div>
|
||||||
<div class="hcol">${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}
|
<div class="hcol">${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}
|
||||||
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
|
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${evening ? '' : '<div id="resume"></div>'}
|
||||||
<div class="homeapps">${homeApps()}</div></div>`;
|
<div class="homeapps">${homeApps()}</div></div>`;
|
||||||
|
if (typeof homeResume === 'function') homeResume($('#resume'));
|
||||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
$('#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('/')) || '/');
|
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use strict';
|
||||||
|
// Send what is playing to a TV or speaker. Safari/iOS use the AirPlay picker; Chrome and Edge use the browser's own cast picker (Chromecast and
|
||||||
|
// compatible TVs). The TV fetches the stream itself, so this works best where it can reach the server the same way this device does.
|
||||||
|
const castOk = el => !!el && (!!(el.remote && el.remote.prompt) || typeof el.webkitShowPlaybackTargetPicker === 'function');
|
||||||
|
async function castTo(el) {
|
||||||
|
if (!el) return toast('Start something playing first');
|
||||||
|
try {
|
||||||
|
if (el.remote && el.remote.prompt) await el.remote.prompt();
|
||||||
|
else if (typeof el.webkitShowPlaybackTargetPicker === 'function') el.webkitShowPlaybackTargetPicker();
|
||||||
|
else toast('This browser cannot send to a TV or speaker. Try Safari (AirPlay) or Chrome (Cast).');
|
||||||
|
} catch (e) { if (e && e.name !== 'NotAllowedError' && e.name !== 'AbortError') toast(e.name === 'NotSupportedError' || e.name === 'InvalidStateError' ? 'No TV or speaker found on this network' : (e.message || 'Could not cast')); }
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
<script src="/calendar-views.js"></script>
|
<script src="/calendar-views.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/search-view.js"></script>
|
<script src="/search-view.js"></script>
|
||||||
|
<script src="/today.js"></script>
|
||||||
<script src="/calendar.js"></script>
|
<script src="/calendar.js"></script>
|
||||||
<script src="/tasks.js"></script>
|
<script src="/tasks.js"></script>
|
||||||
<script src="/audiobooks.js"></script>
|
<script src="/audiobooks.js"></script>
|
||||||
@@ -80,6 +81,8 @@
|
|||||||
<script src="/signal-view.js"></script>
|
<script src="/signal-view.js"></script>
|
||||||
<script src="/mail.js"></script>
|
<script src="/mail.js"></script>
|
||||||
<script src="/share.js"></script>
|
<script src="/share.js"></script>
|
||||||
|
<script src="/cast.js"></script>
|
||||||
|
<script src="/keys.js"></script>
|
||||||
<script src="/boot.js"></script>
|
<script src="/boot.js"></script>
|
||||||
<script src="/shell.js"></script>
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
// Desktop keyboard shortcuts for Mail (the video and music players have their own, in video.js and player.js), and "?" for the list of all of them.
|
||||||
|
addEventListener('keydown', e => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet') || document.body.classList.contains('editing')) return;
|
||||||
|
const t = e.target, tag = t.tagName || '', k = e.key;
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || t.isContentEditable) return;
|
||||||
|
if (k === '?') { e.preventDefault(); return keysHelp(); }
|
||||||
|
if (!location.hash.startsWith('#/mail') || typeof mail === 'undefined') return;
|
||||||
|
if (k === 'j' || k === 'k') { // next / previous message in the list
|
||||||
|
const rows = [...document.querySelectorAll('a.mrow')]; if (!rows.length) return;
|
||||||
|
const sel = rows.findIndex(a => a.classList.contains('sel')), i = Math.max(0, Math.min(rows.length - 1, sel < 0 ? 0 : sel + (k === 'j' ? 1 : -1)));
|
||||||
|
e.preventDefault(); location.hash = rows[i].getAttribute('href'); rows[i].scrollIntoView({ block: 'nearest' }); return;
|
||||||
|
}
|
||||||
|
if (!mail.msg) return;
|
||||||
|
const act = { e: 'archive', '#': 'delete', Delete: 'delete', r: 'reply', a: 'replyAll', f: 'forward', u: 'unread', s: 'flag' }[k];
|
||||||
|
if (act) { e.preventDefault(); mailAction(act); }
|
||||||
|
else if (k === 'Escape' || k === 'x') { location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
|
||||||
|
});
|
||||||
|
function keysHelp() {
|
||||||
|
const row = (k, d) => `<tr><td style="padding:4px 14px 4px 0"><kbd>${k}</kbd></td><td>${d}</td></tr>`;
|
||||||
|
const el = openSheet('Keyboard shortcuts', '', `<table style="width:100%;font-size:14px"><tbody>
|
||||||
|
<tr><th colspan="2" style="text-align:left;padding-top:4px">Anywhere</th></tr>${row('Ctrl/⌘ K', 'Search and commands')}${row('?', 'This list')}${row('Space', 'Play or pause music')}${row('← →', 'Back or forward 10 s')}${row('N / P', 'Next or previous track')}
|
||||||
|
<tr><th colspan="2" style="text-align:left;padding-top:10px">Mail</th></tr>${row('J / K', 'Next or previous message')}${row('E', 'Archive')}${row('# or Delete', 'Delete')}${row('R / A / F', 'Reply, reply all, forward')}${row('U', 'Mark unread')}${row('S', 'Flag')}${row('Esc or X', 'Back to the list')}
|
||||||
|
<tr><th colspan="2" style="text-align:left;padding-top:10px">Video player</th></tr>${row('Space or K', 'Play or pause')}${row('← → / J L', 'Back or forward 10 s (30 s with Shift or J/L)')}${row('↑ ↓', 'Volume')}${row('F', 'Full screen')}${row('M', 'Mute')}${row('N / P', 'Next or previous episode')}${row('0–9', 'Jump to 0–90% of the way through')}</tbody></table>
|
||||||
|
<div class="bar"><button class="btn ghost" id="kx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
|
el.querySelector('#kx').onclick = closeSheet;
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ function plexPinSheet(u) {
|
|||||||
async function plexSwitchTo(u, pin, onError) {
|
async function plexSwitchTo(u, pin, onError) {
|
||||||
try {
|
try {
|
||||||
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
|
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
|
||||||
closeSheet(); plexHome.data = null; px.secs = null;
|
closeSheet(); plexHome.data = null; px.secs = null; wl.id = ''; try { for (const k of Object.keys(localStorage)) if (k.startsWith('vc:')) localStorage.removeItem(k); } catch {} // (what the last person had on their home screen)
|
||||||
toast('Now using Plex as ' + u.title); route();
|
toast('Now using Plex as ' + u.title); route();
|
||||||
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
|
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
|
||||||
}
|
}
|
||||||
+2
-2
@@ -271,11 +271,11 @@ function openNow() {
|
|||||||
<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>
|
<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>
|
<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>
|
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||||
const sk = el.querySelector('#npk');
|
const sk = el.querySelector('#npk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
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; };
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); };
|
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||||
|
|||||||
+2
-1
@@ -21,6 +21,7 @@ async function remindersInit() {
|
|||||||
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
|
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
|
||||||
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
|
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
|
||||||
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
|
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
|
||||||
|
${allowed('mail') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>New mail</b><small>When a new unread message arrives (checked every few minutes)</small></span><input type="checkbox" id="remmail" ${info.mail ? 'checked' : ''}></label>` : ''}
|
||||||
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
|
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
|
||||||
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
|
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
|
||||||
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
|
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
|
||||||
@@ -42,7 +43,7 @@ async function remindersInit() {
|
|||||||
} catch (e) { on1.checked = !on1.checked; toast(e.message); }
|
} catch (e) { on1.checked = !on1.checked; toast(e.message); }
|
||||||
};
|
};
|
||||||
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
|
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
|
||||||
pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
|
pref('remmail', 'mail', e => e.checked); pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
|
||||||
document.getElementById('remclear').onclick = async () => {
|
document.getElementById('remclear').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await reg.getNotifications(); list.forEach(n => n.close());
|
const list = await reg.getNotifications(); list.forEach(n => n.close());
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function searchBooks(term) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewSearch() {
|
function viewSearch() {
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
|
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
|
||||||
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
||||||
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
||||||
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
||||||
@@ -60,7 +60,7 @@ function viewSearch() {
|
|||||||
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
|
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
|
||||||
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
|
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
|
||||||
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
|
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
|
||||||
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}`;
|
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}${allowed('mail') && cfg.mail ? ' · Mail ' + part(r.mail, '✓') : ''}`;
|
||||||
};
|
};
|
||||||
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
|
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
|
||||||
showSources();
|
showSources();
|
||||||
@@ -74,7 +74,7 @@ function viewSearch() {
|
|||||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||||
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
||||||
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
||||||
const [libs, files, music, video, pods, books, ddg, hub] = await Promise.allSettled([
|
const [libs, files, music, video, pods, books, ddg, hub, mailR] = await Promise.allSettled([
|
||||||
libsP,
|
libsP,
|
||||||
libsP.then(l => searchFiles(term, l)),
|
libsP.then(l => searchFiles(term, l)),
|
||||||
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
||||||
@@ -83,6 +83,7 @@ function viewSearch() {
|
|||||||
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
|
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
|
||||||
jfetch('/api/ddg?q=' + enc(term)),
|
jfetch('/api/ddg?q=' + enc(term)),
|
||||||
jfetch('/api/search?q=' + enc(term)),
|
jfetch('/api/search?q=' + enc(term)),
|
||||||
|
allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')),
|
||||||
]);
|
]);
|
||||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||||
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
||||||
@@ -103,6 +104,7 @@ function viewSearch() {
|
|||||||
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
|
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
|
||||||
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `<a class="row" href="#/podcast/show/${enc(pdId(s))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${pdImg('podcasts', s)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(pdF(s, 'title'))}</b><small>Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}</small></span></a>`).join('');
|
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `<a class="row" href="#/podcast/show/${enc(pdId(s))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${pdImg('podcasts', s)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(pdF(s, 'title'))}</b><small>Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}</small></span></a>`).join('');
|
||||||
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `<a class="row" href="#/audio" data-book="${esc(abId(b))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${abCover(b)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(abTitle(b))}</b><small>Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}</small></span></a>`).join('');
|
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `<a class="row" href="#/audio" data-book="${esc(abId(b))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${abCover(b)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(abTitle(b))}</b><small>Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}</small></span></a>`).join('');
|
||||||
|
const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `<a class="row" href="#/mail/m/${enc(m.id)}"><span class="ic" style="background:#d4392f">${icon('mail')}</span><span class="grow"><b>${esc(m.subject)}</b><small>${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}</small></span></a>`).join('');
|
||||||
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
||||||
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
||||||
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||||
@@ -123,12 +125,13 @@ function viewSearch() {
|
|||||||
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
|
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
|
||||||
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
|
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
|
||||||
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
|
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
|
||||||
|
mail: !(allowed('mail') && cfg.mail) ? undefined : mailR.status === 'fulfilled' ? { n: (mailR.value.items || []).length } : { err: mailR.reason && mailR.reason.message },
|
||||||
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
|
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
|
||||||
});
|
});
|
||||||
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
|
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
|
||||||
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
|
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
|
||||||
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
|
res.innerHTML = card + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
|
||||||
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
||||||
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
|
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
|
||||||
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||||
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
|
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ function viewSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
<div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||||
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
|
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
|
||||||
|
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
||||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||||
@@ -128,6 +129,17 @@ function viewSettings() {
|
|||||||
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
|
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
|
||||||
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
|
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
|
||||||
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
|
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
|
||||||
|
if (isAdmin) jfetch('/api/health').then(h => {
|
||||||
|
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||||
|
const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5);
|
||||||
|
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
|
||||||
|
$('#health').innerHTML =
|
||||||
|
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
|
||||||
|
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
|
||||||
|
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
|
||||||
|
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
|
||||||
|
+ row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false);
|
||||||
|
}).catch(() => { const e = $('#health'); if (e) e.innerHTML = '<div class="row"><small>Could not read the health report</small></div>'; });
|
||||||
if (isAdmin) jfetch('/api/system').then(i => {
|
if (isAdmin) jfetch('/api/system').then(i => {
|
||||||
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
|
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
|
||||||
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||||
|
|||||||
+16
-2
@@ -4,10 +4,24 @@
|
|||||||
// ---- Offline notice
|
// ---- Offline notice
|
||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
|
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
|
||||||
bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
|
bar.innerHTML = 'You are offline. <button type="button" id="offinfo" style="background:none;border:0;color:inherit;text-decoration:underline;font:inherit;cursor:pointer;padding:0 0 0 6px;min-height:0">What still works?</button>';
|
||||||
document.body.appendChild(bar);
|
document.body.appendChild(bar);
|
||||||
const sync = () => { bar.hidden = navigator.onLine; };
|
const sync = () => { bar.hidden = navigator.onLine; };
|
||||||
addEventListener('online', sync); addEventListener('offline', sync); sync();
|
bar.querySelector('#offinfo').onclick = () => {
|
||||||
|
const el = openSheet('You are offline', 'The app opens, and these still work from what this device has saved:', `<ul style="line-height:1.7;padding-left:18px">
|
||||||
|
<li><b>Mail</b>: your last message lists and every message you have opened. Deleting and replying wait until you are back online.</li>
|
||||||
|
<li><b>Files</b>: folders you have opened, and the saved list of documents.</li>
|
||||||
|
<li><b>Calendar, tasks, reminders</b>: what was loaded last time.</li>
|
||||||
|
<li><b>Music, video, Signal</b>: need the connection (the song or movie streams from your server).</li></ul>
|
||||||
|
<p class="sub">Nothing is lost: it all catches up when you reconnect.</p><div class="bar"><button class="btn ghost" id="offx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
|
el.querySelector('#offx').onclick = closeSheet;
|
||||||
|
};
|
||||||
|
// Back online, or coming back to the app after a while away: refresh what is on screen quietly.
|
||||||
|
let hiddenAt = 0;
|
||||||
|
const refresh = () => { try { if (typeof mailPoll === 'function') mailPoll(); if (location.hash.startsWith('#/mail') && typeof mailLoadList === 'function' && typeof mail !== 'undefined' && mail.st && $('#mrows') && !mail.msg) mailLoadList(true); else if (/^#\/(home|planner|calendar|tasks)?$/.test(location.hash) && typeof route === 'function' && !document.getElementById('sheet')) route(); } catch {} };
|
||||||
|
addEventListener('online', () => { sync(); refresh(); });
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) hiddenAt = Date.now(); else if (hiddenAt && Date.now() - hiddenAt > 60e3 && navigator.onLine) { hiddenAt = 0; refresh(); } });
|
||||||
|
addEventListener('offline', sync); sync();
|
||||||
|
|
||||||
// ---- Skip link and landmarks
|
// ---- Skip link and landmarks
|
||||||
const skip = document.createElement('a');
|
const skip = document.createElement('a');
|
||||||
|
|||||||
@@ -796,3 +796,4 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vhero { --vtint: transparent; }
|
.vhero { --vtint: transparent; }
|
||||||
.vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; }
|
.vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; }
|
||||||
.vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; }
|
.vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; }
|
||||||
|
kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; background: var(--panel2); font: 12px ui-monospace, Menlo, monospace; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v19';
|
const CACHE = 'hub-v23';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
// Home: "Pick up where you left off" (Continue watching and Continue listening), shown above the day's agenda in the evening and below it in the morning.
|
||||||
|
async function homeResume(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const bits = await Promise.allSettled([
|
||||||
|
allowed('video') ? (async () => {
|
||||||
|
const st = await jfetch('/api/plex/status'); if (!st.signedIn || !st.server) return '';
|
||||||
|
px.serverKey = st.server;
|
||||||
|
const items = ((await px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=8')).Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode').slice(0, 6);
|
||||||
|
return items.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue watching</h3><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>` : '';
|
||||||
|
})() : '',
|
||||||
|
allowed('audio') ? (async () => {
|
||||||
|
const [data] = await Promise.all([abApi('/books'), abLoadProg()]), books = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||||
|
const cont = books.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 6);
|
||||||
|
today.books = cont;
|
||||||
|
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue listening</h3><div class="vrow">${cont.map(b => { const p = abProg[abId(b)], pc = Math.min(100, p.pos / p.dur * 100);
|
||||||
|
return `<a class="vcard" href="#/audio" data-book="${esc(abId(b))}" style="flex:none;width:130px"><span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pc > 1 ? `<i class="vprog"><b style="width:${pc}%"></b></i>` : ''}</span><b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></a>`; }).join('')}</div>` : '';
|
||||||
|
})() : '',
|
||||||
|
]);
|
||||||
|
const html = bits.map(b => b.status === 'fulfilled' ? b.value : '').join('');
|
||||||
|
if (!html || !el.isConnected) return;
|
||||||
|
el.innerHTML = `<h2>Pick up where you left off</h2>${html}`;
|
||||||
|
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||||
|
}
|
||||||
|
const today = { books: [] };
|
||||||
+28
-6
@@ -53,6 +53,19 @@ const vdCard = (m, wide, deck) => {
|
|||||||
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
|
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
|
||||||
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : html;
|
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : html;
|
||||||
};
|
};
|
||||||
|
// ---- Watch later: a normal Plex video playlist called "Watch later" (so it follows the Plex person you use, and shows in Plex's own apps too)
|
||||||
|
const wl = { id: '' };
|
||||||
|
async function wlItems() {
|
||||||
|
if (!wl.id) { const p = ((await px.get('/playlists?playlistType=video')).Metadata || []).find(x => x.title === 'Watch later' && !x.smart); if (!p) return []; wl.id = p.ratingKey; }
|
||||||
|
return (await px.get(`/playlists/${wl.id}/items`)).Metadata || [];
|
||||||
|
}
|
||||||
|
async function wlAdd(rk) {
|
||||||
|
const uri = await plx.uri([rk]);
|
||||||
|
if (!wl.id) { await wlItems(); }
|
||||||
|
if (wl.id) return plx.call('PUT', `/playlists/${wl.id}/items?uri=${encodeURIComponent(uri)}`);
|
||||||
|
const r = await plx.call('POST', `/playlists?type=video&smart=0&title=${encodeURIComponent('Watch later')}&uri=${encodeURIComponent(uri)}`); wl.id = (((r.MediaContainer || {}).Metadata || [])[0] || {}).ratingKey || '';
|
||||||
|
}
|
||||||
|
async function wlRemove(rk) { const it = (await wlItems()).find(x => x.ratingKey === String(rk)); if (it) await plx.call('DELETE', `/playlists/${wl.id}/items/${it.playlistItemID}`); }
|
||||||
/** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */
|
/** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */
|
||||||
async function vdRemove(m, quiet) {
|
async function vdRemove(m, quiet) {
|
||||||
try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); }
|
try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); }
|
||||||
@@ -66,10 +79,11 @@ async function videoHome(st, libKey) {
|
|||||||
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
|
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
|
||||||
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
|
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
|
||||||
store.set('video_lib', cur.key);
|
store.set('video_lib', cur.key);
|
||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||||
<input type="search" id="vq" placeholder="Search movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
<input type="search" id="vq" placeholder="Search movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||||
<div id="vhero"></div><div id="vdeck"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||||
|
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||||
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||||
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||||
const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
|
const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
|
||||||
@@ -89,6 +103,8 @@ async function videoHome(st, libKey) {
|
|||||||
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
|
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
// sort and filter the library below
|
// sort and filter the library below
|
||||||
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
||||||
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
||||||
@@ -147,13 +163,18 @@ async function videoItem(id) {
|
|||||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||||
${m.type !== 'season' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||||
<p class="vsum">${esc(m.summary || '')}</p>
|
<p class="vsum">${esc(m.summary || '')}</p>
|
||||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||||
<div id="vkids"></div></div>`;
|
<div id="vkids"></div></div>`;
|
||||||
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
|
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
|
||||||
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
|
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
|
||||||
|
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
|
||||||
|
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
|
||||||
|
$('#vwl').onclick = async () => { try { on ? await wlRemove(id) : await wlAdd(id); toast(on ? 'Removed from Watch later' : 'Added to Watch later'); route(); } catch (e) { toast(e.message); } };
|
||||||
|
}).catch(() => {});
|
||||||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||||
@@ -167,10 +188,10 @@ async function videoItem(id) {
|
|||||||
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
|
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
|
||||||
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
|
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
if (m.type === 'show' || m.type === 'season') {
|
if (m.type === 'show' || m.type === 'season' || m.type === 'collection') {
|
||||||
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
|
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
|
||||||
if (!$('#vkids')) return;
|
if (!$('#vkids')) return;
|
||||||
$('#vkids').innerHTML = m.type === 'show' ? `<h2>Seasons</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
|
$('#vkids').innerHTML = m.type === 'show' || m.type === 'collection' ? `<h2>${m.type === 'show' ? 'Seasons' : 'In this collection'}</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
|
||||||
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
|
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +234,7 @@ function videoMount() {
|
|||||||
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
||||||
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
||||||
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
||||||
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
|
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
|
||||||
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
||||||
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
||||||
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
|
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
|
||||||
@@ -228,6 +249,7 @@ function videoMount() {
|
|||||||
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
||||||
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
||||||
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
||||||
|
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
|
||||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
||||||
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
||||||
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function keys() {
|
|||||||
return vapid;
|
return vapid;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = { tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
|
const DEFAULTS = { mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
|
||||||
const file = dir => path.join(dir, 'push.json');
|
const file = dir => path.join(dir, 'push.json');
|
||||||
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
|
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
|
||||||
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
|
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
|
||||||
@@ -75,6 +75,19 @@ async function tick(now = new Date(), force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (checkEvents && st.mail && users.can(u, 'mail')) { // new unread mail (the first look only notes what is already there)
|
||||||
|
try {
|
||||||
|
const now2 = (await mail.newest()).filter(m => !m.read), seen = st.mailSeen;
|
||||||
|
if (!Array.isArray(seen)) { st.mailSeen = now2.map(m => m.id); dirty = true; }
|
||||||
|
else {
|
||||||
|
const fresh = now2.filter(m => !seen.includes(m.id));
|
||||||
|
if (fresh.length) {
|
||||||
|
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
||||||
|
await send(dir, st, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
||||||
|
}
|
||||||
if (checkEvents && st.events && users.can(u, 'calendar')) {
|
if (checkEvents && st.events && users.can(u, 'calendar')) {
|
||||||
try {
|
try {
|
||||||
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
||||||
@@ -100,7 +113,7 @@ function start() { if (!timer) { timer = setInterval(() => tick().catch(e => con
|
|||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
|
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
|
||||||
const pub = () => ({ tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
|
const pub = () => ({ mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
|
||||||
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
|
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
|
||||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
const b = JSON.parse((await readBody(req)) || '{}');
|
const b = JSON.parse((await readBody(req)) || '{}');
|
||||||
@@ -115,6 +128,7 @@ async function handle(req, res) {
|
|||||||
if (p === '/api/push/prefs') {
|
if (p === '/api/push/prefs') {
|
||||||
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
||||||
if (typeof b.events === 'boolean') st.events = b.events;
|
if (typeof b.events === 'boolean') st.events = b.events;
|
||||||
|
if (typeof b.mail === 'boolean') st.mail = b.mail;
|
||||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
|
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
|
||||||
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
|
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
|
||||||
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ async function handleRequest(req, res) {
|
|||||||
return users.runAs(user, async () => {
|
return users.runAs(user, async () => {
|
||||||
const rule = ACCESS.find(([re]) => re.test(pathname));
|
const rule = ACCESS.find(([re]) => re.test(pathname));
|
||||||
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
||||||
if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
||||||
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
||||||
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
||||||
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
||||||
@@ -603,6 +603,17 @@ async function handleRequest(req, res) {
|
|||||||
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
||||||
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
||||||
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
|
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
|
||||||
|
if (pathname === '/api/health') { // everything an admin wants to know at a glance
|
||||||
|
const fs = require('fs'), dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), out = { at: Date.now() };
|
||||||
|
try { const f = fs.statfsSync(dir); out.disk = { free: f.bavail * f.bsize, total: f.blocks * f.bsize }; } catch { out.disk = null; }
|
||||||
|
const bk = require('./audit').recent(2000).find(x => x.event === 'backup downloaded'); out.backup = bk ? { t: bk.t, who: bk.who } : null;
|
||||||
|
out.uptime = Math.round((Date.now() - STARTED) / 1000); out.node = process.version;
|
||||||
|
const ps = plexLoad();
|
||||||
|
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
|
||||||
|
try { out.mail = await mail.health(); } catch { out.mail = []; }
|
||||||
|
out.lyrics = lyrics.enabled(); out.signal = signal.enabled();
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
|
||||||
|
}
|
||||||
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
||||||
if (pathname === '/api/settings') return settings.handle(req, res);
|
if (pathname === '/api/settings') return settings.handle(req, res);
|
||||||
if (pathname === '/api/search') return search.handle(req, res);
|
if (pathname === '/api/search') return search.handle(req, res);
|
||||||
|
|||||||
Reference in new issue
Block a user