Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)).
Expand Down Expand Up @@ -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.

Expand All @@ -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.
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
16 changes: 16 additions & 0 deletions src/components/DownloadQuality.tsx
Original file line number Diff line number Diff line change
@@ -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 <fieldset className="streaming-quality download-quality">
<legend><HardDriveDownload/>Download quality</legend>
{DOWNLOAD_QUALITY_OPTIONS.map((option) => <label key={option.value}>
<input type="radio" name="download-quality" value={option.value} checked={quality === option.value} onChange={() => setQuality(option.value)}/>
<span><strong>{option.label}</strong><small>{option.detail}</small></span>
</label>)}
<p>Applies to new downloads on this device. To shrink a song already saved, remove it and download it again.</p>
</fieldset>
}
23 changes: 22 additions & 1 deletion src/components/Player.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <button onClick={() => onSeek(12)}>Lyrics of {track.title} at {Math.floor(time)}</button> }))
Expand Down Expand Up @@ -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() }] })
Expand All @@ -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(<PlayerBar/>)
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(<PlayerBar/>)
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 })
Expand Down
3 changes: 2 additions & 1 deletion src/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -160,5 +161,5 @@ function NowPlaying() {
}
const seek = (value: number) => view.remote ? view.command('seek', value) : player.seekTo(value)

return <section className="now-playing"><header><button onClick={() => player.setNowPlayingOpen(false)} aria-label="Close now playing"><ChevronDown/></button><span>{remoteLabel(view) ?? 'Now playing'}</span><span className="now-playing-actions"><button className={lyricsOpen ? 'active' : ''} onClick={toggleLyrics} aria-label={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'} aria-pressed={lyricsOpen}><MicVocal/></button><DevicePicker placement="down"/><button onClick={() => { player.setNowPlayingOpen(false); player.setQueueOpen(true) }} aria-label="Open queue"><ListMusic/></button></span></header>{lyricsOpen ? <LyricsPanel track={track} time={view.currentTime} onSeek={seek}/> : <div className="now-art"><Artwork id={track.coverArt ?? track.albumId} albumId={track.albumId} alt={track.album} size={1000}/></div>}<div className="now-meta"><span><h1>{track.title}</h1><p>{track.artist}</p></span>{!view.remote && <StartRadioButton track={track} className="now-radio"/>}</div><SeekBar/><Transport large/>{view.remote && <button type="button" className="button secondary play-here" onClick={view.playHere}><MonitorSpeaker/>Play on this device</button>}</section>
return <section className="now-playing"><header><button onClick={() => player.setNowPlayingOpen(false)} aria-label="Close now playing"><ChevronDown/></button><span>{remoteLabel(view) ?? 'Now playing'}</span><span className="now-playing-actions"><button className={lyricsOpen ? 'active' : ''} onClick={toggleLyrics} aria-label={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'} aria-pressed={lyricsOpen}><MicVocal/></button>{!view.remote && <SleepTimerButton/>}<DevicePicker placement="down"/><button onClick={() => { player.setNowPlayingOpen(false); player.setQueueOpen(true) }} aria-label="Open queue"><ListMusic/></button></span></header>{lyricsOpen ? <LyricsPanel track={track} time={view.currentTime} onSeek={seek}/> : <div className="now-art"><Artwork id={track.coverArt ?? track.albumId} albumId={track.albumId} alt={track.album} size={1000}/></div>}<div className="now-meta"><span><h1>{track.title}</h1><p>{track.artist}</p></span>{!view.remote && <StartRadioButton track={track} className="now-radio"/>}</div><SeekBar/><Transport large/>{view.remote && <button type="button" className="button secondary play-here" onClick={view.playHere}><MonitorSpeaker/>Play on this device</button>}</section>
}
18 changes: 17 additions & 1 deletion src/components/PlayerRuntime.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 }) })
Expand Down Expand Up @@ -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')
Expand Down
Loading
Loading