feat(web): scroll-row arrows + stable desktop media tile
Home rows get hover left/right arrows that only show for the scrollable direction (enhanceScrollRow + initScrollRows, called from startApp); visibility recomputed on scroll/resize/content-load. Fix the OS media tile flicker when skipping on desktop PWA: mutate the existing MediaMetadata in place instead of assigning a new one each track (the replacement made Windows tear down and rebuild the tile). Action handlers are now set once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
7498e45b16
commit
d65fbcd11e
5 files changed
+98
-9
No files matched your search
@@ -67,12 +67,14 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
- **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change
|
- **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change
|
||||||
- **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome)
|
- **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome)
|
||||||
- **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields
|
- **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields
|
||||||
|
- **Web** — Horizontal rows (Home) now have hover scroll arrows; only the scrollable direction is shown (no left arrow at the start, no right arrow at the end)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
||||||
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
||||||
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
|
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
|
||||||
- **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state
|
- **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state
|
||||||
|
- **Web (PWA)** — OS media tile no longer disappears/reappears when skipping tracks on desktop: the Media Session metadata is now mutated in place instead of replaced with a new `MediaMetadata` each track (which made Windows rebuild the tile)
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { toast } from './utils.js';
|
|||||||
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
|
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
|
||||||
import { injectPWA } from './pwa.js';
|
import { injectPWA } from './pwa.js';
|
||||||
import { loadSavedTheme } from './themes.js';
|
import { loadSavedTheme } from './themes.js';
|
||||||
|
import { initScrollRows } from './ui/components.js';
|
||||||
import {
|
import {
|
||||||
playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded,
|
playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded,
|
||||||
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
|
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
|
||||||
@@ -221,6 +222,7 @@ window.addEventListener('load', () => {
|
|||||||
injectPWA();
|
injectPWA();
|
||||||
initProgressScrub();
|
initProgressScrub();
|
||||||
initKeyboardShortcuts();
|
initKeyboardShortcuts();
|
||||||
|
initScrollRows();
|
||||||
|
|
||||||
// Click player-art to open Now Playing
|
// Click player-art to open Now Playing
|
||||||
document.getElementById('player-art')?.addEventListener('click', openNowPlaying);
|
document.getElementById('player-art')?.addEventListener('click', openNowPlaying);
|
||||||
|
|||||||
+24
-9
@@ -1019,23 +1019,38 @@ export function setVolume(v) {
|
|||||||
|
|
||||||
// ─── MEDIA SESSION ────────────────────────────────────────────────────────
|
// ─── MEDIA SESSION ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _msHandlersSet = false;
|
||||||
export function updateMediaSession(s) {
|
export function updateMediaSession(s) {
|
||||||
// When native is active, Media3 owns the system notification — don't touch it.
|
// When native is active, Media3 owns the system notification — don't touch it.
|
||||||
if (HAS_BRIDGE) return;
|
if (HAS_BRIDGE) return;
|
||||||
if(!navigator.mediaSession) return;
|
const ms = navigator.mediaSession;
|
||||||
|
if(!ms) return;
|
||||||
const artwork = s.coverArt ? [
|
const artwork = s.coverArt ? [
|
||||||
{ src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' },
|
{ src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' },
|
||||||
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
||||||
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
|
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
|
||||||
] : [];
|
] : [];
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
// Mutate the existing metadata in place rather than assigning a new MediaMetadata —
|
||||||
navigator.mediaSession.setActionHandler('play', togglePlay);
|
// replacing the object makes Windows tear down & rebuild the media tile (visible
|
||||||
navigator.mediaSession.setActionHandler('pause', togglePlay);
|
// flicker when skipping). Spotify-style: keep one object, just update its fields.
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', prevSong);
|
if (ms.metadata) {
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
|
ms.metadata.title = s.title;
|
||||||
navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
|
ms.metadata.artist = s.artist || '';
|
||||||
navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
|
ms.metadata.album = s.album || '';
|
||||||
navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
|
ms.metadata.artwork = artwork;
|
||||||
|
} else {
|
||||||
|
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
||||||
|
}
|
||||||
|
// Action handlers are stable — set them once, not on every track change.
|
||||||
|
if (_msHandlersSet) return;
|
||||||
|
_msHandlersSet = true;
|
||||||
|
ms.setActionHandler('play', togglePlay);
|
||||||
|
ms.setActionHandler('pause', togglePlay);
|
||||||
|
ms.setActionHandler('previoustrack', prevSong);
|
||||||
|
ms.setActionHandler('nexttrack', nextSong);
|
||||||
|
ms.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
|
||||||
|
ms.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
|
||||||
|
ms.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncMediaSessionPosition() {
|
export function syncMediaSessionPosition() {
|
||||||
|
|||||||
@@ -103,3 +103,55 @@ export function mkSection(title) {
|
|||||||
d.innerHTML = `<div class="search-section-title">${title}</div>`;
|
d.innerHTML = `<div class="search-section-title">${title}</div>`;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── HORIZONTAL SCROLL-ROW ARROWS (desktop) ───
|
||||||
|
const _CHEV_L = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
|
||||||
|
const _CHEV_R = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
|
||||||
|
/** Wrap a `.scroll-row` with left/right arrows that appear only for the scrollable direction. */
|
||||||
|
export function enhanceScrollRow(row) {
|
||||||
|
if (!row || row.dataset.saEnhanced) return;
|
||||||
|
row.dataset.saEnhanced = '1';
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'scroll-row-wrap';
|
||||||
|
row.parentNode.insertBefore(wrap, row);
|
||||||
|
wrap.appendChild(row);
|
||||||
|
|
||||||
|
const mkBtn = (cls, html, label) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'scroll-arrow ' + cls;
|
||||||
|
b.setAttribute('aria-label', label);
|
||||||
|
b.innerHTML = html;
|
||||||
|
wrap.appendChild(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const left = mkBtn('sa-left', _CHEV_L, 'Zurück');
|
||||||
|
const right = mkBtn('sa-right', _CHEV_R, 'Weiter');
|
||||||
|
|
||||||
|
const EPS = 4;
|
||||||
|
const update = () => {
|
||||||
|
left.classList.toggle('can-scroll', row.scrollLeft > EPS);
|
||||||
|
right.classList.toggle('can-scroll', row.scrollLeft < row.scrollWidth - row.clientWidth - EPS);
|
||||||
|
// Vertically center the arrows on the cover art (fallback: CSS default 72px)
|
||||||
|
const art = row.querySelector('.card-art') || row.querySelector('.card');
|
||||||
|
if (art) {
|
||||||
|
const top = art.getBoundingClientRect().top - wrap.getBoundingClientRect().top + art.offsetHeight / 2;
|
||||||
|
if (top > 0) { left.style.top = right.style.top = top + 'px'; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const step = () => Math.max(160, row.clientWidth * 0.8);
|
||||||
|
left.addEventListener('click', () => row.scrollBy({ left: -step(), behavior: 'smooth' }));
|
||||||
|
right.addEventListener('click', () => row.scrollBy({ left: step(), behavior: 'smooth' }));
|
||||||
|
|
||||||
|
row.addEventListener('scroll', update, { passive: true });
|
||||||
|
new ResizeObserver(update).observe(row);
|
||||||
|
new MutationObserver(update).observe(row, { childList: true });
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enhance every `.scroll-row` currently in the DOM (the static Home rows). */
|
||||||
|
export function initScrollRows() {
|
||||||
|
document.querySelectorAll('.scroll-row').forEach(enhanceScrollRow);
|
||||||
|
}
|
||||||
+18
@@ -168,6 +168,24 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
|
|||||||
.scroll-row::-webkit-scrollbar{display:none}
|
.scroll-row::-webkit-scrollbar{display:none}
|
||||||
.scroll-row .card{flex-shrink:0;width:145px}
|
.scroll-row .card{flex-shrink:0;width:145px}
|
||||||
|
|
||||||
|
/* Hover arrows for horizontal rows (desktop) — only the scrollable direction shows */
|
||||||
|
.scroll-row-wrap{position:relative}
|
||||||
|
.scroll-arrow{
|
||||||
|
position:absolute;top:72px;transform:translateY(-50%);
|
||||||
|
width:38px;height:38px;border-radius:50%;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:rgba(15,15,24,.82);color:#fff;border:1px solid rgba(255,255,255,.14);
|
||||||
|
cursor:pointer;z-index:6;opacity:0;pointer-events:none;
|
||||||
|
transition:opacity .16s ease,background .16s ease;
|
||||||
|
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
|
||||||
|
box-shadow:0 4px 14px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
.scroll-arrow svg{width:18px;height:18px}
|
||||||
|
.scroll-arrow.sa-left{left:-6px}
|
||||||
|
.scroll-arrow.sa-right{right:-6px}
|
||||||
|
.scroll-arrow:hover{background:rgba(30,30,46,.95)}
|
||||||
|
.scroll-row-wrap:hover .scroll-arrow.can-scroll{opacity:1;pointer-events:auto}
|
||||||
|
|
||||||
/* ── CARDS ── */
|
/* ── 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{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:hover{transform:translateY(-3px);box-shadow:0 12px 32px rgba(0,0,0,.4);border-color:var(--border2)}
|
||||||
|
|||||||
Reference in new issue
Block a user