diff --git a/public/app.js b/public/app.js index 94ded20..5adb00f 100644 --- a/public/app.js +++ b/public/app.js @@ -271,6 +271,7 @@ function homeApps() { function viewHome() { 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 recents = store.get('recents', []).slice(0, 5); const t = typeof cur === 'function' ? cur() : null; @@ -280,12 +281,15 @@ function viewHome() {
${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} ${allowed('music') ? '
' : ''} + ${evening ? '
' : ''}
${allowed('mail') && cfg.mail ? '
' : ''} ${recents.length && allowed('files') ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''}
+ ${evening ? '' : '
'}
${homeApps()}
`; + if (typeof homeResume === 'function') homeResume($('#resume')); renderNpCard(); // the live Now Playing card (it follows the song and has the controls) $('#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('/')) || '/'); diff --git a/public/cast.js b/public/cast.js new file mode 100644 index 0000000..253cf0e --- /dev/null +++ b/public/cast.js @@ -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')); } +} diff --git a/public/index.html b/public/index.html index 8bbbd6f..174e624 100644 --- a/public/index.html +++ b/public/index.html @@ -62,6 +62,7 @@ + @@ -80,6 +81,7 @@ + diff --git a/public/player.js b/public/player.js index 80dd050..39747ea 100644 --- a/public/player.js +++ b/public/player.js @@ -271,11 +271,11 @@ function openNow() {
0:000:00
-
`); +
${castOk(pl.a) ? '' : ''}
`); const sk = el.querySelector('#npk'); sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); }; sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; - el.querySelector('#npq').onclick = openQueue; el.querySelector('#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('#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; diff --git a/public/search-view.js b/public/search-view.js index 16e769f..4c1e5bd 100644 --- a/public/search-view.js +++ b/public/search-view.js @@ -45,7 +45,7 @@ async function searchBooks(term) { } function viewSearch() { - $('#view').innerHTML = `

Search

+ $('#view').innerHTML = `

Search

@@ -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 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') : '✓'; - 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()); showSources(); @@ -74,7 +74,7 @@ function viewSearch() { const enc = encodeURIComponent, lower = term.toLowerCase(); const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server); 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.then(l => searchFiles(term, l)), 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')), jfetch('/api/ddg?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; const sec = (title, html) => html ? `

${title}

${html}
` : ''; @@ -103,6 +104,7 @@ function viewSearch() { const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join(''); const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `${esc(pdF(s, 'title'))}Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}`).join(''); const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `${esc(abTitle(b))}Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}`).join(''); + const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `${icon('mail')}${esc(m.subject)}${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}`).join(''); const d = ddg.status === 'fulfilled' ? ddg.value : {}; const card = (d.text || d.answer) ? `
${d.image ? `` : ''} @@ -123,12 +125,13 @@ function viewSearch() { 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 }, 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 }, }); const notes = [...(h.errors || [])].map(e => `

${esc(e)}

`).join(''); const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')}${esc(l.title || l.url)}${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}`).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 ? `

Movies & TV

${videoHtml}
` : '') + notes; - if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + 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 ? `

Movies & TV

${videoHtml}
` : '') + notes; + if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + 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('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)); diff --git a/public/sw.js b/public/sw.js index 2b6696c..dc3150c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v22'; +const CACHE = 'hub-v23'; 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']; diff --git a/public/today.js b/public/today.js new file mode 100644 index 0000000..98d1b04 --- /dev/null +++ b/public/today.js @@ -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 ? `

Continue watching

${items.map(m => vdCard(m, true)).join('')}
` : ''; + })() : '', + 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 ? `

Continue listening

${cont.map(b => { const p = abProg[abId(b)], pc = Math.min(100, p.pos / p.dur * 100); + return `${pc > 1 ? `` : ''}${esc(abTitle(b))}${esc(abAuthor(b))}`; }).join('')}
` : ''; + })() : '', + ]); + const html = bits.map(b => b.status === 'fulfilled' ? b.value : '').join(''); + if (!html || !el.isConnected) return; + el.innerHTML = `

Pick up where you left off

${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: [] }; diff --git a/public/video.js b/public/video.js index aa34f38..b66c44d 100644 --- a/public/video.js +++ b/public/video.js @@ -234,7 +234,7 @@ function videoMount() { const b = (idn, ic, label, extra = '') => ``; v.innerHTML = `
${icon('back')}Back${esc(title)} -
${b('vmute', icon('vol'), 'Mute')}${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')}
+
${b('vmute', icon('vol'), 'Mute')}${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')}
0:00-0:00
${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `${icon('rew')}10`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `${icon('fwd')}10`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}
@@ -249,6 +249,7 @@ function videoMount() { $('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); }; $('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10); 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; if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} }; $('#vmute').onclick = () => { el.muted = !el.muted; };