diff --git a/public/music-extras.js b/public/music-extras.js index 96a1d24..3680672 100644 --- a/public/music-extras.js +++ b/public/music-extras.js @@ -97,7 +97,7 @@ function plexPinSheet(u) { async function plexSwitchTo(u, pin, onError) { try { await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) }); - closeSheet(); plexHome.data = null; px.secs = null; + closeSheet(); plexHome.data = null; px.secs = null; wl.id = ''; try { for (const k of Object.keys(localStorage)) if (k.startsWith('vc:')) localStorage.removeItem(k); } catch {} // (what the last person had on their home screen) toast('Now using Plex as ' + u.title); route(); } catch (e) { if (onError) onError(e.message); else toast(e.message); } } diff --git a/public/video.js b/public/video.js index d9a8c49..aa34f38 100644 --- a/public/video.js +++ b/public/video.js @@ -53,6 +53,19 @@ const vdCard = (m, wide, deck) => { ${esc(m.type === 'episode' && wide ? m.title : m.title)}${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}`; return deck ? `
${html}
` : html; }; +// ---- Watch later: a normal Plex video playlist called "Watch later" (so it follows the Plex person you use, and shows in Plex's own apps too) +const wl = { id: '' }; +async function wlItems() { + if (!wl.id) { const p = ((await px.get('/playlists?playlistType=video')).Metadata || []).find(x => x.title === 'Watch later' && !x.smart); if (!p) return []; wl.id = p.ratingKey; } + return (await px.get(`/playlists/${wl.id}/items`)).Metadata || []; +} +async function wlAdd(rk) { + const uri = await plx.uri([rk]); + if (!wl.id) { await wlItems(); } + if (wl.id) return plx.call('PUT', `/playlists/${wl.id}/items?uri=${encodeURIComponent(uri)}`); + const r = await plx.call('POST', `/playlists?type=video&smart=0&title=${encodeURIComponent('Watch later')}&uri=${encodeURIComponent(uri)}`); wl.id = (((r.MediaContainer || {}).Metadata || [])[0] || {}).ratingKey || ''; +} +async function wlRemove(rk) { const it = (await wlItems()).find(x => x.ratingKey === String(rk)); if (it) await plx.call('DELETE', `/playlists/${wl.id}/items/${it.playlistItemID}`); } /** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */ async function vdRemove(m, quiet) { try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); } @@ -66,10 +79,11 @@ async function videoHome(st, libKey) { 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('')}
` : ''}
+ v.innerHTML = `

Movies & TV

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

${esc(cur.title)}

+

${esc(cur.title)}

Loading…

`; + plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person // Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once. const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k; const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } }; @@ -89,6 +103,8 @@ async function videoHome(st, libKey) { const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `

${title}

${items.map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); } + wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `

Watch later

${items.slice(0, 20).map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); + px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `

Collections

${items.slice(0, 30).map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); // sort and filter the library below const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = ''; $('#vsort').value = fs; $('#vunwc').checked = fu; @@ -147,13 +163,18 @@ async function videoItem(id) {

${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' ? `` : ''}
+ ${m.type !== 'season' && m.type !== 'collection' ? `` : ''}

${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)}

` : ''}
`; 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') wlItems().then(list => { // Watch later + const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return; + box.insertAdjacentHTML('beforeend', ``); + $('#vwl').onclick = async () => { try { on ? await wlRemove(id) : await wlAdd(id); toast(on ? 'Removed from Watch later' : 'Added to Watch later'); route(); } catch (e) { toast(e.message); } }; + }).catch(() => {}); const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); } if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; }; @@ -167,10 +188,10 @@ async function videoItem(id) { const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return; $('#vact').insertAdjacentHTML('afterbegin', ``); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; }; }).catch(() => {}); - if (m.type === 'show' || m.type === 'season') { + if (m.type === 'show' || m.type === 'season' || m.type === 'collection') { 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('')}
` + $('#vkids').innerHTML = m.type === 'show' || m.type === 'collection' ? `

${m.type === 'show' ? 'Seasons' : 'In this collection'}

${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('')}
`; } }