diff --git a/public/style.css b/public/style.css index 422256e..cc9505a 100644 --- a/public/style.css +++ b/public/style.css @@ -787,3 +787,12 @@ body.nav-mini .ngrp span { display: none; } .ll { text-shadow: 0 1px 12px color-mix(in srgb, var(--bg) 70%, transparent); } .ll.on { text-shadow: 0 0 28px color-mix(in srgb, var(--accent) 35%, transparent); } .lyrart { box-shadow: 0 4px 14px rgba(0, 0, 0, .3); } +.vban { position: relative; display: block; height: clamp(170px, 30vw, 280px); border-radius: 16px; overflow: hidden; margin: 6px 0 14px; background: var(--panel2); color: #fff; box-shadow: 0 12px 32px rgba(0, 0, 0, .3); } +.vban img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; animation: vfade .5s ease both; } +.vban::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, .78), rgba(0, 0, 0, .15) 70%), linear-gradient(transparent 50%, rgba(0, 0, 0, .5)); } +.vbant { position: absolute; z-index: 1; left: 20px; bottom: 18px; right: 20px; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; } +.vbant b { font-size: clamp(20px, 4vw, 32px); line-height: 1.15; letter-spacing: -.02em; } .vbant small { opacity: .85; font-size: 13px; } +.vbant .btn { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; font-style: normal; min-height: 44px; } +.vhero { --vtint: transparent; } +.vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; } +.vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; } diff --git a/public/sw.js b/public/sw.js index 8062d84..67bc89e 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-v18'; +const CACHE = 'hub-v19'; 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/video.js b/public/video.js index 5ac275a..d9a8c49 100644 --- a/public/video.js +++ b/public/video.js @@ -68,14 +68,16 @@ async function videoHome(st, libKey) { store.set('video_lib', cur.key); v.innerHTML = `

Movies & TV

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

${esc(cur.title)}

+

${esc(cur.title)}

Loading…

`; // 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; const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } }; const vcPut = (k, items) => { try { localStorage.setItem(vcK(k), JSON.stringify(items.map(m => ({ ratingKey: m.ratingKey, type: m.type, title: m.title, year: m.year, thumb: m.thumb, art: m.art, parentThumb: m.parentThumb, grandparentTitle: m.grandparentTitle, grandparentRatingKey: m.grandparentRatingKey, parentIndex: m.parentIndex, index: m.index, viewOffset: m.viewOffset, viewCount: m.viewCount, duration: m.duration, leafCount: m.leafCount, viewedLeafCount: m.viewedLeafCount })))); } catch {} }; const rowOf = (id, title, items, deck) => { const el = $(id); if (!el) return; el.innerHTML = items.length ? `

${title}${deck ? '' : ''}

${items.map(m => vdCard(m, true, deck)).join('')}
` : ''; }; - const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true); if ($('#vclr')) $('#vclr').onclick = () => confirmSheet('Clear Continue watching?', 'Takes everything off this row. Your library and watch history stay as they are.', 'Clear all', async () => { for (const m of vd.deck.slice()) await vdRemove(m, true); loadDeck(); }); }; + const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true); + const h = items[0], hb = $('#vhero'); // the most recent one gets a banner with a Resume button + if (hb) hb.innerHTML = h ? `${(h.art || h.thumb) ? `` : ''}${vdLeft(h) ? 'Pick up where you left off · ' + vdLeft(h) + ' min left' : 'Up next'}${esc(h.type === 'episode' ? h.grandparentTitle : h.title)}${h.type === 'episode' ? `${vdEp(h).replace(/^.*· /, '')} · ${esc(h.title)}` : ''}${icon('play')}Resume` : ''; if ($('#vclr')) $('#vclr').onclick = () => confirmSheet('Clear Continue watching?', 'Takes everything off this row. Your library and watch history stay as they are.', 'Clear all', async () => { for (const m of vd.deck.slice()) await vdRemove(m, true); loadDeck(); }); }; const loadDeck = () => px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=16').then(mc => { const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); vcPut('deck', items); if ($('#vdeck')) drawDeck(items); }).catch(() => {}); @@ -98,7 +100,7 @@ async function videoHome(st, libKey) { $('#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 ? `
` : ''; if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message)); - if (!all.length) $('#vgrid').innerHTML = '

Nothing matches.

'; + 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(); }; @@ -152,6 +154,8 @@ async function videoItem(id) { jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', ''); $('#vedit').onclick = () => videoEdit(m); } }).catch(() => {}); + const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour + if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); } if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; }; if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; }; if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };