Skip to content

feat: offline download management — playlist/album download, cancel, remove, and offline art#190

Closed
tbrackbill wants to merge 12 commits into
dddevid:masterfrom
tbrackbill:feature/offline-download-management
Closed

feat: offline download management — playlist/album download, cancel, remove, and offline art#190
tbrackbill wants to merge 12 commits into
dddevid:masterfrom
tbrackbill:feature/offline-download-management

Conversation

@tbrackbill
Copy link
Copy Markdown
Contributor

@tbrackbill tbrackbill commented May 15, 2026

⚠️ Personal fork feature — totally fine to close this. It's opinionated in presentation and I know it may not fit the project's direction. Sharing in case any of it is useful.

What this adds

Full offline download workflow for playlists and albums:

  • Download button in playlist and album action bars (hidden in offline mode)
  • 3-state button: cloud icon (idle) → animated spinner (downloading, tap to cancel) → green cloud_done (complete, tap to remove)
  • Cancel mid-download: removes from queue if not yet started; stops the active download if already running — without affecting other queued playlists
  • Remove downloads: confirmation dialog, deletes songs + cover art files for the playlist/album
  • Offline album art: cover art is saved indexed by coverArt ID (not just song ID) so AlbumArtwork can resolve it in list/grid views when offline
  • Download queue: multiple playlists/albums queue sequentially; interrupted downloads resume on next app launch
  • Download status screen: dedicated screen showing per-song progress with queued/downloading/done/failed states
  • Settings storage tab: "Download All Library" and "Delete All Downloads" controls with size display
  • Song tile badges: downloaded indicator on song rows
  • Robust validation: uses /download endpoint (no transcoding) and checks song.size to detect partial files; 64 KB floor fallback when size is unknown

What I kept out

  • No per-song individual download from song tile long-press (felt like scope creep)
  • No background service / foreground notification — downloads run while the app is open
  • "Download All Library" and per-playlist queue are separate code paths and will conflict if both run simultaneously (library download silently no-ops if the queue is active) — didn't feel right to redesign the whole thing just to fix that

Known limitation

If any song permanently fails to download, the spinner stays on that playlist until the app restarts (when resumeIncompleteDownloads re-queues only the missing songs). Acceptable tradeoff vs adding a distinct error state.

Summary by CodeRabbit

  • New Features

    • Added offline playlist download queuing with progress tracking
    • New download status monitoring screen for active downloads
    • Download status indicators now display across library, playlists, and individual songs
    • Enhanced player shuffle and repeat mode functionality
    • Automated Android release build system
  • Bug Fixes

    • Fixed player navigation behavior with shuffle and repeat-all modes

Review Change Stack

tbrackbill and others added 12 commits April 16, 2026 14:22
…pletion messages

- isSongDownloaded() now rejects files < 64 KB as corrupt/partial so
  interrupted downloads don't permanently block a track from retrying
- downloadSong() deletes the partial file on failure for the same reason
- settings_storage_tab: fire-and-forget startBackgroundDownload so the
  "Library download started" snackbar appears immediately, not after all
  downloads complete
- playlist/album screens: guard against concurrent downloads with an
  explicit isBackgroundDownloadActive check and user-facing message;
  use whenComplete instead of .then so no false "Downloaded N songs"
  message fires when the guard rejects the request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- hasNext/hasPrevious now return true when repeat-all or shuffle is on,
  so the next/prev buttons are never greyed out in those modes
- skipNext wraps to index 0 on repeat-all, or picks a random track on
  shuffle, when already at the last song
- skipPrevious wraps to last track on repeat-all, or picks a random track
  on shuffle, when already at the first song
- toggleShuffle/toggleRepeat now persist their state via StorageService
- _initializePlayer loads persisted shuffle/repeat on startup
- playSong shuffles the new queue in-place when shuffle is already enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hrough pre-shuffled queue

skipNext now checks shuffle first and picks a random non-current track
regardless of queue position, so tapping next always jumps to a random
song when shuffle is on. Removed the re-shuffle-on-playSong logic that
was causing every skip to restart the sequence from the beginning.
_onSongComplete unified through skipNext so auto-advance respects shuffle
the same way as manual skips.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
skipNext() on a single-song queue calls playSong() with the same song,
which hits the 'same song = togglePlayPause' guard and pauses rather than
replaying. Treat repeat-all + queue.length==1 the same as repeat-one:
seek to zero and play directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…es, UPnP upstream)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OfflineService:
- Add downloadedSongIds ValueNotifier (Set<String>) seeded from SharedPrefs
  on initialize(), updated on download success/delete so UI reacts without polling
- Add downloadLog ValueNotifier (List<DownloadLogEntry>) with per-song
  queued/downloading/done/failed status, reset each batch
- Extend DownloadState with currentSong and failedSongs for active-download panel
- Add getPlaylistDownloadStatus() helper returning (downloaded, total)
- Add DownloadStatus enum and DownloadLogEntry model

SongTile:
- _buildTrailing() wrapped in ValueListenableBuilder on downloadedSongIds;
  shows 14px green check_circle badge when song is locally downloaded

PlaylistScreen / AlbumScreen:
- Artwork wrapped in ValueListenableBuilder on downloadedSongIds; shows
  green check_circle badge when every song in the collection is downloaded

Settings (Offline Downloads section):
- Active Downloads row: shows current song artist-title and X/Y progress
  while a batch runs, otherwise "No downloads in progress"
- Playlist Downloads row: shows total downloaded-song count from reactive set;
  navigation to per-playlist breakdown wired in feature/download-detail-screens

Build:
- android/gradle.properties: add android.newDsl=false (AGP 8.11 compat)
- Dockerfile.release + build-release-apk.sh: retained for reference but
  native build via /rw/flutter is the preferred approach going forward

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add green check badge to _PlaylistTile when all songs downloaded
- Create DownloadPlaylistStatusScreen showing downloaded/total per playlist
- Wire Settings > Playlist Downloads row to navigate to new screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch downloadSong() from /stream to /download so files are always
  the original, non-transcoded version and match song.size exactly
- Persist expected file sizes to SharedPrefs before each download so
  reconciliation works even if the app is killed mid-batch
- initialize() now scans the offline dir and recovers any valid files
  that landed on disk but weren't indexed (handles interrupted downloads)
- isSongDownloaded() uses stored expected size when available, 64 KB
  floor as fallback for songs where size is unknown
- One automatic retry pass for failed songs at end of each batch
- deleteSong() and deleteAllDownloads() clean up the expected sizes map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- queuePlaylistDownload() replaces direct startBackgroundDownload calls;
  multiple playlists/albums stack in a sequential download queue processed
  one batch at a time
- queuedPlaylistIds ValueNotifier drives an outline check_circle_outline
  badge in playlists list and Library view when a playlist is queued but
  not yet fully downloaded; fills to check_circle when complete
- Queued state (including full song list JSON) is persisted to SharedPrefs
  so the badge survives app restarts
- initialize() loads queued playlist data and unmarks any that are now
  fully on disk after the reconciliation scan
- PlayerProvider resumes incomplete queued downloads automatically on
  startup via resumeIncompleteDownloads()
- deleteAllDownloads() clears the queue and queued playlist data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Playlist/album download spinner now driven by queuedPlaylistIds
  ValueNotifier instead of local _isDownloading state, so the spinner
  stays visible for the full duration of the download — including when
  additional playlists are queued while one is already in progress
- Also save cover art indexed by song.coverArt ID (art_{id}.jpg) so
  AlbumArtwork can resolve it offline for album/playlist grid views;
  previously art was only stored by song ID and never found by the widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace fragile nested ValueListenableBuilder with explicit listeners:
- _allDownloaded and _isQueued driven by addListener on OfflineService
  notifiers; state is updated via _updateDownloadState() called on load
  and on every notifier change, ensuring correct initial state
- Button has 3 clean states: cloud_download (idle) → spinner (queued/
  downloading, tap cancels) → cloud_done green (complete, tap removes)
- Cancel: removes playlist from queue and stops active download
- Remove: confirmation dialog then deletes all songs in playlist/album
- Added cancelPlaylistDownload() and deletePlaylistDownloads() to OfflineService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cancelPlaylistDownload: only kill the active download when the target
  playlist is currently running; cancelling a queued-but-not-started
  entry no longer stops an unrelated active download
- _updateDownloadState: disk-check fallback now guarded by _isQueued
  (previous state) so it only fires on the queued→not-queued transition,
  not on every song-download event for every open screen
- deletePlaylistDownloads: accepts List<Song> and deletes art_{id}.jpg
  files alongside song files so stale offline art doesn't persist after
  removing downloads
- AlbumArtwork: skip File.existsSync() when downloadedSongIds is empty,
  avoiding synchronous disk stats on every build for non-downloading users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive offline download management system with reactive state binding, file validation, and persistence. It reworks player navigation to support shuffle and repeat-all wraparound, adds release APK build infrastructure via Docker, and integrates offline status indicators throughout the UI.

Changes

Offline Download System and Player Enhancements

Layer / File(s) Summary
Release Build Infrastructure
Dockerfile.release, android/gradle.properties, build-release-apk.sh
Adds Docker-based release APK build pipeline with Flutter, Android SDK, and Gradle cache mounts. Gradle configuration enables explicit DSL flag. Shell script orchestrates containerized build and APK extraction.
Offline Service Types and Reactive State
lib/services/offline_service.dart (types/notifiers)
Introduces DownloadStatus enum, DownloadLogEntry class, extends DownloadState with currentSong and failedSongs. Adds three reactive ValueNotifier fields (downloadedSongIds, downloadLog, queuedPlaylistIds) and persistent storage keys for expected sizes and queued playlists.
Offline Service Initialization and File Reconciliation
lib/services/offline_service.dart (initialize)
Loads expected-size metadata, reconciles prefs-based downloaded IDs with actual on-disk .mp3 files using size validation, loads and resumes queued playlists from persistent state, and unmarks completed playlists.
Offline Download Execution and Persistence
lib/services/offline_service.dart (download lifecycle)
Adds SubsonicService.getDownloadUrl() for non-transcoded downloads. Updates isSongDownloaded to validate file size; persists expected sizes before download; enforces validation on completion with failure-file cleanup; caches cover art by artId; manages background batch downloads with per-index logging and single retry pass; maintains deletion APIs that sync reactive state.
Player Navigation with Shuffle and Repeat Persistence
lib/providers/player_provider.dart
Imports Random for shuffle support. Updates hasNext/hasPrevious getters for repeat-all and shuffle. Extends initialization to load persisted shuffle/repeat state and resume offline downloads. Rewrites _onSongComplete with repeat-one/repeat-all logic; updates skipNext/skipPrevious for random selection and wraparound. Persists shuffle/repeat changes.
Album and Playlist Screen Offline Integration
lib/screens/album_screen.dart, lib/screens/playlist_screen.dart
Both screens add reactive download-state tracking (_allDownloaded, _isQueued) with listener lifecycle management. Replace background-download flow with queue-based initialization. Introduce queue/cancel/remove download flows and dynamic download button that switches icons. Update header artwork with ValueListenableBuilder to show checkmark when fully downloaded.
Library Offline Status Badges
lib/screens/library_screen.dart, lib/screens/playlists_screen.dart
Add nested ValueListenableBuilders observing downloadedSongIds and queuedPlaylistIds. Display check-circle icon when all playlist songs downloaded, check-outline when queued, or default icon otherwise.
Settings Storage Tab Offline Controls
lib/screens/settings_storage_tab.dart
Adds "Active Downloads" row observing downloadState and "Playlist Downloads" row with navigation to new status screen. Changes _downloadAllLibrary from blocking await to fire-and-forget with whenComplete callback.
New Widgets and Offline Artwork
lib/screens/download_playlist_status_screen.dart, lib/widgets/album_artwork.dart, lib/widgets/song_tile.dart
Introduces DownloadPlaylistStatusScreen combining playlist library with per-playlist download progress. Updates AlbumArtwork to resolve offline cover art via artId-based lookup. Updates SongTile trailing row with reactive download checkmark.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dddevid/Musly#115: Prior PR that introduced the initial download-button UI in album_screen.dart; main PR replaces that background-download flow with the new queue-based offline-service architecture.
  • dddevid/Musly#135: Related player navigation changes to shuffle history and back-button behavior; main PR independently extends shuffle/repeat semantics and persistence in skipNext/skipPrevious logic.

Poem

A rabbit hops through downloads now,
With queues and files aligned just so,
Offline art and shuffle ways,
Brightening the listening days,
Persisted state in every play. 🎵

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature: offline download management for playlists/albums with cancel, remove, and offline art capabilities, matching the substantial changes across the codebase.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feature/offline-download-management

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tbrackbill
Copy link
Copy Markdown
Contributor Author

Closing to rebase on current upstream before re-opening.

@tbrackbill tbrackbill closed this May 15, 2026
@tbrackbill tbrackbill deleted the feature/offline-download-management branch May 15, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant