From a3ac8bef30342143b1957e8322057cd3fe9d3bb3 Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 25 Apr 2026 14:49:12 +0200 Subject: [PATCH] Initial commit: split single-file PWA into index.html, styles.css, app.js, sw.js Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + README.md | 20 + app.js | 1642 ++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 626 ++++++++++++++++++++ styles.css | 695 ++++++++++++++++++++++ sw.js | 32 + 6 files changed, 3018 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css create mode 100644 sw.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cfd02b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c96c6d5 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# NovaSonic + +A self-hosted music streaming PWA for Navidrome / Subsonic-compatible backends. + +## Stack + +- `index.html` — app shell +- `styles.css` — all styles +- `app.js` — frontend logic +- `sw.js` — service worker (offline caching) + +## Setup + +Point the app at your Navidrome server URL on first launch. + +## Docker + +```bash +docker compose up -d +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..21638ea --- /dev/null +++ b/app.js @@ -0,0 +1,1642 @@ +// ─── MD5 (correct implementation) ─── +function md5(input) { + function safeAdd(x,y){var l=(x&0xffff)+(y&0xffff);return((x>>16)+(y>>16)+(l>>16)<<16)|(l&0xffff)} + function rol(n,c){return(n<>>32-c)} + function cmn(q,a,b,x,s,t){return safeAdd(rol(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)} + function ff(a,b,c,d,x,s,t){return cmn((b&c)|(~b&d),a,b,x,s,t)} + function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&~d),a,b,x,s,t)} + function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t)} + function ii(a,b,c,d,x,s,t){return cmn(c^(b|~d),a,b,x,s,t)} + // Convert string to little-endian word array + var l=input.length, m=[]; + for(var i=0;i>2]|=(input.charCodeAt(i)&0xff)<<((i%4)*8); + m[l>>2]|=0x80<<((l%4)*8); + m[((l+8>>6)+1)*16-2]=l*8; + // Initial hash values + var a=1732584193,b=-271733879,c=-1732584194,d=271733878; + for(var i=0;i>>(j*8))&0xff).toString(16)).slice(-2);return s} + return hex(a)+hex(b)+hex(c)+hex(d); +} + +// ─── STATE ─── +let cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''}; +let queue=[],qIdx=-1,shuffle=false,repeat=false; +let searchTimer=null,globalSearchTimer=null; +let downloadTarget=null,dlType='track'; +let jobs={}; +const audio=document.getElementById('audio-element'); +const previewAudio=document.getElementById('preview-audio'); + +// ─── SETUP TABS ─── +function switchSetupTab(t){ + document.querySelectorAll('.setup-tab').forEach((el,i)=>el.classList.toggle('active',i===(t==='navidrome'?0:1))); + document.querySelectorAll('.setup-pane').forEach((el,i)=>el.classList.toggle('active',i===(t==='navidrome'?0:1))); +} + +// ─── SUBSONIC API ─── +function apiUrl(method,extra=''){ + const salt=Math.random().toString(36).slice(2,10); + const token=md5(cfg.pass+salt); + return `${cfg.url}/rest/${method}?u=${encodeURIComponent(cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`; +} +async function api(method,extra=''){ + const res=await fetch(apiUrl(method,extra)); + if(!res.ok) throw new Error(`HTTP ${res.status}`); + const data=await res.json(); + const r=data['subsonic-response']; + if(r.status!=='ok') throw new Error(r.error?.message||'API Error'); + return r; +} +function streamUrl(id){return apiUrl('stream',`&id=${id}&format=raw`)} +function downloadUrl(id){return apiUrl('download',`&id=${id}`)} +function coverUrl(id,size=300){return id?apiUrl('getCoverArt',`&id=${id}&size=${size}`):null} + +// ─── CONNECT ─── +async function doConnect(){ + const url=document.getElementById('srv-url').value.trim().replace(/\/$/,''); + const user=document.getElementById('srv-user').value.trim(); + const pass=document.getElementById('srv-pass').value; + const lfKey=document.getElementById('lastfm-key').value.trim(); + const backUrl=document.getElementById('backend-url').value.trim().replace(/\/$/,''); + if(!url||!user||!pass){showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.');return} + const btn=document.getElementById('btn-connect'); + btn.disabled=true;btn.textContent='Verbinde…'; + document.getElementById('setup-error').style.display='none'; + cfg={url,user,pass,lastfmKey:lfKey,backendUrl:backUrl}; + try{ + await api('ping'); + localStorage.setItem('ns_cfg',JSON.stringify(cfg)); + startApp(); + }catch(e){ + cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''}; + showSetupError('Verbindung fehlgeschlagen: '+e.message); + btn.disabled=false;btn.textContent='Verbinden'; + } +} +function showSetupError(m){const el=document.getElementById('setup-error');el.textContent=m;el.style.display='block'} + +function disconnect(){ + localStorage.removeItem('ns_cfg'); + localStorage.removeItem('ns_last_played'); + sessionStorage.removeItem('ns_nav'); + cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''}; + queue=[];qIdx=-1;_navStack=[];_navCursor=-1; + audio.pause();audio.src=''; + document.getElementById('app').style.display='none'; + document.getElementById('setup-screen').style.display='flex'; +} + +// ─── INIT ─── +window.addEventListener('load',()=>{ + const saved=localStorage.getItem('ns_cfg'); + if(saved){cfg=JSON.parse(saved);startApp()} + document.getElementById('srv-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doConnect()}); + audio.addEventListener('timeupdate',updateProgress); + audio.addEventListener('ended',onEnded); + audio.addEventListener('play',()=>{setPlayIcon(true);document.getElementById('player-art').classList.add('spinning')}); + audio.addEventListener('pause',()=>{setPlayIcon(false);document.getElementById('player-art').classList.remove('spinning')}); + previewAudio.addEventListener('timeupdate',updatePreviewProgress); + previewAudio.addEventListener('ended',stopPreview); + audio.volume=0.8; + injectPWA(); + // Now Playing: click player-art to open + const pa=document.getElementById('player-art'); + if(pa)pa.addEventListener('click', openNowPlaying); +}); + +async function startApp(){ + document.getElementById('setup-screen').style.display='none'; + document.getElementById('app').style.display='flex'; + + // Restore last navigation position (survives soft refresh) + if (_navLoad() && _navCursor >= 0 && _navStack[_navCursor]) { + const last = _navStack[_navCursor]; + _navJumping = true; + navigate(last.page, last.data); + _navJumping = false; + } else { + navigate('home'); + } + loadHomeData(); + + // Restore last played song (survives tab close) + _restoreLastPlayed(); + // Restore radio mode state + if(localStorage.getItem('ns_radio')==='1'){radioMode=true;_applyRadioBtn();} +} + +function _restoreLastPlayed() { + try { + const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); + if (!saved || !saved.song) return; + // Restore queue state silently (don't auto-play, just show in player bar) + queue = saved.queue || [saved.song]; + qIdx = saved.qIdx || 0; + const s = queue[qIdx]; + if (!s) return; + // Update player UI without starting playback + document.getElementById('player-title').textContent = s.title || '—'; + document.getElementById('player-artist-name').textContent = s.artist || '—'; + document.getElementById('player-album-sep').style.display = s.album ? '' : 'none'; + document.getElementById('player-album-name').textContent = s.album || ''; + const art = document.getElementById('player-art'); + const cv = coverUrl(s.coverArt, 100); + art.innerHTML = cv ? `` : '
♪
'; + updateStarBtn(); + document.title = `${s.title} — NovaSonic`; + } catch {} +} + +// ─── NAVIGATION HISTORY ───────────────────────────────────────── +// Cursor-based stack: never destructively pop — just move the cursor. +// Stored in sessionStorage so soft reloads survive; tabs start fresh. + +let _navStack = []; // [{page, data}, ...] +let _navCursor = -1; // points to current entry +let _navJumping = false; // true while we're programmatically changing cursor + +function _navSave() { + try { + sessionStorage.setItem('ns_nav', JSON.stringify({stack:_navStack, cursor:_navCursor})); + } catch {} +} + +function _navLoad() { + try { + const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null'); + if (s && Array.isArray(s.stack) && s.stack.length) { + _navStack = s.stack; + _navCursor = s.cursor; + return true; + } + } catch {} + return false; +} + +function navigate(page, data=null) { + // ── Core page switch ── + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); + const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active'; + document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls)); + const pageEl = document.getElementById(`page-${page}`); + if (pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; } + closeSidebar(); + if(['discover','new-releases','recommendations','random'].includes(page))switchNavMode('discover'); + else if(['home','artists','albums','starred','playlists'].includes(page))switchNavMode('library'); + + // ── Load data ── + if (page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists(); + if (page==='albums' && !document.getElementById('albums-grid').children.length) loadAlbums(); + if (page==='starred') loadStarred(); + if (page==='random') loadRandom(); + if (page==='artist-detail' && data) loadArtistDetail(data); + if (page==='album-detail' && data) loadAlbumDetail(data); + if (page==='new-releases') loadNewReleases(); + if (page==='playlists') loadPlaylists(); + if (page==='playlist-detail' && data) loadPlaylistDetail(data); + if (page==='recommendations') loadRecommendations(); + + // ── History stack ── + if (!_navJumping) { + // Truncate any forward entries (user navigated after going back) + _navStack = _navStack.slice(0, _navCursor + 1); + // Avoid duplicate consecutive identical entries + const safe = data ? JSON.parse(JSON.stringify(data)) : null; + const last = _navStack[_navCursor]; + if (!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) { + _navStack.push({ page, data: safe }); + } + _navCursor = _navStack.length - 1; + _navSave(); + + // Keep ONE real browser history entry ahead so popstate fires on back + try { history.pushState({ nsIdx: _navCursor }, '', ''); } catch {} + } +} + +function _navGoBack() { + // Dismiss overlays first (sheet → NP → then real back) + if (document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; } + if (npOpen) { closeNowPlaying(); return; } + if (_navCursor > 0) { + _navCursor--; + const entry = _navStack[_navCursor]; + _navSave(); + _navJumping = true; + navigate(entry.page, entry.data); + _navJumping = false; + } + // Always keep a pushState ahead so next back also fires popstate + try { history.pushState({ nsIdx: _navCursor }, '', ''); } catch {} +} + +window.addEventListener('popstate', () => _navGoBack()); + +document.addEventListener('keydown', e => { + if (e.key !== 'Backspace') return; + const t = document.activeElement?.tagName; + if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return; + e.preventDefault(); + _navGoBack(); +}); + +// Seed an initial browser history entry so the very first back is caught +try { history.replaceState({ nsIdx: 0 }, '', ''); } catch {} + + +async function loadArtistDetail(artist) { + document.getElementById('artist-detail-name').textContent = artist.name; + document.getElementById('artist-back-btn').onclick = () => navigate('artists'); + const hero = document.getElementById('artist-detail-hero'); + hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))'; + + // Clear Last.fm sections + ['artist-bio-section','artist-similar-section','artist-topsongs-section'].forEach(id=>{ + const el=document.getElementById(id); if(el)el.innerHTML=''; + }); + + const g = document.getElementById('artist-albums-grid'); + g.innerHTML = '
'; + + // Parallel: image + albums + last.fm info + const [imgRes, albumRes, infoRes, topRes] = await Promise.allSettled([ + getArtistImage(artist.id), + api('getArtist', `&id=${artist.id}`), + api('getArtistInfo2', `&id=${artist.id}&count=8`), + api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) + ]); + + // Hero image + const img = imgRes.status==='fulfilled' ? imgRes.value : null; + if (img) { + hero.style.background = `url('${img}') center/cover no-repeat`; + hero.querySelector('.artist-hero-overlay').style.background = + 'linear-gradient(to right,rgba(7,7,15,.9) 0%,rgba(7,7,15,.5) 60%,rgba(7,7,15,.2) 100%)'; + } + + // Albums + if (albumRes.status==='fulfilled') { + g.innerHTML = ''; + (albumRes.value.artist?.album || []).forEach(a=>g.appendChild(makeAlbumCard({...a,_fromArtist:artist}))); + } else { + g.innerHTML = `

${albumRes.reason?.message}

`; + } + + // ── Last.fm: bio + const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null; + if (info?.biography) { + const bioEl = document.getElementById('artist-bio-section'); + // Strip Last.fm HTML links, keep plain text + const bio = info.biography.replace(/]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim(); + const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio; + if (short && bioEl) { + window._artistBioFull = bio; + bioEl.innerHTML = `
Über
+
${esc(short)}${bio.length>400 + ?` ` + :''}
`; + } + } + + // ── Last.fm: similar artists + const similar = info?.similarArtist || []; + if (similar.length) { + const simEl = document.getElementById('artist-similar-section'); + if (simEl) { + simEl.innerHTML = `
Ähnliche Künstler
+
${similar.map(a=> + ` + ${esc(a.name)} + `).join('')} +
`; + } + } + + // ── Last.fm / Navidrome: top songs + const topSongs = topRes.status==='fulfilled' ? (topRes.value.topSongs?.song||[]) : []; + if (topSongs.length) { + const tsEl = document.getElementById('artist-topsongs-section'); + if (tsEl) { + tsEl.innerHTML = `
Top Songs
`; + const list = document.createElement('div'); + list.style.marginBottom='20px'; + topSongs.forEach((s,i)=>{ + const cv = coverUrl(s.coverArt, 60); + const row = document.createElement('div'); + row.className='topsong-item'; + row.innerHTML=`
${i+1}
+
${cv?``:'
🎵
'}
+
${esc(s.title)}
${esc(s.album||'')}
+
${s.playCount?s.playCount.toLocaleString():'—'}
`; + row.onclick=()=>playQueue(topSongs,i); + row.addEventListener('contextmenu',e=>{e.preventDefault();showCtxMenu(e,s,topSongs,i)}); + list.appendChild(row); + }); + tsEl.appendChild(list); + } + } +} +async function loadAlbums(){ + const g=document.getElementById('albums-grid');g.innerHTML='
'; + try{const r=await api('getAlbumList2','&type=alphabeticalByName&size=500');g.innerHTML='';(r.albumList2?.album||[]).forEach(a=>g.appendChild(makeAlbumCard(a)))} + catch(e){g.innerHTML=`

${e.message}

`} +} +async function loadAlbumDetail(album){ + document.getElementById('album-back-btn').onclick=()=>album._fromArtist?navigate('artist-detail',album._fromArtist):navigate('albums'); + document.getElementById('album-detail-name').textContent=album.name; + const artistEl=document.getElementById('album-detail-artist');artistEl.textContent=album.artist||''; + artistEl.onclick=()=>{if(album.artistId)navigate('artist-detail',{id:album.artistId,name:album.artist})}; + const cw=document.getElementById('album-cover-wrap'); + const cv=coverUrl(album.coverArt,300); + cw.innerHTML=cv?``:'
💿
'; + const sl=document.getElementById('album-songs');sl.innerHTML='
'; + try{ + const r=await api('getAlbum',`&id=${album.id}`); + const songs=r.album?.song||[]; + const totalMin=Math.round(songs.reduce((s,t)=>s+(t.duration||0),0)/60); + document.getElementById('album-detail-stats').textContent=`${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`; + document.getElementById('btn-play-album').onclick=()=>playQueue(songs,0); + document.getElementById('btn-dl-album').onclick=()=>{songs.forEach((s,i)=>setTimeout(()=>dlSongLocal(s),i*400));toast(`⬇ ${songs.length} Titel Download gestartet`)}; + sl.innerHTML='';songs.forEach((s,i)=>sl.appendChild(makeSongItem(s,i,songs))); + }catch(e){sl.innerHTML=`

${e.message}

`} +} +// ─── PLAYLISTS ─── +async function loadPlaylists(){ + const g=document.getElementById('playlists-grid');g.innerHTML='
'; + try{ + const r=await api('getPlaylists'); + const lists=r.playlists?.playlist||[]; + g.innerHTML=''; + if(!lists.length){g.innerHTML='
🎶

Keine Playlists vorhanden

';return} + lists.forEach(pl=>{ + const cv=coverUrl(pl.coverArt,200); + const c=makeCard(pl.name,pl.songCount+' Titel',cv,'🎶'); + c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();quickPlayPlaylist(pl)}; + c.onclick=()=>navigate('playlist-detail',pl); + g.appendChild(c); + }); + }catch(e){g.innerHTML=`

${e.message}

`} +} +async function quickPlayPlaylist(pl){ + try{const r=await api('getPlaylist',`&id=${pl.id}`);const songs=r.playlist?.entry||[];playQueue(songs,0)} + catch(e){toast('Fehler: '+e.message)} +} +async function loadPlaylistDetail(pl){ + document.getElementById('playlist-detail-name').textContent=pl.name; + const cw=document.getElementById('playlist-cover'); + const cv=coverUrl(pl.coverArt,300); + cw.innerHTML=cv?``:'
🎶
'; + const sl=document.getElementById('playlist-songs');sl.innerHTML='
'; + try{ + const r=await api('getPlaylist',`&id=${pl.id}`); + const songs=r.playlist?.entry||[]; + const totalMin=Math.round(songs.reduce((s,t)=>s+(t.duration||0),0)/60); + document.getElementById('playlist-detail-stats').textContent=`${songs.length} Titel · ${totalMin} Min.`; + document.getElementById('btn-play-playlist').onclick=()=>playQueue(songs,0); + document.getElementById('btn-shuffle-playlist').onclick=()=>{ + const shuffled=[...songs].sort(()=>Math.random()-.5); + playQueue(shuffled,0); + }; + sl.innerHTML='';songs.forEach((s,i)=>sl.appendChild(makeSongItem(s,i,songs))); + }catch(e){sl.innerHTML=`

${e.message}

`} +} + +// ─── RADIO MODE (endloser Shuffle aus der eigenen Bibliothek) ─── +let radioMode=false; +function _applyRadioBtn(){ + const btn=document.getElementById('btn-radio-mode'); + if(btn){btn.classList.toggle('active',radioMode);btn.style.color=radioMode?'var(--blue)':'';} + const nb=document.getElementById('np-radio-btn'); + if(nb){nb.classList.toggle('active',radioMode);nb.style.color=radioMode?'var(--blue)':'';} +} +async function toggleRadioMode(){ + radioMode=!radioMode; + localStorage.setItem('ns_radio',radioMode?'1':'0'); + _applyRadioBtn(); + if(radioMode){ + await fillRadioQueue(); + } +} +async function fillRadioQueue(){ + if(!radioMode)return; + try{ + const r=await api('getRandomSongs','&size=20'); + const newSongs=r.randomSongs?.song||[]; + // Append to current queue so playback doesn't interrupt + queue.push(...newSongs); + renderQueue(); + }catch{} +} +// Override onEnded to refill queue in radio mode +const _origOnEnded=window.onEnded; + +async function loadStarred(){ + const el=document.getElementById('starred-songs');el.innerHTML='
'; + try{ + const r=await api('getStarred2'); + const songs=(r.starred2?.song||[]).map(s=>({...s,_starred:true})); + el.innerHTML=''; + if(!songs.length){el.innerHTML='
⭐

Noch keine Favoriten

';return} + songs.forEach((s,i)=>el.appendChild(makeSongItem(s,i,songs))); + }catch(e){el.innerHTML=`

${e.message}

`} +} +async function loadRandom(){ + const el=document.getElementById('random-songs');el.innerHTML='
'; + try{const r=await api('getRandomSongs','&size=50');const songs=r.randomSongs?.song||[];el.innerHTML='';songs.forEach((s,i)=>el.appendChild(makeSongItem(s,i,songs)))} + catch(e){el.innerHTML=`

${e.message}

`} +} + +// ─── LOCAL SEARCH ─── +function onLocalSearch(val){ + clearTimeout(searchTimer); + if(!val.trim()){navigate('home');return} + navigate('search'); + searchTimer=setTimeout(()=>doLocalSearch(val),350); +} +async function doLocalSearch(q){ + const el=document.getElementById('search-results');el.innerHTML='
'; + try{ + const r=await api('search3',`&query=${encodeURIComponent(q)}&songCount=30&albumCount=10&artistCount=6`); + const res=r.searchResult3||{};el.innerHTML=''; + const artists=res.artist||[],albums=res.album||[],songs=res.song||[]; + if(!artists.length&&!albums.length&&!songs.length){el.innerHTML='
🔍

Keine Ergebnisse

';return} + if(artists.length){const s=mkSection('Künstler');const g=document.createElement('div');g.className='scroll-row';artists.forEach(a=>{const c=makeCard(a.name,a.albumCount+' Alben',null,'🎤');c.onclick=()=>navigate('artist-detail',a);g.appendChild(c);getArtistImage(a.id).then(img=>{if(!img)return;const artEl=c.querySelector('.card-art');if(artEl)artEl.innerHTML=`
`})});s.appendChild(g);el.appendChild(s)} + if(albums.length){const s=mkSection('Alben');const g=document.createElement('div');g.className='scroll-row';albums.forEach(a=>g.appendChild(makeAlbumCard(a)));s.appendChild(g);el.appendChild(s)} + if(songs.length){const s=mkSection('Titel');const g=document.createElement('div');g.className='scroll-row';songs.forEach((so,i)=>{const cv=coverUrl(so.coverArt,200);const c=makeCard(so.title,so.artist||'',cv,'🎵');c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();playQueue(songs,i)};c.onclick=()=>playQueue(songs,i);g.appendChild(c)});s.appendChild(g);el.appendChild(s)} + }catch(e){el.innerHTML=`

${e.message}

`} +} +function mkSection(title){const d=document.createElement('div');d.className='search-section';d.innerHTML=`
${title}
`;return d} + +// ─── ITUNES API ─── +const ITUNES='https://itunes.apple.com'; +async function itunesSearch(query,entity='musicTrack',limit=25){ + const r=await fetch(`${ITUNES}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=de`); + const d=await r.json();return d.results||[]; +} +async function itunesLookupAlbum(collectionId){ + const r=await fetch(`${ITUNES}/lookup?id=${collectionId}&entity=song`); + const d=await r.json();return(d.results||[]).filter(x=>x.wrapperType==='track'); +} +async function itunesTopCharts(){ + // Try Apple RSS (DE then US), then fall back to iTunes Search API + const urls=['https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json','https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json']; + for(const url of urls){try{const r=await fetch(url,{signal:AbortSignal.timeout(6000)});if(!r.ok)continue;const d=await r.json();if(d.feed?.results?.length)return d.feed.results;}catch{}} + try{ + const r=await fetch('https://itunes.apple.com/search?term=new+music&media=music&entity=album&limit=50&country=de'); + const d=await r.json(); + return(d.results||[]).map(a=>({name:a.collectionName,artistName:a.artistName,artworkUrl:a.artworkUrl100?.replace('100x100','300x300'),artworkUrl100:a.artworkUrl100})); + }catch{return[]} +} +async function itunesTopSongs(){ + const urls=['https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json','https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json']; + for(const url of urls){try{const r=await fetch(url,{signal:AbortSignal.timeout(6000)});if(!r.ok)continue;const d=await r.json();if(d.feed?.results?.length)return d.feed.results;}catch{}} + try{ + const r=await fetch('https://itunes.apple.com/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de'); + const d=await r.json(); + return(d.results||[]).map(t=>({name:t.trackName,artistName:t.artistName,artworkUrl:t.artworkUrl100})); + }catch{return[]} +} + +// ─── GLOBAL SEARCH ─── +function onGlobalSearch(val){ + clearTimeout(globalSearchTimer); + const el=document.getElementById('global-search-results'); + if(!val.trim()){el.innerHTML='
🔍

Gib einen Suchbegriff ein

';return} + el.innerHTML='
'; + globalSearchTimer=setTimeout(()=>doGlobalSearch(val),400); +} +async function doGlobalSearch(q){ + const el=document.getElementById('global-search-results'); + try{ + const [tracks,albums,artists]=await Promise.all([ + itunesSearch(q,'musicTrack',25), + itunesSearch(q,'album',10), + itunesSearch(q,'musicArtist',6) + ]); + el.innerHTML=''; + if(artists.length){ + const s=mkSection('Künstler'); + const row=document.createElement('div');row.className='scroll-row'; + artists.forEach(a=>{ + const c=makeCard(a.artistName,'iTunes Künstler',null,'🎤'); + c.onclick=()=>loadItunesArtistResults(a.artistId,a.artistName); + row.appendChild(c); + // Lazy-load: grab artwork from their first album + itunesSearch(a.artistName,'album',1).then(res=>{ + if(!res[0])return; + const imgUrl=res[0].artworkUrl100?.replace('100x100','300x300'); + if(!imgUrl)return; + const artEl=c.querySelector('.card-art'); + if(!artEl)return; + const img=document.createElement('img'); + img.src=imgUrl; img.loading='lazy'; img.style.cssText='width:100%;height:100%;object-fit:cover;transition:transform .4s'; + // Keep overlay + const overlay=artEl.querySelector('.play-overlay'); + artEl.innerHTML=''; artEl.appendChild(img); if(overlay)artEl.appendChild(overlay); + img.onerror=()=>{ artEl.innerHTML='
🎤
'; if(overlay)artEl.appendChild(overlay); }; + c.addEventListener('mouseenter',()=>img.style.transform='scale(1.05)'); + c.addEventListener('mouseleave',()=>img.style.transform='scale(1)'); + }).catch(()=>{}); + }); + s.appendChild(row);el.appendChild(s); + } + if(albums.length){ + const s=mkSection('Alben'); + const row=document.createElement('div');row.className='scroll-row'; + albums.forEach(a=>{ + const art=a.artworkUrl100?.replace('100x100','300x300'); + const c=makeCard(a.collectionName,a.artistName,art,'💿'); + c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();loadItunesAlbum(a)}; + c.querySelector('.dl-btn-overlay')?.remove(); + // Add download overlay button + const dlOverlay=document.createElement('button'); + dlOverlay.className='dl-btn-overlay'; + dlOverlay.title='Auf Server laden'; + dlOverlay.innerHTML=``; + dlOverlay.onclick=(e)=>{e.stopPropagation();openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a})}; + c.querySelector('.play-overlay').appendChild(dlOverlay); + c.onclick=()=>loadItunesAlbum(a); + row.appendChild(c); + }); + s.appendChild(row);el.appendChild(s); + } + if(tracks.length){ + const s=mkSection('Titel'); + const list=document.createElement('div');list.className='song-list'; + tracks.forEach((t,i)=>list.appendChild(makeGlobalTrackItem(t,i,tracks))); + s.appendChild(list);el.appendChild(s); + } + if(!artists.length&&!albums.length&&!tracks.length){ + el.innerHTML='
🔍

Keine Ergebnisse gefunden

'; + } + }catch(e){el.innerHTML=`

Suche fehlgeschlagen: ${e.message}

`} +} + +async function loadItunesAlbum(album){ + try{ + const tracks=await itunesLookupAlbum(album.collectionId); + if(!tracks.length){toast('Keine Tracks gefunden');return} + // Show in a mini overlay list in discover page + const el=document.getElementById('global-search-results'); + const s=document.createElement('div');s.className='search-section'; + s.innerHTML=`
+ +
+
${esc(album.collectionName)}
+
${esc(album.artistName)}
+
+ +
+
Tracks
`; + const list=document.createElement('div');list.className='song-list'; + tracks.forEach((t,i)=>list.appendChild(makeGlobalTrackItem(t,i,tracks))); + s.appendChild(list); + el.innerHTML='';el.appendChild(s); + }catch(e){toast('Fehler: '+e.message)} +} + +async function loadItunesArtistResults(artistId,artistName){ + const el=document.getElementById('global-search-results'); + el.innerHTML='
'; + try{ + const albums=await itunesSearch(artistName,'album',20); + const artistAlbums=albums.filter(a=>a.artistId===artistId||a.artistName===artistName); + el.innerHTML=''; + const header=document.createElement('div'); + header.innerHTML=`
+ +
${esc(artistName)}
+
+
Alben
`; + el.appendChild(header); + const grid=document.createElement('div');grid.className='grid grid-4'; + (artistAlbums.length?artistAlbums:albums).forEach(a=>{ + const art=a.artworkUrl100?.replace('100x100','300x300'); + const c=makeCard(a.collectionName,a.artistName,art,'💿'); + c.onclick=()=>loadItunesAlbum(a); + grid.appendChild(c); + }); + el.appendChild(grid); + }catch(e){el.innerHTML=`

${e.message}

`} +} + +function makeGlobalTrackItem(t,idx,list){ + const el=document.createElement('div');el.className='gtrack-item'; + const art=t.artworkUrl60||t.artworkUrl100||''; + el.innerHTML=` +
${art?``:'
🎵
'}
+
+
${esc(t.trackName||t.collectionName||'—')}
+
${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}
+
+
+ ${t.previewUrl?``:''} + +
`; + el.onclick=()=>{if(t.previewUrl)playPreview(t)}; + return el; +} + +// ─── NEW RELEASES ─── +async function loadNewReleases(){ + const albumEl=document.getElementById('nr-albums');const songEl=document.getElementById('nr-songs'); + albumEl.innerHTML='
';songEl.innerHTML=''; + try{ + const [albums,songs]=await Promise.all([itunesTopCharts(),itunesTopSongs()]); + albumEl.innerHTML=''; + albums.slice(0,30).forEach(a=>{ + const art=a.artworkUrl100?.replace('100x100','300x300')||a.artworkUrl; + const c=makeCard(a.name,a.artistName,art,'💿'); + c.onclick=()=>itunesSearch(a.name+' '+a.artistName,'album',5).then(res=>{if(res[0])loadItunesAlbum(res[0])}); + albumEl.appendChild(c); + }); + songs.slice(0,40).forEach((s,i)=>{ + const itTrack={trackName:s.name,artistName:s.artistName,artworkUrl60:s.artworkUrl?.replace('100x100','60x60'),collectionName:''}; + const el=document.createElement('div');el.className='gtrack-item'; + el.innerHTML=` +
${itTrack.artworkUrl60?``:`
🎵
`}
+
+
${esc(s.name)}
+
${esc(s.artistName||'')}
+
+
+ #${i+1} + +
`; + el.onclick=()=>{itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res=>{if(res[0]&&res[0].previewUrl)playPreview(res[0])})} + songEl.appendChild(el); + }); + }catch(e){albumEl.innerHTML=`

${e.message}

`} +} + +// ─── RECOMMENDATIONS (Last.fm + iTunes fallback) ─── +async function loadRecommendations(){ + const el=document.getElementById('reco-content'); + const currentSong=queue[qIdx]; + if(!currentSong){ + el.innerHTML='
🎵

Spiele zuerst Musik in deiner Bibliothek

'; + return; + } + el.innerHTML='
'; + try{ + let html=''; + const artist=currentSong.artist||''; + const title=currentSong.title||''; + html+=`
+
🎧
+
Basierend auf
${esc(title)}
${esc(artist)}
+
`; + el.innerHTML=html+'
'; + + // Try Last.fm first + if(cfg.lastfmKey){ + await loadLastfmRecommendations(el,artist,title,html); + } else { + await loadItunesFallbackRecommendations(el,artist,title,html); + el.querySelector('#reco-nolastfm') && null; + const note=document.createElement('div'); + note.style.cssText='margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)'; + note.innerHTML='💡 Tipp: Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.'; + el.appendChild(note); + } + }catch(e){el.innerHTML=`

${e.message}

`} +} + +async function loadLastfmRecommendations(el,artist,title,html){ + const BASE='https://ws.audioscrobbler.com/2.0'; + const key=cfg.lastfmKey; + try{ + const [simArtists,topTracks,simTracks]=await Promise.all([ + fetch(`${BASE}/?method=artist.getSimilar&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=12`).then(r=>r.json()), + fetch(`${BASE}/?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()), + fetch(`${BASE}/?method=track.getSimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()), + ]); + el.innerHTML=html; + const simArtistList=simArtists.similarartists?.artist||[]; + const topTrackList=topTracks.toptracks?.track||[]; + const simTrackList=simTracks.similartracks?.track||[]; + + if(simArtistList.length){ + el.innerHTML+=`
Ähnliche Künstler
`; + el.innerHTML+=simArtistList.map(a=>``).join(''); + el.innerHTML+='
'; + } + if(simTrackList.length){ + const sec=document.createElement('div');sec.innerHTML=`
Ähnliche Tracks
`; + const list=document.createElement('div');list.className='song-list'; + simTrackList.slice(0,15).forEach(t=>{ + const row=document.createElement('div');row.className='gtrack-item'; + row.innerHTML=`
🎵
+
${esc(t.name)}
${esc(t.artist?.name||'')}
+
+ + +
`; + row.onclick=()=>searchAndPreview(t.name,t.artist?.name||''); + list.appendChild(row); + }); + sec.appendChild(list);el.appendChild(sec); + } + if(topTrackList.length){ + const sec=document.createElement('div');sec.style.marginTop='20px';sec.innerHTML=`
Top Tracks von ${esc(artist)}
`; + const list=document.createElement('div');list.className='song-list'; + topTrackList.slice(0,10).forEach(t=>{ + const row=document.createElement('div');row.className='gtrack-item'; + row.innerHTML=`
🎵
+
${esc(t.name)}
${esc(t.artist?.name||artist)}
+
+ + +
`; + row.onclick=()=>searchAndPreview(t.name,artist); + list.appendChild(row); + }); + sec.appendChild(list);el.appendChild(sec); + } + }catch(e){await loadItunesFallbackRecommendations(el,artist,title,html)} +} + +async function loadItunesFallbackRecommendations(el,artist,title,html){ + const results=await itunesSearch(artist,'musicTrack',20); + el.innerHTML=html; + const sec=document.createElement('div'); + sec.innerHTML=`
Mehr von ${esc(artist)}
`; + const list=document.createElement('div');list.className='song-list'; + results.filter(t=>t.artistName===artist).slice(0,15).forEach(t=>list.appendChild(makeGlobalTrackItem(t,0,[t]))); + if(!list.children.length)results.slice(0,15).forEach(t=>list.appendChild(makeGlobalTrackItem(t,0,[t]))); + sec.appendChild(list);el.appendChild(sec); +} + +async function searchSimilarArtist(artistName){ + navigate('discover'); + document.getElementById('global-search-input').value=artistName; + onGlobalSearch(artistName); +} +async function searchAndPreview(trackName,artistName){ + try{const res=await itunesSearch(trackName+' '+artistName,'musicTrack',1);if(res[0]&&res[0].previewUrl)playPreview(res[0]);else toast('Kein Preview verfügbar')} + catch{toast('Suche fehlgeschlagen')} +} + +// ─── PREVIEW PLAYER ─── +let previewCurrent=null; +function playPreview(track){ + if(!track.previewUrl){toast('Kein Preview verfügbar');return} + // Pause main player, remember if it was playing so we can resume later + const wasPlaying=!audio.paused; + if(wasPlaying) audio.pause(); + previewCurrent=track; + previewCurrent._resumeMain=wasPlaying; + // Inherit current volume from main volume slider + previewAudio.volume=audio.volume; + previewAudio.src=track.previewUrl; + previewAudio.play(); + document.getElementById('preview-title').innerHTML=esc(track.trackName||'—')+'30s Preview'; + document.getElementById('preview-sub').textContent=(track.artistName||'')+(track.collectionName?' · '+track.collectionName:''); + const art=track.artworkUrl100||track.artworkUrl60||''; + document.getElementById('preview-art').innerHTML=art?``:'
♪
'; + document.getElementById('preview-bar').classList.add('visible'); + document.getElementById('preview-fill').style.width='0%'; +} +function stopPreview(resume){ + const shouldResume = resume !== false && previewCurrent?._resumeMain; + previewAudio.pause();previewAudio.src=''; + document.getElementById('preview-bar').classList.remove('visible'); + document.getElementById('preview-fill').style.width='0%'; + previewCurrent=null; + // Resume main player if it was playing before preview started + if(shouldResume && audio.src) audio.play(); +} +function updatePreviewProgress(){ + if(!previewAudio.duration)return; + document.getElementById('preview-fill').style.width=(previewAudio.currentTime/previewAudio.duration*100)+'%'; +} + +// ─── DOWNLOAD MODAL ─── +function openDlModal(track){ + if(!cfg.backendUrl){toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.');return} + downloadTarget=track;dlType='track'; + document.querySelectorAll('.modal-opt').forEach(o=>o.classList.toggle('selected',o.dataset.type==='track')); + const info=document.getElementById('dl-modal-info'); + const art=track.artworkUrl100||track.artworkUrl60||''; + info.innerHTML=`${art?``:''} +
${esc(track.trackName||track.collectionName||'—')}
${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}
`; + document.getElementById('dl-album-sub').textContent=track.collectionName?`"${track.collectionName}" herunterladen`:'Album herunterladen'; + document.getElementById('download-modal').classList.add('open'); +} +function closeDlModal(){document.getElementById('download-modal').classList.remove('open')} +function selectDlType(el,type){ + dlType=type; + document.querySelectorAll('.modal-opt').forEach(o=>o.classList.remove('selected')); + el.classList.add('selected'); +} +async function executeDownload(){ + if(!downloadTarget||!cfg.backendUrl)return; + closeDlModal(); + const jobId='job_'+Date.now(); + const payload={ + type:dlType, + artist:downloadTarget.artistName||'Unknown Artist', + title:downloadTarget.trackName||downloadTarget.collectionName||'Unknown', + album:downloadTarget.collectionName||'', + artworkUrl:downloadTarget.artworkUrl100||downloadTarget.artworkUrl60||'', + itunesId:downloadTarget.trackId||downloadTarget.collectionId||null, + collectionId:downloadTarget.collectionId||null + }; + addJob(jobId,payload.title,payload.artist); + try{ + const res=await fetch(`${cfg.backendUrl}/api/download`,{ + method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({...payload,jobId}) + }); + if(!res.ok)throw new Error(`HTTP ${res.status}`); + pollJob(jobId); + }catch(e){ + updateJob(jobId,'error','Verbindung zum Backend fehlgeschlagen'); + toast('❌ Backend nicht erreichbar: '+e.message); + } +} + +// ─── JOBS ─── +function addJob(id,title,artist){ + jobs[id]={title,artist,progress:0,status:'pending'}; + renderJobs(); +} +function updateJob(id,status,msg,progress){ + if(!jobs[id])return; + jobs[id]={...jobs[id],status,msg:msg||'',progress:progress??jobs[id].progress}; + renderJobs(); + if(status==='done'){setTimeout(()=>{delete jobs[id];renderJobs()},5000)} + if(status==='error'){setTimeout(()=>{delete jobs[id];renderJobs()},8000)} +} +function renderJobs(){ + const panel=document.getElementById('jobs-panel'); + panel.innerHTML=''; + Object.entries(jobs).forEach(([id,j])=>{ + const c=document.createElement('div');c.className='job-card'; + const isDone=j.status==='done',isErr=j.status==='error'; + const statusText=isErr?'Fehler':isDone?'✓ Fertig! Bibliothek wird gescannt…':j.msg||'Wird heruntergeladen…'; + c.innerHTML=`
+
${esc(j.title)}
+
${esc(j.artist)}
+
+
+
${statusText}
`; + panel.appendChild(c); + }); +} +async function pollJob(jobId){ + if(!jobs[jobId])return; + try{ + const res=await fetch(`${cfg.backendUrl}/api/status/${jobId}`); + const d=await res.json(); + updateJob(jobId,d.status,d.message,d.progress); + if(d.status!=='done'&&d.status!=='error')setTimeout(()=>pollJob(jobId),1500); + else if(d.status==='done'){toast(`✅ ${jobs[jobId]?.title||'Track'} gespeichert!`);triggerNaviScan()} + }catch{updateJob(jobId,'error','Status-Abfrage fehlgeschlagen')} +} +async function triggerNaviScan(){ + try{await api('startScan')}catch{} +} + +// ─── LOCAL DOWNLOAD (from Navidrome) ─── +function dlSongLocal(s){ + const a=document.createElement('a');a.href=downloadUrl(s.id); + a.download=`${s.artist||'Unknown'} - ${s.title}.${s.suffix||'mp3'}`; + document.body.appendChild(a);a.click();document.body.removeChild(a); + toast(`⬇ ${s.title}`); +} +function downloadCurrent(){if(qIdx>=0&&queue[qIdx])dlSongLocal(queue[qIdx])} + +// ─── PLAYER ─── +function playQueue(songs,startIdx){queue=songs.map(s=>({...s}));qIdx=startIdx;playCurrent();renderQueue()} +function playCurrent(){ + if(qIdx<0||qIdx>=queue.length)return; + const s=queue[qIdx]; + audio.src=streamUrl(s.id);audio.play(); + updatePlayerUI(s);renderQueue(); +} +function updatePlayerUI(s){ + document.getElementById('player-title').textContent=s.title; + document.getElementById('player-artist-name').textContent=s.artist||'—'; + document.getElementById('player-album-sep').style.display=s.album?'':'none'; + document.getElementById('player-album-name').textContent=s.album||''; + const art=document.getElementById('player-art'); + const cv=coverUrl(s.coverArt,100); + art.innerHTML=cv?``:'
♪
'; + updateStarBtn(); + document.title=`${s.title} — NovaSonic`; + updateMediaSession(s); + if(document.getElementById('page-recommendations').classList.contains('active'))loadRecommendations(); + // Save last played to localStorage + try { + localStorage.setItem('ns_last_played', JSON.stringify({ + song: {id:s.id,title:s.title,artist:s.artist,album:s.album,albumId:s.albumId, + artistId:s.artistId,coverArt:s.coverArt,duration:s.duration, + _starred:s._starred||false}, + queue: queue.map(t=>({id:t.id,title:t.title,artist:t.artist,album:t.album, + albumId:t.albumId,artistId:t.artistId,coverArt:t.coverArt, + duration:t.duration,_starred:t._starred||false})), + qIdx + })); + } catch {} + // Sync NP overlay (direct DOM, no recursion) + _syncNpDirect(s); +} +function _syncNpDirect(s){ + if(!npOpen||!s)return; + const cv=coverUrl(s.coverArt,400); + const cover=document.getElementById('np-cover'); + const bg=document.getElementById('np-bg'); + if(cover)cover.innerHTML=cv?``:'
♪
'; + if(bg)bg.style.backgroundImage=cv?`url('${cv}')`:'none'; + const t=document.getElementById('np-title'); + if(t)t.textContent=s.title||'—'; + const an=document.getElementById('np-artist-name');if(an)an.textContent=s.artist||'—'; + const asep=document.getElementById('np-album-sep');if(asep)asep.style.display=s.album?'':'none'; + const aln=document.getElementById('np-album-name');if(aln)aln.textContent=s.album||''; + syncNpStar(); +} +function updateMediaSession(s){ + if(!navigator.mediaSession)return; + navigator.mediaSession.metadata=new MediaMetadata({title:s.title,artist:s.artist||'',album:s.album||'',artwork:s.coverArt?[{src:coverUrl(s.coverArt,300),sizes:'300x300'}]:[]}); + navigator.mediaSession.setActionHandler('play',togglePlay); + navigator.mediaSession.setActionHandler('pause',togglePlay); + navigator.mediaSession.setActionHandler('previoustrack',prevSong); + navigator.mediaSession.setActionHandler('nexttrack',nextSong); +} +function togglePlay(){if(audio.src)audio.paused?audio.play():audio.pause()} +function setPlayIcon(playing){ + document.getElementById('play-icon').innerHTML=playing?'':''; + // Sync NP overlay play icon (no recursion - direct DOM update) + if(npOpen){ + document.getElementById('np-play-icon').innerHTML=playing?'':''; + const cover=document.getElementById('np-cover'); + if(cover)cover.classList.toggle('playing',playing); + } +} +function prevSong(){if(audio.currentTime>3){audio.currentTime=0;return}if(qIdx>0){qIdx--;playCurrent()}} +function nextSong(){ + if(shuffle){qIdx=Math.floor(Math.random()*queue.length)} + else if(qIdx{if(qIdx{ + const item=document.createElement('div');item.className='queue-item'+(i===qIdx?' active':''); + item.innerHTML=`
${i+1}
${esc(s.title)}
${esc(s.artist||'')}
`; + item.onclick=()=>{qIdx=i;playCurrent()};el.appendChild(item); + }); + el.querySelector('.active')?.scrollIntoView({block:'nearest'}); +} +let queueOpen=false; +function toggleQueue(){queueOpen=!queueOpen;document.getElementById('queue-panel').classList.toggle('open',queueOpen);if(queueOpen)renderQueue()} + +// ─── SETTINGS ─── +function openSettings(){ + document.getElementById('s-url').value=cfg.url||''; + document.getElementById('s-user').value=cfg.user||''; + document.getElementById('s-pass').value=''; + document.getElementById('s-lastfm').value=cfg.lastfmKey||''; + document.getElementById('s-backend').value=cfg.backendUrl||''; + document.getElementById('settings-modal').classList.add('open'); + checkBackend(); +} +function closeSettings(){document.getElementById('settings-modal').classList.remove('open')} +function saveSettings(){ + const pass=document.getElementById('s-pass').value; + cfg.url=document.getElementById('s-url').value.trim().replace(/\/$/,''); + cfg.user=document.getElementById('s-user').value.trim(); + if(pass)cfg.pass=pass; + cfg.lastfmKey=document.getElementById('s-lastfm').value.trim(); + cfg.backendUrl=document.getElementById('s-backend').value.trim().replace(/\/$/,''); + localStorage.setItem('ns_cfg',JSON.stringify(cfg)); + closeSettings();toast('✅ Gespeichert'); +} +async function checkBackend(){ + const dot=document.getElementById('backend-status-dot'); + const txt=document.getElementById('backend-status-text'); + const url=document.getElementById('s-backend').value.trim()||cfg.backendUrl; + if(!url){dot.className='status-dot';txt.textContent='Keine URL konfiguriert';return} + dot.className='status-dot';txt.textContent='Verbinde…'; + try{ + const r=await fetch(`${url}/api/health`,{signal:AbortSignal.timeout(4000)}); + const d=await r.json(); + dot.className='status-dot ok';txt.textContent=`Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`; + }catch(e){dot.className='status-dot err';txt.textContent='Nicht erreichbar: '+e.message} +} + +// ─── CARD / ITEM MAKERS ─── +function makeCard(title,sub,imgUrl,fallback='💿'){ + const c=document.createElement('div');c.className='card'; + c.innerHTML=`
+ ${imgUrl?``:`
${fallback}
`} +
+
+
${esc(title)}
${esc(sub||'')}
`; + return c; +} +function makeAlbumCard(a){ + const cv=coverUrl(a.coverArt,200); + const c=makeCard(a.name,a.artist||'',cv,'💿'); + c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();quickPlayAlbum(a)}; + c.onclick=()=>navigate('album-detail',a); + return c; +} +function makeSongItem(s,idx,list){ + const el=document.createElement('div'); + if(s.starred&&!s._starred)s._starred=true; + el.className='song-item'+(queue[qIdx]?.id===s.id?' playing':''); + const cv=coverUrl(s.coverArt,60); + el.innerHTML=`
${idx+1}
+
${cv?``:'
♪
'}
+
+
${esc(s.title)}
+
+ ${s.artistId||s.artist?`${esc(s.artist||'')}`:''} + ${s.albumId||s.album?` · ${esc(s.album||'')}`:''} +
+
+
+ ${formatDur(s.duration)} +
+ + +
+
`; + const btns=el.querySelectorAll('.icon-btn'); + btns[0].onclick=(e)=>{e.stopPropagation();toggleStar(s,btns[0])}; + btns[1].onclick=(e)=>{e.stopPropagation();dlSongLocal(s)}; + el.onclick=()=>playQueue(list,idx); + el.addEventListener('contextmenu',e=>{e.preventDefault();showCtxMenu(e,s,list,idx)}); + return el; +} +async function quickPlayAlbum(a){ + try{const r=await api('getAlbum',`&id=${a.id}`);playQueue(r.album?.song||[],0)} + catch(e){toast('Fehler: '+e.message)} +} + + +// Song list: navigate to artist or album from song row +function s_goArtist(e,name,id){ + if(!name)return; + navigate('artist-detail',{id:id||name,name}); +} +function s_goAlbum(e,albumId,albumName,artist,artistId){ + if(albumId){ + navigate('album-detail',{id:albumId,name:albumName,artist,artistId:artistId||'',coverArt:''}); + } +} + +// ─── HOME ─── +async function loadHomeData(){ + loadAlbumRow('home-random','getAlbumList2','&type=random&size=20'); + loadAlbumRow('home-newest','getAlbumList2','&type=newest&size=20'); + loadAlbumRow('home-frequent','getAlbumList2','&type=frequent&size=20'); +} +async function loadAlbumRow(id,method,extra){ + const c=document.getElementById(id); + if(!c)return; + c.innerHTML='
'; + try{ + const r=await api(method,extra); + c.innerHTML=''; + (r.albumList2?.album||[]).forEach(a=>c.appendChild(makeAlbumCard(a))); + }catch(e){c.innerHTML=`
${e.message}
`} +} + +// ─── ARTISTS ─── +const artistImgCache={}; +async function getArtistImage(artistId){ + if(artistImgCache[artistId]!==undefined)return artistImgCache[artistId]; + try{ + const r=await api('getArtistInfo2',`&id=${artistId}&count=0`); + const info=r.artistInfo2||{}; + const img=info.largeImageUrl||info.mediumImageUrl||info.smallImageUrl||null; + const isBlank=img&&(img.includes('2a96cbd8b46e442fc41c2b86b821562f')||img.endsWith('/noimage/')); + artistImgCache[artistId]=isBlank?null:img; + return artistImgCache[artistId]; + }catch{artistImgCache[artistId]=null;return null;} +} +async function loadArtists(){ + const g=document.getElementById('artists-grid'); + g.innerHTML='
'; + try{ + const r=await api('getArtists'); + const all=(r.artists?.index||[]).flatMap(i=>i.artist||[]); + g.innerHTML=''; + all.forEach(a=>{ + const c=makeCard(a.name,a.albumCount+' Alben',null,'🎤'); + c.onclick=()=>navigate('artist-detail',a); + g.appendChild(c); + getArtistImage(a.id).then(imgUrl=>{ + if(!imgUrl)return; + const artEl=c.querySelector('.card-art'); + if(!artEl)return; + const overlay=artEl.querySelector('.play-overlay'); + const img=document.createElement('img'); + img.src=imgUrl;img.loading='lazy';img.style.cssText='width:100%;height:100%;object-fit:cover;transition:transform .4s'; + artEl.innerHTML='';artEl.appendChild(img);if(overlay)artEl.appendChild(overlay); + c.addEventListener('mouseenter',()=>img.style.transform='scale(1.05)'); + c.addEventListener('mouseleave',()=>img.style.transform='scale(1)'); + }); + }); + }catch(e){g.innerHTML=`

${e.message}

`} +} + +// ─── PWA ─── +function injectPWA(){ + const m={name:'NovaSonic',short_name:'NovaSonic',description:'Subsonic/Navidrome Music Client', + start_url:'index.html',display:'standalone',background_color:'#07070f',theme_color:'#07070f', + icons:[{src:"data:image/svg+xml,♫",sizes:'192x192',type:'image/svg+xml'}]}; + try{ + const blob=new Blob([JSON.stringify(m)],{type:'application/json'}); + const link=document.createElement('link');link.rel='manifest';link.href=URL.createObjectURL(blob); + document.head.appendChild(link); + }catch{} + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js').catch(() => {}); + } +} + + +// ─── NP ACTION SHEET ───────────────────────────────────────────── +function openNpMenu() { + const s = queue[qIdx]; + if (!s) return; + // Populate song header + const cv = coverUrl(s.coverArt, 80); + document.getElementById('np-sheet-song').innerHTML = ` +
${cv ? `` : '
🎵
'}
+
${esc(s.title)}
${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}
`; + // Actions + const items = [ + { icon: '', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } }, + { icon: '', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } }, + { icon: '', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } }, + { icon: '', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId)navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } }, + { icon: '', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } }, + { icon: '', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } }, + ]; + document.getElementById('np-sheet-items').innerHTML = items.map((item,i) => + `
${item.icon}${item.label}
` + ).join(''); + document.querySelectorAll('.np-sheet-item').forEach((el,i) => { + el.addEventListener('click', () => items[i].fn()); + }); + document.getElementById('np-sheet-overlay').classList.add('open'); + document.getElementById('np-sheet').classList.add('open'); +} +function closeNpMenu() { + document.getElementById('np-sheet-overlay').classList.remove('open'); + document.getElementById('np-sheet').classList.remove('open'); +} + +// ─── PLAYER BAR clicks ─────────────────────────────────────────── +function playerBarGoAlbum() { + const s = queue[qIdx]; + if (!s) return; + if (npOpen) closeNowPlaying(); + if (s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''}); +} +function playerBarGoArtist() { + const s = queue[qIdx]; + if (!s || !s.artist) return; + if (npOpen) closeNowPlaying(); + navigate('artist-detail', {id:s.artistId||s.artist, name:s.artist}); +} + +// ─── HELPERS ─── +function formatDur(s){if(!s)return'0:00';const m=Math.floor(s/60),sec=Math.floor(s%60);return`${m}:${sec.toString().padStart(2,'0')}`} +function esc(s){return(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} +function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;const c=document.getElementById('toast-container');c.appendChild(t);setTimeout(()=>t.remove(),3000)} +function openSidebar(){document.getElementById('sidebar').classList.add('mobile-open');document.getElementById('mobile-overlay').classList.add('active')} +function closeSidebar(){document.getElementById('sidebar').classList.remove('mobile-open');document.getElementById('mobile-overlay').classList.remove('active')} +function switchNavMode(mode){ + const lib=document.getElementById('bnav-library'); + const disc=document.getElementById('bnav-discover'); + if(!lib||!disc)return; + lib.style.display=mode==='library'?'grid':'none'; + disc.style.display=mode==='discover'?'grid':'none'; + document.getElementById('bnav-tab-library')?.classList.toggle('active',mode==='library'); + document.getElementById('bnav-tab-discover')?.classList.toggle('active',mode==='discover'); +} + + +// ─── CONTEXT MENU ─────────────────────────────────────────────── +let cachedPlaylists = []; + +function showCtxMenu(e, song, list, idx) { + const menu = document.getElementById('ctx-menu'); + menu.innerHTML = ''; + menu.style.display = 'block'; + + const items = [ + { icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) }, + { icon: '+', label: 'Als Nächstes einfügen', fn: () => { + queue.splice(qIdx+1, 0, {...song}); + renderQueue(); toast('In Warteschlange eingefügt'); + }}, + { icon: '⬇', label: 'Am Ende der Queue', fn: () => { + queue.push({...song}); renderQueue(); toast('Zur Queue hinzugefügt'); + }}, + { sep: true }, + { icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) }, + { icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => { + const starBtn = menu._songEl?.querySelector('.icon-btn'); + // toggle via API directly + api(song._starred ? 'unstar' : 'star', `&id=${song.id}`) + .then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); }) + .catch(err => toast('Fehler: ' + err.message)); + }}, + { sep: true }, + { icon: 'ℹ', label: 'Song-Info', fn: () => showSongInfo(song) }, + { icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) }, + ]; + + items.forEach(item => { + if (item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; } + const el = document.createElement('div'); + el.className = 'ctx-item'; + el.innerHTML = `${item.icon}${item.label}`; + el.onclick = () => { hideCtxMenu(); item.fn(); }; + menu.appendChild(el); + }); + + // Position: keep inside viewport + const vw = window.innerWidth, vh = window.innerHeight; + let x = e.clientX, y = e.clientY; + menu.style.left = '-9999px'; menu.style.top = '-9999px'; menu.style.display = 'block'; + const w = menu.offsetWidth, h = menu.offsetHeight; + if (x + w > vw - 8) x = vw - w - 8; + if (y + h > vh - 8) y = vh - h - 8; + menu.style.left = x + 'px'; menu.style.top = y + 'px'; +} + +function hideCtxMenu() { + const m = document.getElementById('ctx-menu'); + m.style.display = 'none'; m.innerHTML = ''; +} + +document.addEventListener('click', e => { + if (!document.getElementById('ctx-menu').contains(e.target)) hideCtxMenu(); +}); +document.addEventListener('keydown', e => { if (e.key === 'Escape') hideCtxMenu(); }); + +// ─── PLAYLIST MODAL (Add to / Create) ──────────────────────────── +let plModalSongs = []; + +async function openPlModal(songs) { + plModalSongs = songs; + document.getElementById('pl-modal-title').textContent = + songs.length === 1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`; + document.getElementById('pl-new-name').value = ''; + const list = document.getElementById('pl-list'); + list.innerHTML = '
Lädt…
'; + document.getElementById('pl-modal').classList.add('open'); + try { + const r = await api('getPlaylists'); + cachedPlaylists = r.playlists?.playlist || []; + list.innerHTML = ''; + if (!cachedPlaylists.length) { + list.innerHTML = '
Noch keine Playlists vorhanden
'; + return; + } + cachedPlaylists.forEach(pl => { + const row = document.createElement('div'); + row.className = 'pl-row'; + row.innerHTML = ` + ${esc(pl.name)}${pl.songCount} Titel`; + row.onclick = () => addSongsToPlaylist(pl.id, pl.name); + list.appendChild(row); + }); + } catch(e) { list.innerHTML = `
${e.message}
`; } +} + +function closePlModal() { + document.getElementById('pl-modal').classList.remove('open'); + plModalSongs = []; +} + +async function addSongsToPlaylist(plId, plName) { + closePlModal(); + try { + // Subsonic API: updatePlaylist with songIdToAdd params + const ids = plModalSongs.map(s => `&songIdToAdd=${s.id}`).join(''); + await api('updatePlaylist', `&playlistId=${plId}${ids}`); + toast(`✅ ${plModalSongs.length === 1 ? plModalSongs[0].title : plModalSongs.length + ' Songs'} → ${plName}`); + } catch(e) { toast('Fehler: ' + e.message); } +} + +async function createAndAddPlaylist() { + const name = document.getElementById('pl-new-name').value.trim(); + if (!name) { document.getElementById('pl-new-name').focus(); return; } + closePlModal(); + try { + const ids = plModalSongs.map(s => `&songId=${s.id}`).join(''); + await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`); + toast(`✅ Playlist "${name}" erstellt`); + // Refresh playlist list if on that page + if (document.getElementById('page-playlists').classList.contains('active')) loadPlaylists(); + } catch(e) { toast('Fehler: ' + e.message); } +} + +async function createPlaylistPrompt() { + document.getElementById('pl-new-name').value = ''; + plModalSongs = []; + document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen'; + document.getElementById('pl-list').innerHTML = '
Füge Songs später per Rechtsklick hinzu.
'; + document.getElementById('pl-modal').classList.add('open'); +} + +// click outside closes pl-modal +document.addEventListener('click', e => { + const m = document.getElementById('pl-modal'); + if (m.classList.contains('open') && e.target === m) closePlModal(); +}); + +// ─── SONG INFO MODAL ───────────────────────────────────────────── +function showSongInfo(s) { + const cv = coverUrl(s.coverArt, 200); + document.getElementById('info-cover').innerHTML = cv + ? `` + : '
🎵
'; + document.getElementById('info-top-title').textContent = s.title || '—'; + document.getElementById('info-top-sub').textContent = (s.artist || '—') + (s.album ? ' · ' + s.album : ''); + + const fields = [ + ['Titel', s.title], + ['Künstler', s.artist], + ['Album', s.album], + ['Jahr', s.year], + ['Track #', s.track], + ['Disc #', s.discNumber], + ['Genre', s.genre], + ['Dauer', formatDur(s.duration)], + ['Bitrate', s.bitRate ? s.bitRate + ' kbps' : null], + ['Format', s.suffix?.toUpperCase()], + ['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10 + ' MB' : null], + ['Pfad', s.path], + ['Plays', s.playCount], + ].filter(([, v]) => v != null && v !== '' && v !== undefined); + + document.getElementById('info-rows').innerHTML = fields.map(([l,v]) => + `
${l}${esc(String(v))}
` + ).join(''); + document.getElementById('info-modal').classList.add('open'); +} + +function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); } +document.addEventListener('click', e => { + const m = document.getElementById('info-modal'); + if (m.classList.contains('open') && e.target === m) closeInfoModal(); +}); + + + +// ─── NOW PLAYING OVERLAY ───────────────────────────────────────── +let npOpen = false; + +function openNowPlaying() { + npOpen = true; + const overlay = document.getElementById('np-overlay'); + overlay.classList.add('open'); + if (npQueueMode) switchNpTab('player'); + syncNowPlaying(); + // Attach swipe-down once + if (!overlay._swipeAttached) { + overlay._swipeAttached = true; + let startY=0, startT=0, dragging=false; + const handle = document.getElementById('np-drag-handle'); + const inner = document.getElementById('np-inner'); + + function onStart(clientY) { + startY = clientY; startT = Date.now(); dragging = true; + overlay.style.transition = 'none'; + } + function onMove(clientY) { + if (!dragging) return; + const dy = Math.max(0, clientY - startY); + overlay.style.transform = `translateY(${dy}px)`; + } + function onEnd(clientY) { + if (!dragging) return; + dragging = false; + overlay.style.transition = ''; + const dy = clientY - startY; + const dt = Date.now() - startT; + const fast = dt < 250 && dy > 50; + const far = dy > window.innerHeight * 0.3; + if (fast || far) { + closeNowPlaying(); + } else { + overlay.style.transform = ''; + } + } + // Touch + overlay.addEventListener('touchstart', e => { + // Only start drag from handle OR top 80px of overlay + const rect = overlay.getBoundingClientRect(); + const ty = e.touches[0].clientY - rect.top; + if (ty < 100) onStart(e.touches[0].clientY); + }, {passive:true}); + overlay.addEventListener('touchmove', e => { + onMove(e.touches[0].clientY); + }, {passive:true}); + overlay.addEventListener('touchend', e => onEnd(e.changedTouches[0].clientY), {passive:true}); + // Mouse (desktop) + handle.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); }); + document.addEventListener('mousemove', e => onMove(e.clientY)); + document.addEventListener('mouseup', e => onEnd(e.clientY)); + } +} + +function closeNowPlaying() { + npOpen = false; + const overlay = document.getElementById('np-overlay'); + overlay.style.transform = ''; + overlay.classList.remove('open'); +} + + +function syncNowPlaying() { + if (!npOpen) return; + const s = queue[qIdx]; + if (!s) return; + _syncNpDirect(s); + const shuffleBtn=document.getElementById('np-shuffle-btn'); + const repeatBtn=document.getElementById('np-repeat-btn'); + const volSlider=document.getElementById('np-vol-slider'); + if(shuffleBtn)shuffleBtn.classList.toggle('active', shuffle); + if(repeatBtn)repeatBtn.classList.toggle('active', repeat !== false); + if(volSlider)volSlider.value = Math.round(audio.volume * 100); + // play icon & cover pulse + const playing=!audio.paused; + const npIcon=document.getElementById('np-play-icon'); + if(npIcon)npIcon.innerHTML=playing?'':''; + const cover=document.getElementById('np-cover'); + if(cover)cover.classList.toggle('playing', playing); +} + +function syncNpStar() { + if (!npOpen) return; + const s = queue[qIdx]; + const icon = document.getElementById('np-star-icon'); + if (!icon || !s) return; + const starred = s._starred || s.starred; + icon.setAttribute('fill', starred ? 'var(--accent)' : 'none'); + icon.setAttribute('stroke', starred ? 'var(--accent)' : 'rgba(255,255,255,.5)'); +} + + +function npToggleStar() { + const s = queue[qIdx]; + if (!s) return; + api(s._starred || s.starred ? 'unstar' : 'star', `&id=${s.id}`) + .then(() => { s._starred = !(s._starred || s.starred); s.starred = null; syncNpStar(); updateStarBtn(); }) + .catch(e => toast('Fehler: ' + e.message)); +} + +function npSeek(e) { + const bar = document.getElementById('np-bar'); + const rect = bar.getBoundingClientRect(); + audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; +} + +let npQueueMode = false; +function switchNpTab(tab) { + npQueueMode = tab === 'queue'; + document.getElementById('np-overlay').classList.toggle('queue-mode', npQueueMode); + document.getElementById('np-tab-player').classList.toggle('active', !npQueueMode); + document.getElementById('np-tab-queue').classList.toggle('active', npQueueMode); + if (npQueueMode) renderNpQueue(); +} +function renderNpQueue() { + const el = document.getElementById('np-queue-list'); + if (!el) return; + el.innerHTML = ''; + if (!queue.length) { + el.innerHTML = '
Warteschlange leer
'; + return; + } + queue.forEach((s, i) => { + const item = document.createElement('div'); + item.className = 'np-queue-item' + (i === qIdx ? ' nq-active' : ''); + const cv = coverUrl(s.coverArt, 60); + item.innerHTML = `
${i + 1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist || '')}
`; + item.onclick = () => { qIdx = i; playCurrent(); switchNpTab('player'); }; + el.appendChild(item); + }); + el.querySelector('.nq-active')?.scrollIntoView({ block: 'nearest' }); +} +function npGoArtist() { + const s = queue[qIdx]; + if (!s) return; + closeNowPlaying(); + if (s.artistId) navigate('artist-detail', { id: s.artistId, name: s.artist }); +} +function npGoAlbum() { + const s = queue[qIdx]; + if (!s || !s.albumId) return; + closeNowPlaying(); + navigate('album-detail', { id: s.albumId, name: s.album||'', artist: s.artist||'', artistId: s.artistId||'', coverArt: s.coverArt||'' }); +} + +// Hook into existing audio events to keep NP in sync + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..2313c5f --- /dev/null +++ b/index.html @@ -0,0 +1,626 @@ + + + + + + + + + +NovaSonic + + + + + + + +
+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
Für Empfehlungen & Similar Artists. Kostenlos registrieren unter last.fm/api/account/create
+
+
+ + +
Für Download-to-Server Funktion. Backend separat starten (novasonic-server.js)
+
+
+ +
+
+
+ + +
+
+ + + + +
+ + +
+ + +
+ + +
+ + +
+
ZufälligMehr
+
+
Neueste AlbenMehr
+
+
Häufig gespielt
+
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+
Alben
+
+
+ + +
+ +
+
+ + +
+ +
+
💿
+
+
Album
+
—
+
+
+
+ + +
+
+
+
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
🎶
+
+
Playlist
+
—
+
+
+ + +
+
+
+
+
+ + +
+ +
+
+ + + + + +
+ +
+ +
+
+
🔍

Gib einen Suchbegriff ein um loszulegen

+
+
+ + +
+ +
Top Alben weltweit
+
+
Top Songs
+
+
+ + +
+ +
+
🎵

Spiele zuerst Musik in deiner Bibliothek,
dann zeigen wir dir ähnliche Künstler und Tracks

+
+
+ +
+
+ + +
+
Warteschlange
+
+
+ + +
+
+
♪
+
+
+
Kein Titel
+ +
+
+ — · +
+
+ 🔀 Shuffle + 🔁 Repeat +
+
+
+
+
+ + + + + +
+
+ 0:00 +
+ 0:00 +
+
+
+ + + +
+ + +
+
+
+
+ + +
+
♪
+
+
—30s Preview
+
iTunes Store
+
+
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+
+

Zu Playlist hinzufügen

+
+
+ +
+ + +
+
+
+
+ + +
+
+
+
🎵
+
—
—
+
+
+ +
+
+ + + +
+
+
+
+ +
+ + +
+ +
+ +
+
+
♪
+
+ +
+
+
Kein Titel
+
+ — · — +
+
+ +
+ +
+
+
+
+
+ 0:00 + 0:00 +
+
+ +
+ + + + + +
+ +
+ +
+ + + +
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..09e9637 --- /dev/null +++ b/styles.css @@ -0,0 +1,695 @@ +:root { + --bg:#07070f;--bg2:#0d0d1a;--bg3:#12121f; + --surface:#1a1a2e;--surface2:#22223a; + --border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12); + --accent:#e8a020;--accent2:#f0b840; + --accent-dim:rgba(232,160,32,0.15);--accent-glow:rgba(232,160,32,0.3); + --blue:#4f8ef7;--blue-dim:rgba(79,142,247,0.15); + --green:#4caf7d;--green-dim:rgba(76,175,125,0.15); + --text:#f0eefc;--text2:#8888aa;--text3:#555577; + --danger:#e05555; + --radius:12px;--radius-sm:8px; + --player-h:112px;--sidebar-w:240px; + --font:'Outfit',sans-serif;--mono:'JetBrains Mono',monospace; +} +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden} +::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:2px} + +/* ── SETUP ── */ +#setup-screen{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 60% 50% at 30% 50%,rgba(232,160,32,.08) 0%,transparent 70%),radial-gradient(ellipse 40% 60% at 80% 30%,rgba(80,60,160,.1) 0%,transparent 70%)} +.setup-card{width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:20px;padding:40px 36px;box-shadow:0 40px 80px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.04)} +.setup-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px} +.logo-icon{width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--accent),#c07010);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 8px 24px var(--accent-glow)} +.setup-logo h1{font-size:24px;font-weight:700;letter-spacing:-.5px} +.setup-logo h1 em{color:var(--accent);font-style:normal} +.setup-tabs{display:flex;gap:4px;background:var(--bg2);border-radius:8px;padding:3px;margin-bottom:24px} +.setup-tab{flex:1;padding:8px;border:none;background:none;color:var(--text2);font-family:var(--font);font-size:13px;font-weight:500;border-radius:6px;cursor:pointer;transition:background .2s,color .2s} +.setup-tab.active{background:var(--surface);color:var(--text)} +.field{margin-bottom:14px} +.field label{display:block;font-size:11px;font-weight:600;color:var(--text2);margin-bottom:5px;letter-spacing:.5px;text-transform:uppercase} +.field input{width:100%;padding:11px 14px;background:var(--bg2);border:1px solid var(--border2);border-radius:var(--radius-sm);color:var(--text);font-family:var(--font);font-size:14px;outline:none;transition:border-color .2s,box-shadow .2s} +.field input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)} +.field input::placeholder{color:var(--text3)} +.field .hint{font-size:11px;color:var(--text3);margin-top:4px;line-height:1.4} +.btn-primary{width:100%;margin-top:20px;padding:13px;background:linear-gradient(135deg,var(--accent),#c07010);color:#000;font-family:var(--font);font-size:15px;font-weight:700;border:none;border-radius:var(--radius-sm);cursor:pointer;transition:opacity .2s,transform .15s;letter-spacing:.2px} +.btn-primary:hover{opacity:.9;transform:translateY(-1px)} +.btn-primary:disabled{opacity:.5;cursor:default;transform:none} +.setup-error{margin-top:12px;padding:10px 14px;background:rgba(224,85,85,.12);border:1px solid rgba(224,85,85,.3);border-radius:var(--radius-sm);color:#ff8a8a;font-size:13px;display:none} +.setup-pane{display:none}.setup-pane.active{display:block} + +/* ── APP LAYOUT ── */ +#app{display:none;height:100vh;flex-direction:column} +.app-body{display:flex;flex:1;overflow:hidden} + +/* ── SIDEBAR ── */ +.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--player-h)} +.sidebar-header{padding:18px 18px 10px;display:flex;align-items:center;justify-content:space-between} +.sidebar-logo{display:flex;align-items:center;gap:9px} +.sidebar-logo .icon{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--accent),#c07010);display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 4px 12px var(--accent-glow)} +.sidebar-logo span{font-size:16px;font-weight:700} +.sidebar-logo em{color:var(--accent);font-style:normal} +.header-btns{display:flex;gap:4px} +.hbtn{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:5px;border-radius:6px;transition:color .2s;display:flex;align-items:center} +.hbtn:hover{color:var(--text)} +.hbtn.danger:hover{color:var(--danger)} + +.search-wrap{padding:0 12px 10px} +.search-box{display:flex;align-items:center;gap:8px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 12px;cursor:text;transition:border-color .2s} +.search-box:focus-within{border-color:var(--border2)} +.search-box input{flex:1;background:none;border:none;outline:none;color:var(--text);font-family:var(--font);font-size:13px} +.search-box input::placeholder{color:var(--text3)} +.search-box svg{flex-shrink:0;color:var(--text3)} + +nav{flex:1;overflow-y:auto;padding:4px 10px} +.nav-section{margin-bottom:18px} +.nav-section-title{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:0 8px;margin-bottom:3px} +.nav-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--text2);transition:background .15s,color .15s;user-select:none} +.nav-item:hover{background:var(--surface);color:var(--text)} +.nav-item.active{background:var(--accent-dim);color:var(--accent)} +.nav-item.active-blue{background:var(--blue-dim);color:var(--blue)} +.nav-item svg{flex-shrink:0;width:15px;height:15px} + +/* ── MAIN ── */ +.main{flex:1;overflow-y:auto;overflow-x:hidden;padding-bottom:var(--player-h);background:var(--bg);background-image:radial-gradient(ellipse 80% 40% at 50% 0%,rgba(232,160,32,.04) 0%,transparent 60%)} +.page{display:none;padding:24px 28px} +.page.active{display:block} +.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:10px} +.page-title{font-size:24px;font-weight:800;letter-spacing:-.5px} +.page-title em{color:var(--accent);font-style:normal} +.page-title .blue{color:var(--blue)} + +/* ── SECTION ── */ +.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:24px} +.section-title{font-size:16px;font-weight:700} +.section-more{font-size:12px;color:var(--text2);cursor:pointer;transition:color .15s} +.section-more:hover{color:var(--accent)} + +/* ── GRID ── */ +.grid{display:grid;gap:14px} +.grid-4{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))} +.scroll-row{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;scrollbar-width:none} +.scroll-row::-webkit-scrollbar{display:none} +.scroll-row .card{flex-shrink:0;width:145px} + +/* ── CARDS ── */ +.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s} +.card:hover{transform:translateY(-3px);box-shadow:0 12px 32px rgba(0,0,0,.4);border-color:var(--border2)} +.card-art{aspect-ratio:1;background:var(--surface2);position:relative;overflow:hidden} +.card-art img{width:100%;height:100%;object-fit:cover;transition:transform .4s} +.card:hover .card-art img{transform:scale(1.05)} +.card-art .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:34px;color:var(--text3);background:linear-gradient(135deg,var(--surface2),var(--bg3))} +.card-art .play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45);opacity:0;transition:opacity .2s;gap:8px} +.card:hover .play-overlay{opacity:1} +.play-btn{width:40px;height:40px;border-radius:50%;background:var(--accent);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transform:scale(.8);transition:transform .2s;box-shadow:0 4px 16px rgba(0,0,0,.5)} +.card:hover .play-btn{transform:scale(1)} +.play-btn svg{color:#000} +.dl-btn-overlay{width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.15);border:1px solid rgba(255,255,255,.3);cursor:pointer;display:flex;align-items:center;justify-content:center;transform:scale(.8);transition:transform .2s,background .2s;backdrop-filter:blur(4px)} +.card:hover .dl-btn-overlay{transform:scale(1)} +.dl-btn-overlay:hover{background:rgba(255,255,255,.3)} +.dl-btn-overlay svg{color:#fff} +.card-info{padding:10px} +.card-title{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.card-sub{font-size:11px;color:var(--text2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* ── BADGES ── */ +.badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:20px;letter-spacing:.3px} +.badge-new{background:var(--blue-dim);color:var(--blue)} +.badge-itunes{background:rgba(252,100,100,.12);color:#fc6464} +.badge-local{background:var(--green-dim);color:var(--green)} + +/* ── GLOBAL SEARCH RESULTS ── */ +.gtrack-item{display:grid;grid-template-columns:52px 1fr auto;gap:12px;align-items:center;padding:8px 12px;border-radius:9px;cursor:pointer;transition:background .15s} +.gtrack-item:hover{background:var(--surface)} +.gtrack-art{width:52px;height:52px;border-radius:6px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.gtrack-art img{width:100%;height:100%;object-fit:cover} +.gtrack-info .gtrack-title{font-size:14px;font-weight:600} +.gtrack-info .gtrack-sub{font-size:12px;color:var(--text2);margin-top:2px} +.gtrack-actions{display:flex;align-items:center;gap:6px;opacity:0;transition:opacity .15s} +.gtrack-item:hover .gtrack-actions{opacity:1} + +/* ── ICON BTN ── */ +.icon-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:6px;border-radius:6px;display:flex;align-items:center;transition:color .15s,background .15s} +.icon-btn:hover{color:var(--accent);background:var(--accent-dim)} +.icon-btn.starred{color:var(--accent)} +.icon-btn.blue:hover{color:var(--blue);background:var(--blue-dim)} +.icon-btn.green:hover{color:var(--green);background:var(--green-dim)} + +/* ── SONG LIST ── */ +.song-list{display:flex;flex-direction:column;gap:2px} +.song-item{display:grid;grid-template-columns:32px 44px 1fr auto;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;cursor:pointer;transition:background .15s} +.song-thumb{width:44px;height:44px;border-radius:6px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.song-thumb img{width:100%;height:100%;object-fit:cover} +.song-thumb .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px;color:var(--text3)} +.song-item:hover{background:var(--surface)} +.song-item.playing{background:var(--accent-dim)} +.song-item.playing .song-title{color:var(--accent)} +.song-num{font-size:12px;color:var(--text3);text-align:center;font-family:var(--mono)} +.song-item.playing .song-num{color:var(--accent)} +.song-info .song-title{font-size:13px;font-weight:500}.song-link{cursor:pointer;transition:color .15s}.song-link:hover{color:var(--accent);text-decoration:underline} +.song-info .song-artist{font-size:11px;color:var(--text2);margin-top:1px} +.song-meta{display:flex;align-items:center;gap:6px} +.song-dur{font-size:11px;color:var(--text3);font-family:var(--mono)} +.song-actions{display:flex;gap:2px;opacity:0;transition:opacity .15s} +.song-item:hover .song-actions{opacity:1} + +/* ── ALBUM DETAIL ── */ +.back-btn{background:none;border:none;color:var(--text2);cursor:pointer;display:flex;align-items:center;gap:6px;font-family:var(--font);font-size:12px;padding:6px 0;margin-bottom:16px;transition:color .15s} +.back-btn:hover{color:var(--text)} +.album-header{display:flex;gap:24px;align-items:flex-end;margin-bottom:28px} +.album-cover{width:170px;height:170px;border-radius:12px;background:var(--surface2);flex-shrink:0;overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,.5)} +.album-cover img{width:100%;height:100%;object-fit:cover} +.album-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:50px} +.album-type{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:5px} +.album-name{font-size:28px;font-weight:800;letter-spacing:-1px;margin-bottom:5px;line-height:1.1} +.album-artist-name{font-size:14px;color:var(--text2);margin-bottom:10px;cursor:pointer} +.album-artist-name:hover{color:var(--accent)} +.album-stats{font-size:12px;color:var(--text3)} +.album-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap} +.btn-play-all{display:flex;align-items:center;gap:7px;padding:10px 20px;border-radius:25px;background:var(--accent);color:#000;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s} +.btn-play-all:hover{opacity:.9;transform:scale(1.02)} +.btn-outline{display:flex;align-items:center;gap:7px;padding:10px 18px;border-radius:25px;background:var(--surface);border:1px solid var(--border2);color:var(--text);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s} +.btn-outline:hover{background:var(--surface2)} +.btn-outline.blue{border-color:rgba(79,142,247,.4);color:var(--blue)} +.btn-outline.blue:hover{background:var(--blue-dim)} + +/* ── PLAYER ── */ +.player{position:fixed;bottom:0;left:0;right:0;height:var(--player-h);background:rgba(8,8,20,.93);backdrop-filter:blur(28px);border-top:1px solid var(--border2);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:0;padding:0 20px;z-index:100} +.player-track{min-width:0;display:flex;align-items:center;gap:10px;overflow:hidden} +.player-art{width:52px;height:52px;border-radius:8px;background:var(--surface2);flex-shrink:0;overflow:hidden;position:relative} +.player-art img{width:100%;height:100%;object-fit:cover} +.player-art .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:20px;color:var(--text3)} +.player-art.spinning img{animation:spin 8s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +.player-info{min-width:0} +.player-title{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .15s} +/* Player bar clickable title/artist */ +.player-title:hover{color:var(--accent);text-decoration:underline} +#player-artist-name{color:var(--text2)} +#player-artist-name:hover{color:var(--accent-2,#f0b840)} +#player-album-name{color:var(--text3)} +#player-album-name:hover{color:var(--accent)} + +/* NP action sheet */ +.np-sheet-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:400;opacity:0;pointer-events:none;transition:opacity .25s;backdrop-filter:blur(2px)} +.np-sheet-overlay.open{opacity:1;pointer-events:all} +.np-sheet{position:fixed;bottom:0;left:0;right:0;background:var(--surface);border-radius:20px 20px 0 0;padding:16px 0 max(24px,env(safe-area-inset-bottom));z-index:401;transform:translateY(100%);transition:transform .3s cubic-bezier(.32,.72,0,1)} +.np-sheet.open{transform:translateY(0)} +.np-sheet-handle{width:36px;height:4px;background:var(--surface2);border-radius:2px;margin:0 auto 16px} +.np-sheet-song{display:flex;align-items:center;gap:12px;padding:0 20px 16px;border-bottom:1px solid var(--border)} +.np-sheet-song .thumb{width:44px;height:44px;border-radius:8px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.np-sheet-song .thumb img{width:100%;height:100%;object-fit:cover} +.np-sheet-song .title{font-size:14px;font-weight:600} +.np-sheet-song .sub{font-size:12px;color:var(--text2);margin-top:2px} +.np-sheet-item{display:flex;align-items:center;gap:14px;padding:13px 20px;cursor:pointer;transition:background .12s;font-size:14px;font-weight:500} +.np-sheet-item:hover{background:var(--surface2)} +.np-sheet-item svg{color:var(--text2);flex-shrink:0} +.np-sheet-item.danger{color:var(--danger)} +.np-sheet-item.danger svg{color:var(--danger)} + +.player-artist{font-size:11px;color:var(--text2);margin-top:2px} +.player-badge{display:inline-block;font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;margin-left:5px;vertical-align:middle} +.player-controls{display:flex;flex-direction:column;align-items:center;gap:7px;width:420px;padding:0 24px} +.ctrl-buttons{display:flex;align-items:center;gap:10px} +.ctrl-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:5px;border-radius:7px;display:flex;align-items:center;transition:color .15s} +#btn-repeat{position:relative} +#btn-repeat.repeat-one::after,#np-repeat-btn.repeat-one::after{content:'1';position:absolute;top:1px;right:1px;font-size:7px;font-weight:700;line-height:11px;text-align:center;width:11px;height:11px;border-radius:50%;background:var(--accent);color:#000;pointer-events:none} +#np-repeat-btn{position:relative} +.ctrl-btn:hover{color:var(--text)} +.ctrl-btn.active{color:var(--accent)} +.play-pause-btn{width:38px;height:38px;border-radius:50%;background:var(--accent);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .15s,background .15s;box-shadow:0 0 18px var(--accent-glow)} +.play-pause-btn:hover{transform:scale(1.08);background:var(--accent2)} +.play-pause-btn svg{color:#000} +.progress-bar-wrap{width:100%;display:flex;align-items:center;gap:7px} +.progress-time{font-size:10px;color:var(--text3);font-family:var(--mono);min-width:32px} +.progress-time.right{text-align:right} +.progress-bar{flex:1;height:6px;background:var(--surface2);border-radius:3px;cursor:pointer;position:relative} +.progress-fill{height:100%;background:var(--accent);border-radius:3px;width:0%;transition:width .1s linear;position:relative} +.progress-fill::after{content:'';position:absolute;right:-7px;top:50%;transform:translateY(-50%);width:14px;height:14px;border-radius:50%;background:var(--accent2);opacity:0;transition:opacity .15s;box-shadow:0 0 6px var(--accent-glow)} +.progress-bar:hover .progress-fill::after{opacity:1} +.player-right{display:flex;align-items:center;justify-content:flex-end;gap:6px} +.volume-wrap{display:flex;align-items:center;gap:7px} +.volume-slider{width:72px;height:3px;background:var(--surface2);border-radius:2px;cursor:pointer;appearance:none;-webkit-appearance:none;outline:none} +.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:var(--accent);cursor:pointer} + +/* ── PREVIEW BAR ── */ +.preview-bar{position:fixed;bottom:var(--player-h);left:0;right:0;background:rgba(15,15,35,.95);backdrop-filter:blur(16px);border-top:1px solid var(--blue);padding:10px 20px;display:flex;align-items:center;gap:14px;z-index:99;transform:translateY(100%);transition:transform .3s;font-size:13px} +.preview-bar.visible{transform:translateY(0)} +.preview-info{flex:1;min-width:0} +.preview-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.preview-sub{font-size:11px;color:var(--text2);margin-top:2px} +.preview-badge{font-size:10px;font-weight:700;color:var(--blue);background:var(--blue-dim);padding:2px 6px;border-radius:4px;margin-left:6px} +.preview-prog{flex:1;height:2px;background:var(--surface2);border-radius:2px;overflow:hidden;max-width:200px} +.preview-fill{height:100%;background:var(--blue);border-radius:2px;width:0%;transition:width .5s linear} +.preview-close{background:none;border:none;color:var(--text3);cursor:pointer;font-size:18px;line-height:1;padding:2px 6px;border-radius:4px;transition:color .15s} +.preview-close:hover{color:var(--text)} + +/* ── QUEUE PANEL ── */ +.queue-panel{position:fixed;right:0;top:0;bottom:var(--player-h);width:290px;background:var(--bg2);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s;z-index:90;display:flex;flex-direction:column;padding:18px 0} +.queue-panel.open{transform:translateX(0)} +.queue-title{font-size:15px;font-weight:700;padding:0 18px 14px;border-bottom:1px solid var(--border)} +.queue-list{flex:1;overflow-y:auto;padding:6px 10px} +.queue-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;cursor:pointer;transition:background .15s} +.queue-item:hover{background:var(--surface)} +.queue-item.active{background:var(--accent-dim)} +.queue-item.active .qi-title{color:var(--accent)} +.qi-idx{font-size:10px;color:var(--text3);width:16px;text-align:center;flex-shrink:0;font-family:var(--mono)} +.qi-info{flex:1;min-width:0} +.qi-title{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.qi-artist{font-size:10px;color:var(--text2)} + +/* ── DOWNLOAD MODAL ── */ +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);backdrop-filter:blur(4px);z-index:200;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} +.modal-overlay.open{opacity:1;pointer-events:all} +.modal{width:min(460px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:18px;padding:28px;box-shadow:0 40px 80px rgba(0,0,0,.7)} +.modal h2{font-size:18px;font-weight:700;margin-bottom:6px} +.modal .modal-sub{font-size:13px;color:var(--text2);margin-bottom:20px;line-height:1.5} +.modal-art{width:64px;height:64px;border-radius:8px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.modal-art img{width:100%;height:100%;object-fit:cover} +.modal-track-info{display:flex;gap:14px;align-items:center;padding:14px;background:var(--bg2);border-radius:10px;margin-bottom:18px} +.mti-name{font-size:14px;font-weight:600} +.mti-sub{font-size:12px;color:var(--text2);margin-top:3px} +.modal-opts{display:flex;flex-direction:column;gap:8px;margin-bottom:20px} +.modal-opt{display:flex;align-items:center;gap:10px;padding:12px 14px;background:var(--bg2);border:1px solid var(--border);border-radius:9px;cursor:pointer;transition:border-color .2s,background .2s} +.modal-opt:hover{border-color:var(--border2);background:var(--bg3)} +.modal-opt.selected{border-color:var(--blue);background:var(--blue-dim)} +.modal-opt svg{color:var(--text2)} +.modal-opt.selected svg{color:var(--blue)} +.opt-label{font-size:13px;font-weight:600} +.opt-sub{font-size:11px;color:var(--text2);margin-top:2px} +.modal-disclaimer{font-size:11px;color:var(--text3);line-height:1.5;padding:10px 12px;background:var(--bg3);border-radius:7px;margin-bottom:16px} +.modal-btns{display:flex;gap:8px} +.modal-btns button{flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;transition:opacity .15s} +.btn-cancel{background:var(--surface2);color:var(--text2)} +.btn-cancel:hover{opacity:.8} +.btn-download{background:linear-gradient(135deg,var(--blue),#2d6ad4);color:#fff} +.btn-download:hover{opacity:.9} +.btn-download:disabled{opacity:.5;cursor:default} + +/* ── DOWNLOAD JOBS ── */ +.jobs-panel{position:fixed;bottom:calc(var(--player-h) + 16px);right:20px;width:310px;display:flex;flex-direction:column;gap:8px;z-index:150} +.job-card{background:var(--surface);border:1px solid var(--border2);border-radius:10px;padding:12px 14px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:slideUp .25s ease} +@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}} +.job-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} +.job-title{font-size:12px;font-weight:600;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.job-status{font-size:10px;color:var(--text2)} +.job-bar{height:3px;background:var(--surface2);border-radius:2px;overflow:hidden} +.job-fill{height:100%;background:linear-gradient(90deg,var(--blue),var(--accent));border-radius:2px;width:0%;transition:width .4s ease} +.job-fill.done{background:var(--green);width:100%!important} +.job-fill.error{background:var(--danger);width:100%!important} +.job-sub{font-size:10px;color:var(--text3);margin-top:5px} + +/* ── RECOMMENDATIONS ── */ +.reco-grid{display:flex;flex-direction:column;gap:3px;margin-bottom:24px} +.reco-artist-chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;transition:border-color .2s,background .2s;margin:3px} +.reco-artist-chip:hover{border-color:var(--blue);background:var(--blue-dim);color:var(--blue)} + + +/* ── CONTEXT MENU ── */ +.ctx-menu{position:fixed;background:var(--surface);border:1px solid var(--border2);border-radius:10px;padding:5px;z-index:500;min-width:200px;box-shadow:0 16px 40px rgba(0,0,0,.6);animation:ctxIn .12s ease} +@keyframes ctxIn{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}} +.ctx-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:7px;cursor:pointer;font-size:13px;font-weight:500;color:var(--text);transition:background .12s} +.ctx-item:hover{background:var(--surface2)} +.ctx-item.danger{color:var(--danger)} +.ctx-item.danger:hover{background:rgba(224,85,85,.12)} +.ctx-item svg{flex-shrink:0;color:var(--text2)} +.ctx-item:hover svg{color:inherit} +.ctx-separator{height:1px;background:var(--border);margin:4px 0} +.ctx-submenu-wrap{position:relative} +.ctx-submenu{position:absolute;left:100%;top:0;background:var(--surface);border:1px solid var(--border2);border-radius:10px;padding:5px;min-width:180px;max-height:260px;overflow-y:auto;box-shadow:0 16px 40px rgba(0,0,0,.6);display:none;z-index:501} +.ctx-submenu-wrap:hover .ctx-submenu{display:block} +.ctx-submenu .ctx-item{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* ── PLAYLIST CREATE / ADD MODAL ── */ +.pl-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:400;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} +.pl-modal.open{opacity:1;pointer-events:all} +.pl-modal .inner{width:min(380px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:16px;padding:24px;box-shadow:0 32px 64px rgba(0,0,0,.7)} +.pl-modal h3{font-size:17px;font-weight:700;margin-bottom:16px} +.pl-list{display:flex;flex-direction:column;gap:4px;max-height:240px;overflow-y:auto;margin-bottom:14px} +.pl-row{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;cursor:pointer;transition:background .12s;font-size:13px;font-weight:500} +.pl-row:hover{background:var(--surface2)} +.pl-row svg{color:var(--text2);flex-shrink:0} +.pl-new-row{border-top:1px solid var(--border);margin-top:6px;padding-top:10px} +.pl-input{width:100%;padding:9px 12px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-family:var(--font);font-size:13px;outline:none;margin-bottom:10px;transition:border-color .2s} +.pl-input:focus{border-color:var(--accent)} +.pl-btns{display:flex;gap:8px} +.pl-btns button{flex:1;padding:10px;border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;border:none} + +/* ── SONG INFO MODAL ── */ +.info-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:400;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} +.info-modal.open{opacity:1;pointer-events:all} +.info-modal .inner{width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:16px;padding:0;overflow:hidden;box-shadow:0 32px 64px rgba(0,0,0,.7)} +.info-header{display:flex;gap:16px;padding:20px;background:var(--bg2)} +.info-cover{width:80px;height:80px;border-radius:8px;overflow:hidden;background:var(--surface2);flex-shrink:0} +.info-cover img{width:100%;height:100%;object-fit:cover} +.info-top-title{font-size:18px;font-weight:800;margin-bottom:4px} +.info-top-sub{font-size:13px;color:var(--text2)} +.info-rows{padding:16px 20px;display:flex;flex-direction:column;gap:10px} +.info-row{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;border-bottom:1px solid var(--border);padding-bottom:8px} +.info-row:last-child{border:none;padding:0} +.info-label{color:var(--text2);font-weight:500;min-width:90px} +.info-val{color:var(--text);text-align:right;word-break:break-all} +.info-close{display:block;width:calc(100% - 40px);margin:0 20px 20px;padding:11px;border-radius:9px;background:var(--surface2);border:none;font-family:var(--font);font-size:14px;font-weight:600;color:var(--text);cursor:pointer} +.info-close:hover{background:var(--surface)} + + +/* ── BOTTOM NAV (PWA Mobile) ── */ +.bottom-nav{ + display:none; + position:fixed;bottom:0;left:0;right:0; + height:80px; + background:rgba(8,8,20,.97); + backdrop-filter:blur(28px); + border-top:1px solid var(--border2); + z-index:200; + padding:0 4px; + padding-bottom:env(safe-area-inset-bottom); +} +.bottom-nav-inner{ + display:flex;flex-direction:column;align-items:stretch; + height:100%;max-width:500px;margin:0 auto; +} +.bnav-item{ + display:flex;flex-direction:column;align-items:center;gap:4px; + padding:8px 12px;border-radius:12px;cursor:pointer; + font-size:10px;font-weight:600;color:var(--text3); + letter-spacing:.3px;min-width:52px;text-align:center; + transition:color .15s,background .15s; + border:none;background:none;font-family:var(--font); + -webkit-tap-highlight-color:transparent; +} +.bnav-item.active{color:var(--accent)} +.bnav-item.active svg{filter:drop-shadow(0 0 6px var(--accent-glow))} +.bnav-item.active-blue{color:var(--blue)} +.bnav-item svg{flex-shrink:0;transition:transform .15s} +.bnav-item:active svg{transform:scale(.88)} +/* Tab strip */ +.bnav-tabs{display:flex;justify-content:center;gap:6px;padding:5px 16px 2px;flex-shrink:0} +.bnav-tab{flex:1;padding:3px 0;border-radius:7px;border:none;font-family:var(--font);font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text3);cursor:pointer;background:none;transition:color .15s,background .15s;-webkit-tap-highlight-color:transparent} +.bnav-tab.active{background:var(--surface2);color:var(--text)} +.bnav-pages{display:grid;grid-template-columns:repeat(4,1fr);align-items:center;width:100%;gap:6px;flex:1} + +@media(max-width:768px){ + .bottom-nav{display:flex;flex-direction:column;justify-content:flex-start} + /* player sits above bottom nav */ + .player{bottom:80px} + /* push content above player + bottom nav */ + .main{padding-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom) + 64px)} + .page{padding-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom) + 96px)} + .queue-panel{bottom:calc(var(--player-h) + 80px)} + .preview-bar{bottom:calc(var(--player-h) + 80px)} + .toast-container{bottom:calc(var(--player-h) + 80px + 16px)} + .jobs-panel{bottom:calc(var(--player-h) + 80px + 16px)} + .bnav-pages{grid-template-columns:repeat(4,1fr);gap:6px} + .bnav-item{min-width:0;width:100%;padding:8px 0 6px;box-sizing:border-box} + #settings-modal{align-items:flex-start;padding-top:max(env(safe-area-inset-top),16px);padding-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom) + 8px)} + #settings-modal .modal{max-height:100%!important;overflow:hidden} +} + + +/* ── NOW PLAYING FULL SCREEN ── */ +.np-overlay{ + position:fixed;inset:0;z-index:300; + display:flex;flex-direction:column; + background:var(--bg); + transform:translateY(100%); + transition:transform .4s cubic-bezier(.32,.72,0,1); + overflow:hidden; +} +.np-overlay.open{transform:translateY(0)} + +/* Blurred album art as background */ +.np-bg{ + position:absolute;inset:-40px; + background-size:cover;background-position:center; + filter:blur(60px) brightness(.35) saturate(1.4); + transform:scale(1.1); + transition:background-image .6s ease; + z-index:0; +} + +.np-inner{ + position:relative;z-index:1; + display:flex;flex-direction:column; + align-items:center; + height:100%; + padding:env(safe-area-inset-top,20px) 32px 32px; + padding-top:max(env(safe-area-inset-top),20px); + gap:0; + max-width:480px; + margin:0 auto; + width:100%; +} + +/* Top bar */ +.np-topbar{ + display:flex;align-items:center;justify-content:space-between; + width:100%;margin-bottom:28px; +} +.np-close{ + background:rgba(255,255,255,.1);border:none; + width:36px;height:36px;border-radius:50%; + display:flex;align-items:center;justify-content:center; + cursor:pointer;color:var(--text); + backdrop-filter:blur(8px); + transition:background .2s; +} +.np-close:hover{background:rgba(255,255,255,.18)} +.np-close svg{pointer-events:none} +.np-label{font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:rgba(255,255,255,.5)} +.np-menu-btn{ + background:rgba(255,255,255,.1);border:none; + width:36px;height:36px;border-radius:50%; + display:flex;align-items:center;justify-content:center; + cursor:pointer;color:var(--text);backdrop-filter:blur(8px); + transition:background .2s; +} +.np-menu-btn:hover{background:rgba(255,255,255,.18)} + +/* Big cover art */ +.np-cover{ + width:min(300px, calc(100vw - 80px)); + height:min(300px, calc(100vw - 80px)); + border-radius:16px; + overflow:hidden; + background:var(--surface2); + box-shadow:0 32px 80px rgba(0,0,0,.7); + margin-bottom:36px; + flex-shrink:0; + transition:transform .3s; +} +.np-cover img{width:100%;height:100%;object-fit:cover} +.np-cover.playing{animation:npPulse 4s ease-in-out infinite} +@keyframes npPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.025)}} +.np-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:80px} + +/* Song info */ +.np-info{width:100%;display:flex;align-items:center;justify-content:space-between;margin-bottom:24px} +.np-text{flex:1;min-width:0} +.np-title{font-size:22px;font-weight:800;letter-spacing:-.4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:5px} +.np-artist{font-size:15px;color:rgba(255,255,255,.65);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .15s} +#np-artist-name:hover,#np-album-name:hover{color:var(--accent);cursor:pointer} +.np-star{ + background:none;border:none;cursor:pointer; + padding:8px;margin-left:8px;flex-shrink:0; + display:flex;align-items:center; + transition:transform .15s; +} +.np-star:hover{transform:scale(1.15)} + +/* Progress */ +.np-progress{width:100%;margin-bottom:10px} +.np-bar{ + width:100%;height:4px;background:rgba(255,255,255,.2); + border-radius:2px;cursor:pointer;position:relative;margin-bottom:10px; +} +.np-bar-fill{ + height:100%;background:#fff;border-radius:2px; + width:0%;transition:width .1s linear;position:relative; +} +.np-bar-fill::after{ + content:'';position:absolute;right:-7px;top:50%; + transform:translateY(-50%); + width:14px;height:14px;border-radius:50%;background:#fff; + opacity:0;transition:opacity .15s; + box-shadow:0 0 8px rgba(255,255,255,.6); +} +.np-bar:hover .np-bar-fill::after{opacity:1} +.np-times{display:flex;justify-content:space-between;font-size:11px;color:rgba(255,255,255,.45);font-family:var(--mono)} + +/* Controls */ +.np-controls{display:flex;align-items:center;justify-content:space-between;width:100%;margin-bottom:28px} +.np-ctrl{ + background:none;border:none;color:rgba(255,255,255,.7); + cursor:pointer;padding:10px;border-radius:50%; + display:flex;align-items:center;justify-content:center; + transition:color .15s,transform .15s,background .15s; +} +.np-ctrl:hover{color:#fff;background:rgba(255,255,255,.08)} +.np-ctrl.active{color:var(--accent)} +.np-play{ + width:64px;height:64px;border-radius:50%; + background:#fff;border:none;cursor:pointer; + display:flex;align-items:center;justify-content:center; + box-shadow:0 8px 32px rgba(0,0,0,.4); + transition:transform .15s; +} +.np-play:hover{transform:scale(1.06)} +.np-play:active{transform:scale(.95)} +.np-play svg{color:#000} + +/* Volume + extras */ +.np-extras{display:flex;align-items:center;justify-content:space-between;width:100%;gap:12px} +.np-vol{display:flex;align-items:center;gap:10px;flex:1} +.np-vol-slider{ + flex:1;height:3px;background:rgba(255,255,255,.2); + border-radius:2px;cursor:pointer; + appearance:none;-webkit-appearance:none;outline:none; +} +.np-vol-slider::-webkit-slider-thumb{ + -webkit-appearance:none;width:13px;height:13px; + border-radius:50%;background:#fff;cursor:pointer; +} +.np-icon-btn{ + background:none;border:none;color:rgba(255,255,255,.6); + cursor:pointer;padding:8px;border-radius:50%; + display:flex;align-items:center; + transition:color .15s,background .15s; +} +.np-icon-btn:hover{color:#fff;background:rgba(255,255,255,.08)} +/* NP tabs */ +.np-tabs{display:flex;gap:2px;background:rgba(255,255,255,.08);border-radius:20px;padding:3px} +.np-tab{background:none;border:none;color:rgba(255,255,255,.45);font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:5px 16px;border-radius:16px;cursor:pointer;transition:all .2s;font-family:var(--font)} +.np-tab.active{background:rgba(255,255,255,.15);color:#fff} +/* NP queue view */ +#np-player-view{display:flex;flex-direction:column;align-items:center;width:100%;flex:1;min-height:0} +#np-queue-view{display:none;flex-direction:column;width:100%;flex:1;overflow-y:auto;padding-top:8px} +.np-overlay.queue-mode #np-player-view{display:none} +.np-overlay.queue-mode #np-queue-view{display:flex} +.np-queue-item{display:flex;align-items:center;gap:10px;padding:9px 6px;border-radius:10px;cursor:pointer;transition:background .15s} +.np-queue-item:hover{background:rgba(255,255,255,.06)} +.np-queue-item.nq-active .nqi-title{color:var(--accent)} +.nqi-num{font-size:11px;color:rgba(255,255,255,.28);width:20px;text-align:center;flex-shrink:0;font-family:var(--mono)} +.nqi-art{width:38px;height:38px;border-radius:7px;overflow:hidden;background:rgba(255,255,255,.08);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:16px} +.nqi-art img{width:100%;height:100%;object-fit:cover} +.nqi-info{flex:1;min-width:0} +.nqi-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:rgba(255,255,255,.9)} +.nqi-artist{font-size:11px;color:rgba(255,255,255,.4);margin-top:2px} + +/* player-art clickable */ +#player-art{cursor:pointer} +#player-art:hover img{opacity:.85} + +/* ── RADIO SHUFFLE BUTTON ── */ +.btn-radio{display:flex;align-items:center;gap:7px;padding:9px 18px;border-radius:25px;background:linear-gradient(135deg,#4f8ef7,#2d6ad4);color:#fff;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s;box-shadow:0 0 18px rgba(79,142,247,.35)} +.btn-radio:hover{opacity:.9;transform:scale(1.02)} +.btn-radio.active{background:linear-gradient(135deg,var(--danger),#c03030);box-shadow:0 0 18px rgba(224,85,85,.4)} + +/* ── ARTIST PAGE ── */ + +/* ── SHUFFLE/REPEAT STATUS BADGES ── */ +.player-status-row{display:flex;align-items:center;gap:6px;margin-top:3px;min-height:16px} +.status-badge{font-size:9px;font-weight:700;letter-spacing:.4px;padding:2px 6px;border-radius:10px;text-transform:uppercase;opacity:0;transition:opacity .2s,transform .2s;transform:scale(.8);pointer-events:none} +.status-badge.on{opacity:1;transform:scale(1)} +.badge-shuffle{background:var(--accent-dim);color:var(--accent)} +.badge-repeat{background:var(--blue-dim);color:var(--blue)} + +/* ── LAST.FM ARTIST INFO ── */ +.artist-bio{font-size:13px;color:var(--text2);line-height:1.65;margin-bottom:20px;padding:16px;background:var(--surface);border-radius:10px;border:1px solid var(--border)} +.artist-bio a{color:var(--accent);text-decoration:none} +.similar-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:24px} +.similar-chip{padding:6px 12px;background:var(--surface);border:1px solid var(--border2);border-radius:20px;font-size:12px;font-weight:600;cursor:pointer;transition:border-color .15s,color .15s} +.similar-chip:hover{border-color:var(--accent);color:var(--accent)} +.topsong-item{display:grid;grid-template-columns:28px 44px 1fr auto;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;cursor:pointer;transition:background .15s} +.topsong-item:hover{background:var(--surface)} +.topsong-num{font-size:11px;color:var(--text3);font-family:var(--mono);text-align:center} +.topsong-thumb{width:44px;height:44px;border-radius:6px;overflow:hidden;background:var(--surface2)} +.topsong-thumb img{width:100%;height:100%;object-fit:cover} +.topsong-title{font-size:13px;font-weight:500} +.topsong-plays{font-size:11px;color:var(--text3);font-family:var(--mono)} + +/* ── NP MOBILE SWIPE ── */ +.np-drag-handle{width:100%;height:36px;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;touch-action:none} +.np-drag-handle-bar{width:40px;height:4px;background:rgba(255,255,255,.25);border-radius:2px} + +.artist-hero{height:180px;border-radius:14px;overflow:hidden;position:relative;margin-bottom:24px;background:linear-gradient(135deg,var(--surface2),var(--surface))} +.artist-hero-overlay{position:absolute;inset:0;background:linear-gradient(to right,rgba(7,7,15,.85) 0%,transparent 100%);display:flex;align-items:flex-end;padding:20px} +.artist-hero-name{font-size:32px;font-weight:800;letter-spacing:-1px} + +/* ── MISC ── */ +.spinner{width:28px;height:28px;border:3px solid var(--surface2);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin:50px auto;display:block} +.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:70px 20px;color:var(--text3);text-align:center} +.empty-state .icon{font-size:44px;margin-bottom:14px} +.empty-state p{font-size:14px} +.toast-container{position:fixed;bottom:calc(var(--player-h) + 16px);left:50%;transform:translateX(-50%);z-index:201;display:flex;flex-direction:column;align-items:center;gap:6px} +.toast{background:var(--surface);border:1px solid var(--border2);color:var(--text);font-size:12px;padding:9px 14px;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:toastIn .25s ease,toastOut .25s ease 2.75s forwards;white-space:nowrap} +@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} +@keyframes toastOut{to{opacity:0;transform:translateY(8px)}} + +/* ── SETTINGS MODAL ── */ +.settings-section{margin-bottom:20px} +.settings-section h3{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);margin-bottom:10px} +.settings-row{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg2);border-radius:8px;margin-bottom:6px} +.settings-row label{font-size:13px;font-weight:500} +.settings-row .hint{font-size:11px;color:var(--text3);margin-top:2px} +.toggle{width:36px;height:20px;background:var(--surface2);border-radius:10px;cursor:pointer;position:relative;transition:background .2s;flex-shrink:0} +.toggle.on{background:var(--accent)} +.toggle::after{content:'';position:absolute;width:14px;height:14px;background:#fff;border-radius:50%;top:3px;left:3px;transition:transform .2s} +.toggle.on::after{transform:translateX(16px)} +.status-dot{width:8px;height:8px;border-radius:50%;background:var(--surface2);flex-shrink:0} +.status-dot.ok{background:var(--green)} +.status-dot.err{background:var(--danger)} + +/* ── RESPONSIVE ── */ +@media(max-width:768px){ + :root{--sidebar-w:0px} + .sidebar{position:fixed;left:-260px;width:260px;height:100vh;z-index:150;transition:left .3s;border-right:1px solid var(--border2)} + .sidebar.mobile-open{left:0;box-shadow:10px 0 40px rgba(0,0,0,.6)} + .page{padding:16px 14px} + .album-header{flex-direction:column;align-items:flex-start} + .album-cover{width:130px;height:130px} + .album-name{font-size:22px} + .player-controls{width:auto;padding:0 8px;min-width:160px} + .player-right .volume-wrap{display:none} + .mobile-menu-btn{display:flex!important} + .jobs-panel{left:16px;right:16px;width:auto} +} +.mobile-menu-btn{display:none;background:none;border:none;color:var(--text);cursor:pointer;padding:8px} +@media(max-width:768px){ + :root{--player-h:62px} + /* Single-row compact player: [art][info+star][prev][play][next] + thin progress strip */ + .player{ + display:grid!important; + grid-template-columns:1fr auto!important; + grid-template-rows:1fr!important; + padding:0 10px 14px; + gap:0 8px; + align-items:center; + } + .player .player-art{width:36px!important;height:36px!important;border-radius:6px;flex-shrink:0} + .player-track{max-width:none!important;width:100%!important;gap:8px} + .player-info{flex:1;min-width:0;position:relative} + .player-info>div:first-child{justify-content:flex-start!important;padding-right:24px} + .player-title{flex:1;min-width:0;font-size:12px!important} + .player-artist{font-size:10px!important;margin-top:1px} + #btn-star-current{position:absolute;right:0;top:50%;transform:translateY(-50%);flex-shrink:0} + .player-status-row{display:none} + /* Controls: horizontal row with only prev/play/next */ + .player-controls{display:flex!important;flex-direction:row!important;width:auto!important;padding:0!important;gap:0!important;align-items:center;flex-shrink:0;min-width:0!important} + .ctrl-buttons{gap:2px} + #btn-shuffle,#btn-repeat{display:none!important} + .play-pause-btn{width:40px;height:40px} + /* Thin progress strip pinned to bottom of player bar */ + .progress-bar-wrap{position:absolute;bottom:0;left:0;right:0;height:14px;padding:0 10px;gap:5px;display:flex;align-items:center} + #time-total{display:none!important} + .progress-time{font-size:9px!important;min-width:22px!important;color:var(--text3)} + .progress-bar{height:2px!important;border-radius:0} + .progress-fill::after{display:none} + .player-right{display:none!important} +} +.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:140} +.mobile-overlay.active{display:block} + +/* ── SEARCH page ── */ +.search-section{margin-bottom:24px} +.search-section-title{font-size:11px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..91d9292 --- /dev/null +++ b/sw.js @@ -0,0 +1,32 @@ +const CACHE = 'ns-v12'; +const PRECACHE = ['/', 'index.html', 'styles.css', 'app.js', 'sw.js']; +const SKIP_URLS = ['/rest/stream', '/rest/download']; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', e => { + if (SKIP_URLS.some(u => e.request.url.includes(u))) return; + e.respondWith( + caches.match(e.request).then(cached => + cached || fetch(e.request).then(res => { + if (res.status === 200 && e.request.method === 'GET') { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }).catch(() => cached) + ) + ); +});