Movies & TV: custom player controls (seek bar, skip 10 s, previous and next episode, volume, speed, picture in picture), breadcrumbs and library pills on every page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b79026157
commit
f232dc32d3
4 files changed
+154
-31
No files matched your search
@@ -47,7 +47,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
|
||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
|
||||
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Keys: space/k, left/right 10 s, f fullscreen, m mute. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
|
||||
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
|
||||
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
|
||||
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
|
||||
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
|
||||
|
||||
@@ -76,6 +76,11 @@ const ICONS = {
|
||||
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
||||
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
||||
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
||||
rew: '<path d="M12 5V2L7 6l5 4V7a6 6 0 11-6 6"/>',
|
||||
fwd: '<path d="M12 5V2l5 4-5 4V7a6 6 0 106 6"/>',
|
||||
vol: '<path d="M4 9v6h4l5 4V5L8 9H4zM16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"/>',
|
||||
mute: '<path d="M4 9v6h4l5 4V5L8 9H4zM17 9l4 6M21 9l-4 6"/>',
|
||||
pip: '<rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"/>',
|
||||
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
||||
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
||||
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
||||
|
||||
+46
-11
@@ -599,9 +599,7 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vbg { position: absolute; inset: -8px -16px auto -16px; width: calc(100% + 32px); height: 220px; object-fit: cover; opacity: .18; z-index: -1; mask-image: linear-gradient(#000, transparent); -webkit-mask-image: linear-gradient(#000, transparent); }
|
||||
.vep { align-items: flex-start; }
|
||||
.vwrap { max-width: 1280px; margin: 0 auto; }
|
||||
.vbar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vwrap video { width: 100%; max-height: 78dvh; background: #000; border-radius: 12px; display: block; }
|
||||
.vnext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; padding: 12px 14px; border-radius: 12px; background: var(--panel2); }
|
||||
.vnext b { flex: 1; min-width: 160px; }
|
||||
@media (min-width: 768px) { .vgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .vpost { width: 190px; } }
|
||||
@@ -609,12 +607,49 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vrow { scrollbar-width: thin; }
|
||||
.vrow::-webkit-scrollbar { height: 6px; } .vrow::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
|
||||
|
||||
.vwrap.vfull { position: fixed; inset: 0; z-index: 90; max-width: none; background: #000; display: flex; flex-direction: column; padding: calc(var(--sat) + 6px) 8px calc(var(--sab) + 6px); }
|
||||
.vwrap.vfull video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
|
||||
.vwrap.vfull .vbar { position: absolute; top: calc(var(--sat) + 8px); left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
|
||||
.vwrap.vfull:hover .vbar, .vwrap.vfull .vbar:focus-within { opacity: 1; }
|
||||
.vwrap:fullscreen { background: #000; max-width: none; display: flex; flex-direction: column; padding: 6px 8px; }
|
||||
.vwrap:fullscreen video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
|
||||
.vwrap:fullscreen .vbar { position: absolute; top: 8px; left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
|
||||
.vwrap:fullscreen:hover .vbar, .vwrap:fullscreen .vbar:focus-within { opacity: 1; }
|
||||
@media (hover: none) { .vwrap.vfull .vbar, .vwrap:fullscreen .vbar { opacity: 1; } }
|
||||
|
||||
/* Movies & TV player controls */
|
||||
.vstage { position: relative; background: #000; border-radius: 12px; overflow: hidden; user-select: none; -webkit-user-select: none; }
|
||||
.vstage video { width: 100%; max-height: 78dvh; display: block; background: #000; }
|
||||
.vstage.vhide { cursor: none; }
|
||||
.vtop, .vctl { position: absolute; left: 0; right: 0; z-index: 3; color: #fff; transition: opacity .25s; }
|
||||
.vtop { top: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px 28px; background: linear-gradient(rgba(0, 0, 0, .7), transparent); }
|
||||
.vctl { bottom: 0; padding: 26px 12px 8px; background: linear-gradient(transparent, rgba(0, 0, 0, .8)); }
|
||||
.vstage.vhide .vtop, .vstage.vhide .vctl { opacity: 0; pointer-events: none; }
|
||||
.vtop .btn { background: rgba(255, 255, 255, .15); color: #fff; border: 0; min-height: 38px; }
|
||||
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vseek { display: flex; align-items: center; gap: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.vseek input { flex: 1; margin: 0; }
|
||||
.vctl input[type=range] { -webkit-appearance: none; appearance: none; height: 22px; background: transparent; accent-color: var(--accent); cursor: pointer; }
|
||||
.vctl input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
|
||||
.vctl input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; margin-top: -5px; }
|
||||
.vctl input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
|
||||
.vctl input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
|
||||
.vrow2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.vleft, .vright { display: flex; align-items: center; gap: 4px; }
|
||||
.vb { border: 0; background: transparent; color: #fff; min-width: 44px; height: 44px; border-radius: 10px; display: inline-grid; place-items: center; cursor: pointer; font: inherit; }
|
||||
.vb:hover { background: rgba(255, 255, 255, .18); }
|
||||
.vb:disabled { opacity: .35; cursor: default; }
|
||||
.vb svg { width: 24px; height: 24px; }
|
||||
.vb.big { width: 54px; height: 54px; background: rgba(255, 255, 255, .2); border-radius: 50%; } .vb.big svg { width: 28px; height: 28px; }
|
||||
.vb.txt { padding: 0 10px; font-size: 14px; }
|
||||
.vskip { position: relative; display: grid; place-items: center; } .vskip em { position: absolute; font-style: normal; font-size: 9px; font-weight: 700; top: 8px; }
|
||||
.vright input[type=range] { width: 80px; }
|
||||
.vflash { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(.9); z-index: 4; padding: 10px 18px; border-radius: 30px; background: rgba(0, 0, 0, .65); color: #fff; font-size: 22px; opacity: 0; pointer-events: none; }
|
||||
.vflash.on { animation: vflash .7s ease-out; } @keyframes vflash { 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 70% { opacity: 1; } 100% { opacity: 0; transform: translate(-50%, -60%) scale(1.05); } }
|
||||
.vspin { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 3; pointer-events: none; }
|
||||
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
||||
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
||||
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
||||
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
|
||||
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
|
||||
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
||||
.vstage:fullscreen video { height: 100%; }
|
||||
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
||||
|
||||
.vnav { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.vcrumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
|
||||
.vcrumbs a { color: var(--muted); } .vcrumbs a:hover { color: var(--text); } .vcrumbs i { font-style: normal; opacity: .6; }
|
||||
.vnav .sgrp { margin-left: auto; }
|
||||
.vtop .vcrumbs { color: rgba(255, 255, 255, .8); } .vtop .vcrumbs a { color: rgba(255, 255, 255, .8); }
|
||||
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
|
||||
+102
-19
@@ -9,6 +9,24 @@ const vdClock = s => { s = Math.max(0, Math.round(s)); const h = Math.floor(s /
|
||||
const vdEp = m => m.type === 'episode' ? `${esc(m.grandparentTitle || '')} · S${m.parentIndex || '?'}E${m.index || '?'}` : '';
|
||||
const vdPct = m => m.viewOffset && m.duration ? Math.min(100, Math.round(100 * m.viewOffset / m.duration)) : 0;
|
||||
|
||||
let vdLibCache = { t: 0, v: null };
|
||||
async function vdLibs() {
|
||||
if (vdLibCache.v && Date.now() - vdLibCache.t < 5 * 60e3) return vdLibCache.v;
|
||||
const v = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show').map(d => ({ key: d.key, title: d.title })); vdLibCache = { t: Date.now(), v }; return v;
|
||||
}
|
||||
/** Where you are in Movies & TV, with a way back at every level: the library, the show, the season. (Also used in the player.) */
|
||||
const vdCrumbs = m => {
|
||||
const c = [`<a href="#/video/lib/${esc(m.librarySectionID || '')}">${esc(m.librarySectionTitle || 'Library')}</a>`];
|
||||
if (m.type === 'season') c.push(`<a href="#/video/item/${m.parentRatingKey}">${esc(m.parentTitle || 'Show')}</a>`);
|
||||
if (m.type === 'episode') { c.push(`<a href="#/video/item/${m.grandparentRatingKey}">${esc(m.grandparentTitle || 'Show')}</a>`); c.push(`<a href="#/video/item/${m.parentRatingKey}">Season ${m.parentIndex || ''}</a>`); }
|
||||
return c.join('<i>›</i>');
|
||||
};
|
||||
/** The strip at the top of an item page: Back, the breadcrumb, and a pill for every library (Movies, TV Shows, ...). */
|
||||
async function vdNav(m, back) {
|
||||
let libs = []; try { libs = await vdLibs(); } catch {}
|
||||
return `<div class="vnav"><a class="btn ghost" href="${back}">${icon('back')}Back</a><nav class="vcrumbs" aria-label="Where you are">${vdCrumbs(m)}</nav>
|
||||
${libs.length ? `<div class="sgrp" role="navigation" aria-label="Libraries">${libs.map(l => `<a href="#/video/lib/${l.key}" class="${String(l.key) === String(m.librarySectionID) ? 'on' : ''}">${esc(l.title)}</a>`).join('')}</div>` : ''}</div>`;
|
||||
}
|
||||
async function viewVideo(args = []) {
|
||||
const v = $('#view'), [kind, key] = args;
|
||||
if (kind !== 'watch') videoStop();
|
||||
@@ -82,7 +100,7 @@ async function videoItem(id) {
|
||||
const meta = [m.year, m.contentRating, m.duration ? vdTime(m.duration) : '', res, m.audienceRating ? '★ ' + m.audienceRating : (m.rating ? '★ ' + m.rating : '')].filter(Boolean).join(' · ');
|
||||
const playable = m.type === 'movie' || m.type === 'episode';
|
||||
const next = m.OnDeck && m.OnDeck.Metadata && m.OnDeck.Metadata[0];
|
||||
v.innerHTML = `<div class="wrap vdetail"><div class="bar"><a class="btn ghost" href="${back}">${icon('back')}Back</a></div>
|
||||
v.innerHTML = `<div class="wrap vdetail">${await vdNav(m, back)}
|
||||
<div class="vhero">${m.art ? `<img class="vbg" alt="" src="${vdArt(m.art, 1000, 560)}" onerror="this.remove()">` : ''}
|
||||
<div class="vhead"><span class="vimg vpost" style="aspect-ratio:2/3">${m.thumb ? `<img alt="" src="${vdArt(m.thumb, 300, 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}</span>
|
||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||
@@ -128,28 +146,87 @@ async function videoWatch(id) {
|
||||
try { pl.a.pause(); } catch {}
|
||||
const media = m.Media[0], part = media.Part[0], streams = part.Stream || [], fromStart = store.get('video_from_start') === id;
|
||||
if (fromStart) store.del('video_from_start');
|
||||
vp = { id, m, media, part, streams, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false };
|
||||
vp = { id, m, media, part, streams, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false, sibs: null, speed: 1 };
|
||||
document.body.classList.add('onvideo');
|
||||
const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
|
||||
v.innerHTML = `<div class="vwrap"><div class="vbar"><a class="btn ghost" id="vback" href="${m.type === 'episode' ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><b class="vtitle">${esc(title)}</b><button class="btn ghost" id="vfs" aria-label="Full screen">Full screen</button><button class="btn ghost" id="vset">Quality & tracks</button></div>
|
||||
<video id="vv" controls controlsList="nofullscreen" playsinline autoplay></video><p class="sub" id="vmsg" role="status"></p><div id="vnextbox"></div></div>`;
|
||||
const el = $('#vv'); vp.el = el;
|
||||
const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title, ep = m.type === 'episode';
|
||||
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"><video id="vv" playsinline autoplay></video>
|
||||
<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>
|
||||
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
||||
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
||||
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div>
|
||||
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', '1×', 'Speed', ' txt')}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}</div></div></div>
|
||||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||
const el = $('#vv'), stage = $('#vstage'); vp.el = el;
|
||||
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
|
||||
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1))); $('#vvol').value = Math.round(el.volume * 100);
|
||||
const total = () => (vp.m.duration || 0) / 1000, here = () => vp.base + el.currentTime;
|
||||
let drag = false, hideT = null;
|
||||
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
|
||||
const paint = () => {
|
||||
const t = total() || el.duration || 0, p = here();
|
||||
if (!drag) { $('#vrange').value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; }
|
||||
$('#vcur').textContent = vdClock(drag ? $('#vrange').value / 1000 * t : p); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - (drag ? $('#vrange').value / 1000 * t : p)));
|
||||
};
|
||||
const flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
||||
vp.flash = flash; vp.wake = wake;
|
||||
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
||||
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
||||
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) { vp.pos = vp.base + el.currentTime; } });
|
||||
el.addEventListener('playing', () => { msg(''); vdReport('playing'); });
|
||||
el.addEventListener('pause', () => { if (!el.ended) vdReport('paused'); });
|
||||
el.addEventListener('waiting', () => { vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
|
||||
el.addEventListener('canplay', () => { if (vp) vp.waitSince = 0; });
|
||||
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
||||
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
||||
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
|
||||
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
|
||||
const range = $('#vrange');
|
||||
range.oninput = () => { drag = true; paint(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
|
||||
// clicks: mouse click plays or pauses, double click goes full screen; touch: a tap shows or hides the controls, a double tap on the left or right side skips 10 s
|
||||
let lastTap = 0;
|
||||
stage.addEventListener('pointerup', e => {
|
||||
if (e.target.closest('.vctl, .vtop, #vnextbox')) return;
|
||||
if (e.pointerType === 'touch') {
|
||||
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
||||
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
|
||||
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
|
||||
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
|
||||
});
|
||||
let lastDbl = 0; stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; paint(); });
|
||||
el.addEventListener('play', () => { $('#vpp').innerHTML = icon('pause'); $('#vpp').setAttribute('aria-label', 'Pause'); wake(); });
|
||||
el.addEventListener('pause', () => { $('#vpp').innerHTML = icon('play'); $('#vpp').setAttribute('aria-label', 'Play'); wake(); if (!el.ended) vdReport('paused'); });
|
||||
el.addEventListener('volumechange', () => { $('#vmute').innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
|
||||
el.addEventListener('playing', () => { msg(''); $('#vspin').hidden = true; vdReport('playing'); });
|
||||
el.addEventListener('waiting', () => { $('#vspin').hidden = false; vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
|
||||
el.addEventListener('canplay', () => { $('#vspin').hidden = true; if (vp) vp.waitSince = 0; });
|
||||
el.addEventListener('ended', () => videoEnded());
|
||||
el.addEventListener('error', () => { if (vp && vp.mode === 'direct') { msg('The browser cannot play this file directly. Converting it…'); vp.q = 'auto'; vp.forceHls = true; videoStart(vp.pos); } else msg('Could not play this. Try a lower quality in Quality & tracks.'); });
|
||||
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
|
||||
videoStart(vp.pos);
|
||||
if (ep) px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`).then(c => { if (vp && vp.id === id) { vp.sibs = c.Metadata || []; vp.sibIdx = vp.sibs.findIndex(x => x.ratingKey === id); const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = vp.sibIdx <= 0; if (nx) nx.disabled = vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; } }).catch(() => {});
|
||||
paint(); videoStart(vp.pos);
|
||||
}
|
||||
/** Jump to a point in the whole film or episode. A converted stream only covers what Plex has made from its start point, so a seek outside it restarts the conversion there. */
|
||||
function videoSeek(abs) {
|
||||
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
|
||||
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
|
||||
if (vp.mode === 'direct') { el.currentTime = abs; return; }
|
||||
const rel = abs - vp.base;
|
||||
if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs);
|
||||
}
|
||||
function videoSkip(d) {
|
||||
if (!vp) return; videoSeek(vp.base + vp.el.currentTime + d);
|
||||
if (vp.flash) vp.flash((d < 0 ? '−' : '+') + Math.abs(d) + ' s');
|
||||
}
|
||||
/** Previous or next episode (or, for the previous one, back to the start if you are more than 5 seconds in). */
|
||||
function videoGo(dir) {
|
||||
if (!vp || !vp.sibs) return;
|
||||
if (dir < 0 && vp.base + vp.el.currentTime > 5) { videoSeek(0); if (vp.flash) vp.flash('Restart'); return; }
|
||||
const t = vp.sibs[vp.sibIdx + dir]; if (t) location.hash = `#/video/watch/${t.ratingKey}`;
|
||||
}
|
||||
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
||||
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||
function videoFullscreen() {
|
||||
if (!vp) return; const box = document.querySelector('.vwrap'), el = vp.el;
|
||||
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el;
|
||||
const native = document.fullscreenElement || document.webkitFullscreenElement;
|
||||
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
|
||||
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
|
||||
@@ -206,9 +283,9 @@ async function videoEnded() {
|
||||
px.get(`/:/scrobble?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); tlSend('stopped', id, m.duration || 0, m.duration || 0);
|
||||
if (m.type !== 'episode') return;
|
||||
try {
|
||||
const leaves = (await px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`)).Metadata || [], i = leaves.findIndex(x => x.ratingKey === id), nx = leaves[i + 1];
|
||||
const leaves = vp.sibs || (await px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`)).Metadata || [], i = leaves.findIndex(x => x.ratingKey === id), nx = leaves[i + 1];
|
||||
if (!nx || !vp || vp.id !== id) return;
|
||||
const box = $('#vnextbox'); if (!box) return; let n = 10;
|
||||
const box = $('#vnextbox'); if (!box) return; let n = 10; vp.wake && vp.wake();
|
||||
box.innerHTML = `<div class="vnext"><b>Up next: S${nx.parentIndex}E${nx.index} · ${esc(nx.title)}</b><span id="vcount">in ${n}s</span><button class="btn" id="vgo">Play now</button><button class="btn ghost" id="vcx">Stop</button></div>`;
|
||||
const go = () => { clearInterval(tm); location.hash = `#/video/watch/${nx.ratingKey}`; };
|
||||
const tm = setInterval(() => { n--; const c = $('#vcount'); if (!c) return clearInterval(tm); c.textContent = 'in ' + n + 's'; if (n <= 0) go(); }, 1000);
|
||||
@@ -225,9 +302,15 @@ function videoStop() {
|
||||
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoStop(); });
|
||||
addEventListener('pagehide', () => { if (vp) { try { navigator.sendBeacon && navigator.sendBeacon('/plex/video/:/transcode/universal/stop?session=' + vp.session); } catch {} vdReport('stopped'); } });
|
||||
addEventListener('keydown', e => {
|
||||
if (!vp || !vp.el || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
|
||||
const el = vp.el, k = e.key;
|
||||
if (!vp || !vp.el || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') && e.target.type !== 'range' || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
|
||||
const el = vp.el, k = e.key; if (vp.wake) vp.wake();
|
||||
if (e.target.type === 'range' && (k === 'ArrowLeft' || k === 'ArrowRight')) return; // (the sliders use their own arrows)
|
||||
if (k === ' ' || k === 'k') { e.preventDefault(); el.paused ? el.play() : el.pause(); }
|
||||
else if (k === 'ArrowRight') { e.preventDefault(); el.currentTime += 10; } else if (k === 'ArrowLeft') { e.preventDefault(); el.currentTime = Math.max(0, el.currentTime - 10); }
|
||||
else if (k === 'f') { e.preventDefault(); videoFullscreen(); } else if (k === 'Escape') { const b = document.querySelector('.vwrap.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } } else if (k === 'm') el.muted = !el.muted;
|
||||
else if (k === 'ArrowRight' || k === 'l') { e.preventDefault(); videoSkip(k === 'l' || e.shiftKey ? 30 : 10); } else if (k === 'ArrowLeft' || k === 'j') { e.preventDefault(); videoSkip(k === 'j' || e.shiftKey ? -30 : -10); }
|
||||
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
||||
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
|
||||
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
||||
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||
}, true);
|
||||
Reference in new issue
Block a user