diff --git a/README.md b/README.md index 0522a56..65a52df 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,13 @@ The PWA should eventually provide: Tap **Download** on an album or playlist page, or the download button on any track row, and Tomify saves the song to the phone or computer for listening with no connection. The **Downloads** page (in the desktop sidebar, and behind the account button on mobile) lists what is saved with its status (queued, downloading with a percentage, available offline, or failed with the reason), plays it, retries failures and removes it. It shows how much music is stored, the space the browser says is left (`navigator.storage.estimate()`), and whether the browser agreed to keep the downloads (`navigator.storage.persist()`, asked on the first download); without that, the browser may clear them when the device runs low on space. -- **What is saved.** The original file (`stream.view` with `format=raw`, never HLS chunks or a transcode), the song's metadata and its artwork, in IndexedDB (one database per account, `src/lib/offline-db.ts`). Songs download one at a time in the background while Tomify is open, carry on after a reload, and wait while the device is offline. A song in more than one download (a single track and its album, or two playlists) is stored once and kept until nothing includes it. +- **What is saved.** By default the original file (`stream.view` with `format=raw`, never HLS chunks), the song's metadata and its artwork, in IndexedDB (one database per account, `src/lib/offline-db.ts`). Songs download one at a time in the background while Tomify is open, carry on after a reload, and wait while the device is offline. A song in more than one download (a single track and its album, or two playlists) is stored once and kept until nothing includes it. - **Playing.** Tomify always plays a valid saved copy (its record says it finished and the stored file is the size that was downloaded) instead of streaming, online or not (`LocalFirstSourceResolver` in `src/lib/sourceResolver.ts`). The next song in the queue is pre-loaded from the device too. Anything not downloaded streams as before. -- **Playlists that change.** A playlist edited or reordered after it was downloaded shows **Update download**, which fetches the new songs and lets go of the ones taken out. +- **Download quality.** The **Download quality** choice on the Downloads page (per device) picks what new downloads are saved as: **Original**, **High** (an MP3 at 320 kbps, about 2.4 MB a minute) or **Space saver** (MP3 at 128 kbps, about 1 MB a minute). The smaller ones are made by Navidrome as it sends them (`stream.view` with `format=mp3&maxBitRate=…`, which needs ffmpeg on the box, as Navidrome's own transcoding does). Songs already saved keep their quality; a failed song retried takes the quality chosen now. +- **Playlists that change.** Downloaded playlists keep themselves up to date. When Tomify opens, comes back online, or comes back to the foreground (at most every 15 minutes), it checks each downloaded playlist with `getPlaylist`: songs added download, songs taken out are let go, and a rename shows on the Downloads page. The check waits while the phone reports mobile data or data saver (Android Chrome reports this; iPhone Safari doesn't, so there it runs on any connection). A playlist the server no longer has, deleted or no longer shared, keeps what is on the device until you remove it. **Update download** on the playlist page still brings it up to date straight away. - **Running out of space.** If the browser refuses a write for lack of space (iPhone and iPad Safari give a web app far less room than Chrome), or the space left is smaller than the next song, that song and the rest of the queue are marked failed with "Not enough storage space on this device" and nothing half-written is kept. Remove some downloads, then **Retry all**. -There is one quality for now: the original file, so a FLAC album takes its full size. A later **Keep downloaded** mode should keep selected playlists or albums synchronised when the device next has a suitable connection. +Downloads only happen while Tomify is open: a phone does not let a web app download in the background once it is closed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how Tomify is built today and [docs/ROADMAP.md](docs/ROADMAP.md) for the roadmap and what is done. @@ -191,6 +192,7 @@ GitHub Actions runs the frozen-lockfile install, lint, typecheck, production bui - Navidrome playlists can be created and edited in Tomify: **New playlist** on the Playlists page, the **+** button on any track row or **Add to playlist** on an album to add music, and **Edit** on your own playlist to rename it, move tracks up or down, remove tracks or delete it (deleting also removes its Tomify shares). Edits save straight away through the Subsonic `createPlaylist`, `updatePlaylist` and `deletePlaylist` calls; reordering rewrites the whole track list with `createPlaylist` and sends long song lists as a POST form (OpenSubsonic `formPost`). Only the owner sees the editing controls. - Playlists are private to the account that made them, with a **Share** button for sharing one with other Tomify accounts (read-only; recipients see it under **Shared with you**). Navidrome itself would hand admins every user's playlists and everyone other users' public ones, so Tomify lists only your own plus those shared with you. Navidrome has no per-user sharing, so sharing a Navidrome playlist also marks it public in Navidrome (other Subsonic clients can then see it) and unsharing it from everyone makes it private again. An account appears in the Share list once it has signed in to Tomify. Shares live in the D1 `playlist_shares` table (`drizzle/0007_playlist_shares.sql`). - Persistent HTML5 player with queue, seek, volume, mute, shuffle (reorders the upcoming songs once, so nothing repeats; turning it off restores the original order), repeat, previous/next and full-screen mobile now playing. +- Sleep timer: the moon button in Now Playing pauses this device after 15, 30 or 45 minutes, 1 or 1.5 hours, or at the end of the song playing now (no crossfade into the next song, which is left ready to play). It stays in memory only, so it ends if the app is closed, and is hidden while you control another device (`src/stores/sleepTimer.ts`). - Media Session metadata (absolute artwork in six sizes, or only the album's Tomify replacement cover when it has one, refreshed as soon as replacements load or change), transport controls and position updates. - Installable manifest, app icons, standalone mode, cached app shell and cached artwork. - Offline downloads of tracks, albums and playlists, played from the device in preference to streaming (see [Offline playback](#offline-playback)). @@ -314,7 +316,7 @@ The Worker code is `worker/features/dj.ts` (`POST /api/dj/segment` and `POST /ap ### Known limitations - Drag-to-reorder is not included yet (playlists reorder with up/down buttons). -- Offline downloads store only the original file (no smaller phone copies yet), download only while Tomify is open, and are not refreshed automatically when an album or playlist changes on the server. Signing out leaves an account's downloads on the device. +- Offline downloads only run while Tomify is open, and a song already saved can only change quality by removing and downloading it again. Albums are not re-checked against the server (playlists are). Signing out leaves an account's downloads on the device. - Authentication tokens live in browser local storage. This is appropriate for trusted household devices; a shared device should be signed out after use. - A cross-origin deployment requires correct Navidrome CORS configuration. A same-origin proxy avoids that operational edge. @@ -331,4 +333,4 @@ That split is explicit and independently deployable. Navidrome should allow the ### Recommended next task -Offline downloads v1 (roadmap 3.1) is in. Next for offline listening: smaller download qualities for phones (an agent-made AAC copy), and **Keep downloaded** so edited playlists update themselves on Wi-Fi. +Offline downloads (roadmap 3.1) are in, with smaller download qualities and playlists that update themselves. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 095606b..88fc4d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,7 +45,7 @@ All 20 are done. Each row names the PR that finished it. | 1.10 | Nightly D1 export and agent state backup; uptime check | Done (#39) | `scripts/backup.sh` with a systemd timer; see docs/RECOVERY.md "Backups and uptime checks". | | 1.11 | Replace the stale `ARCHITECTURE.md` and `ROADMAP.md`; add `DEPLOYMENT.md` | Done | Both documents are rewritten, and `DEPLOYMENT.md` gathers the site, agent, box, secrets and backup steps. | | 2.9 | Mobile Library tab, Albums paging, recovery source badges, star/unstar, plainer language | Partly done | See task 20. | -| 3.1 | Offline downloads v1 | Done (#79) | Tracks, albums and playlists download as original files to IndexedDB with artwork and metadata (`src/lib/offline-db.ts`, `src/stores/downloads.ts`); `LocalFirstSourceResolver` plays a valid local copy before streaming. The Downloads page shows real storage figures with per-item status, retry and remove, and is in the mobile account sheet. One quality (original) so far. | +| 3.1 | Offline downloads v1 | Done (#79) | Tracks, albums and playlists download as original files to IndexedDB with artwork and metadata (`src/lib/offline-db.ts`, `src/stores/downloads.ts`); `LocalFirstSourceResolver` plays a valid local copy before streaming. The Downloads page shows real storage figures with per-item status, retry and remove, and is in the mobile account sheet. Download quality per device (original, MP3 320 or MP3 128 made by Navidrome), and downloaded playlists update themselves from the server when Tomify opens, comes online or returns to the foreground. | | 3.2 | Album completeness view and bulk "recover missing tracks" | Done (#128) | Administrators can check an album against MusicBrainz and add the missing tracks to Recovery, one item per track or as one release item mapped to the tracklist. Library health lists the checked albums that came up short. | | 3.3 | Supervised promotion and duplicate merge | Done (#129) | Library health plans each merge or promotion, moves copies to `_Duplicates` instead of deleting them, journals every change for undo and relinks matched songs in playlists. Needs write access to `/srv/music` for the agent (docs/RECOVERY.md). | | 3.4 | Smart playlists, listening statistics, "Anna is playing…" | Done (#130) | Rule-based smart playlists evaluated in the browser, a Listening stats page from Tomify's history, and "Listening now" on Home from Navidrome's now-playing list. | diff --git a/src/components/DownloadQuality.tsx b/src/components/DownloadQuality.tsx new file mode 100644 index 0000000..d12fd59 --- /dev/null +++ b/src/components/DownloadQuality.tsx @@ -0,0 +1,16 @@ +import { HardDriveDownload } from 'lucide-react' +import { DOWNLOAD_QUALITY_OPTIONS, useDownloadQuality } from '../stores/downloadQuality' + +/** Per-device choice of what new downloads are saved as. Songs already downloaded keep their quality. */ +export function DownloadQuality() { + const quality = useDownloadQuality((state) => state.quality) + const setQuality = useDownloadQuality((state) => state.setQuality) + return
+ Download quality + {DOWNLOAD_QUALITY_OPTIONS.map((option) => )} +

Applies to new downloads on this device. To shrink a song already saved, remove it and download it again.

+
+} diff --git a/src/components/Player.test.tsx b/src/components/Player.test.tsx index 4364cb8..8c758af 100644 --- a/src/components/Player.test.tsx +++ b/src/components/Player.test.tsx @@ -5,6 +5,7 @@ import { usePlayer } from '../stores/player' import { useLivePlayback, type LivePlaybackSnapshot } from '../stores/livePlayback' import { PlayerBar } from './Player' import { useRadio } from '../stores/radio' +import { useSleepTimer } from '../stores/sleepTimer' vi.mock('./Artwork', () => ({ Artwork: () => null })) vi.mock('./Lyrics', () => ({ LyricsPanel: ({ track, time, onSeek }: { track: { title: string }; time: number; onSeek: (time: number) => void }) => })) @@ -33,7 +34,7 @@ describe('PlayerBar across devices', () => { })) }) - afterEach(() => vi.unstubAllGlobals()) + afterEach(() => { useSleepTimer.getState().cancel(); vi.unstubAllGlobals() }) it('shows and controls the other device, updating the button straight away', async () => { useLivePlayback.setState({ snapshot: remote(), devices: [{ deviceId: 'desktop', deviceName: 'Windows · Chrome', lastSeen: Date.now() }] }) @@ -48,6 +49,26 @@ describe('PlayerBar across devices', () => { expect(usePlayer.getState().playing).toBe(false) }) + it('sets and turns off a sleep timer from Now Playing, and hides it while another device plays', () => { + usePlayer.setState({ nowPlayingOpen: true }) + const { unmount } = render() + fireEvent.click(screen.getByRole('button', { name: 'Sleep timer' })) + fireEvent.click(screen.getByRole('menuitem', { name: '30 minutes' })) + expect(useSleepTimer.getState().timer).toMatchObject({ kind: 'minutes', minutes: 30 }) + const button = screen.getByRole('button', { name: 'Sleep timer: Stops in 30 min' }) + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'End of this song' })) + expect(useSleepTimer.getState().timer).toEqual({ kind: 'end-of-track' }) + fireEvent.click(screen.getByRole('button', { name: 'Sleep timer: Stops after this song' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Turn off timer' })) + expect(useSleepTimer.getState().timer).toBeNull() + unmount() + + useLivePlayback.setState({ snapshot: remote(), devices: [{ deviceId: 'desktop', deviceName: 'Windows · Chrome', lastSeen: Date.now() }] }) + render() + expect(screen.queryByRole('button', { name: 'Sleep timer' })).not.toBeInTheDocument() + }) + it('moves playback here from the other device at the same track', () => { useLivePlayback.setState({ snapshot: remote(), devices: [{ deviceId: 'desktop', deviceName: 'Windows · Chrome', lastSeen: Date.now() }] }) usePlayer.setState({ nowPlayingOpen: true }) diff --git a/src/components/Player.tsx b/src/components/Player.tsx index dfef94f..4d6ca4d 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -4,6 +4,7 @@ import { Artwork } from './Artwork' import { StartRadioButton } from './SongRadio' import { useRadio } from '../stores/radio' import { LyricsPanel } from './Lyrics' +import { SleepTimerButton } from './SleepTimer' import { formatDuration } from '../lib/format' import { usePlayer } from '../stores/player' import { controlRemote, isDeviceOnline, livePosition, localPlaybackDeviceId, localPlaybackDeviceName, useLivePlayback, useRemoteSession, type LivePlaybackCommandAction } from '../stores/livePlayback' @@ -160,5 +161,5 @@ function NowPlaying() { } const seek = (value: number) => view.remote ? view.command('seek', value) : player.seekTo(value) - return
{remoteLabel(view) ?? 'Now playing'}
{lyricsOpen ? :
}

{track.title}

{track.artist}

{!view.remote && }
{view.remote && }
+ return
{remoteLabel(view) ?? 'Now playing'}{!view.remote && }
{lyricsOpen ? :
}

{track.title}

{track.artist}

{!view.remote && }
{view.remote && }
} diff --git a/src/components/PlayerRuntime.test.tsx b/src/components/PlayerRuntime.test.tsx index 01fa2f0..9b3883e 100644 --- a/src/components/PlayerRuntime.test.tsx +++ b/src/components/PlayerRuntime.test.tsx @@ -15,6 +15,7 @@ import { FakeAudioContext } from '../test/fakeAudioContext' import { useNormalisation } from '../stores/normalisation' import { useDj } from '../stores/dj' import { useCrossfade } from '../stores/crossfade' +import { useSleepTimer } from '../stores/sleepTimer' import { FakeIndexedDb } from '../test/fakeIndexedDb' import { offlineAccount, resetOfflineDbForTests, storeDownload } from '../lib/offline-db' @@ -63,7 +64,7 @@ describe('PlayerRuntime', () => { vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => { requests.push(String(url)); return Response.json({ 'subsonic-response': { status: 'ok' } }) })) }) - afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + afterEach(() => { useSleepTimer.getState().cancel(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) const fail = (element: HTMLAudioElement, code = 2) => { Object.defineProperty(element, 'error', { configurable: true, get: () => ({ code }) }) @@ -110,6 +111,21 @@ describe('PlayerRuntime', () => { expect(requests.filter((url) => url.includes('/rest/scrobble.view') && url.includes('submission=false'))).toHaveLength(1) }) + it('stops at the end of the song when the sleep timer is set to the end of this song', async () => { + const [first] = await startPlaying() + useSleepTimer.getState().startEndOfTrack() + await act(async () => { first.dispatchEvent(new Event('ended')) }) + // The queue moves on to the next song, ready to play, but nothing starts. + expect(usePlayer.getState().currentIndex).toBe(1) + expect(usePlayer.getState().playing).toBe(false) + expect(useSleepTimer.getState().timer).toBeNull() + // With the timer used up, the next song that ends plays on as normal. + await act(async () => { usePlayer.getState().setPlaying(true) }) + await act(async () => { audio().find((element) => streamed(element) === 'b')!.dispatchEvent(new Event('ended')) }) + expect(usePlayer.getState().currentIndex).toBe(2) + expect(usePlayer.getState().playing).toBe(true) + }) + it('does not hand off to a pre-loaded next track whose download failed', async () => { const [first, second] = await startPlaying() expect(streamed(second)).toBe('b') diff --git a/src/components/PlayerRuntime.tsx b/src/components/PlayerRuntime.tsx index 37ce52f..daf6cdd 100644 --- a/src/components/PlayerRuntime.tsx +++ b/src/components/PlayerRuntime.tsx @@ -18,6 +18,7 @@ import { useNormalisation } from '../stores/normalisation' import { useDj } from '../stores/dj' import { gainModeAt, originalVolumeScale, queueIndexOf, streamGainMode } from '../lib/loudness' import { crossfadeSeconds, fadeInLevel, fadeOutLevel, GAPLESS_LEAD_SECONDS, volumeCanFade } from '../lib/crossfade' +import { sleepsAtTrackEnd, takeTrackEnd } from '../stores/sleepTimer' import { MAX_CROSSFADE_SECONDS, useCrossfade } from '../stores/crossfade' import type { PlayerState } from '../stores/player' import type { Track } from '../types' @@ -237,6 +238,8 @@ export function PlayerRuntime() { const current = state.queue[state.currentIndex] const upcoming = upcomingTrack(state) if (!current || !upcoming || !state.playing) return + // The sleep timer is waiting for this song to end, so nothing starts early. + if (sleepsAtTrackEnd()) return if (canFade.current === null) canFade.current = volumeCanFade() const seconds = crossfadeSeconds(current, upcoming, useCrossfade.getState().seconds, { canFade: canFade.current, dj: useDj.getState().active }) const lead = Math.max(seconds, GAPLESS_LEAD_SECONDS) @@ -252,6 +255,7 @@ export function PlayerRuntime() { const standby = getAudio(otherSlot(slot)) if (!state.playing || activeSlot.current !== slot || state.queue[state.currentIndex]?.id !== current.id || upcomingTrack(state)?.id !== nextId) return if (!element || element.paused || element.duration - element.currentTime > lead + 0.5) return + if (sleepsAtTrackEnd()) return // Only a next song that can start at once takes over early; otherwise it starts when this one ends, as before. if (!standby || loadedTrackIds.current[otherSlot(slot)] !== nextId || standby.error || standby.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) return finishPlay(current) @@ -709,7 +713,9 @@ export function PlayerRuntime() { onEnded: (event: React.SyntheticEvent) => { if (activeSlot.current !== slot) return if (track) finishPlay(track) - if (usePlayer.getState().repeat === 'one') { + if (takeTrackEnd()) { + usePlayer.getState().next({ pause: true }) + } else if (usePlayer.getState().repeat === 'one') { submittedTrackId.current = undefined if (track) void api.scrobble(track.id, false).catch(() => undefined) event.currentTarget.currentTime = 0 diff --git a/src/components/SleepTimer.tsx b/src/components/SleepTimer.tsx new file mode 100644 index 0000000..aba6999 --- /dev/null +++ b/src/components/SleepTimer.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from 'react' +import { Check, Moon } from 'lucide-react' +import { SLEEP_TIMER_MINUTES, sleepTimerLabel, useSleepTimer } from '../stores/sleepTimer' + +// Now Playing's sleep timer button: a menu of lengths, "End of this song" and Off. +export function SleepTimerButton() { + const timer = useSleepTimer((state) => state.timer) + const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const container = useRef(null) + + // The minutes left refresh while the timer runs. + useEffect(() => { + if (timer?.kind !== 'minutes') return + setNow(Date.now()) + const interval = setInterval(() => setNow(Date.now()), 15_000) + return () => clearInterval(interval) + }, [timer]) + + useEffect(() => { + if (!open) return + const close = (event: MouseEvent) => { if (!container.current?.contains(event.target as Node)) setOpen(false) } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + + const label = sleepTimerLabel(timer, now) + const { startMinutes, startEndOfTrack, cancel } = useSleepTimer.getState() + const choose = (action: () => void) => { action(); setOpen(false) } + + return
+ + {open &&
+ Sleep timer + {label &&

{label}

} + {SLEEP_TIMER_MINUTES.map((minutes) => { + const active = timer?.kind === 'minutes' && timer.minutes === minutes + return + })} + + {timer && } +
} +
+} diff --git a/src/lib/offline-db.ts b/src/lib/offline-db.ts index 77c3194..d97aa4e 100644 --- a/src/lib/offline-db.ts +++ b/src/lib/offline-db.ts @@ -4,11 +4,14 @@ // A song is kept while anything owns it: a single-track download (`track`) or a collection (`album:`, `playlist:`). import { normaliseServerUrl, type AuthSession } from './navidrome' import type { DownloadStatus, Track } from '../types' +import type { DownloadQuality } from '../stores/downloadQuality' export type OfflineStatus = Extract -export interface OfflineTrack { id: string; track: Track; status: OfflineStatus; owners: string[]; bytes?: number; error?: string; addedAt: number; updatedAt: number } +// `quality` is what the song is (or will be) saved as; records from before download qualities are originals. +export interface OfflineTrack { id: string; track: Track; status: OfflineStatus; owners: string[]; quality?: DownloadQuality; bytes?: number; error?: string; addedAt: number; updatedAt: number } export type CollectionKind = 'album' | 'playlist' -export interface OfflineCollection { key: string; kind: CollectionKind; id: string; name: string; artist?: string; coverArt?: string; trackIds: string[]; addedAt: number; updatedAt: number } +// `owner` is the playlist's owner, so a playlist shared with this account can be checked for changes later. +export interface OfflineCollection { key: string; kind: CollectionKind; id: string; name: string; artist?: string; owner?: string; coverArt?: string; trackIds: string[]; addedAt: number; updatedAt: number } interface OfflineFile { key: string; blob: Blob } type StoreName = 'tracks' | 'collections' | 'files' diff --git a/src/pages/Downloads.test.tsx b/src/pages/Downloads.test.tsx index 5bde2d1..ae3fbf0 100644 --- a/src/pages/Downloads.test.tsx +++ b/src/pages/Downloads.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FakeIndexedDb } from '../test/fakeIndexedDb' import { offlineAccount, resetOfflineDbForTests, saveCollection, saveTracks, type OfflineTrack } from '../lib/offline-db' import { resetDownloadsForTests, useDownloads } from '../stores/downloads' +import { useDownloadQuality } from '../stores/downloadQuality' import { usePlayer } from '../stores/player' import { CollectionDownloadButton, TrackDownloadButton } from '../components/DownloadButton' import { Downloads } from './Downloads' @@ -54,6 +55,21 @@ describe('Downloads', () => { expect(await screen.findByText('Nothing downloaded yet')).toBeInTheDocument() }) + it('chooses the download quality and shows which playlists update themselves', async () => { + await saveTracks(account, [record('t1', ['playlist:pl-1'], 'available-offline', { quality: 'small' })]) + await saveCollection(account, { key: 'playlist:pl-1', kind: 'playlist', id: 'pl-1', name: 'Road trip', trackIds: ['t1'], addedAt: 1, updatedAt: 1 }) + await saveTracks(account, [record('t2', ['track'], 'available-offline', { quality: 'high' })]) + await useDownloads.getState().bind(session) + render() + + expect(within(screen.getByText('Road trip').closest('li')!).getByText(/Updates automatically/)).toBeInTheDocument() + expect(within(screen.getByText('Song t2').closest('li')!).getByText(/MP3 320/)).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /Original/ })).toBeChecked() + fireEvent.click(screen.getByRole('radio', { name: /Space saver/ })) + expect(useDownloadQuality.getState().quality).toBe('small') + useDownloadQuality.setState({ quality: 'original' }) + }) + it('says so when the browser cannot store music', () => { vi.stubGlobal('indexedDB', undefined) resetDownloadsForTests() diff --git a/src/pages/Downloads.tsx b/src/pages/Downloads.tsx index c0d80da..d9765d8 100644 --- a/src/pages/Downloads.tsx +++ b/src/pages/Downloads.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { Album, CircleAlert, CircleCheck, CloudDownload, Database, HardDrive, ListMusic, LoaderCircle, Music2, Play, RotateCw, ShieldCheck, Trash2 } from 'lucide-react' import { artKey, readFile, TRACK_OWNER, type OfflineCollection, type OfflineTrack } from '../lib/offline-db' import { formatBytes } from '../lib/format' +import { DownloadQuality } from '../components/DownloadQuality' +import { downloadQualityLabel } from '../stores/downloadQuality' import { summariseDownloads, useDownloads, type DownloadSummary } from '../stores/downloads' import { usePlayer } from '../stores/player' @@ -55,7 +57,7 @@ function CollectionRow({ collection, tracks }: { collection: OfflineCollection; const ready = playable(collection.trackIds, tracks) return
  • - {collection.name}{collection.kind === 'album' ? 'Album' : 'Playlist'}{collection.artist ? ` · ${collection.artist}` : ''} · {collection.trackIds.length} songs · {formatBytes(summary.bytes)}{summary.error && {summary.error}} + {collection.name}{collection.kind === 'album' ? 'Album' : 'Playlist'}{collection.artist ? ` · ${collection.artist}` : ''} · {collection.trackIds.length} songs · {formatBytes(summary.bytes)}{collection.kind === 'playlist' ? ' · Updates automatically' : ''}{summary.error && {summary.error}} @@ -71,7 +73,7 @@ function TrackRow({ record, progress, queue }: { record: OfflineTrack; progress? const ready = queue.filter((item) => item.status === 'available-offline') return
  • - {record.track.title}{record.track.artist} · {record.track.album}{record.bytes ? ` · ${formatBytes(record.bytes)}` : ''}{record.error && {record.error}} + {record.track.title}{record.track.artist} · {record.track.album}{record.bytes ? ` · ${formatBytes(record.bytes)}` : ''}{record.quality && record.quality !== 'original' ? ` · ${downloadQualityLabel(record.quality)}` : ''}{record.error && {record.error}} @@ -98,7 +100,7 @@ export function Downloads() { const free = storage?.quota !== undefined && storage.usage !== undefined ? Math.max(0, storage.quota - storage.usage) : undefined return
    -
    Offline

    Downloads

    Music saved on this device plays with no connection, and Tomify plays the saved copy whenever it has one.

    +
    Offline

    Downloads

    Music saved on this device plays with no connection, and Tomify plays the saved copy whenever it has one. Downloaded playlists update themselves when songs are added or taken out.

    {!supported &&
    This browser can’t keep music offline

    Downloads need IndexedDB storage, which this browser has turned off (private browsing often does).

    } {notice &&
    Downloads stopped

    {notice}

    {failed.length > 0 && }
    } {supported &&
    @@ -106,6 +108,7 @@ export function Downloads() {
    Space left for Tomify{free !== undefined ? `${formatBytes(free)} of ${formatBytes(storage?.quota)}` : 'Not reported by this browser'}
    Storage{persisted ? 'Kept until you remove it' : 'The browser may clear it when space runs low'}
    } + {supported &&
    } {supported && !ready &&

    Loading downloads…

    } {ready && !albumsAndPlaylists.length && !singles.length &&

    Nothing downloaded yet

    Tap Download on an album or playlist, or the download button on a song, to keep it on this device.

    } {albumsAndPlaylists.length > 0 &&

    Albums and playlists

      {albumsAndPlaylists.map((collection) => )}
    } diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index ce9a99f..0439220 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -65,7 +65,7 @@ export function PlaylistDetail() { - return
    {owned ? 'Playlist' : `Shared by ${playlist.owner}`}

    {name}

    {tracks.length} songs · {formatHours(duration)}

    {owned && shares && saveNavidromePlaylistShares(api, playlist.id, users)}/>}{owned && }
    + return
    {owned ? 'Playlist' : `Shared by ${playlist.owner}`}

    {name}

    {tracks.length} songs · {formatHours(duration)}

    {owned && shares && saveNavidromePlaylistShares(api, playlist.id, users)}/>}{owned && }
    {owned && editing && <>
    void rename(event)}> setDraftName(event.target.value)} aria-label="Playlist name" maxLength={200}/>

    Use the arrows to reorder and × to remove a track. Changes save as you make them.

    diff --git a/src/stores/downloadQuality.ts b/src/stores/downloadQuality.ts new file mode 100644 index 0000000..4c3cc7a --- /dev/null +++ b/src/stores/downloadQuality.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +// What new downloads are saved as on this device. Songs already downloaded keep the quality they were saved in. +// original: the untouched file (stream.view format=raw). +// high / small: an MP3 the music server makes on the fly (stream.view format=mp3 with maxBitRate). +export type DownloadQuality = 'original' | 'high' | 'small' + +export const DOWNLOAD_QUALITY_OPTIONS: { value: DownloadQuality; label: string; detail: string; kbps?: number }[] = [ + { value: 'original', label: 'Original', detail: 'The untouched file. A FLAC song is often 25 to 40 MB.' }, + { value: 'high', label: 'High (MP3 320 kbps)', detail: 'About 2.4 MB a minute, a quarter of a FLAC file or less.', kbps: 320 }, + { value: 'small', label: 'Space saver (MP3 128 kbps)', detail: 'About 1 MB a minute. Fits the most music on the phone.', kbps: 128 }, +] + +export const downloadBitRate = (quality: DownloadQuality | undefined) => DOWNLOAD_QUALITY_OPTIONS.find((option) => option.value === quality)?.kbps +export const downloadQualityLabel = (quality: DownloadQuality | undefined) => quality === 'high' ? 'MP3 320' : quality === 'small' ? 'MP3 128' : 'Original' + +interface DownloadQualityState { quality: DownloadQuality; setQuality(quality: DownloadQuality): void } + +export const useDownloadQuality = create()(persist((set) => ({ + quality: 'original', + setQuality: (quality) => set({ quality }), +}), { name: 'tomify-download-quality' })) diff --git a/src/stores/downloads.test.ts b/src/stores/downloads.test.ts index 3126544..ba022db 100644 --- a/src/stores/downloads.test.ts +++ b/src/stores/downloads.test.ts @@ -4,6 +4,7 @@ import { NavidromeClient } from '../lib/navidrome' import { LocalFirstSourceResolver } from '../lib/sourceResolver' import { cachedLocalAudioUrl, localAudioUrl, offlineAccount, resetOfflineDbForTests, storeDownload } from '../lib/offline-db' import { QUOTA_MESSAGE, resetDownloadsForTests, summariseDownloads, useDownloads } from './downloads' +import { useDownloadQuality } from './downloadQuality' import type { Track } from '../types' const session = { serverUrl: 'https://music.example', username: 'Tom', salt: 's', token: 't' } @@ -41,6 +42,7 @@ describe('offline downloads', () => { }) afterEach(() => { + useDownloadQuality.setState({ quality: 'original' }) resetDownloadsForTests() vi.unstubAllGlobals() delete (navigator as { storage?: unknown }).storage @@ -105,6 +107,104 @@ describe('offline downloads', () => { await until(() => expect(status('t3')).toBe('available-offline')) }) + it('downloads a smaller MP3 when a smaller download quality is chosen, keeping songs already saved as they are', async () => { + await useDownloads.getState().downloadTracks([song('t1')]) + await until(() => expect(status('t1')).toBe('available-offline')) + useDownloadQuality.getState().setQuality('small') + await useDownloads.getState().downloadTracks([song('t1'), song('t2', { duration: 100 })]) + await until(() => expect(status('t2')).toBe('available-offline')) + + const streams = requests.filter((url) => url.includes('/rest/stream.view')).map((url) => new URL(url).searchParams) + expect(streams).toHaveLength(2) + expect(streams[1].get('id')).toBe('t2') + expect(streams[1].get('format')).toBe('mp3') + expect(streams[1].get('maxBitRate')).toBe('128') + expect(useDownloads.getState().tracks.t1.quality).toBe('original') + expect(useDownloads.getState().tracks.t2.quality).toBe('small') + }) + + it('retries a failed download in the quality chosen now', async () => { + respond = () => new Response('nope', { status: 500 }) + await useDownloads.getState().downloadTracks([song('t1')]) + await until(() => expect(status('t1')).toBe('failed')) + respond = defaultResponse + useDownloadQuality.getState().setQuality('high') + await useDownloads.getState().retry(['t1']) + await until(() => expect(status('t1')).toBe('available-offline')) + const last = new URL(requests.filter((url) => url.includes('/rest/stream.view')).at(-1)!) + expect(last.searchParams.get('maxBitRate')).toBe('320') + }) + + describe('downloaded playlists', () => { + let serverPlaylist: { id: string; name: string; owner: string; entry: Track[] } | null + beforeEach(() => { + serverPlaylist = null + respond = (url) => { + if (url.pathname.endsWith('/getPlaylist.view')) { + if (!serverPlaylist) return Response.json({ 'subsonic-response': { status: 'failed', error: { code: 70, message: 'Playlist not found' } } }) + return Response.json({ 'subsonic-response': { status: 'ok', playlist: serverPlaylist } }) + } + return defaultResponse(url) + } + }) + const playlistRequests = () => requests.filter((url) => url.includes('/rest/getPlaylist.view')) + + it('update themselves from the server: new songs download and removed ones are let go', async () => { + await useDownloads.getState().downloadCollection('playlist', { id: 'pl-1', name: 'Road trip', owner: 'Tom' }, [song('t1'), song('t2')]) + await until(() => expect(status('t2')).toBe('available-offline')) + serverPlaylist = { id: 'pl-1', name: 'Road trip 2026', owner: 'Tom', entry: [song('t3'), song('t1')] } + + await useDownloads.getState().syncPlaylists() + expect(useDownloads.getState().collections['playlist:pl-1']).toMatchObject({ name: 'Road trip 2026', trackIds: ['t3', 't1'] }) + expect(status('t2')).toBeUndefined() + await until(() => expect(status('t3')).toBe('available-offline')) + }) + + it('are checked when Tomify opens, and left alone when nothing changed', async () => { + await useDownloads.getState().downloadCollection('playlist', { id: 'pl-1', name: 'Road trip', owner: 'Tom' }, [song('t1')]) + await until(() => expect(status('t1')).toBe('available-offline')) + serverPlaylist = { id: 'pl-1', name: 'Road trip', owner: 'Tom', entry: [song('t1'), song('t2')] } + resetDownloadsForTests() + resetOfflineDbForTests() + await useDownloads.getState().bind(session) + await until(() => expect(status('t2')).toBe('available-offline')) + expect(playlistRequests()).toHaveLength(1) + + const streams = requests.filter((url) => url.includes('/rest/stream.view')).length + await useDownloads.getState().syncPlaylists() + expect(playlistRequests()).toHaveLength(2) + expect(requests.filter((url) => url.includes('/rest/stream.view'))).toHaveLength(streams) + }) + + it('keep what is on the device when the server no longer has the playlist', async () => { + await useDownloads.getState().downloadCollection('playlist', { id: 'pl-1', name: 'Road trip', owner: 'Tom' }, [song('t1')]) + await until(() => expect(status('t1')).toBe('available-offline')) + await useDownloads.getState().syncPlaylists() + expect(playlistRequests()).toHaveLength(1) + expect(useDownloads.getState().collections['playlist:pl-1'].trackIds).toEqual(['t1']) + expect(status('t1')).toBe('available-offline') + }) + + it('read a playlist shared by someone else as that owner\'s', async () => { + await useDownloads.getState().downloadCollection('playlist', { id: 'pl-anna', name: 'Anna mix', owner: 'anna' }, [song('t1')]) + await until(() => expect(status('t1')).toBe('available-offline')) + serverPlaylist = { id: 'pl-anna', name: 'Anna mix', owner: 'anna', entry: [song('t1'), song('t5')] } + await useDownloads.getState().syncPlaylists() + await until(() => expect(status('t5')).toBe('available-offline')) + }) + + it('wait while the phone is on mobile data', async () => { + await useDownloads.getState().downloadCollection('playlist', { id: 'pl-1', name: 'Road trip', owner: 'Tom' }, [song('t1')]) + Object.defineProperty(navigator, 'connection', { configurable: true, value: { type: 'cellular' } }) + try { + await useDownloads.getState().syncPlaylists() + expect(playlistRequests()).toHaveLength(0) + } finally { + delete (navigator as { connection?: unknown }).connection + } + }) + }) + it('marks a failed download and retries it', async () => { respond = () => new Response('nope', { status: 500 }) await useDownloads.getState().downloadTracks([song('t1')]) diff --git a/src/stores/downloads.ts b/src/stores/downloads.ts index e2d7ee9..d6011f9 100644 --- a/src/stores/downloads.ts +++ b/src/stores/downloads.ts @@ -2,15 +2,18 @@ import { create } from 'zustand' import { NavidromeClient, type AuthSession } from '../lib/navidrome' import { artKey, collectionKey, deleteCollection, deleteTracks, forgetLocalAudio, isQuotaError, offlineAccount, offlineSupported, readFile, readLibrary, saveCollection, saveTracks, storeDownload, storeFile, TRACK_OWNER, type CollectionKind, type OfflineCollection, type OfflineTrack } from '../lib/offline-db' import type { DownloadStatus, Track } from '../types' +import { downloadBitRate, useDownloadQuality, type DownloadQuality } from './downloadQuality' // Offline downloads: the queue runs one song at a time in the background (one at a time keeps -// a phone's memory and connection free for playback), from the Navidrome stream URL with -// format=raw, so the stored copy is the original file rather than a transcode or HLS chunks. +// a phone's memory and connection free for playback), from the Navidrome stream URL: format=raw +// for the original file, or an MP3 the server makes for the smaller download qualities (never HLS chunks). +// Downloaded playlists keep themselves up to date: Tomify checks them against the server when it +// opens, comes back online or comes back to the foreground (see syncPlaylists). export const QUOTA_MESSAGE = 'Not enough storage space on this device. Remove some downloads, then retry.' const OFFLINE_MESSAGE = 'Waiting for a connection.' -export interface CollectionInput { id: string; name: string; artist?: string; coverArt?: string } +export interface CollectionInput { id: string; name: string; artist?: string; owner?: string; coverArt?: string } export interface DownloadSummary { status: DownloadStatus; total: number; done: number; failed: number; bytes: number; error?: string } interface DownloadsState { @@ -29,16 +32,29 @@ interface DownloadsState { removeTrack(id: string): Promise removeCollection(key: string): Promise retry(ids: string[]): Promise + syncPlaylists(): Promise } let client: NavidromeClient | null = null let active: { account: string; id: string; controller: AbortController } | null = null let pumping = false let listening = false +let syncing = false +let lastSync = 0 +// How often the downloaded playlists are checked for changes, at most. +export const PLAYLIST_SYNC_INTERVAL_MS = 15 * 60_000 const now = () => Date.now() const errorMessage = (error: unknown, fallback: string) => error instanceof Error && error.message ? error.message : fallback const unique = (ids: string[]) => [...new Set(ids)] +const currentQuality = (): DownloadQuality => useDownloadQuality.getState().quality +const offline = () => typeof navigator !== 'undefined' && navigator.onLine === false +// Automatic updates wait when the phone says it is on mobile data or in data saver mode (Android Chrome +// reports this; iPhone Safari does not, so there they run on any connection). +function meteredConnection() { + const connection = typeof navigator === 'undefined' ? undefined : (navigator as Navigator & { connection?: { type?: string; saveData?: boolean } }).connection + return Boolean(connection && (connection.saveData || connection.type === 'cellular')) +} export const useDownloads = create()((set, get) => { // State is updated first so the page reacts at once; IndexedDB follows. A failed write is reported, not hidden. @@ -71,9 +87,9 @@ export const useDownloads = create()((set, get) => { const records = unique(tracks.map((track) => track.id)).map((id) => { const track = tracks.find((candidate) => candidate.id === id)! const record = existing[id] - if (!record) return { id, track, status: 'queued', owners: [owner], addedAt: now(), updatedAt: now() } satisfies OfflineTrack + if (!record) return { id, track, status: 'queued', owners: [owner], quality: currentQuality(), addedAt: now(), updatedAt: now() } satisfies OfflineTrack const status = record.status === 'failed' ? 'queued' : record.status - return { ...record, track: record.status === 'available-offline' ? record.track : track, owners: unique([...record.owners, owner]), status, error: status === 'queued' ? undefined : record.error, updatedAt: now() } + return { ...record, track: record.status === 'available-offline' ? record.track : track, owners: unique([...record.owners, owner]), status, quality: record.status === 'failed' ? currentQuality() : record.quality, error: status === 'queued' ? undefined : record.error, updatedAt: now() } }) await persistTracks(account, records) } @@ -110,7 +126,7 @@ export const useDownloads = create()((set, get) => { if (estimate?.quota && estimate.usage !== undefined && estimate.quota - estimate.usage < size * 1.05) throw new DOMException(QUOTA_MESSAGE, 'QuotaExceededError') } - async function fetchAudio(url: string, track: Track, signal: AbortSignal, onProgress: (share: number) => void) { + async function fetchAudio(url: string, track: Track, expectedSize: number | undefined, signal: AbortSignal, onProgress: (share: number) => void) { const response = await fetch(url, { signal, credentials: 'omit', cache: 'no-store' }).catch((error: unknown) => { if (signal.aborted) throw error throw new Error('Tomify cannot reach the music server.') @@ -122,7 +138,7 @@ export const useDownloads = create()((set, get) => { const body = await response.json().catch(() => null) as { 'subsonic-response'?: { error?: { message?: string } } } | null throw new Error(body?.['subsonic-response']?.error?.message ?? 'The music server refused the download.') } - const total = Number(response.headers.get('content-length')) || track.size || 0 + const total = Number(response.headers.get('content-length')) || expectedSize || 0 const chunks: BlobPart[] = [] if (response.body) { const reader = response.body.getReader() @@ -166,9 +182,12 @@ export const useDownloads = create()((set, get) => { const stillWanted = () => !controller.signal.aborted && get().account === account && Boolean(get().tracks[queued.id]) set((state) => ({ tracks: { ...state.tracks, [queued.id]: { ...queued, status: 'downloading', error: undefined } }, progress: { ...state.progress, [queued.id]: 0 } })) try { - await ensureSpace(queued.track.size) - const url = client.url('stream', { id: queued.id, format: 'raw' }).toString() - const audio = await fetchAudio(url, queued.track, controller.signal, (share) => set((state) => ({ progress: { ...state.progress, [queued.id]: share } }))) + // A smaller quality is an MP3 the server makes as it goes, so it sends no size: estimate it from the length. + const kbps = downloadBitRate(queued.quality) + const expectedSize = kbps ? (queued.track.duration ? Math.round(queued.track.duration * kbps * 125) : undefined) : queued.track.size + await ensureSpace(expectedSize) + const url = client.url('stream', kbps ? { id: queued.id, format: 'mp3', maxBitRate: kbps } : { id: queued.id, format: 'raw' }).toString() + const audio = await fetchAudio(url, queued.track, expectedSize, controller.signal, (share) => set((state) => ({ progress: { ...state.progress, [queued.id]: share } }))) const artwork = await fetchArtwork(account, queued.track.coverArt, controller.signal) if (!stillWanted()) return const latest = get().tracks[queued.id]! @@ -214,6 +233,48 @@ export const useDownloads = create()((set, get) => { } } + // Saves an album or playlist download and links its songs; a playlist that changed lets go of the songs taken out. + async function collect(account: string, kind: CollectionKind, item: CollectionInput, tracks: Track[]) { + const key = collectionKey(kind, item.id) + const previous = get().collections[key] + const trackIds = unique(tracks.map((track) => track.id)) + const collection: OfflineCollection = { key, kind, id: item.id, name: item.name, artist: item.artist, owner: item.owner ?? previous?.owner, coverArt: item.coverArt, trackIds, addedAt: previous?.addedAt ?? now(), updatedAt: now() } + set((state) => ({ collections: { ...state.collections, [key]: collection } })) + try { await saveCollection(account, collection) } catch (error) { set({ notice: isQuotaError(error) ? QUOTA_MESSAGE : errorMessage(error, 'The download could not be saved.') }); return } + await link(account, key, tracks) + if (previous) await unlink(account, key, previous.trackIds.filter((id) => !trackIds.includes(id))) + void pump() + // The album or playlist cover, for the Downloads page with no connection. + if (item.coverArt && item.coverArt !== previous?.coverArt) void fetchArtwork(account, item.coverArt).then((art) => art && get().account === account ? storeFile(account, art.key, art.blob) : undefined).catch(() => undefined) + } + + // Brings each downloaded playlist in line with the server: new songs download, removed ones are let go, + // and a rename shows on the Downloads page. A playlist the server no longer has (deleted, or no longer + // shared) keeps what is on the device; Remove on the Downloads page clears it. + async function syncPlaylists(force = false) { + const { account, ready } = get() + if (!account || !ready || !client || syncing || offline() || meteredConnection()) return + if (!force && now() - lastSync < PLAYLIST_SYNC_INTERVAL_MS) return + syncing = true + lastSync = now() + const server = client + try { + for (const collection of Object.values(get().collections).filter((item) => item.kind === 'playlist')) { + if (get().account !== account || client !== server) return + const playlist = await server.playlist(collection.id, undefined, collection.owner).catch(() => null) + // Removed from downloads while the server was being asked. + if (!playlist || !get().collections[collection.key]) continue + const tracks = playlist.entry ?? [] + const ids = unique(tracks.map((track) => track.id)) + const unchanged = playlist.name === collection.name && ids.length === collection.trackIds.length && ids.every((id, index) => id === collection.trackIds[index]) + if (unchanged) continue + await collect(account, 'playlist', { id: collection.id, name: playlist.name, owner: playlist.owner ?? collection.owner, coverArt: playlist.coverArt ?? collection.coverArt }, tracks) + } + } finally { + syncing = false + } + } + return { supported: offlineSupported(), ready: false, @@ -231,7 +292,11 @@ export const useDownloads = create()((set, get) => { active?.controller.abort() set({ supported: offlineSupported(), ready: false, account, tracks: {}, collections: {}, progress: {}, notice: null }) if (!account) return - if (!listening && typeof window !== 'undefined') { listening = true; window.addEventListener('online', () => void pump()) } + if (!listening && typeof window !== 'undefined') { + listening = true + window.addEventListener('online', () => { void pump(); void syncPlaylists(true) }) + document.addEventListener('visibilitychange', () => { if (!document.hidden) void syncPlaylists() }) + } try { const library = await readLibrary(account) if (get().account !== account) return @@ -243,6 +308,7 @@ export const useDownloads = create()((set, get) => { const persisted = await (navigator.storage?.persisted?.() ?? Promise.resolve(null)).catch(() => null) set({ persisted }) void pump() + void syncPlaylists(true) } catch (error) { if (get().account === account) set({ ready: false, supported: false, notice: errorMessage(error, 'Offline storage is not available in this browser.') }) } @@ -258,24 +324,15 @@ export const useDownloads = create()((set, get) => { }, downloadCollection: async (kind, item, tracks) => { - const { account, ready, collections } = get() + const { account, ready } = get() if (!account || !ready) return set({ notice: null }) void requestPersistence() - const key = collectionKey(kind, item.id) - const previous = collections[key] - const trackIds = unique(tracks.map((track) => track.id)) - const collection: OfflineCollection = { key, kind, id: item.id, name: item.name, artist: item.artist, coverArt: item.coverArt, trackIds, addedAt: previous?.addedAt ?? now(), updatedAt: now() } - set((state) => ({ collections: { ...state.collections, [key]: collection } })) - try { await saveCollection(account, collection) } catch (error) { set({ notice: isQuotaError(error) ? QUOTA_MESSAGE : errorMessage(error, 'The download could not be saved.') }); return } - await link(account, key, tracks) - // A playlist that changed since it was downloaded: songs taken out of it are released. - if (previous) await unlink(account, key, previous.trackIds.filter((id) => !trackIds.includes(id))) - void pump() - // The album or playlist cover, for the Downloads page with no connection. - if (item.coverArt) void fetchArtwork(account, item.coverArt).then((art) => art && get().account === account ? storeFile(account, art.key, art.blob) : undefined).catch(() => undefined) + await collect(account, kind, item, tracks) }, + syncPlaylists: () => syncPlaylists(true), + removeTrack: async (id) => { const { account } = get() if (account) await unlink(account, TRACK_OWNER, [id]) @@ -295,7 +352,7 @@ export const useDownloads = create()((set, get) => { const { account, tracks } = get() if (!account) return set({ notice: null }) - await persistTracks(account, ids.map((id) => tracks[id]).filter((record): record is OfflineTrack => record?.status === 'failed').map((record) => ({ ...record, status: 'queued', error: undefined, updatedAt: now() }))) + await persistTracks(account, ids.map((id) => tracks[id]).filter((record): record is OfflineTrack => record?.status === 'failed').map((record) => ({ ...record, status: 'queued', quality: currentQuality(), error: undefined, updatedAt: now() }))) void pump() }, } @@ -321,5 +378,7 @@ export function resetDownloadsForTests() { client = null active = null pumping = false + syncing = false + lastSync = 0 useDownloads.setState({ supported: offlineSupported(), ready: false, account: null, tracks: {}, collections: {}, progress: {}, persisted: null, notice: null }) } diff --git a/src/stores/player.ts b/src/stores/player.ts index 9d2828b..c8ee495 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -67,7 +67,7 @@ function withoutOne(tracks: Track[], id: string): Track[] { export interface PlayerState { queue: Track[]; currentIndex: number; playing: boolean; unshuffledQueue: Track[] | null; shuffle: boolean; repeat: RepeatMode; currentTime: number; duration: number; volume: number; muted: boolean; queueOpen: boolean; nowPlayingOpen: boolean; activitySerial: number; - playTracks(tracks: Track[], startIndex?: number, options?: { keepOrder?: boolean }): void; playNow(track: Track): void; addToQueue(track: Track): void; removeFromQueue(index: number): void; moveInQueue(from: number, to: number): void; clearQueue(): void; next(): void; previous(): void; setPlaying(value: boolean): void; setProgress(currentTime: number, duration: number): void; seekTo(value: number): void; setVolume(value: number): void; toggleMuted(): void; toggleShuffle(): void; cycleRepeat(): void; setQueueOpen(value: boolean): void; setNowPlayingOpen(value: boolean): void; + playTracks(tracks: Track[], startIndex?: number, options?: { keepOrder?: boolean }): void; playNow(track: Track): void; addToQueue(track: Track): void; removeFromQueue(index: number): void; moveInQueue(from: number, to: number): void; clearQueue(): void; next(options?: { pause?: boolean }): void; previous(): void; setPlaying(value: boolean): void; setProgress(currentTime: number, duration: number): void; seekTo(value: number): void; setVolume(value: number): void; toggleMuted(): void; toggleShuffle(): void; cycleRepeat(): void; setQueueOpen(value: boolean): void; setNowPlayingOpen(value: boolean): void; } let seekHandler: ((value: number) => void) | undefined @@ -131,10 +131,11 @@ export const usePlayer = create((set, get) => ({ moveInQueue: (from, to) => set((s) => { const queue = [...s.queue]; const [item] = queue.splice(from, 1); queue.splice(to, 0, item); let currentIndex = s.currentIndex; if (from === currentIndex) currentIndex = to; else if (from < currentIndex && to >= currentIndex) currentIndex--; else if (from > currentIndex && to <= currentIndex) currentIndex++; return { queue, currentIndex, activitySerial: s.activitySerial + 1 } }), clearQueue: () => set((s) => ({ queue: [], unshuffledQueue: null, currentIndex: -1, playing: false, currentTime: 0, duration: 0, activitySerial: s.activitySerial + 1 })), - next: () => { + // `pause` moves on to the next song without starting it (the sleep timer stopping at the end of a song). + next: (options = {}) => { const s = get() if (!s.queue.length) return - if (s.repeat === 'one') { + if (s.repeat === 'one' && !options.pause) { s.seekTo(0) if (!s.playing) set({ playing: true, activitySerial: s.activitySerial + 1 }) startHandler?.(s.queue[s.currentIndex]) @@ -151,7 +152,7 @@ export const usePlayer = create((set, get) => ({ } const track = s.queue[nextIndex] - set({ currentIndex: nextIndex, playing: true, currentTime: 0, duration: track.duration ?? 0, activitySerial: s.activitySerial + 1 }) + set({ currentIndex: nextIndex, playing: !options.pause, currentTime: 0, duration: track.duration ?? 0, activitySerial: s.activitySerial + 1 }) startHandler?.(track) }, diff --git a/src/stores/sleepTimer.test.ts b/src/stores/sleepTimer.test.ts new file mode 100644 index 0000000..3cad370 --- /dev/null +++ b/src/stores/sleepTimer.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { usePlayer } from './player' +import { sleepTimerLabel, takeTrackEnd, useSleepTimer } from './sleepTimer' +import type { Track } from '../types' + +const tracks: Track[] = [{ id: '1', title: 'One', artist: 'A', album: 'X' }, { id: '2', title: 'Two', artist: 'A', album: 'X' }] + +describe('sleep timer', () => { + beforeEach(() => { + vi.useFakeTimers() + usePlayer.getState().clearQueue() + usePlayer.getState().playTracks(tracks) + }) + afterEach(() => { useSleepTimer.getState().cancel(); vi.useRealTimers() }) + + it('pauses playback once the chosen minutes are up', () => { + useSleepTimer.getState().startMinutes(15) + expect(sleepTimerLabel(useSleepTimer.getState().timer)).toBe('Stops in 15 min') + vi.advanceTimersByTime(14 * 60_000) + expect(usePlayer.getState().playing).toBe(true) + expect(sleepTimerLabel(useSleepTimer.getState().timer)).toBe('Stops in under a minute') + vi.advanceTimersByTime(60_000) + expect(usePlayer.getState().playing).toBe(false) + expect(useSleepTimer.getState().timer).toBeNull() + }) + + it('also stops on the next progress update when a hidden page held the timer back', () => { + useSleepTimer.getState().startMinutes(30) + vi.setSystemTime(Date.now() + 31 * 60_000) + usePlayer.getState().setProgress(12, 200) + expect(usePlayer.getState().playing).toBe(false) + }) + + it('can be turned off or replaced', () => { + useSleepTimer.getState().startMinutes(15) + useSleepTimer.getState().startMinutes(60) + vi.advanceTimersByTime(20 * 60_000) + expect(usePlayer.getState().playing).toBe(true) + useSleepTimer.getState().cancel() + vi.advanceTimersByTime(60 * 60_000) + expect(usePlayer.getState().playing).toBe(true) + }) + + it('takes the end of the song once when set to the end of this song', () => { + expect(takeTrackEnd()).toBe(false) + useSleepTimer.getState().startEndOfTrack() + expect(sleepTimerLabel(useSleepTimer.getState().timer)).toBe('Stops after this song') + expect(takeTrackEnd()).toBe(true) + expect(takeTrackEnd()).toBe(false) + }) + + it('moves to the next song without starting it', () => { + usePlayer.getState().next({ pause: true }) + expect(usePlayer.getState().currentIndex).toBe(1) + expect(usePlayer.getState().playing).toBe(false) + }) +}) diff --git a/src/stores/sleepTimer.ts b/src/stores/sleepTimer.ts new file mode 100644 index 0000000..872f992 --- /dev/null +++ b/src/stores/sleepTimer.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand' +import { usePlayer } from './player' + +// Sleep timer: pauses this device's playback after a number of minutes, or when the song playing now ends. +// It lives only in memory, so closing the app ends it, the same as the music. + +export type SleepTimer = { kind: 'minutes'; minutes: number; endsAt: number } | { kind: 'end-of-track' } + +export const SLEEP_TIMER_MINUTES = [15, 30, 45, 60, 90] + +interface SleepTimerState { + timer: SleepTimer | null + startMinutes(minutes: number): void + startEndOfTrack(): void + cancel(): void +} + +let timeout: ReturnType | undefined +const clear = () => { if (timeout !== undefined) clearTimeout(timeout); timeout = undefined } + +function fire() { + clear() + useSleepTimer.setState({ timer: null }) + if (usePlayer.getState().playing) usePlayer.getState().setPlaying(false) +} + +export const useSleepTimer = create()((set) => ({ + timer: null, + startMinutes: (minutes) => { + clear() + const endsAt = Date.now() + minutes * 60_000 + set({ timer: { kind: 'minutes', minutes, endsAt } }) + timeout = setTimeout(fire, minutes * 60_000) + }, + startEndOfTrack: () => { clear(); set({ timer: { kind: 'end-of-track' } }) }, + cancel: () => { clear(); set({ timer: null }) }, +})) + +// Phones hold back timers in a hidden page, so each progress update also checks the clock. +usePlayer.subscribe((state, previous) => { + const timer = useSleepTimer.getState().timer + if (timer?.kind === 'minutes' && state.currentTime !== previous.currentTime && Date.now() >= timer.endsAt) fire() +}) + +// Asked by the player while a song plays out: true means let it finish rather than start the next one early. +export const sleepsAtTrackEnd = () => useSleepTimer.getState().timer?.kind === 'end-of-track' + +// Called by the player when a song ends on its own. True means the timer took it: stop instead of playing on. +export function takeTrackEnd() { + if (!sleepsAtTrackEnd()) return false + clear() + useSleepTimer.setState({ timer: null }) + return true +} + +export function sleepTimerLabel(timer: SleepTimer | null, now = Date.now()) { + if (!timer) return null + if (timer.kind === 'end-of-track') return 'Stops after this song' + const left = Math.max(0, Math.ceil((timer.endsAt - now) / 60_000)) + return left <= 1 ? 'Stops in under a minute' : `Stops in ${left} min` +} diff --git a/src/styles.css b/src/styles.css index 724a595..aa88335 100644 --- a/src/styles.css +++ b/src/styles.css @@ -120,7 +120,7 @@ button.settings-link{border:0;background:none;width:100%;font:inherit;font-weigh .now-meta{display:flex;align-items:center;justify-content:space-between;gap:16px}.now-meta>span{min-width:0}.now-radio{border:0;background:none;padding:8px;color:#c7ccc7;flex:none}.now-radio svg{width:24px;height:24px}.now-radio.active,.radio-start.active svg{color:var(--accent)}.radio-status{display:block;color:var(--muted);font-size:.75rem} @media(max-width:800px){.track-list .radio-start{display:none}} .crossfade-setting>input[type=range]{width:calc(100% - 16px);margin:0 8px;accent-color:var(--accent)}.crossfade-setting>label{cursor:default}.crossfade-setting>label:hover{background:none} -.now-playing-actions>button.active{color:var(--accent)}.lyrics-panel{flex:1;min-height:0;width:min(650px,100%);margin:2vh auto;overflow-y:auto;overscroll-behavior:contain;mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent);padding:18vh 0}.lyrics-panel ol{list-style:none;margin:0;padding:0;display:grid;gap:.55em;font:700 clamp(1.25rem,3.4vw,2rem)/1.3 Manrope;letter-spacing:-.02em}.lyrics-panel ol.synced li{color:#ffffff59;transition:color .3s}.lyrics-panel ol.synced li.sung{color:#ffffff8c}.lyrics-panel ol.synced li.active{color:var(--text)}.lyrics-panel ol.plain{font-size:clamp(1.05rem,2.6vw,1.4rem);color:#dfe4df}.lyrics-panel li button{all:unset;cursor:pointer}.lyrics-panel li button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.lyrics-panel small{display:block;margin-top:2em;color:var(--muted)}.lyrics-panel.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted);mask-image:none;padding:0}.lyrics-panel.empty svg{width:32px;height:32px;color:#4f564f} +.now-playing-actions>button.active,.sleep-timer>button.active{color:var(--accent)}.sleep-timer .device-menu>button span{flex:1}.sleep-timer .device-menu .sleep-timer-status{color:var(--accent);margin:0 6px 6px}.lyrics-panel{flex:1;min-height:0;width:min(650px,100%);margin:2vh auto;overflow-y:auto;overscroll-behavior:contain;mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent);padding:18vh 0}.lyrics-panel ol{list-style:none;margin:0;padding:0;display:grid;gap:.55em;font:700 clamp(1.25rem,3.4vw,2rem)/1.3 Manrope;letter-spacing:-.02em}.lyrics-panel ol.synced li{color:#ffffff59;transition:color .3s}.lyrics-panel ol.synced li.sung{color:#ffffff8c}.lyrics-panel ol.synced li.active{color:var(--text)}.lyrics-panel ol.plain{font-size:clamp(1.05rem,2.6vw,1.4rem);color:#dfe4df}.lyrics-panel li button{all:unset;cursor:pointer}.lyrics-panel li button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.lyrics-panel small{display:block;margin-top:2em;color:var(--muted)}.lyrics-panel.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted);mask-image:none;padding:0}.lyrics-panel.empty svg{width:32px;height:32px;color:#4f564f} @media(max-width:800px){.lyrics-panel{padding:10vh 0}} .party-check{display:flex;align-items:center;gap:10px;color:var(--text)}.party-check input{accent-color:var(--accent);width:18px;height:18px}.party-code{display:grid;gap:8px;justify-items:start;margin-bottom:28px;padding:22px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.party-code small{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.party-code strong{font-size:3rem;letter-spacing:.18em;color:var(--accent);line-height:1}.party-code span{color:var(--muted);word-break:break-all}.party-code div{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}.party-code p{margin:0;color:var(--muted)}.party-queue li{align-items:center}.party-queue li>span,.party-list li>span:not(.party-cover){display:grid;min-width:0;flex:1}.party-queue li>span::before{display:none}.party-queue strong,.party-list strong,.party-queue small,.party-list small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.party-votes{display:inline-flex;align-items:center;gap:4px;color:var(--muted)}.party-queue .icon-button,.party-list .icon-button{background:none;border:0;color:var(--muted);padding:6px}.party-queue .icon-button svg,.party-list .icon-button svg{width:18px} .party-guest{max-width:640px;margin:0 auto;padding:16px 16px 60px}.party-guest>header{display:flex;align-items:center;gap:10px;margin-bottom:22px}.party-guest>header .button{margin-left:auto}.party-join{display:grid;gap:14px;justify-items:center;text-align:center;padding:30px 4px}.party-join>svg{color:var(--accent)}.party-join h1{margin:0}.party-join p{margin:0;color:var(--muted)}.party-join label{display:grid;gap:6px;width:100%;text-align:left}.party-join label>span{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.party-join input{background:#111311;color:var(--text);border:1px solid #3a3f3a;border-radius:9px;padding:13px;outline:0;font-size:1.05rem}.party-join input:focus{border-color:var(--accent)}.party-join .party-code-input{font-size:1.6rem;letter-spacing:.2em;text-align:center;text-transform:uppercase}.party-join .button{width:100%;justify-content:center}