diff --git a/README.md b/README.md index afb3f55..89ee0de 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke for iOS 13+, so for most accounts only NE Hub lists are available. - **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 Music tab shows a Now Playing card with the controls above the albums, artists and playlists; the queue (play next / add to queue) and position are remembered. **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. +- **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. **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. - **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, diff --git a/public/app.js b/public/app.js index aa2ecbb..2d46a4f 100644 --- a/public/app.js +++ b/public/app.js @@ -244,7 +244,7 @@ function viewHome() { const t = typeof cur === 'function' ? cur() : null; $('#view').innerHTML = `

${greet}

${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} - ${t && allowed('music') ? `

Now playing

` : ''} + ${allowed('music') ? '
' : ''}
${recents.length && allowed('files') ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''} @@ -263,7 +263,7 @@ function viewHome() { ${allowed('mail') ? `

Mail

${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
` : ''}
`; - const np = $('#hnp'); if (np) np.onclick = openNow; + renderNpCard(); // the live Now Playing card (it follows the song and has the controls) $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); @@ -1285,18 +1285,29 @@ const next = () => advance(false); const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } }; const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); }; pl.a.preload = 'auto'; +// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account). +let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0; +const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {}); +function tlReport(state) { + const t = cur(); if (!t) return; + if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over + tlKey = t.key; tlAt = Date.now(); tlPos = (pl.a.currentTime || 0) * 1000; tlDur = isFinite(pl.a.duration) ? pl.a.duration * 1000 : 0; + tlSend(state, tlKey, tlPos, tlDur); +} +function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; } pl.a.addEventListener('ended', () => advance(true)); let lastSave = 0, lastPos = 0; pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } + if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); } const t = cur(), d = pl.a.duration; if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} } }); addEventListener('pagehide', saveState); ['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI())); -pl.a.addEventListener('playing', () => { pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; }); -pl.a.addEventListener('pause', () => { pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; }); +pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; }); +pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; }); pl.a.addEventListener('canplaythrough', () => tcKick()); ['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); })); pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); }); @@ -1334,6 +1345,7 @@ function renderMini() { } let miniSwiped = false; function stopPlayer() { + tlStop(); pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {} if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; } tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose(); @@ -1365,12 +1377,13 @@ function syncUI() { const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000); const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; } } -// A Now Playing card with the controls, at the top of every Music screen (the albums, artists and playlists stay right below it). +// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it). function renderNpCard() { - const old = $('#npcard'), wrap = document.querySelector('#view .wrap'), t = cur(); - if (!location.hash.startsWith('#/music') || !wrap || !t) { if (old) old.remove(); return; } + const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music'); + const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null; + if (!host || !t || !allowed('music')) { if (old) old.remove(); return; } const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard'; - if (!wrap.contains(el)) wrap.prepend(el); + if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); } el.innerHTML = `
NOW PLAYING${esc(t.title)}${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
0:000:00
diff --git a/tools/player-test.js b/tools/player-test.js index 554ae4a..ee19ac0 100644 --- a/tools/player-test.js +++ b/tools/player-test.js @@ -8,9 +8,9 @@ class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration= play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}} const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}}; const el=()=>({remove(){},classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); -const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[],querySelector:()=>null}, +const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[],querySelector:()=>null},allowed:()=>true, navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}}, - Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:''}}; + Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:'#/tasks'}}; vm.createContext(ctx); vm.runInContext(code+";this.pl=pl;this.next=next;this.prev=prev;this.setShuffle=setShuffle;this.setRepeat=setRepeat;this.playQueue=playQueue;this.advance=advance;this.cur=cur;this.recover=recover;this.srcOf=srcOf;",ctx); const T=n=>Array.from({length:n},(_,i)=>({key:'k'+i,title:'t'+i,artist:'a',album:'',thumb:'',src:'/plex/p'+i,dur:1000})); const eq=(n,c)=>{console.log((c?'PASS ':'FAIL ')+n); if(!c)process.exitCode=1}; @@ -30,5 +30,12 @@ const sleep=ms=>new Promise(r=>setTimeout(r,ms)); pl.a.error={code:4}; ctx.recover(true); await sleep(400); await sleep(300); eq('then skips unplayable track',pl.i===1&&toasts.some(t=>t.includes('Skipping'))); // network stall retries same source pl.a.paused=false;pl.a.readyState=1;pl.a.error=null; const before=pl.retries; ctx.recover(true); await sleep(400); eq('stall reloads stream',pl.retries===before+1); + // Plex is told what is playing (shows in its dashboard) + const sent=[]; ctx.fetch=u=>{sent.push(new URL(u,'http://x').searchParams);return Promise.resolve()}; + ctx.playQueue(T(3),0); await sleep(300); pl.a.currentTime=12; pl.a.fire('playing'); + eq('tells Plex it is playing',sent.length===1&&sent[0].get('state')==='playing'&&sent[0].get('ratingKey')==='k0'&&sent[0].get('time')==='12000'); + pl.a.pause(); eq('tells Plex it paused',sent[sent.length-1].get('state')==='paused'); + ctx.advance(false); await sleep(300); pl.a.fire('playing'); + eq('stops the old song, plays the new',sent.some(p=>p.get('state')==='stopped'&&p.get('ratingKey')==='k0')&&sent[sent.length-1].get('ratingKey')==='k1'); process.exit(process.exitCode||0); })();