'use strict'; // Movies & TV from your Plex server (the same Plex sign-in and Plex Home user as Music). Browse, search, resume, play, mark watched. // The player plays the file straight when the browser can, otherwise asks Plex to convert it as it plays (HLS, with hls.js where the browser has no HLS of its own). const vd = { libs: null, onDeck: null }; const vdEnc = encodeURIComponent; const vdArt = (thumb, w = 300, h = 450) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${h}&minSize=1&upscale=1&url=${vdEnc(thumb)}` : ''; const vdTime = ms => { const s = Math.round((ms || 0) / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`; }; const vdClock = s => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s % 60).padStart(2, '0'); }; 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; async function viewVideo(args = []) { const v = $('#view'), [kind, key] = args; if (kind !== 'watch') videoStop(); v.innerHTML = '

Movies & TV

Loading…

'; try { const st = await jfetch('/api/plex/status'); if (!location.hash.startsWith('#/video')) return; if (!st.signedIn || !st.server) { v.innerHTML = `

Movies & TV

Movies and TV shows come from your Plex server. Sign in to Plex (and choose the server) in the Music tab first; it is the same sign-in.

Open Music
`; return; } px.serverKey = st.server; if (kind === 'watch') return videoWatch(key); if (kind === 'item') return videoItem(key); if (kind === 'lib') return videoHome(st, key); return videoHome(st); } catch (e) { v.innerHTML = `

Movies & TV

${esc(e.message)}

`; $('#vretry').onclick = () => route(); } } // ---- Home: Continue Watching, then a library at a time const vdCard = (m, wide) => { const href = m.type === 'episode' && !wide ? `#/video/item/${m.ratingKey}` : `#/video/item/${m.ratingKey}`; const pct = vdPct(m), seen = m.viewCount > 0 && !m.viewOffset; const thumb = wide ? (m.type === 'episode' ? m.thumb : m.art || m.thumb) : (m.parentThumb && m.type === 'season' ? m.thumb || m.parentThumb : m.thumb); return ` ${thumb ? `` : `${icon('film')}`}${seen ? `${icon('check')}` : ''}${pct ? `` : ''} ${esc(m.type === 'episode' && wide ? m.title : m.title)}${m.type === 'episode' ? vdEp(m) : esc([m.year, m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}`; }; async function videoHome(st, libKey) { const v = $('#view'); const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show'); if (!secs.length) { v.innerHTML = '

Movies & TV

This Plex server has no movie or TV libraries (or your Plex user cannot see them).

'; return; } const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0]; store.set('video_lib', cur.key); v.innerHTML = `

Movies & TV

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

${esc(cur.title)}

Loading…

`; // Continue watching and the library load side by side px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=12').then(mc => { const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); if (!items.length || !$('#vdeck')) return; $('#vdeck').innerHTML = `

Continue watching

${items.map(m => vdCard(m, true)).join('')}
`; }).catch(() => {}); const type = cur.type === 'movie' ? 1 : 2, size = 120; let start = 0, total = 0, all = []; const load = async () => { const mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=titleSort&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`); if (!$('#vgrid')) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items); $('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .empty')?.remove(); $('#vmore').innerHTML = start < total ? `
` : ''; if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message)); if (!all.length) $('#vgrid').innerHTML = '

Nothing in this library.

'; }; $('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `

${esc(e.message)}

`; }); let t; $('#vq').oninput = e => { clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; } t = setTimeout(async () => { try { const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=10`), rows = []; for (const h of mc.Hub || []) if (['movie', 'show', 'episode'].includes(h.type)) for (const m of h.Metadata || []) rows.push(m); if (!$('#vsr') || $('#vq').value.trim() !== q) return; $('#vsr').innerHTML = rows.length ? `

Results

${rows.map(m => vdCard(m)).join('')}
` : '

Nothing found.

'; } catch (err) { if ($('#vsr')) $('#vsr').innerHTML = `

${esc(err.message)}

`; } }, 350); }; } // ---- A movie, a show, a season or an episode async function videoItem(id) { const v = $('#view'); const mc = await px.get(`/library/metadata/${id}?includeOnDeck=1`), m = (mc.Metadata || [])[0]; if (!m) { v.innerHTML = '

That item is gone.

'; return; } const back = m.type === 'season' ? `#/video/item/${m.parentRatingKey}` : m.type === 'episode' ? `#/video/item/${m.parentRatingKey || m.grandparentRatingKey}` : '#/video'; const names = (arr, k = 'tag', n = 8) => (arr || []).slice(0, n).map(x => esc(x[k])).join(', '); const media = (m.Media || [])[0] || {}, res = media.videoResolution ? (/^\d+$/.test(media.videoResolution) ? media.videoResolution + 'p' : media.videoResolution.toUpperCase()) : ''; 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 = `
${icon('back')}Back
${m.art ? `` : ''}
${m.thumb ? `` : `${icon('film')}`}

${esc(m.type === 'episode' ? m.title : m.title)}

${m.type === 'episode' ? `

${vdEp(m)}

` : m.type === 'season' ? `

${esc(m.parentTitle || '')}

` : ''}

${esc(meta)}

${playable ? `${m.viewOffset ? '' : ''}` : m.type === 'show' && next ? `` : ''} ${m.type !== 'season' ? `` : ''}

${esc(m.summary || '')}

${m.Genre && m.Genre.length ? `

Genres ${names(m.Genre)}

` : ''}${m.Director && m.Director.length ? `

Director ${names(m.Director, 'tag', 3)}

` : ''}${m.Role && m.Role.length ? `

Cast ${names(m.Role, 'tag', 8)}

` : ''}
`; 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}`; }; if ($('#vseen')) $('#vseen').onclick = async () => { const seen = m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount); try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); } }; if (m.type === 'show' || m.type === 'season') { const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || []; if (!$('#vkids')) return; $('#vkids').innerHTML = m.type === 'show' ? `

Seasons

${kids.map(k => vdCard(k)).join('')}
` : `

Episodes

${kids.map(e => `${e.thumb ? `` : `${icon('film')}`}${e.viewCount > 0 && !e.viewOffset ? `${icon('check')}` : ''}${vdPct(e) ? `` : ''}${e.index}. ${esc(e.title)}${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}`).join('')}
`; } } // ---- The player let vp = null; // what is playing now const canDirect = media => { const c = (media.container || '').toLowerCase(), vc = (media.videoCodec || '').toLowerCase(), ac = (media.audioCodec || '').toLowerCase(); return ['mp4', 'm4v', 'mov'].includes(c) && vc === 'h264' && ['aac', 'mp3'].includes(ac); }; const QUALITIES = [['auto', 'Auto', { kbps: 20000 }], ['original', 'Original (no conversion)', null], ['1080', '1080p · 10 Mbps', { kbps: 10000, res: '1920x1080' }], ['720', '720p · 4 Mbps', { kbps: 4000, res: '1280x720' }], ['480', '480p · 1.5 Mbps', { kbps: 1500, res: '854x480' }]]; let hlsLib = null; const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.Hls) return ok(window.Hls); const s = document.createElement('script'); s.src = '/vendor/hls.min.js?v=1.7.3'; s.onload = () => ok(window.Hls); s.onerror = () => no(new Error('Could not load the video player library')); document.head.appendChild(s); })); async function videoWatch(id) { const v = $('#view'), mc = await px.get(`/library/metadata/${id}`), m = (mc.Metadata || [])[0]; if (!m || !(m.Media || []).length) { v.innerHTML = '

That item has no playable file.

Back
'; return; } videoStop(); 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 }; document.body.classList.add('onvideo'); const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title; v.innerHTML = `
${icon('back')}Back${esc(title)}

`; const el = $('#vv'); vp.el = el; const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; }; $('#vset').onclick = videoSettings; 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; }); 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); } /** Start (or restart) playing from `sec` with the current quality and tracks. */ async function videoStart(sec) { if (!vp) return; const el = vp.el, { m, media, part } = vp; sec = Math.max(0, sec || 0); if (m.duration && sec * 1000 > m.duration - 15000) sec = 0; if (vp.hls) { try { vp.hls.destroy(); } catch {} vp.hls = null; } fetch('/plex/video/:/transcode/universal/stop?session=' + vp.session).catch(() => {}); vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()); const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub)); if (direct) { vp.mode = 'direct'; vp.base = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true }); const t = $('#vmsg'); if (t) t.textContent = ''; } else { vp.mode = 'hls'; vp.base = sec; const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' }); if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); } const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…'; if (el.canPlayType('application/vnd.apple.mpegurl')) { el.src = url; } else { try { const Hls = await loadHls(); if (!vp || vp.el !== el) return; if (!Hls.isSupported()) throw new Error('This browser cannot play converted video'); const h = vp.hls = new Hls({ maxBufferLength: 40, enableWorker: true }); h.loadSource(url); h.attachMedia(el); h.on(Hls.Events.ERROR, (e, d) => { if (d.fatal) { if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { const tt = $('#vmsg'); if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) h.recoverMediaError(); else { const tt = $('#vmsg'); if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; } } }); } catch (e) { const tt = $('#vmsg'); if (tt) tt.textContent = e.message; return; } } } el.play().catch(() => {}); } const vdReport = state => { if (!vp) return; const pos = (vp.base + (vp.el ? vp.el.currentTime : 0)) * 1000; tlSend(state, vp.id, pos, vp.m.duration || 0); }; function videoSettings() { if (!vp) return; const aud = vp.streams.filter(s => s.streamType === 2), sub = vp.streams.filter(s => s.streamType === 3); const here = () => vp.base + (vp.el ? vp.el.currentTime : 0); const el = openSheet('Quality & tracks', vp.mode === 'direct' ? 'Playing the original file' : 'Plex is converting this for your browser', ` ${aud.length > 1 ? `` : ''} ${sub.length ? `

Subtitles are drawn into the picture by Plex, so choosing one converts the video.

` : ''}
`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#vapply').onclick = () => { vp.q = el.querySelector('#vq2').value; store.set('video_q', vp.q); vp.audio = el.querySelector('#va2') ? +el.querySelector('#va2').value : 0; vp.sub = el.querySelector('#vs2') ? +el.querySelector('#vs2').value : 0; vp.forceHls = false; const at = here(); closeSheet(); videoStart(at); }; } /** The end: mark watched and, for TV, offer the next episode. */ async function videoEnded() { if (!vp || vp.done) return; vp.done = true; const { id, m } = vp; 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]; if (!nx || !vp || vp.id !== id) return; const box = $('#vnextbox'); if (!box) return; let n = 10; box.innerHTML = `
Up next: S${nx.parentIndex}E${nx.index} · ${esc(nx.title)}in ${n}s
`; 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); $('#vgo').onclick = go; $('#vcx').onclick = () => { clearInterval(tm); box.innerHTML = ''; }; } catch { /* no next episode */ } } /** Stop playing: tell Plex where we got to and stop its conversion. Also runs whenever you leave the player. */ function videoStop() { if (!vp) return; const s = vp; vp = null; document.body.classList.remove('onvideo'); clearInterval(s.timer); try { if (!s.done) tlSend('stopped', s.id, (s.base + (s.el ? s.el.currentTime : s.pos)) * 1000, s.m.duration || 0); } catch {} try { if (s.hls) s.hls.destroy(); if (s.el) { s.el.pause(); s.el.removeAttribute('src'); s.el.load(); } } catch {} fetch('/plex/video/:/transcode/universal/stop?session=' + s.session).catch(() => {}); } 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 (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(); el.requestFullscreen && (document.fullscreenElement ? document.exitFullscreen() : el.requestFullscreen()); } else if (k === 'm') el.muted = !el.muted; }, true);