Movies & TV: arrows and drag to slide rows (no scrollbar needed), Trailer button (Plex extras, else a YouTube search)
build-container / build (push) Successful in 11m18s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-23 10:30:24 -04:00
1 parent d598501393
commit 7198efaccd
5 files changed
+46 -2

No files matched your search

+1
View File
@@ -82,6 +82,7 @@
<script src="/mail.js"></script> <script src="/mail.js"></script>
<script src="/share.js"></script> <script src="/share.js"></script>
<script src="/cast.js"></script> <script src="/cast.js"></script>
<script src="/rows.js"></script>
<script src="/keys.js"></script> <script src="/keys.js"></script>
<script src="/boot.js"></script> <script src="/boot.js"></script>
<script src="/shell.js"></script> <script src="/shell.js"></script>
+23
View File
@@ -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();
})();
+7
View File
@@ -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; } .vrow > .vcard:not(.wide) { flex: none; width: 130px; }
@media (min-width: 768px) { .vrow > .vcard:not(.wide) { width: 150px; } } @media (min-width: 768px) { .vrow > .vcard:not(.wide) { width: 150px; } }
.vrow > .vcard:not(.wide) .vimg { aspect-ratio: 2/3; } .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; } }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // 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 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+14 -1
View File
@@ -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 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', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); } if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
}).catch(() => {}); }).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', `<button class="btn ghost" id="vtr">${icon('play')}Trailer</button>`);
$('#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 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; 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>`); box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
@@ -230,7 +242,7 @@ const vdTitle = m => m.type === 'episode' ? `${m.grandparentTitle} · S${m.paren
function videoMount() { function videoMount() {
if (!vp) return; videoMini(false); if (!vp) return; videoMini(false);
const v = $('#view'), m = vp.m, id = vp.id, ep = m.type === 'episode', title = vdTitle(m), el = vp.el; 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 = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`; const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage"> v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b> <div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
@@ -269,6 +281,7 @@ function videoMount() {
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); } } else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
}); });
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake); stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
if (m.type === 'clip' || m.extraType) { const bk = $('#vback'); if (bk) bk.setAttribute('href', store.get('video_back') || '#/video'); } // a trailer: Back returns to its movie
vdUi(); wake(); vdUi(); wake();
} }
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */ /** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */