Mail in Search, Pick up where you left off on Home (evening first), Cast/AirPlay buttons
build-container / build (push) Successful in 13m46s
build-container / build (push) Successful in 13m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1981e66b8a
commit
3166a7b1ab
8 files changed
+56
-9
No files matched your search
@@ -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() {
|
||||
<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('music') ? '<div id="npslot"></div>' : ''}
|
||||
${evening ? '<div id="resume"></div>' : ''}
|
||||
<div class="homecols">
|
||||
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></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>
|
||||
</div>
|
||||
${evening ? '' : '<div id="resume"></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)
|
||||
$('#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('/')) || '/');
|
||||
|
||||
@@ -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="/app.js"></script>
|
||||
<script src="/search-view.js"></script>
|
||||
<script src="/today.js"></script>
|
||||
<script src="/calendar.js"></script>
|
||||
<script src="/tasks.js"></script>
|
||||
<script src="/audiobooks.js"></script>
|
||||
@@ -80,6 +81,7 @@
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
<script src="/keys.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
|
||||
+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>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div 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');
|
||||
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;
|
||||
|
||||
@@ -45,7 +45,7 @@ async function searchBooks(term) {
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
@@ -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 ? `<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 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 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 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">` : ''}
|
||||
@@ -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 => `<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('') : '';
|
||||
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;
|
||||
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;
|
||||
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 && !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('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));
|
||||
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
+2
-1
@@ -234,7 +234,7 @@ function videoMount() {
|
||||
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">
|
||||
<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="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>
|
||||
@@ -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; };
|
||||
|
||||
Reference in new issue
Block a user