- Search titles, actors and directors; results grouped into Movies, TV shows, Actors, Directors and Episodes - Tap an actor or director to see everything they are in - Filter the library by genre, decade, rating and watched or unwatched, alongside sorting - README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1c720c74f
commit
d9a59b393d
2 files changed
+16
-7
No files matched your search
@@ -206,6 +206,8 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
|
||||
|
||||
**Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`.
|
||||
|
||||
**Movies & TV search:** search by title, actor or director (results grouped into Movies, TV shows, Actors, Directors and Episodes); tap an actor or director to see everything they are in; filter the library by genre, decade, rating (G, PG, PG-13, R, TV ratings) and watched or unwatched, together with the sort.
|
||||
|
||||
**Name tiles on the sign-in page:** on a device you have remembered (Settings → Family → *Remember this device*, or any family device) the sign-in page shows everyone's name, children marked KID. Tap yours, then enter your PIN (it signs in as soon as the last digit is typed) or use your passkey; a child with no PIN just taps. Any other browser still shows only *Sign in with passkey*, so strangers see no names and cannot guess PINs. A remembered personal device gets a normal long session; a family device keeps its short sessions. *Forget* switches it off again.
|
||||
|
||||
**Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. Each board card has a **+** to add to the shopping list, the family tasks, a shared checklist (pick which) or the Family calendar (all day, or at a time): write it, tap your name (PIN unless you are a child) and it is added as you, if that part of the hub is turned on for you. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. The admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). The Family calendar and Family task list are shared by everyone and appear on the board.
|
||||
|
||||
+14
-7
@@ -80,9 +80,9 @@ async function videoHome(st, libKey) {
|
||||
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
|
||||
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 movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></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 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><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><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.
|
||||
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||
@@ -108,10 +108,12 @@ async function videoHome(st, libKey) {
|
||||
// sort and filter the library below
|
||||
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
||||
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
||||
px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||
let start = 0, total = 0, all = [], gen = 0;
|
||||
const load = async () => {
|
||||
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked ? '&unwatched=1' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked || $('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
|
||||
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
|
||||
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
|
||||
@@ -119,7 +121,7 @@ async function videoHome(st, libKey) {
|
||||
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||||
};
|
||||
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
||||
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); };
|
||||
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr', '#vwatch']) $(id).onchange = () => reload();
|
||||
reload();
|
||||
// press and hold (or right-click) a card: quick actions
|
||||
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
|
||||
@@ -133,14 +135,19 @@ async function videoHome(st, libKey) {
|
||||
vv.addEventListener('touchstart', e => { const c = e.target.closest('.vcard'); held = false; clearTimeout(hold); if (c && c.dataset.rk) hold = setTimeout(() => { held = true; menu(c); }, 550); }, { passive: true });
|
||||
for (const ev of ['touchend', 'touchmove', 'touchcancel']) vv.addEventListener(ev, () => clearTimeout(hold), { passive: true });
|
||||
vv.addEventListener('click', e => { if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; } const x = e.target.closest('.vx'); if (x) { e.preventDefault(); const rk = x.dataset.rm; vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: 'movie' }).then(loadDeck); } }, true);
|
||||
const personBar = () => { const t = $('#vlt'); if (t) t.innerHTML = vd.person ? `${esc(vd.person.name)} <button class="btn ghost" id="vpc" style="min-height:32px;margin-left:8px">Show everything</button>` : esc(cur.title); const c = $('#vpc'); if (c) c.onclick = () => { vd.person = null; personBar(); reload(); }; };
|
||||
vd.person = null;
|
||||
let t; $('#vq').oninput = e => {
|
||||
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
|
||||
t = setTimeout(async () => {
|
||||
try {
|
||||
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=10`), rows = [];
|
||||
for (const h of mc.Hub || []) if (['movie', 'show', 'episode'].includes(h.type)) for (const m of h.Metadata || []) rows.push(m);
|
||||
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=24`), groups = [];
|
||||
for (const h of mc.Hub || []) { const items = h.Metadata || []; if (!items.length) continue; if (h.type === 'movie') groups.push(['Movies', items, 'card']); else if (h.type === 'show') groups.push(['TV shows', items, 'card']); else if (h.type === 'episode') groups.push(['Episodes', items, 'card']); else if (h.type === 'actor' || h.type === 'director') groups.push([h.type === 'actor' ? 'Actors' : 'Directors', items.filter(m => m.filter), 'person']); }
|
||||
if (!$('#vsr') || $('#vq').value.trim() !== q) return;
|
||||
$('#vsr').innerHTML = rows.length ? `<h2>Results</h2><div class="vgrid">${rows.map(m => vdCard(m)).join('')}</div>` : '<p class="empty">Nothing found.</p>';
|
||||
const order = ['Movies', 'TV shows', 'Actors', 'Directors', 'Episodes'];
|
||||
groups.sort((x, y) => order.indexOf(x[0]) - order.indexOf(y[0]));
|
||||
$('#vsr').innerHTML = groups.some(g => g[1].length) ? groups.filter(g => g[1].length).map(([title, items, kind]) => kind === 'card' ? `<h2>${title} <small class="sub">${items.length}</small></h2><div class="vgrid">${items.map(m => vdCard(m)).join('')}</div>` : `<h2>${title}</h2><div class="seg" style="flex-wrap:wrap">${items.map(m => `<a href="#" data-vp="${esc(m.filter)}" data-vn="${esc(m.tag || m.title || '')}">${esc(m.tag || m.title || '')}</a>`).join('')}</div>`).join('') : '<p class="empty">Nothing found. Try fewer words, or part of a name.</p>';
|
||||
$('#vsr').querySelectorAll('[data-vp]').forEach(a => a.onclick = e => { e.preventDefault(); vd.person = { filter: a.dataset.vp, name: a.dataset.vn }; $('#vq').value = ''; $('#vsr').innerHTML = ''; personBar(); reload(); $('#vlt').scrollIntoView({ behavior: 'smooth' }); });
|
||||
} catch (err) { if ($('#vsr')) $('#vsr').innerHTML = `<p class="err">${esc(err.message)}</p>`; }
|
||||
}, 350);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user