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
build-container / build (push) Successful in 11m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d598501393
commit
7198efaccd
5 files changed
+46
-2
No files matched your search
@@ -82,6 +82,7 @@
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
<script src="/rows.js"></script>
|
||||
<script src="/keys.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; } }
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
|
||||
+14
-1
@@ -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', '<button class="btn ghost" id="vedit">Edit</button>'); $('#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', `<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
|
||||
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>`);
|
||||
@@ -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 = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
||||
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>
|
||||
@@ -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(); }
|
||||
});
|
||||
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();
|
||||
}
|
||||
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
|
||||
|
||||
Reference in new issue
Block a user