Movies & TV: favorites, a family movie night list to vote on, and mixes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 10:20:33 -04:00
1 parent fe651a8c37
commit 2766424bb2
7 files changed
+107 -2

No files matched your search

+1
View File
@@ -1102,3 +1102,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
.vnrow { gap: 10px; } .vnart { width: 44px; flex: none; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; background: var(--panel2); display: grid; place-items: center; } .vnart img { width: 100%; height: 100%; object-fit: cover; } .vnvote { min-height: 36px; padding: 0 12px; flex: none; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v106';
const CACHE = 'hub-v108';
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'];
+33 -1
View File
@@ -73,6 +73,21 @@ async function vdRemove(m, quiet) {
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
}
// ---- Favorites (yours) and Movie night (the family's list to vote on)
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
function vdFamilyRows() {
const drawNight = items => {
const el = $('#vnight'); if (!el) return;
el.innerHTML = items.length ? `<h2>Movie night</h2><div class="list">${items.map((x, i) => `<div class="row vnrow"><a class="vnart" href="#/video/item/${esc(x.id)}">${x.thumb ? `<img loading="lazy" alt="" src="${vdArt(x.thumb, 90, 135)}" onerror="this.remove()">` : icon('film')}</a>
<a class="grow" href="#/video/item/${esc(x.id)}" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc([x.year || '', 'added by ' + x.byName].filter(Boolean).join(' · '))}${x.voters.length ? ' · ' + esc(x.voters.join(', ')) : ''}</small></a>
<button class="btn${x.voted ? '' : ' ghost'} vnvote" data-v="${esc(x.id)}" aria-pressed="${x.voted}" aria-label="${x.voted ? 'Take back your vote for ' : 'Vote for '}${esc(x.title)}">${i === 0 && x.votes > 1 ? '★ ' : ''}${x.votes} ${x.votes === 1 ? 'vote' : 'votes'}</button><button class="ibtn" data-r="${esc(x.id)}" aria-label="Remove ${esc(x.title)}" title="Remove">${icon('x')}</button></div>`).join('')}</div>` : '';
el.querySelectorAll('[data-v]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/vote', vfOpt({ id: b.dataset.v }))).items); } catch (e) { toast(e.message); } });
el.querySelectorAll('[data-r]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/remove', vfOpt({ id: b.dataset.r }))).items); } catch (e) { toast(e.message); } });
};
vfApi('/night').then(r => drawNight(r.items)).catch(() => {});
vfApi('/favs').then(r => { const el = $('#vfavr'); if (el) el.innerHTML = r.items.length ? `<h2>Favorites</h2><div class="vrow">${r.items.slice(0, 30).map(x => vdCard(vfCard(x), false)).join('')}</div>` : ''; }).catch(() => {});
}
async function videoHome(st, libKey) {
const v = $('#view');
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
@@ -81,7 +96,7 @@ async function videoHome(st, libKey) {
store.set('video_lib', cur.key);
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 titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<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 id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></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><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></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.
@@ -97,12 +112,20 @@ async function videoHome(st, libKey) {
}).catch(() => {});
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
const type = cur.type === 'movie' ? 1 : 2, size = 120;
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('');
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
} catch (er) { el.querySelector('.empty').textContent = er.message; } }); }
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
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(() => {});
}
vdFamilyRows();
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
@@ -189,6 +212,15 @@ async function videoItem(id) {
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
};
});
if (m.type === 'movie' || m.type === 'show') { // Favorite, and Movie night (the family's shared list to vote on)
const mc = { id: String(id), title: m.title, year: m.year || 0, type: m.type, thumb: m.thumb || '' };
Promise.all([vfApi('/favs'), vfApi('/night')]).then(([f, n]) => {
const box = $('#vact'); if (!box || $('#vfav')) return; const fav = f.items.some(x => x.id === mc.id), night = n.items.some(x => x.id === mc.id);
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vfav">${fav ? '♥ Favorite' : '♡ Favorite'}</button><button class="btn ghost" id="vmn">${night ? '✓ On movie night' : '+ Movie night'}</button>`);
$('#vfav').onclick = async () => { try { await vfApi('/fav', vfOpt({ ...mc, on: !fav })); toast(fav ? 'Removed from Favorites' : 'Added to Favorites'); route(); } catch (e) { toast(e.message); } };
$('#vmn').onclick = async () => { try { if (night) { location.hash = '#/video'; return; } await vfApi('/night/add', vfOpt(mc)); toast('Added to movie night. Everyone can vote on it from Movies & TV.'); route(); } catch (e) { toast(e.message); } };
}).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>`);