diff --git a/public/index.html b/public/index.html index 174e624..0c38ed3 100644 --- a/public/index.html +++ b/public/index.html @@ -82,6 +82,7 @@ + diff --git a/public/rows.js b/public/rows.js new file mode 100644 index 0000000..8f82463 --- /dev/null +++ b/public/rows.js @@ -0,0 +1,23 @@ +'use strict'; +// Sideways rows (Continue watching, Collections, Recently added...): arrow buttons at the ends, and click-and-drag with a mouse, so nobody has to +// reach for the scrollbar. Touch and trackpads scroll them natively. Any .vrow that shows up on the page gets these. +(function () { + const enhance = row => { + if (row.dataset.sl) return; row.dataset.sl = '1'; + const wrap = document.createElement('div'); wrap.className = 'vslide'; row.parentNode.insertBefore(wrap, row); wrap.appendChild(row); + const mk = (cls, label, d) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'vsl ' + cls; b.setAttribute('aria-label', label); b.innerHTML = d < 0 ? '‹' : '›'; b.onclick = () => row.scrollBy({ left: d * Math.max(200, row.clientWidth * .85), behavior: 'smooth' }); wrap.appendChild(b); return b; }; + const l = mk('l', 'Scroll left', -1), r = mk('r', 'Scroll right', 1); + const sync = () => { l.hidden = row.scrollLeft < 8; r.hidden = row.scrollLeft + row.clientWidth >= row.scrollWidth - 8; }; + row.addEventListener('scroll', sync, { passive: true }); addEventListener('resize', sync); new ResizeObserver(sync).observe(row); sync(); setTimeout(sync, 400); setTimeout(sync, 1500); // (posters arrive after the row is drawn) + let down = null, moved = false; + row.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse' || e.button !== 0) return; down = { x: e.clientX, left: row.scrollLeft }; moved = false; }); + addEventListener('pointermove', e => { if (!down || !row.isConnected) return; const dx = e.clientX - down.x; if (Math.abs(dx) > 5) { moved = true; row.classList.add('drag'); } if (moved) row.scrollLeft = down.left - dx; }); + addEventListener('pointerup', () => { if (down) { down = null; setTimeout(() => row.classList.remove('drag'), 0); } }); + row.addEventListener('click', e => { if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; } }, true); // (a drag is not a tap on a poster) + row.addEventListener('dragstart', e => e.preventDefault()); + }; + let queued = 0; + const scan = () => { queued = 0; document.querySelectorAll('.vrow:not([data-sl])').forEach(enhance); }; + new MutationObserver(() => { if (!queued) queued = requestAnimationFrame(scan); }).observe(document.getElementById('view') || document.body, { childList: true, subtree: true }); + scan(); +})(); diff --git a/public/style.css b/public/style.css index abfcde3..ce2e8ff 100644 --- a/public/style.css +++ b/public/style.css @@ -801,3 +801,10 @@ kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); bo .vrow > .vcard:not(.wide) { flex: none; width: 130px; } @media (min-width: 768px) { .vrow > .vcard:not(.wide) { width: 150px; } } .vrow > .vcard:not(.wide) .vimg { aspect-ratio: 2/3; } +/* sideways rows: arrows and drag */ +.vslide { position: relative; } +.vrow { scrollbar-width: none; } .vrow::-webkit-scrollbar { display: none; } +.vrow.drag { cursor: grabbing; user-select: none; scroll-snap-type: none; } .vrow.drag * { pointer-events: none; } +.vsl { position: absolute; top: 28%; z-index: 4; width: 38px; height: 38px; min-height: 0; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 92%, transparent); color: var(--text); font-size: 24px; line-height: 1; display: grid; place-items: center; cursor: pointer; box-shadow: 0 4px 14px rgba(0, 0, 0, .3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } +.vsl[hidden] { display: none; } .vsl.l { left: -6px; } .vsl.r { right: -6px; } +@media (hover: none) { .vsl { display: none; } } diff --git a/public/sw.js b/public/sw.js index e9b8da0..1af1d22 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-v26'; +const CACHE = 'hub-v27'; 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 b66c44d..09e562e 100644 --- a/public/video.js +++ b/public/video.js @@ -170,6 +170,18 @@ 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(() => {}); + if (m.type === 'movie' || m.type === 'show') px.get(`/library/metadata/${id}/extras`).catch(() => ({})).then(mc => { // Trailer: one from Plex when it has any, otherwise a YouTube search + const box = $('#vact'); if (!box || $('#vtr')) return; + const ex = (mc.Metadata || []).filter(x => (x.Media || []).length), tr = ex.filter(x => (x.subtype || '') === 'trailer'), list = tr.length ? tr : ex; + box.insertAdjacentHTML('beforeend', ``); + $('#vtr').onclick = () => { + const yt = () => window.open('https://www.youtube.com/results?search_query=' + vdEnc(`${m.title} ${m.year || ''} ${m.type === 'show' ? 'tv series' : 'movie'} official trailer`), '_blank', 'noopener'); + if (!list.length) return yt(); + const go = x => { store.set('video_back', location.hash); location.hash = '#/video/watch/' + x.ratingKey; }; + if (list.length === 1) return go(list[0]); + 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') wlItems().then(list => { // Watch later const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return; box.insertAdjacentHTML('beforeend', ``); @@ -230,7 +242,7 @@ const vdTitle = m => m.type === 'episode' ? `${m.grandparentTitle} · S${m.paren function videoMount() { if (!vp) return; videoMini(false); const v = $('#view'), m = vp.m, id = vp.id, ep = m.type === 'episode', title = vdTitle(m), el = vp.el; - document.body.classList.add('onvideo'); + document.body.classList.add('onvideo'); // (for a trailer or other extra, Back returns to the page you came from) const b = (idn, ic, label, extra = '') => ``; v.innerHTML = `