diff --git a/README.md b/README.md index 927172d..f43aec0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/video.js b/public/video.js index 2ec2b57..fb57458 100644 --- a/public/video.js +++ b/public/video.js @@ -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 = `

Movies & TV

${secs.length > 1 ? `
${secs.map(s => `${esc(s.title)}`).join('')}
` : ''}
-
+

${esc(cur.title)}

-

Loading…

`; +

Loading…

`; 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 => ``).join('')); }).catch(() => {}); + px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => ``).join('')); }).catch(() => {}); px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => ``).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 ? `
` : ''; @@ -119,7 +121,7 @@ async function videoHome(st, libKey) { if (!all.length) { $('#vgrid').innerHTML = `
${icon('film')}

Nothing matches.

${$('#vunwc').checked || genre ? '' : ''}
`; 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 = ''.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `

${esc(e.message)}

`; }); }; - $('#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)} ` : 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 ? `

Results

${rows.map(m => vdCard(m)).join('')}
` : '

Nothing found.

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

${title} ${items.length}

${items.map(m => vdCard(m)).join('')}
` : `

${title}

${items.map(m => `${esc(m.tag || m.title || '')}`).join('')}
`).join('') : '

Nothing found. Try fewer words, or part of a name.

'; + $('#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 = `

${esc(err.message)}

`; } }, 350); };