From 6278e278cde0c322dd91ee9b5251899bdaf31413 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Thu, 16 Apr 2026 18:15:45 -0700 Subject: [PATCH 1/9] feat: add download status indicators and settings download panels OfflineService: - Add downloadedSongIds ValueNotifier (Set) seeded from SharedPrefs on initialize(), updated on download success/delete so UI reacts without polling - Add downloadLog ValueNotifier (List) 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 --- Dockerfile.release | 64 +++++++++++++++++++ android/gradle.properties | 1 + build-release-apk.sh | 27 ++++++++ lib/screens/album_screen.dart | 59 ++++++++++++------ lib/screens/playlist_screen.dart | 62 ++++++++++++++----- lib/screens/settings_storage_tab.dart | 89 +++++++++++++++++++++++++++ lib/services/offline_service.dart | 84 +++++++++++++++++++++++-- lib/widgets/song_tile.dart | 61 ++++++++++-------- 8 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 Dockerfile.release create mode 100755 build-release-apk.sh diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..9b42e15 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + FLUTTER_SUPPRESS_ANALYTICS=1 \ + PUB_CACHE=/opt/pub-cache \ + GRADLE_USER_HOME=/opt/gradle-cache + +# ── System deps ──────────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + openjdk-17-jdk-headless \ + wget curl git unzip xz-utils zip \ + libglu1-mesa ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ + PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH + +# ── Android SDK (including cmake so Gradle never tries to auto-install it) ───── +ENV ANDROID_SDK_ROOT=/opt/android-sdk \ + ANDROID_HOME=/opt/android-sdk +RUN mkdir -p /opt/android-sdk/cmdline-tools \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \ + -O /tmp/cmdline-tools.zip \ + && unzip -q /tmp/cmdline-tools.zip -d /opt/android-sdk/cmdline-tools \ + && mv /opt/android-sdk/cmdline-tools/cmdline-tools \ + /opt/android-sdk/cmdline-tools/latest \ + && rm /tmp/cmdline-tools.zip + +ENV PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH + +RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true \ + && sdkmanager \ + "platform-tools" \ + "platforms;android-35" \ + "build-tools;35.0.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + +# ── Flutter SDK ──────────────────────────────────────────────────────────────── +RUN git clone --depth 1 --branch stable \ + https://github.com/flutter/flutter.git /opt/flutter \ + && git config --global --add safe.directory /opt/flutter + +ENV PATH=/opt/flutter/bin:$PATH + +RUN flutter --version +RUN yes | flutter doctor --android-licenses > /dev/null 2>&1 || true + +# ── App source & build ───────────────────────────────────────────────────────── +WORKDIR /app +COPY . . + +# Cache mounts keep Gradle + pub downloads outside the overlay layer, +# preventing "No space left on device" errors on large Gradle transform sets. +RUN --mount=type=cache,target=/opt/gradle-cache \ + --mount=type=cache,target=/opt/pub-cache \ + flutter pub get + +RUN --mount=type=cache,target=/opt/gradle-cache \ + --mount=type=cache,target=/opt/pub-cache \ + flutter build apk --release + +# Output: /app/build/app/outputs/flutter-apk/app-release.apk diff --git a/android/gradle.properties b/android/gradle.properties index 21dbfa5..b22cec2 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +android.newDsl=false diff --git a/build-release-apk.sh b/build-release-apk.sh new file mode 100755 index 0000000..e0d09ea --- /dev/null +++ b/build-release-apk.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build a release APK using Docker (data stored in /rw/docker). +# Usage: ./build-release-apk.sh [output-dir] +# The APK is copied to (default: ./release-apk) +set -euo pipefail + +OUTPUT_DIR="${1:-$(pwd)/release-apk}" +IMAGE_TAG="musly-release-builder" +CONTAINER_NAME="musly-apk-build" + +cd "$(dirname "$0")" + +echo "==> Building Docker image: $IMAGE_TAG" +sudo docker build -f Dockerfile.release -t "$IMAGE_TAG" . + +echo "==> Extracting APK from image" +sudo docker rm -f "$CONTAINER_NAME" 2>/dev/null || true +sudo docker create --name "$CONTAINER_NAME" "$IMAGE_TAG" echo done +mkdir -p "$OUTPUT_DIR" +sudo docker cp \ + "$CONTAINER_NAME:/app/build/app/outputs/flutter-apk/app-release.apk" \ + "$OUTPUT_DIR/musly-release.apk" +sudo docker rm -f "$CONTAINER_NAME" + +echo "" +echo "==> APK ready: $OUTPUT_DIR/musly-release.apk" +ls -lh "$OUTPUT_DIR/musly-release.apk" diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 137458d..461b8d1 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -224,24 +224,47 @@ class _AlbumScreenState extends State { onPressed: () => Navigator.pop(context), ), flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 40, - left: ScreenHelper.isSmallScreen(context) ? 24 : 40, - right: ScreenHelper.isSmallScreen(context) ? 24 : 40, - bottom: ScreenHelper.isSmallScreen(context) ? 60 : 80, - ), - child: AlbumArtwork( - coverArt: _album!.coverArt, - size: ScreenHelper.isSmallScreen(context) ? 200 : 280, - borderRadius: 10, - preserveAspectRatio: true, - ), - ), - ], + background: ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + final allDownloaded = _songs.isNotEmpty && + _songs.every((s) => ids.contains(s.id)); + return Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 40, + left: ScreenHelper.isSmallScreen(context) ? 24 : 40, + right: ScreenHelper.isSmallScreen(context) ? 24 : 40, + bottom: ScreenHelper.isSmallScreen(context) ? 60 : 80, + ), + child: AlbumArtwork( + coverArt: _album!.coverArt, + size: ScreenHelper.isSmallScreen(context) ? 200 : 280, + borderRadius: 10, + preserveAspectRatio: true, + ), + ), + if (allDownloaded) + Positioned( + bottom: 86, + right: 46, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 28, + ), + ), + ), + ], + ); + }, ), ), actions: [ diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 9e8473e..a76b15c 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -359,24 +359,52 @@ class _PlaylistScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - Container( - width: 150, - height: 150, - decoration: BoxDecoration( - color: AppTheme.appleMusicRed.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: _playlist!.coverArt != null - ? AlbumArtwork( - coverArt: _playlist!.coverArt, - size: 150, - borderRadius: 12, - ) - : const Icon( - CupertinoIcons.music_note_list, - color: AppTheme.appleMusicRed, - size: 64, + ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + final songs = _playlist!.songs ?? []; + final allDownloaded = songs.isNotEmpty && + songs.every((s) => ids.contains(s.id)); + return Stack( + children: [ + Container( + width: 150, + height: 150, + decoration: BoxDecoration( + color: AppTheme.appleMusicRed.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: _playlist!.coverArt != null + ? AlbumArtwork( + coverArt: _playlist!.coverArt, + size: 150, + borderRadius: 12, + ) + : const Icon( + CupertinoIcons.music_note_list, + color: AppTheme.appleMusicRed, + size: 64, + ), ), + if (allDownloaded) + Positioned( + bottom: 6, + right: 6, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + ), + ), + ], + ); + }, ), const SizedBox(height: 16), Text( diff --git a/lib/screens/settings_storage_tab.dart b/lib/screens/settings_storage_tab.dart index db7e9d2..fb19ff6 100644 --- a/lib/screens/settings_storage_tab.dart +++ b/lib/screens/settings_storage_tab.dart @@ -137,6 +137,10 @@ class _SettingsStorageTabState extends State { _buildDivider(), _buildOfflineInfo(), _buildDivider(), + _buildActiveDownloadsRow(), + _buildDivider(), + _buildPlaylistStatusRow(), + _buildDivider(), _buildDownloadAllLibraryButton(), _buildDivider(), _buildDeleteDownloadsButton(), @@ -662,6 +666,91 @@ class _SettingsStorageTabState extends State { ); } + Widget _buildActiveDownloadsRow() { + return ValueListenableBuilder( + valueListenable: _offlineService.downloadState, + builder: (context, state, _) { + final subtitle = state.isDownloading && state.currentSong != null + ? '${state.currentSong!.artist ?? ''} – ${state.currentSong!.title} (${state.currentProgress}/${state.totalCount})' + : state.isDownloading + ? '${state.currentProgress}/${state.totalCount}' + : 'No downloads in progress'; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: state.isDownloading + ? const [Color(0xFF34C759), Color(0xFF30D158)] + : const [Color(0xFF8E8E93), Color(0xFFAEAEB2)], + ), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + state.isDownloading + ? CupertinoIcons.arrow_down_circle_fill + : CupertinoIcons.arrow_down_circle, + color: Colors.white, + size: 18, + ), + ), + title: const Text('Active Downloads', style: TextStyle(fontSize: 16)), + subtitle: Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: _isDark ? AppTheme.darkSecondaryText : AppTheme.lightSecondaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(CupertinoIcons.chevron_right, size: 16), + // Navigation wired up in feature/download-detail-screens + onTap: null, + ); + }, + ); + } + + Widget _buildPlaylistStatusRow() { + return ValueListenableBuilder>( + valueListenable: _offlineService.downloadedSongIds, + builder: (context, ids, _) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5856D6), Color(0xFF7B68EE)], + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + CupertinoIcons.music_note_list, + color: Colors.white, + size: 18, + ), + ), + title: const Text('Playlist Downloads', style: TextStyle(fontSize: 16)), + subtitle: Text( + '${ids.length} songs downloaded', + style: TextStyle( + fontSize: 12, + color: _isDark ? AppTheme.darkSecondaryText : AppTheme.lightSecondaryText, + ), + ), + trailing: const Icon(CupertinoIcons.chevron_right, size: 16), + // Navigation wired up in feature/download-detail-screens + onTap: null, + ); + }, + ); + } + Widget _buildDownloadAllLibraryButton() { return ValueListenableBuilder( valueListenable: _offlineService.downloadState, diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 7fc1db6..dcdd413 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -9,17 +9,31 @@ import '../models/song.dart'; import '../models/playlist.dart'; import 'subsonic_service.dart'; +enum DownloadStatus { queued, downloading, done, failed } + +class DownloadLogEntry { + final Song song; + final DownloadStatus status; + const DownloadLogEntry(this.song, this.status); + DownloadLogEntry copyWith({DownloadStatus? status}) => + DownloadLogEntry(song, status ?? this.status); +} + class DownloadState { final bool isDownloading; final int currentProgress; final int totalCount; final int downloadedCount; + final Song? currentSong; + final List failedSongs; DownloadState({ this.isDownloading = false, this.currentProgress = 0, this.totalCount = 0, this.downloadedCount = 0, + this.currentSong, + this.failedSongs = const [], }); DownloadState copyWith({ @@ -27,12 +41,16 @@ class DownloadState { int? currentProgress, int? totalCount, int? downloadedCount, + Song? currentSong, + List? failedSongs, }) { return DownloadState( isDownloading: isDownloading ?? this.isDownloading, currentProgress: currentProgress ?? this.currentProgress, totalCount: totalCount ?? this.totalCount, downloadedCount: downloadedCount ?? this.downloadedCount, + currentSong: currentSong ?? this.currentSong, + failedSongs: failedSongs ?? this.failedSongs, ); } } @@ -52,6 +70,15 @@ class OfflineService { final ValueNotifier downloadState = ValueNotifier( DownloadState(), ); + + /// Reactive set of song IDs that are confirmed downloaded on disk. + /// Widgets can listen to this to show/hide the green checkmark badge. + final ValueNotifier> downloadedSongIds = ValueNotifier({}); + + /// Per-batch download log, cleared at the start of each batch. + /// Used by the Active Downloads detail screen. + final ValueNotifier> downloadLog = ValueNotifier([]); + bool _isBackgroundDownloadActive = false; static const String _keyDownloadedSongs = 'offline_downloaded_songs'; @@ -71,6 +98,10 @@ class OfflineService { if (!await offlineDirectory.exists()) { await offlineDirectory.create(recursive: true); } + + // Seed the reactive set from SharedPrefs so existing downloads are visible + // immediately without waiting for a background download to run. + downloadedSongIds.value = getDownloadedSongIds().toSet(); } String _getSongPath(String songId) { @@ -150,6 +181,14 @@ class OfflineService { return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; } + /// Returns (downloaded, total) for a list of songs — used by the + /// Playlist Status settings panel. + (int, int) getPlaylistDownloadStatus(List songs) { + final ids = downloadedSongIds.value; + final downloaded = songs.where((s) => ids.contains(s.id)).length; + return (downloaded, songs.length); + } + Future downloadSong( Song song, SubsonicService subsonicService, { @@ -177,6 +216,8 @@ class OfflineService { downloadedIds.add(song.id); await _prefs?.setStringList(_keyDownloadedSongs, downloadedIds); } + // Notify reactive listeners (SongTile badges, playlist checkmarks) + downloadedSongIds.value = {...downloadedSongIds.value, song.id}; try { if (song.coverArt != null) { @@ -282,11 +323,17 @@ class OfflineService { } } + // Reset the per-batch log and seed it with queued entries + downloadLog.value = songs + .map((s) => DownloadLogEntry(s, DownloadStatus.queued)) + .toList(); + downloadState.value = DownloadState( isDownloading: true, currentProgress: 0, totalCount: songs.length, downloadedCount: alreadyDownloadedCount, + failedSongs: [], ); if (_offlineDir == null) await initialize(); @@ -305,23 +352,35 @@ class OfflineService { final batch = pendingSongs.skip(i).take(concurrentDownloads).toList(); // Download all songs in the batch concurrently - final downloadFutures = batch.map((song) async { + final downloadFutures = batch.asMap().entries.map((entry) async { + final batchIdx = entry.key; + final song = entry.value; if (!_isBackgroundDownloadActive) return false; + final logIdx = i + batchIdx; + _updateLogEntry(logIdx, DownloadStatus.downloading); + final success = await downloadSong(song, subsonicService); completedCount++; + _updateLogEntry(logIdx, success ? DownloadStatus.done : DownloadStatus.failed); + final newDownloadedCount = getDownloadedCount(); + final newFailed = success + ? downloadState.value.failedSongs + : [...downloadState.value.failedSongs, song]; + downloadState.value = downloadState.value.copyWith( currentProgress: completedCount, downloadedCount: newDownloadedCount, + failedSongs: newFailed, ); if (!success) { debugPrint('Failed to download song: ${song.title}'); } return success; - }); + }).toList(); // Wait for all downloads in the batch to complete await Future.wait(downloadFutures); @@ -330,7 +389,10 @@ class OfflineService { debugPrint('Error during background download: $e'); } finally { _isBackgroundDownloadActive = false; - downloadState.value = downloadState.value.copyWith(isDownloading: false); + downloadState.value = downloadState.value.copyWith( + isDownloading: false, + currentSong: null, + ); // Always disable wake lock when download finishes or fails if (!kIsWeb) { @@ -344,9 +406,20 @@ class OfflineService { } } + void _updateLogEntry(int index, DownloadStatus status) { + final log = List.from(downloadLog.value); + if (index < log.length) { + log[index] = log[index].copyWith(status: status); + downloadLog.value = log; + } + } + void cancelBackgroundDownload() { _isBackgroundDownloadActive = false; - downloadState.value = downloadState.value.copyWith(isDownloading: false); + downloadState.value = downloadState.value.copyWith( + isDownloading: false, + currentSong: null, + ); } bool get isBackgroundDownloadActive => _isBackgroundDownloadActive; @@ -386,6 +459,7 @@ class OfflineService { final downloadedIds = getDownloadedSongIds(); downloadedIds.remove(songId); await _prefs?.setStringList(_keyDownloadedSongs, downloadedIds); + downloadedSongIds.value = {...downloadedSongIds.value}..remove(songId); return true; } catch (e) { @@ -408,6 +482,7 @@ class OfflineService { } await _prefs?.setStringList(_keyDownloadedSongs, []); + downloadedSongIds.value = {}; } catch (e) { debugPrint('Error deleting all downloads: $e'); } @@ -476,7 +551,6 @@ class OfflineService { } String getPlayableUrl(Song song, SubsonicService subsonicService) { - if (song.isLocal == true && song.path != null) { return 'file://${song.path}'; } diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart index 46438d8..8a26687 100644 --- a/lib/widgets/song_tile.dart +++ b/lib/widgets/song_tile.dart @@ -184,33 +184,42 @@ class SongTile extends StatelessWidget { } Widget _buildTrailing(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (song.starred == true) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - CupertinoIcons.heart_fill, - size: 14, - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.7), + return ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + final isDownloaded = ids.contains(song.id); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isDownloaded) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon(Icons.check_circle, size: 14, color: Colors.green), + ), + if (song.starred == true) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.heart_fill, + size: 14, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7), + ), + ), + if (showDuration) + Text( + song.formattedDuration, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.more_horiz), + iconSize: 20, + color: Theme.of(context).textTheme.bodySmall?.color, + onPressed: () => _showOptions(context), ), - ), - if (showDuration) - Text( - song.formattedDuration, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.more_horiz), - iconSize: 20, - color: Theme.of(context).textTheme.bodySmall?.color, - onPressed: () => _showOptions(context), - ), - ], + ], + ); + }, ); } From de6b898f23adf3dac49a522f39103fa2bfda0fa7 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 17 Apr 2026 07:58:01 -0700 Subject: [PATCH 2/9] feat: add playlist download status screen and fix library badges - 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 --- .../download_playlist_status_screen.dart | 112 ++++++++++++++++++ lib/screens/playlists_screen.dart | 25 +++- lib/screens/settings_storage_tab.dart | 8 +- 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 lib/screens/download_playlist_status_screen.dart diff --git a/lib/screens/download_playlist_status_screen.dart b/lib/screens/download_playlist_status_screen.dart new file mode 100644 index 0000000..ef8b4af --- /dev/null +++ b/lib/screens/download_playlist_status_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import '../providers/library_provider.dart'; +import '../services/offline_service.dart'; +import '../theme/app_theme.dart'; +import '../widgets/widgets.dart'; + +class DownloadPlaylistStatusScreen extends StatelessWidget { + const DownloadPlaylistStatusScreen({super.key}); + + @override + Widget build(BuildContext context) { + final libraryProvider = Provider.of(context); + final playlists = libraryProvider.playlists; + + return Scaffold( + appBar: AppBar( + title: const Text('Playlist Downloads'), + leading: IconButton( + icon: const Icon(CupertinoIcons.back), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + if (playlists.isEmpty) { + return const Center(child: Text('No playlists found')); + } + return ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + final songs = playlist.songs; + final total = songs?.length ?? playlist.songCount ?? 0; + final downloaded = songs != null + ? songs.where((s) => ids.contains(s.id)).length + : 0; + final allDownloaded = total > 0 && downloaded == total; + final hasSongs = songs != null; + + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppTheme.appleMusicRed.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: playlist.coverArt != null + ? AlbumArtwork( + coverArt: playlist.coverArt, + size: 48, + borderRadius: 8, + ) + : const Icon( + CupertinoIcons.music_note_list, + color: AppTheme.appleMusicRed, + size: 24, + ), + ), + title: Text( + playlist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: hasSongs + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$downloaded / $total', + style: TextStyle( + fontSize: 14, + color: allDownloaded + ? Colors.green + : downloaded > 0 + ? Colors.orange + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + fontWeight: allDownloaded + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + if (allDownloaded) ...[ + const SizedBox(width: 6), + const Icon(Icons.check_circle, + color: Colors.green, size: 18), + ], + ], + ) + : Text( + '$total songs', + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 2b5d145..cfa9fbe 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; import '../providers/providers.dart'; +import '../services/offline_service.dart'; import '../theme/app_theme.dart'; import '../widgets/widgets.dart'; import 'playlist_screen.dart'; @@ -210,10 +211,26 @@ class _PlaylistTile extends StatelessWidget { '${playlist.songCount ?? 0} songs', style: theme.textTheme.bodySmall, ), - trailing: const Icon( - CupertinoIcons.chevron_right, - size: 18, - color: AppTheme.lightSecondaryText, + trailing: ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + final songs = playlist.songs; + final allDownloaded = songs != null && + songs.isNotEmpty && + songs.every((s) => ids.contains(s.id)); + if (allDownloaded) { + return const Icon( + Icons.check_circle, + size: 20, + color: Colors.green, + ); + } + return const Icon( + CupertinoIcons.chevron_right, + size: 18, + color: AppTheme.lightSecondaryText, + ); + }, ), onTap: onTap, onLongPress: onLongPress, diff --git a/lib/screens/settings_storage_tab.dart b/lib/screens/settings_storage_tab.dart index fb19ff6..4df0bc4 100644 --- a/lib/screens/settings_storage_tab.dart +++ b/lib/screens/settings_storage_tab.dart @@ -10,6 +10,7 @@ import '../services/cache_settings_service.dart'; import '../services/local_music_service.dart'; import '../services/offline_service.dart'; import '../theme/app_theme.dart'; +import 'download_playlist_status_screen.dart'; class SettingsStorageTab extends StatefulWidget { const SettingsStorageTab({super.key}); @@ -744,8 +745,11 @@ class _SettingsStorageTabState extends State { ), ), trailing: const Icon(CupertinoIcons.chevron_right, size: 16), - // Navigation wired up in feature/download-detail-screens - onTap: null, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DownloadPlaylistStatusScreen(), + ), + ), ); }, ); From 1a41b13acfd9f86e98b0d32ff3429c3cff90f261 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 17 Apr 2026 12:44:19 -0700 Subject: [PATCH 3/9] fix: use /download endpoint and song.size for robust download validation - 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 --- lib/services/offline_service.dart | 213 +++++++++++++++++++++++++++-- lib/services/subsonic_service.dart | 6 + 2 files changed, 207 insertions(+), 12 deletions(-) diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index dcdd413..90626c6 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -42,6 +42,7 @@ class DownloadState { int? totalCount, int? downloadedCount, Song? currentSong, + bool clearCurrentSong = false, List? failedSongs, }) { return DownloadState( @@ -49,7 +50,7 @@ class DownloadState { currentProgress: currentProgress ?? this.currentProgress, totalCount: totalCount ?? this.totalCount, downloadedCount: downloadedCount ?? this.downloadedCount, - currentSong: currentSong ?? this.currentSong, + currentSong: clearCurrentSong ? null : (currentSong ?? this.currentSong), failedSongs: failedSongs ?? this.failedSongs, ); } @@ -83,12 +84,29 @@ class OfflineService { static const String _keyDownloadedSongs = 'offline_downloaded_songs'; static const String _keyPendingScrobbles = 'pending_scrobbles'; + static const String _keyExpectedSizes = 'offline_expected_sizes'; + static const String _keyQueuedPlaylists = 'offline_queued_playlists'; + static const String _keyQueuedPlaylistData = 'offline_queued_playlist_data'; static const String _keyParallelDownloads = 'parallel_downloads_count'; static const String _keyKeepScreenOn = 'offline_keep_screen_on'; static const int _defaultParallelDownloads = 3; static const int _maxParallelDownloads = 5; + Map _expectedSizes = {}; + + /// Playlist IDs that have been queued for download but aren't fully done. + /// Drives the outline-check badge in playlist list views. + final ValueNotifier> queuedPlaylistIds = ValueNotifier({}); + + /// playlistId → serialised song list, so we can resume without LibraryProvider. + Map>> _queuedPlaylistData = {}; + + /// Sequential download queue: each entry is (playlistId, songs). + final List<({String playlistId, List songs, SubsonicService service})> + _downloadQueue = []; + bool _queueProcessorRunning = false; + Future initialize() async { _prefs ??= await SharedPreferences.getInstance(); final dir = await getApplicationDocumentsDirectory(); @@ -99,9 +117,144 @@ class OfflineService { await offlineDirectory.create(recursive: true); } - // Seed the reactive set from SharedPrefs so existing downloads are visible - // immediately without waiting for a background download to run. - downloadedSongIds.value = getDownloadedSongIds().toSet(); + // Load expected sizes map + final sizesJson = _prefs?.getString(_keyExpectedSizes); + if (sizesJson != null) { + try { + final raw = json.decode(sizesJson) as Map; + _expectedSizes = raw.map((k, v) => MapEntry(k, v as int)); + } catch (_) {} + } + + // Seed from SharedPrefs first + final prefsIds = getDownloadedSongIds().toSet(); + + // Reconcile with disk: any valid .mp3 on disk that isn't in the index + // gets added (handles interrupted downloads where file landed but prefs + // weren't updated before the app was killed) + final diskIds = {}; + final offDir = Directory(_offlineDir!); + if (await offDir.exists()) { + await for (final entity in offDir.list()) { + if (entity is File && entity.path.endsWith('.mp3')) { + final songId = entity.path.split('/').last.replaceAll('.mp3', ''); + if (_isFileValid(songId, entity)) diskIds.add(songId); + } + } + } + + final merged = {...prefsIds, ...diskIds}; + if (merged.length != prefsIds.length) { + await _prefs?.setStringList(_keyDownloadedSongs, merged.toList()); + } + downloadedSongIds.value = merged; + + // Load queued playlist tracking + final queuedIds = _prefs?.getStringList(_keyQueuedPlaylists) ?? []; + final queuedDataJson = _prefs?.getString(_keyQueuedPlaylistData); + if (queuedDataJson != null) { + try { + final raw = json.decode(queuedDataJson) as Map; + _queuedPlaylistData = raw.map( + (k, v) => MapEntry(k, (v as List).cast>()), + ); + } catch (_) {} + } + queuedPlaylistIds.value = queuedIds.toSet(); + + // Unmark any playlists that are now fully on disk + _checkAndUnmarkCompleted(merged); + } + + /// Removes playlists from the queued set if all their songs are now present. + void _checkAndUnmarkCompleted(Set presentIds) { + final nowDone = {}; + for (final playlistId in queuedPlaylistIds.value) { + final data = _queuedPlaylistData[playlistId]; + if (data == null || data.isEmpty) continue; + final songIds = data.map((s) => s['id']?.toString() ?? '').where((id) => id.isNotEmpty); + if (songIds.every(presentIds.contains)) nowDone.add(playlistId); + } + if (nowDone.isEmpty) return; + for (final id in nowDone) { _queuedPlaylistData.remove(id); } + queuedPlaylistIds.value = queuedPlaylistIds.value.difference(nowDone); + _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); + } + + /// Queue a playlist for download. If the processor isn't running, start it. + /// Multiple calls stack up and are processed sequentially. + Future queuePlaylistDownload( + String playlistId, + List songs, + SubsonicService subsonicService, + ) async { + if (_offlineDir == null) await initialize(); + + // Persist queued state so outline badge appears immediately + _queuedPlaylistData[playlistId] = songs.map((s) => s.toJson()).toList(); + queuedPlaylistIds.value = {...queuedPlaylistIds.value, playlistId}; + await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + await _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); + + // Filter to only songs that still need downloading + final missing = songs.where((s) => !isSongDownloaded(s.id)).toList(); + if (missing.isEmpty) { + _checkAndUnmarkCompleted(downloadedSongIds.value); + return; + } + + _downloadQueue.add((playlistId: playlistId, songs: missing, service: subsonicService)); + _startQueueProcessor(); + } + + void _startQueueProcessor() { + if (_queueProcessorRunning) return; + _queueProcessorRunning = true; + _processQueue(); + } + + Future _processQueue() async { + while (_downloadQueue.isNotEmpty) { + final entry = _downloadQueue.removeAt(0); + await startBackgroundDownload(entry.songs, entry.service); + _checkAndUnmarkCompleted(downloadedSongIds.value); + } + _queueProcessorRunning = false; + } + + /// Called at startup to re-queue any playlists that were interrupted. + Future resumeIncompleteDownloads(SubsonicService subsonicService) async { + if (_queuedPlaylistData.isEmpty) return; + for (final entry in _queuedPlaylistData.entries) { + final missing = entry.value + .map((s) => Song.fromJson(s)) + .where((s) => !isSongDownloaded(s.id)) + .toList(); + if (missing.isEmpty) continue; + _downloadQueue.add((playlistId: entry.key, songs: missing, service: subsonicService)); + } + if (_downloadQueue.isNotEmpty) _startQueueProcessor(); + } + + /// Returns true if the file on disk is complete. + /// Uses the stored expected size when available, falls back to 64 KB floor. + bool _isFileValid(String songId, File file) { + try { + final len = file.lengthSync(); + final expected = _expectedSizes[songId]; + if (expected != null && expected > 0) { + return len >= expected; + } + return len >= 65536; + } catch (_) { + return false; + } + } + + Future _persistExpectedSize(String songId, int bytes) async { + _expectedSizes[songId] = bytes; + await _prefs?.setString(_keyExpectedSizes, json.encode(_expectedSizes)); } String _getSongPath(String songId) { @@ -146,7 +299,8 @@ class OfflineService { bool isSongDownloaded(String songId) { if (_offlineDir == null) return false; final file = File(_getSongPath(songId)); - return file.existsSync(); + if (!file.existsSync()) return false; + return _isFileValid(songId, file); } List getDownloadedSongIds() { @@ -196,9 +350,16 @@ class OfflineService { }) async { if (_offlineDir == null) await initialize(); + // Persist expected size before downloading so reconciliation can use it + // even if the app is killed mid-download. + if (song.size != null && song.size! > 0) { + await _persistExpectedSize(song.id, song.size!); + } + + final filePath = _getSongPath(song.id); try { - final url = subsonicService.getStreamUrl(song.id); - final filePath = _getSongPath(song.id); + // Use /download (original file, no transcoding) so size matches song.size + final url = subsonicService.getDownloadUrl(song.id); final dio = Dio(); await dio.download( @@ -211,6 +372,10 @@ class OfflineService { }, ); + // Validate against stored expected size (or 64 KB floor if unknown) + if (!isSongDownloaded(song.id)) { + throw Exception('Downloaded file for ${song.id} failed size check'); + } final downloadedIds = getDownloadedSongIds(); if (!downloadedIds.contains(song.id)) { downloadedIds.add(song.id); @@ -387,11 +552,20 @@ class OfflineService { } } catch (e) { debugPrint('Error during background download: $e'); - } finally { - _isBackgroundDownloadActive = false; + } + + // One automatic retry pass for any failed songs + final toRetry = List.from(downloadState.value.failedSongs); + if (toRetry.isNotEmpty && _isBackgroundDownloadActive) { + debugPrint('Retrying ${toRetry.length} failed song(s)...'); + final retryFailed = []; + for (final song in toRetry) { + if (!_isBackgroundDownloadActive) break; + final success = await downloadSong(song, subsonicService); + if (!success) retryFailed.add(song); + } downloadState.value = downloadState.value.copyWith( - isDownloading: false, - currentSong: null, + failedSongs: retryFailed, ); // Always disable wake lock when download finishes or fails @@ -404,6 +578,12 @@ class OfflineService { } } } + + _isBackgroundDownloadActive = false; + downloadState.value = downloadState.value.copyWith( + isDownloading: false, + clearCurrentSong: true, + ); } void _updateLogEntry(int index, DownloadStatus status) { @@ -418,7 +598,7 @@ class OfflineService { _isBackgroundDownloadActive = false; downloadState.value = downloadState.value.copyWith( isDownloading: false, - currentSong: null, + clearCurrentSong: true, ); } @@ -460,6 +640,8 @@ class OfflineService { downloadedIds.remove(songId); await _prefs?.setStringList(_keyDownloadedSongs, downloadedIds); downloadedSongIds.value = {...downloadedSongIds.value}..remove(songId); + _expectedSizes.remove(songId); + await _prefs?.setString(_keyExpectedSizes, json.encode(_expectedSizes)); return true; } catch (e) { @@ -482,6 +664,13 @@ class OfflineService { } await _prefs?.setStringList(_keyDownloadedSongs, []); + await _prefs?.remove(_keyExpectedSizes); + await _prefs?.remove(_keyQueuedPlaylists); + await _prefs?.remove(_keyQueuedPlaylistData); + _expectedSizes = {}; + _queuedPlaylistData = {}; + _downloadQueue.clear(); + queuedPlaylistIds.value = {}; downloadedSongIds.value = {}; } catch (e) { debugPrint('Error deleting all downloads: $e'); diff --git a/lib/services/subsonic_service.dart b/lib/services/subsonic_service.dart index 150cd51..9b76f0f 100644 --- a/lib/services/subsonic_service.dart +++ b/lib/services/subsonic_service.dart @@ -420,6 +420,12 @@ class SubsonicService { return '${_config!.normalizedUrl}/rest/getCoverArt?$queryString'; } + /// Returns the /download URL for a song — always original file, no transcoding. + /// Use this for offline downloads so file size matches song.size exactly. + String getDownloadUrl(String songId) { + return _buildUrl('download', {'id': songId}); + } + String getStreamUrl(String songId, {int? maxBitRate, String? format}) { if (_jellyfin != null) return _jellyfin! From 30fc506f3ca7667479fa3a241cddb0c9657cad26 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 17 Apr 2026 12:51:53 -0700 Subject: [PATCH 4/9] feat: playlist download queue with outline-check badge and auto-resume - 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 --- lib/providers/player_provider.dart | 6 +++++ lib/screens/album_screen.dart | 23 +++++-------------- lib/screens/library_screen.dart | 37 ++++++++++++++++++++++++++++++ lib/screens/playlist_screen.dart | 23 +++++-------------- lib/screens/playlists_screen.dart | 34 +++++++++++++++------------ 5 files changed, 74 insertions(+), 49 deletions(-) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index 9e82e7e..f458901 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -1124,6 +1124,12 @@ class PlayerProvider extends ChangeNotifier { notifyListeners(); }); + // Resume any playlists that were queued for download but interrupted + _offlineService.initialize().then((_) { + _offlineService.resumeIncompleteDownloads(_subsonicService); + }); + + _storageService.getRepeatMode().then((saved) { _repeatMode = RepeatMode.values[saved.clamp(0, RepeatMode.values.length - 1)]; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 461b8d1..44c707f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -86,33 +86,22 @@ class _AlbumScreenState extends State { Future _downloadAlbum() async { if (_songs.isEmpty) return; - final subsonicService = Provider.of( - context, - listen: false, - ); final offlineService = OfflineService(); + final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); setState(() => _isDownloading = true); - offlineService.startBackgroundDownload(_songs, subsonicService).then((_) { - if (mounted) { - setState(() => _isDownloading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Downloaded ${_songs.length} songs from ${_album!.name}', - ), - duration: const Duration(seconds: 3), - ), - ); - } + offlineService + .queuePlaylistDownload(_album!.id, _songs, subsonicService) + .whenComplete(() { + if (mounted) setState(() => _isDownloading = false); }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Downloading ${_songs.length} songs in background…'), + content: Text('Queued ${_songs.length} songs from ${_album!.name} for download…'), duration: const Duration(seconds: 2), ), ); diff --git a/lib/screens/library_screen.dart b/lib/screens/library_screen.dart index e8fc2d5..f69e8b0 100644 --- a/lib/screens/library_screen.dart +++ b/lib/screens/library_screen.dart @@ -22,6 +22,7 @@ import 'artist_screen.dart'; import 'radio_screen.dart'; import 'all_songs_screen.dart'; import '../l10n/app_localizations.dart'; +import '../services/offline_service.dart'; import '../widgets/album_artwork.dart' show isLocalFilePath; class LibraryScreen extends StatefulWidget { @@ -478,6 +479,42 @@ class _LibraryScreenState extends State { ], ), ), + if (item.type == 'Playlist') + ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, ids, _) { + return ValueListenableBuilder>( + valueListenable: OfflineService().queuedPlaylistIds, + builder: (context, queued, _) { + final playlists = Provider.of( + context, + listen: false, + ).playlists; + final playlist = playlists.cast().firstWhere( + (p) => p.id == item.id, + orElse: () => null, + ); + final songs = playlist?.songs; + final allDownloaded = songs != null && + songs.isNotEmpty && + songs.every((s) => ids.contains(s.id)); + if (allDownloaded) { + return const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon(Icons.check_circle, color: Colors.green, size: 18), + ); + } + if (queued.contains(item.id)) { + return const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon(Icons.check_circle_outline, color: Colors.green, size: 18), + ); + } + return const SizedBox.shrink(); + }, + ); + }, + ), ], ), ), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a76b15c..90d9c84 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -279,33 +279,22 @@ class _PlaylistScreenState extends State { final songs = _playlist?.songs; if (songs == null || songs.isEmpty) return; - final subsonicService = Provider.of( - context, - listen: false, - ); final offlineService = OfflineService(); + final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); setState(() => _isDownloading = true); - offlineService.startBackgroundDownload(songs, subsonicService).then((_) { - if (mounted) { - setState(() => _isDownloading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Downloaded ${songs.length} songs from ${_playlist!.name}', - ), - duration: const Duration(seconds: 3), - ), - ); - } + offlineService + .queuePlaylistDownload(widget.playlistId, songs, subsonicService) + .whenComplete(() { + if (mounted) setState(() => _isDownloading = false); }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Downloading ${songs.length} songs in background…'), + content: Text('Queued ${songs.length} songs from ${_playlist!.name} for download…'), duration: const Duration(seconds: 2), ), ); diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index cfa9fbe..236c37d 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -214,21 +214,25 @@ class _PlaylistTile extends StatelessWidget { trailing: ValueListenableBuilder>( valueListenable: OfflineService().downloadedSongIds, builder: (context, ids, _) { - final songs = playlist.songs; - final allDownloaded = songs != null && - songs.isNotEmpty && - songs.every((s) => ids.contains(s.id)); - if (allDownloaded) { - return const Icon( - Icons.check_circle, - size: 20, - color: Colors.green, - ); - } - return const Icon( - CupertinoIcons.chevron_right, - size: 18, - color: AppTheme.lightSecondaryText, + return ValueListenableBuilder>( + valueListenable: OfflineService().queuedPlaylistIds, + builder: (context, queued, _) { + final songs = playlist.songs; + final allDownloaded = songs != null && + songs.isNotEmpty && + songs.every((s) => ids.contains(s.id)); + if (allDownloaded) { + return const Icon(Icons.check_circle, size: 20, color: Colors.green); + } + if (queued.contains(playlist.id)) { + return const Icon(Icons.check_circle_outline, size: 20, color: Colors.green); + } + return const Icon( + CupertinoIcons.chevron_right, + size: 18, + color: AppTheme.lightSecondaryText, + ); + }, ); }, ), From 437e4e6b82b33cb72df56c6184b10340e028d25b Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 15 May 2026 11:10:06 -0700 Subject: [PATCH 5/9] fix: reactive download spinner and offline album art by coverArt ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/screens/album_screen.dart | 43 ++++++++++++++++++------------- lib/screens/playlist_screen.dart | 9 +------ lib/services/offline_service.dart | 19 +++++++++++++- lib/widgets/album_artwork.dart | 23 +++++++++++++++++ 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 44c707f..d965b9e 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -24,7 +24,6 @@ class _AlbumScreenState extends State { Album? _album; List _songs = []; bool _isLoading = true; - bool _isDownloading = false; @override void initState() { @@ -90,13 +89,7 @@ class _AlbumScreenState extends State { final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); - setState(() => _isDownloading = true); - - offlineService - .queuePlaylistDownload(_album!.id, _songs, subsonicService) - .whenComplete(() { - if (mounted) setState(() => _isDownloading = false); - }); + offlineService.queuePlaylistDownload(_album!.id, _songs, subsonicService); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -258,16 +251,30 @@ class _AlbumScreenState extends State { ), actions: [ if (!isOffline) - IconButton( - tooltip: 'Download album', - onPressed: _isDownloading ? null : _downloadAlbum, - icon: _isDownloading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(CupertinoIcons.cloud_download), + ValueListenableBuilder>( + valueListenable: OfflineService().queuedPlaylistIds, + builder: (context, queued, _) { + return ValueListenableBuilder>( + valueListenable: OfflineService().downloadedSongIds, + builder: (context, downloaded, _) { + final allDownloaded = _songs.isNotEmpty && + _songs.every((s) => downloaded.contains(s.id)); + final isQueued = queued.contains(_album!.id); + final isSpinning = isQueued && !allDownloaded; + return IconButton( + tooltip: isSpinning ? 'Downloading…' : 'Download album', + onPressed: isSpinning ? null : _downloadAlbum, + icon: isSpinning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(CupertinoIcons.cloud_download), + ); + }, + ); + }, ), ], ), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 90d9c84..7fac371 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -28,7 +28,6 @@ class PlaylistScreen extends StatefulWidget { class _PlaylistScreenState extends State { Playlist? _playlist; bool _isLoading = true; - bool _isDownloading = false; bool _isSelecting = false; bool _isReordering = false; final Set _selectedIndices = {}; @@ -283,13 +282,7 @@ class _PlaylistScreenState extends State { final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); - setState(() => _isDownloading = true); - - offlineService - .queuePlaylistDownload(widget.playlistId, songs, subsonicService) - .whenComplete(() { - if (mounted) setState(() => _isDownloading = false); - }); + offlineService.queuePlaylistDownload(widget.playlistId, songs, subsonicService); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 90626c6..d09551c 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -269,6 +269,10 @@ class OfflineService { return '$_offlineDir/$songId.jpg'; } + String _getCoverArtByArtIdPath(String coverArtId) { + return '$_offlineDir/art_${coverArtId.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_')}.jpg'; + } + String? getLocalCoverArtPath(String songId) { if (_offlineDir == null) return null; final path = _getCoverArtPath(songId); @@ -276,6 +280,13 @@ class OfflineService { return null; } + String? getLocalCoverArtPathByCoverArtId(String? coverArtId) { + if (_offlineDir == null || coverArtId == null || coverArtId.isEmpty) return null; + final path = _getCoverArtByArtIdPath(coverArtId); + if (File(path).existsSync()) return path; + return null; + } + Future saveLyrics(String songId, Map data) async { if (_offlineDir == null) await initialize(); try { @@ -389,7 +400,13 @@ class OfflineService { final coverUrl = subsonicService.getCoverArtUrl(song.coverArt, size: 600); if (coverUrl.isNotEmpty) { final dioCover = Dio(); - await dioCover.download(coverUrl, _getCoverArtPath(song.id)); + final songCoverPath = _getCoverArtPath(song.id); + await dioCover.download(coverUrl, songCoverPath); + // Also save indexed by coverArt ID so album/playlist views can find it offline + final artIdPath = _getCoverArtByArtIdPath(song.coverArt!); + if (!File(artIdPath).existsSync()) { + await File(songCoverPath).copy(artIdPath); + } } } } catch (e) { diff --git a/lib/widgets/album_artwork.dart b/lib/widgets/album_artwork.dart index 6196599..06d2cc0 100644 --- a/lib/widgets/album_artwork.dart +++ b/lib/widgets/album_artwork.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../services/subsonic_service.dart'; import '../services/player_ui_settings_service.dart'; +import '../services/offline_service.dart'; bool isLocalFilePath(String? s) { if (s == null || s.isEmpty) return false; @@ -188,6 +189,16 @@ class AlbumArtwork extends StatelessWidget { ); } + final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); + if (offlinePath != null) { + return Image.file( + File(offlinePath), + key: ValueKey('offline_natural_$coverArt'), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + ); + } + return Builder( builder: (context) { final imageUrl = _ImageUrlCache.getUrl( @@ -229,6 +240,18 @@ class AlbumArtwork extends StatelessWidget { ); } + final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); + if (offlinePath != null) { + return Image.file( + File(offlinePath), + key: ValueKey('offline_$coverArt'), + fit: BoxFit.cover, + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + ); + } + return Builder( builder: (context) { final imageUrl = _ImageUrlCache.getUrl( From 34c34e4501161c7ee5184ea899d8ee9629671e0f Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 15 May 2026 14:38:00 -0700 Subject: [PATCH 6/9] fix: durable download state machine for playlist and album screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/screens/album_screen.dart | 113 ++++++++++++++++++++++-------- lib/screens/playlist_screen.dart | 98 +++++++++++++++++++++----- lib/services/offline_service.dart | 16 +++++ 3 files changed, 180 insertions(+), 47 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index d965b9e..4bdf924 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -25,10 +25,36 @@ class _AlbumScreenState extends State { List _songs = []; bool _isLoading = true; + bool _allDownloaded = false; + bool _isQueued = false; + @override void initState() { super.initState(); _loadAlbum(); + OfflineService().downloadedSongIds.addListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); + } + + @override + void dispose() { + OfflineService().downloadedSongIds.removeListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); + super.dispose(); + } + + void _updateDownloadState() { + if (!mounted || _songs.isEmpty) return; + final ids = OfflineService().downloadedSongIds.value; + final allDown = _songs.every((s) => ids.contains(s.id)); + final queued = _album != null && + OfflineService().queuedPlaylistIds.value.contains(_album!.id); + if (allDown != _allDownloaded || queued != _isQueued) { + setState(() { + _allDownloaded = allDown; + _isQueued = queued; + }); + } } Future _loadAlbum() async { @@ -61,6 +87,7 @@ class _AlbumScreenState extends State { _songs = songs; _isLoading = false; }); + _updateDownloadState(); } } catch (e) { if (mounted) { @@ -83,24 +110,73 @@ class _AlbumScreenState extends State { } Future _downloadAlbum() async { - if (_songs.isEmpty) return; - + if (_songs.isEmpty || _album == null) return; final offlineService = OfflineService(); final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); - offlineService.queuePlaylistDownload(_album!.id, _songs, subsonicService); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Queued ${_songs.length} songs from ${_album!.name} for download…'), + content: Text('Queued ${_songs.length} songs for download…'), duration: const Duration(seconds: 2), ), ); } } + Future _cancelDownload() async { + if (_album == null) return; + await OfflineService().cancelPlaylistDownload(_album!.id); + } + + Future _removeDownloads() async { + if (_songs.isEmpty || _album == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove downloads?'), + content: Text('Remove all ${_songs.length} downloaded songs from "${_album!.name}"?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Remove', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await OfflineService().deletePlaylistDownloads(_songs.map((s) => s.id).toList()); + } + } + + Widget _buildDownloadButton(BuildContext context) { + if (_allDownloaded) { + return IconButton( + tooltip: 'Downloaded — tap to remove', + onPressed: _removeDownloads, + icon: const Icon(Icons.cloud_done, color: Colors.green), + ); + } + if (_isQueued) { + return IconButton( + tooltip: 'Downloading — tap to cancel', + onPressed: _cancelDownload, + icon: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + return IconButton( + tooltip: 'Download album', + onPressed: _downloadAlbum, + icon: const Icon(CupertinoIcons.cloud_download), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -250,32 +326,7 @@ class _AlbumScreenState extends State { ), ), actions: [ - if (!isOffline) - ValueListenableBuilder>( - valueListenable: OfflineService().queuedPlaylistIds, - builder: (context, queued, _) { - return ValueListenableBuilder>( - valueListenable: OfflineService().downloadedSongIds, - builder: (context, downloaded, _) { - final allDownloaded = _songs.isNotEmpty && - _songs.every((s) => downloaded.contains(s.id)); - final isQueued = queued.contains(_album!.id); - final isSpinning = isQueued && !allDownloaded; - return IconButton( - tooltip: isSpinning ? 'Downloading…' : 'Download album', - onPressed: isSpinning ? null : _downloadAlbum, - icon: isSpinning - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(CupertinoIcons.cloud_download), - ); - }, - ); - }, - ), + if (!isOffline) _buildDownloadButton(context), ], ), SliverToBoxAdapter( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 7fac371..cf4abc9 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -32,10 +32,37 @@ class _PlaylistScreenState extends State { bool _isReordering = false; final Set _selectedIndices = {}; + bool _allDownloaded = false; + bool _isQueued = false; + @override void initState() { super.initState(); _loadPlaylist(); + OfflineService().downloadedSongIds.addListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); + } + + @override + void dispose() { + OfflineService().downloadedSongIds.removeListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); + super.dispose(); + } + + void _updateDownloadState() { + if (!mounted) return; + final songs = _playlist?.songs ?? []; + if (songs.isEmpty) return; + final ids = OfflineService().downloadedSongIds.value; + final allDown = songs.every((s) => ids.contains(s.id)); + final queued = OfflineService().queuedPlaylistIds.value.contains(widget.playlistId); + if (allDown != _allDownloaded || queued != _isQueued) { + setState(() { + _allDownloaded = allDown; + _isQueued = queued; + }); + } } Future _loadPlaylist() async { @@ -51,6 +78,7 @@ class _PlaylistScreenState extends State { _playlist = playlist; _isLoading = false; }); + _updateDownloadState(); } } catch (e) { if (mounted) { @@ -277,23 +305,72 @@ class _PlaylistScreenState extends State { Future _downloadPlaylist() async { final songs = _playlist?.songs; if (songs == null || songs.isEmpty) return; - final offlineService = OfflineService(); final subsonicService = Provider.of(context, listen: false); await offlineService.initialize(); - offlineService.queuePlaylistDownload(widget.playlistId, songs, subsonicService); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Queued ${songs.length} songs from ${_playlist!.name} for download…'), + content: Text('Queued ${songs.length} songs for download…'), duration: const Duration(seconds: 2), ), ); } } + Future _cancelDownload() async { + await OfflineService().cancelPlaylistDownload(widget.playlistId); + } + + Future _removeDownloads() async { + final songs = _playlist?.songs ?? []; + if (songs.isEmpty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove downloads?'), + content: Text('Remove all ${songs.length} downloaded songs from "${_playlist!.name}"?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Remove', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await OfflineService().deletePlaylistDownloads(songs.map((s) => s.id).toList()); + } + } + + Widget _buildDownloadButton(BuildContext context) { + if (_allDownloaded) { + return IconButton( + tooltip: 'Downloaded — tap to remove', + onPressed: _removeDownloads, + icon: const Icon(Icons.cloud_done, color: Colors.green), + ); + } + if (_isQueued) { + return IconButton( + tooltip: 'Downloading — tap to cancel', + onPressed: _cancelDownload, + icon: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + return IconButton( + tooltip: 'Download playlist', + onPressed: _downloadPlaylist, + icon: const Icon(CupertinoIcons.cloud_download), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -573,18 +650,7 @@ class _PlaylistScreenState extends State { icon: const Icon(CupertinoIcons.checkmark_circle), onPressed: _toggleSelectMode, ), - if (!isOffline) - IconButton( - tooltip: 'Download playlist', - onPressed: _isDownloading ? null : _downloadPlaylist, - icon: _isDownloading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(CupertinoIcons.cloud_download), - ), + if (!isOffline) _buildDownloadButton(context), ], ], ), diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index d09551c..44c8739 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -619,6 +619,22 @@ class OfflineService { ); } + Future cancelPlaylistDownload(String playlistId) async { + _downloadQueue.removeWhere((e) => e.playlistId == playlistId); + if (_isBackgroundDownloadActive) cancelBackgroundDownload(); + queuedPlaylistIds.value = queuedPlaylistIds.value.difference({playlistId}); + _queuedPlaylistData.remove(playlistId); + await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + final data = _queuedPlaylistData; + await _prefs?.setString(_keyQueuedPlaylistData, json.encode(data)); + } + + Future deletePlaylistDownloads(List songIds) async { + for (final id in songIds) { + await deleteSong(id); + } + } + bool get isBackgroundDownloadActive => _isBackgroundDownloadActive; Future downloadPlaylist( From 79f27c91caa1278aaa4b8c86e54041622a53af0f Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 15 May 2026 15:08:56 -0700 Subject: [PATCH 7/9] fix: cancel safety, disk check guard, art file cleanup, artwork perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- lib/screens/album_screen.dart | 22 +++++++++++++---- lib/screens/playlist_screen.dart | 25 +++++++++++++++---- lib/services/offline_service.dart | 31 +++++++++++++++++++----- lib/widgets/album_artwork.dart | 40 +++++++++++++++++-------------- 4 files changed, 86 insertions(+), 32 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 4bdf924..d884627 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -45,10 +45,24 @@ class _AlbumScreenState extends State { void _updateDownloadState() { if (!mounted || _songs.isEmpty) return; - final ids = OfflineService().downloadedSongIds.value; - final allDown = _songs.every((s) => ids.contains(s.id)); + final offline = OfflineService(); + final ids = offline.downloadedSongIds.value; + bool allDown = _songs.every((s) => ids.contains(s.id)); final queued = _album != null && - OfflineService().queuedPlaylistIds.value.contains(_album!.id); + offline.queuedPlaylistIds.value.contains(_album!.id); + if (!allDown && !queued && _isQueued) { + allDown = _songs.every((s) => offline.isSongDownloaded(s.id)); + if (allDown) { + final missing = _songs + .where((s) => !ids.contains(s.id)) + .map((s) => s.id) + .toSet(); + if (missing.isNotEmpty) { + offline.downloadedSongIds.value = {...ids, ...missing}; + return; + } + } + } if (allDown != _allDownloaded || queued != _isQueued) { setState(() { _allDownloaded = allDown; @@ -147,7 +161,7 @@ class _AlbumScreenState extends State { ), ); if (confirmed == true && mounted) { - await OfflineService().deletePlaylistDownloads(_songs.map((s) => s.id).toList()); + await OfflineService().deletePlaylistDownloads(_songs); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index cf4abc9..bba02c4 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -54,9 +54,26 @@ class _PlaylistScreenState extends State { if (!mounted) return; final songs = _playlist?.songs ?? []; if (songs.isEmpty) return; - final ids = OfflineService().downloadedSongIds.value; - final allDown = songs.every((s) => ids.contains(s.id)); - final queued = OfflineService().queuedPlaylistIds.value.contains(widget.playlistId); + final offline = OfflineService(); + final ids = offline.downloadedSongIds.value; + bool allDown = songs.every((s) => ids.contains(s.id)); + final queued = offline.queuedPlaylistIds.value.contains(widget.playlistId); + // Reactive set can lag behind disk after a download completes — verify directly, + // but only on the queued→not-queued transition to avoid per-song disk I/O for + // every open screen while something else is downloading. + if (!allDown && !queued && _isQueued) { + allDown = songs.every((s) => offline.isSongDownloaded(s.id)); + if (allDown) { + final missing = songs + .where((s) => !ids.contains(s.id)) + .map((s) => s.id) + .toSet(); + if (missing.isNotEmpty) { + offline.downloadedSongIds.value = {...ids, ...missing}; + return; // listener will re-fire with the corrected set + } + } + } if (allDown != _allDownloaded || queued != _isQueued) { setState(() { _allDownloaded = allDown; @@ -341,7 +358,7 @@ class _PlaylistScreenState extends State { ), ); if (confirmed == true && mounted) { - await OfflineService().deletePlaylistDownloads(songs.map((s) => s.id).toList()); + await OfflineService().deletePlaylistDownloads(songs); } } diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 44c8739..95ad12f 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -620,18 +620,37 @@ class OfflineService { } Future cancelPlaylistDownload(String playlistId) async { + // Check before removing: if it's still in the queue it hasn't started yet. + final wasStillQueued = _downloadQueue.any((e) => e.playlistId == playlistId); _downloadQueue.removeWhere((e) => e.playlistId == playlistId); - if (_isBackgroundDownloadActive) cancelBackgroundDownload(); + // Only cancel the active background download when this playlist is the one + // currently running (already popped from the queue and in startBackgroundDownload). + if (_isBackgroundDownloadActive && !wasStillQueued) { + cancelBackgroundDownload(); + } queuedPlaylistIds.value = queuedPlaylistIds.value.difference({playlistId}); _queuedPlaylistData.remove(playlistId); await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); - final data = _queuedPlaylistData; - await _prefs?.setString(_keyQueuedPlaylistData, json.encode(data)); + await _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); } - Future deletePlaylistDownloads(List songIds) async { - for (final id in songIds) { - await deleteSong(id); + Future deletePlaylistDownloads(List songs) async { + for (final song in songs) { + await deleteSong(song.id); + } + // deleteSong can't reach art_* files (no coverArtId available from songId alone). + // Delete them here using the unique coverArtIds in this set of songs. + if (_offlineDir == null) return; + final coverArtIds = songs + .map((s) => s.coverArt) + .whereType() + .where((id) => id.isNotEmpty) + .toSet(); + for (final artId in coverArtIds) { + try { + final f = File(_getCoverArtByArtIdPath(artId)); + if (f.existsSync()) await f.delete(); + } catch (_) {} } } diff --git a/lib/widgets/album_artwork.dart b/lib/widgets/album_artwork.dart index 06d2cc0..7a395eb 100644 --- a/lib/widgets/album_artwork.dart +++ b/lib/widgets/album_artwork.dart @@ -189,14 +189,16 @@ class AlbumArtwork extends StatelessWidget { ); } - final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); - if (offlinePath != null) { - return Image.file( - File(offlinePath), - key: ValueKey('offline_natural_$coverArt'), - fit: BoxFit.contain, - errorBuilder: (_, _, _) => _buildPlaceholder(isDark), - ); + if (OfflineService().downloadedSongIds.value.isNotEmpty) { + final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); + if (offlinePath != null) { + return Image.file( + File(offlinePath), + key: ValueKey('offline_natural_$coverArt'), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + ); + } } return Builder( @@ -240,16 +242,18 @@ class AlbumArtwork extends StatelessWidget { ); } - final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); - if (offlinePath != null) { - return Image.file( - File(offlinePath), - key: ValueKey('offline_$coverArt'), - fit: BoxFit.cover, - cacheWidth: cacheSize, - cacheHeight: cacheSize, - errorBuilder: (_, _, _) => _buildPlaceholder(isDark), - ); + if (OfflineService().downloadedSongIds.value.isNotEmpty) { + final offlinePath = OfflineService().getLocalCoverArtPathByCoverArtId(coverArt); + if (offlinePath != null) { + return Image.file( + File(offlinePath), + key: ValueKey('offline_$coverArt'), + fit: BoxFit.cover, + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + ); + } } return Builder( From 3caf86f08a2f2d2e5ffb690811a3e36154644982 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 15 May 2026 20:17:00 -0700 Subject: [PATCH 8/9] fix: intent-based playlist download tracking (downloadedPlaylistIds) Replace file-presence checks with a dedicated downloadedPlaylistIds notifier so cross-playlist songs don't produce false solid-check badges. The solid check now reflects explicit download intent rather than whether individual audio files happen to exist on disk. Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/album_screen.dart | 1 + lib/screens/library_screen.dart | 19 ++++------------- lib/screens/playlist_screen.dart | 1 + lib/screens/playlists_screen.dart | 11 ++++------ lib/services/offline_service.dart | 34 ++++++++++++++++++++++++++++++- lib/widgets/album_artwork.dart | 4 ++-- pubspec.yaml | 2 +- 7 files changed, 46 insertions(+), 26 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index d884627..1eacedf 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -161,6 +161,7 @@ class _AlbumScreenState extends State { ), ); if (confirmed == true && mounted) { + await OfflineService().cancelPlaylistDownload(_album!.id); await OfflineService().deletePlaylistDownloads(_songs); } } diff --git a/lib/screens/library_screen.dart b/lib/screens/library_screen.dart index f69e8b0..4bcaa39 100644 --- a/lib/screens/library_screen.dart +++ b/lib/screens/library_screen.dart @@ -481,24 +481,12 @@ class _LibraryScreenState extends State { ), if (item.type == 'Playlist') ValueListenableBuilder>( - valueListenable: OfflineService().downloadedSongIds, - builder: (context, ids, _) { + valueListenable: OfflineService().downloadedPlaylistIds, + builder: (context, downloaded, _) { return ValueListenableBuilder>( valueListenable: OfflineService().queuedPlaylistIds, builder: (context, queued, _) { - final playlists = Provider.of( - context, - listen: false, - ).playlists; - final playlist = playlists.cast().firstWhere( - (p) => p.id == item.id, - orElse: () => null, - ); - final songs = playlist?.songs; - final allDownloaded = songs != null && - songs.isNotEmpty && - songs.every((s) => ids.contains(s.id)); - if (allDownloaded) { + if (downloaded.contains(item.id)) { return const Padding( padding: EdgeInsets.only(left: 8), child: Icon(Icons.check_circle, color: Colors.green, size: 18), @@ -658,6 +646,7 @@ class _LibraryScreenState extends State { listen: false, ); try { + await OfflineService().cancelPlaylistDownload(item.id); await libraryProvider.deletePlaylist(item.id); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index bba02c4..7b822bd 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -358,6 +358,7 @@ class _PlaylistScreenState extends State { ), ); if (confirmed == true && mounted) { + await OfflineService().cancelPlaylistDownload(widget.playlistId); await OfflineService().deletePlaylistDownloads(songs); } } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 236c37d..c717aaa 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -157,6 +157,7 @@ class PlaylistsScreen extends StatelessWidget { title: const Text('Delete Playlist'), onTap: () async { Navigator.pop(context); + await OfflineService().cancelPlaylistDownload(playlist.id); await libraryProvider.deletePlaylist(playlist.id); }, ), @@ -212,16 +213,12 @@ class _PlaylistTile extends StatelessWidget { style: theme.textTheme.bodySmall, ), trailing: ValueListenableBuilder>( - valueListenable: OfflineService().downloadedSongIds, - builder: (context, ids, _) { + valueListenable: OfflineService().downloadedPlaylistIds, + builder: (context, downloaded, _) { return ValueListenableBuilder>( valueListenable: OfflineService().queuedPlaylistIds, builder: (context, queued, _) { - final songs = playlist.songs; - final allDownloaded = songs != null && - songs.isNotEmpty && - songs.every((s) => ids.contains(s.id)); - if (allDownloaded) { + if (downloaded.contains(playlist.id)) { return const Icon(Icons.check_circle, size: 20, color: Colors.green); } if (queued.contains(playlist.id)) { diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 95ad12f..aaf1808 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -87,6 +87,7 @@ class OfflineService { static const String _keyExpectedSizes = 'offline_expected_sizes'; static const String _keyQueuedPlaylists = 'offline_queued_playlists'; static const String _keyQueuedPlaylistData = 'offline_queued_playlist_data'; + static const String _keyDownloadedPlaylists = 'offline_downloaded_playlists'; static const String _keyParallelDownloads = 'parallel_downloads_count'; static const String _keyKeepScreenOn = 'offline_keep_screen_on'; @@ -99,6 +100,11 @@ class OfflineService { /// Drives the outline-check badge in playlist list views. final ValueNotifier> queuedPlaylistIds = ValueNotifier({}); + /// Playlist IDs the user explicitly completed downloading (intent-based). + /// Drives the solid-check badge — decoupled from individual song file presence + /// so cross-playlist songs don't create false positives. + final ValueNotifier> downloadedPlaylistIds = ValueNotifier({}); + /// playlistId → serialised song list, so we can resume without LibraryProvider. Map>> _queuedPlaylistData = {}; @@ -162,6 +168,10 @@ class OfflineService { } queuedPlaylistIds.value = queuedIds.toSet(); + // Load intent-based downloaded playlist tracking + final downloadedPlaylistList = _prefs?.getStringList(_keyDownloadedPlaylists) ?? []; + downloadedPlaylistIds.value = downloadedPlaylistList.toSet(); + // Unmark any playlists that are now fully on disk _checkAndUnmarkCompleted(merged); } @@ -178,8 +188,10 @@ class OfflineService { if (nowDone.isEmpty) return; for (final id in nowDone) { _queuedPlaylistData.remove(id); } queuedPlaylistIds.value = queuedPlaylistIds.value.difference(nowDone); + downloadedPlaylistIds.value = {...downloadedPlaylistIds.value, ...nowDone}; _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); + _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); } /// Queue a playlist for download. If the processor isn't running, start it. @@ -218,7 +230,23 @@ class OfflineService { while (_downloadQueue.isNotEmpty) { final entry = _downloadQueue.removeAt(0); await startBackgroundDownload(entry.songs, entry.service); - _checkAndUnmarkCompleted(downloadedSongIds.value); + // If every song in this playlist landed on disk, record it as fully downloaded. + final playlistData = _queuedPlaylistData[entry.playlistId]; + if (playlistData != null && playlistData.isNotEmpty) { + final presentIds = downloadedSongIds.value; + final allDone = playlistData.every( + (s) => presentIds.contains(s['id']?.toString() ?? ''), + ); + if (allDone) { + downloadedPlaylistIds.value = {...downloadedPlaylistIds.value, entry.playlistId}; + await _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); + } + } + // Always clear queued state after the attempt. + _queuedPlaylistData.remove(entry.playlistId); + queuedPlaylistIds.value = queuedPlaylistIds.value.difference({entry.playlistId}); + await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + await _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); } _queueProcessorRunning = false; } @@ -629,8 +657,10 @@ class OfflineService { cancelBackgroundDownload(); } queuedPlaylistIds.value = queuedPlaylistIds.value.difference({playlistId}); + downloadedPlaylistIds.value = downloadedPlaylistIds.value.difference({playlistId}); _queuedPlaylistData.remove(playlistId); await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + await _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); await _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); } @@ -719,10 +749,12 @@ class OfflineService { await _prefs?.remove(_keyExpectedSizes); await _prefs?.remove(_keyQueuedPlaylists); await _prefs?.remove(_keyQueuedPlaylistData); + await _prefs?.remove(_keyDownloadedPlaylists); _expectedSizes = {}; _queuedPlaylistData = {}; _downloadQueue.clear(); queuedPlaylistIds.value = {}; + downloadedPlaylistIds.value = {}; downloadedSongIds.value = {}; } catch (e) { debugPrint('Error deleting all downloads: $e'); diff --git a/lib/widgets/album_artwork.dart b/lib/widgets/album_artwork.dart index 7a395eb..acdcb83 100644 --- a/lib/widgets/album_artwork.dart +++ b/lib/widgets/album_artwork.dart @@ -196,7 +196,7 @@ class AlbumArtwork extends StatelessWidget { File(offlinePath), key: ValueKey('offline_natural_$coverArt'), fit: BoxFit.contain, - errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + errorBuilder: (ctx, err, stack) => _buildPlaceholder(isDark), ); } } @@ -251,7 +251,7 @@ class AlbumArtwork extends StatelessWidget { fit: BoxFit.cover, cacheWidth: cacheSize, cacheHeight: cacheSize, - errorBuilder: (_, _, _) => _buildPlaceholder(isDark), + errorBuilder: (ctx, err, stack) => _buildPlaceholder(isDark), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 5adb7c8..03cac9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ publish_to: 'none' # Read more about Android versioning at https://developer.android.com/studio/publish/versioning # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # In Windows, build-name is used as the major, minor, and patch parts -version: 1.0.13+1 +version: 1.0.14+1 environment: sdk: '>=3.0.0 <4.0.0' From b325811cd82c6cf25d895562850c25b5c7faa052 Mon Sep 17 00:00:00 2001 From: tbrackbill Date: Fri, 15 May 2026 20:36:52 -0700 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20QA=20hardening=20=E2=80=94=20wake=20?= =?UTF-8?q?lock,=20delete=20race,=20shared=20art,=20intent-based=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move WakelockPlus.disable() outside retry block so it always fires - cancelBackgroundDownload() before wiping index in deleteAllDownloads - Remove shared art_* deletion from deletePlaylistDownloads (cross-playlist safe) - Swap downloadedSongIds listeners to downloadedPlaylistIds in playlist/album screens - Simplify _updateDownloadState to intent-based contains() lookup - Version 1.0.13+4 Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/album_screen.dart | 32 ++++----------- lib/screens/playlist_screen.dart | 33 +++------------- lib/services/offline_service.dart | 65 +++++++++++++++---------------- pubspec.yaml | 2 +- 4 files changed, 46 insertions(+), 86 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 1eacedf..f71d754 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -32,37 +32,22 @@ class _AlbumScreenState extends State { void initState() { super.initState(); _loadAlbum(); - OfflineService().downloadedSongIds.addListener(_updateDownloadState); + OfflineService().downloadedPlaylistIds.addListener(_updateDownloadState); OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); } @override void dispose() { - OfflineService().downloadedSongIds.removeListener(_updateDownloadState); + OfflineService().downloadedPlaylistIds.removeListener(_updateDownloadState); OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); super.dispose(); } void _updateDownloadState() { - if (!mounted || _songs.isEmpty) return; + if (!mounted || _album == null) return; final offline = OfflineService(); - final ids = offline.downloadedSongIds.value; - bool allDown = _songs.every((s) => ids.contains(s.id)); - final queued = _album != null && - offline.queuedPlaylistIds.value.contains(_album!.id); - if (!allDown && !queued && _isQueued) { - allDown = _songs.every((s) => offline.isSongDownloaded(s.id)); - if (allDown) { - final missing = _songs - .where((s) => !ids.contains(s.id)) - .map((s) => s.id) - .toSet(); - if (missing.isNotEmpty) { - offline.downloadedSongIds.value = {...ids, ...missing}; - return; - } - } - } + final allDown = offline.downloadedPlaylistIds.value.contains(_album!.id); + final queued = offline.queuedPlaylistIds.value.contains(_album!.id); if (allDown != _allDownloaded || queued != _isQueued) { setState(() { _allDownloaded = allDown; @@ -298,10 +283,9 @@ class _AlbumScreenState extends State { ), flexibleSpace: FlexibleSpaceBar( background: ValueListenableBuilder>( - valueListenable: OfflineService().downloadedSongIds, - builder: (context, ids, _) { - final allDownloaded = _songs.isNotEmpty && - _songs.every((s) => ids.contains(s.id)); + valueListenable: OfflineService().downloadedPlaylistIds, + builder: (context, downloaded, _) { + final allDownloaded = _album != null && downloaded.contains(_album!.id); return Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 7b822bd..811ac29 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -39,41 +39,22 @@ class _PlaylistScreenState extends State { void initState() { super.initState(); _loadPlaylist(); - OfflineService().downloadedSongIds.addListener(_updateDownloadState); + OfflineService().downloadedPlaylistIds.addListener(_updateDownloadState); OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); } @override void dispose() { - OfflineService().downloadedSongIds.removeListener(_updateDownloadState); + OfflineService().downloadedPlaylistIds.removeListener(_updateDownloadState); OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); super.dispose(); } void _updateDownloadState() { if (!mounted) return; - final songs = _playlist?.songs ?? []; - if (songs.isEmpty) return; final offline = OfflineService(); - final ids = offline.downloadedSongIds.value; - bool allDown = songs.every((s) => ids.contains(s.id)); + final allDown = offline.downloadedPlaylistIds.value.contains(widget.playlistId); final queued = offline.queuedPlaylistIds.value.contains(widget.playlistId); - // Reactive set can lag behind disk after a download completes — verify directly, - // but only on the queued→not-queued transition to avoid per-song disk I/O for - // every open screen while something else is downloading. - if (!allDown && !queued && _isQueued) { - allDown = songs.every((s) => offline.isSongDownloaded(s.id)); - if (allDown) { - final missing = songs - .where((s) => !ids.contains(s.id)) - .map((s) => s.id) - .toSet(); - if (missing.isNotEmpty) { - offline.downloadedSongIds.value = {...ids, ...missing}; - return; // listener will re-fire with the corrected set - } - } - } if (allDown != _allDownloaded || queued != _isQueued) { setState(() { _allDownloaded = allDown; @@ -437,11 +418,9 @@ class _PlaylistScreenState extends State { child: Column( children: [ ValueListenableBuilder>( - valueListenable: OfflineService().downloadedSongIds, - builder: (context, ids, _) { - final songs = _playlist!.songs ?? []; - final allDownloaded = songs.isNotEmpty && - songs.every((s) => ids.contains(s.id)); + valueListenable: OfflineService().downloadedPlaylistIds, + builder: (context, downloaded, _) { + final allDownloaded = downloaded.contains(widget.playlistId); return Stack( children: [ Container( diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index aaf1808..f2f59d1 100644 --- a/lib/services/offline_service.dart +++ b/lib/services/offline_service.dart @@ -112,6 +112,7 @@ class OfflineService { final List<({String playlistId, List songs, SubsonicService service})> _downloadQueue = []; bool _queueProcessorRunning = false; + String? _activePlaylistId; Future initialize() async { _prefs ??= await SharedPreferences.getInstance(); @@ -173,11 +174,11 @@ class OfflineService { downloadedPlaylistIds.value = downloadedPlaylistList.toSet(); // Unmark any playlists that are now fully on disk - _checkAndUnmarkCompleted(merged); + await _checkAndUnmarkCompleted(merged); } /// Removes playlists from the queued set if all their songs are now present. - void _checkAndUnmarkCompleted(Set presentIds) { + Future _checkAndUnmarkCompleted(Set presentIds) async { final nowDone = {}; for (final playlistId in queuedPlaylistIds.value) { final data = _queuedPlaylistData[playlistId]; @@ -189,9 +190,9 @@ class OfflineService { for (final id in nowDone) { _queuedPlaylistData.remove(id); } queuedPlaylistIds.value = queuedPlaylistIds.value.difference(nowDone); downloadedPlaylistIds.value = {...downloadedPlaylistIds.value, ...nowDone}; - _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); - _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); - _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); + await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); + await _prefs?.setString(_keyQueuedPlaylistData, json.encode(_queuedPlaylistData)); + await _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); } /// Queue a playlist for download. If the processor isn't running, start it. @@ -212,7 +213,7 @@ class OfflineService { // Filter to only songs that still need downloading final missing = songs.where((s) => !isSongDownloaded(s.id)).toList(); if (missing.isEmpty) { - _checkAndUnmarkCompleted(downloadedSongIds.value); + await _checkAndUnmarkCompleted(downloadedSongIds.value); return; } @@ -229,8 +230,11 @@ class OfflineService { Future _processQueue() async { while (_downloadQueue.isNotEmpty) { final entry = _downloadQueue.removeAt(0); + _activePlaylistId = entry.playlistId; await startBackgroundDownload(entry.songs, entry.service); + _activePlaylistId = null; // If every song in this playlist landed on disk, record it as fully downloaded. + // playlistData may be null if cancelPlaylistDownload ran during the download. final playlistData = _queuedPlaylistData[entry.playlistId]; if (playlistData != null && playlistData.isNotEmpty) { final presentIds = downloadedSongIds.value; @@ -242,7 +246,7 @@ class OfflineService { await _prefs?.setStringList(_keyDownloadedPlaylists, downloadedPlaylistIds.value.toList()); } } - // Always clear queued state after the attempt. + // Always clear queued state after the attempt (cancel may have already done this). _queuedPlaylistData.remove(entry.playlistId); queuedPlaylistIds.value = queuedPlaylistIds.value.difference({entry.playlistId}); await _prefs?.setStringList(_keyQueuedPlaylists, queuedPlaylistIds.value.toList()); @@ -612,15 +616,14 @@ class OfflineService { downloadState.value = downloadState.value.copyWith( failedSongs: retryFailed, ); + } - // Always disable wake lock when download finishes or fails - if (!kIsWeb) { - try { - await WakelockPlus.disable(); - debugPrint('Wake lock disabled after download'); - } catch (e) { - debugPrint('Failed to disable wake lock: $e'); - } + if (!kIsWeb) { + try { + await WakelockPlus.disable(); + debugPrint('Wake lock disabled after download'); + } catch (e) { + debugPrint('Failed to disable wake lock: $e'); } } @@ -648,12 +651,10 @@ class OfflineService { } Future cancelPlaylistDownload(String playlistId) async { - // Check before removing: if it's still in the queue it hasn't started yet. - final wasStillQueued = _downloadQueue.any((e) => e.playlistId == playlistId); _downloadQueue.removeWhere((e) => e.playlistId == playlistId); - // Only cancel the active background download when this playlist is the one - // currently running (already popped from the queue and in startBackgroundDownload). - if (_isBackgroundDownloadActive && !wasStillQueued) { + // Only cancel the active background download when this specific playlist is + // the one currently running — not any other playlist that happens to be active. + if (_isBackgroundDownloadActive && _activePlaylistId == playlistId) { cancelBackgroundDownload(); } queuedPlaylistIds.value = queuedPlaylistIds.value.difference({playlistId}); @@ -668,20 +669,8 @@ class OfflineService { for (final song in songs) { await deleteSong(song.id); } - // deleteSong can't reach art_* files (no coverArtId available from songId alone). - // Delete them here using the unique coverArtIds in this set of songs. - if (_offlineDir == null) return; - final coverArtIds = songs - .map((s) => s.coverArt) - .whereType() - .where((id) => id.isNotEmpty) - .toSet(); - for (final artId in coverArtIds) { - try { - final f = File(_getCoverArtByArtIdPath(artId)); - if (f.existsSync()) await f.delete(); - } catch (_) {} - } + // art_* files (indexed by coverArtId) are intentionally left behind — they + // may be shared by songs in other playlists. deleteAllDownloads clears them. } bool get isBackgroundDownloadActive => _isBackgroundDownloadActive; @@ -735,6 +724,14 @@ class OfflineService { Future deleteAllDownloads() async { if (_offlineDir == null) return; + // Stop any active download before wiping the index, otherwise the running + // download will write song IDs back into the cleared set. + if (_isBackgroundDownloadActive) { + cancelBackgroundDownload(); + _activePlaylistId = null; + } + _downloadQueue.clear(); + try { final dir = Directory(_offlineDir!); if (await dir.exists()) { diff --git a/pubspec.yaml b/pubspec.yaml index 03cac9b..40c60ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ publish_to: 'none' # Read more about Android versioning at https://developer.android.com/studio/publish/versioning # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # In Windows, build-name is used as the major, minor, and patch parts -version: 1.0.14+1 +version: 1.0.13+4 environment: sdk: '>=3.0.0 <4.0.0'