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/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 137458d..f71d754 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -24,12 +24,36 @@ class _AlbumScreenState extends State { Album? _album; List _songs = []; bool _isLoading = true; - bool _isDownloading = false; + + bool _allDownloaded = false; + bool _isQueued = false; @override void initState() { super.initState(); _loadAlbum(); + OfflineService().downloadedPlaylistIds.addListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); + } + + @override + void dispose() { + OfflineService().downloadedPlaylistIds.removeListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); + super.dispose(); + } + + void _updateDownloadState() { + if (!mounted || _album == null) return; + final offline = OfflineService(); + final allDown = offline.downloadedPlaylistIds.value.contains(_album!.id); + final queued = offline.queuedPlaylistIds.value.contains(_album!.id); + if (allDown != _allDownloaded || queued != _isQueued) { + setState(() { + _allDownloaded = allDown; + _isQueued = queued; + }); + } } Future _loadAlbum() async { @@ -62,6 +86,7 @@ class _AlbumScreenState extends State { _songs = songs; _isLoading = false; }); + _updateDownloadState(); } } catch (e) { if (mounted) { @@ -84,41 +109,74 @@ class _AlbumScreenState extends State { } Future _downloadAlbum() async { - if (_songs.isEmpty) return; - - final subsonicService = Provider.of( - context, - listen: false, - ); + if (_songs.isEmpty || _album == null) return; 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); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Downloading ${_songs.length} songs in background…'), + 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().cancelPlaylistDownload(_album!.id); + await OfflineService().deletePlaylistDownloads(_songs); + } + } + + 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); @@ -224,39 +282,50 @@ 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().downloadedPlaylistIds, + builder: (context, downloaded, _) { + final allDownloaded = _album != null && downloaded.contains(_album!.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: [ - 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), - ), + if (!isOffline) _buildDownloadButton(context), ], ), SliverToBoxAdapter( 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/library_screen.dart b/lib/screens/library_screen.dart index e8fc2d5..4bcaa39 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,30 @@ class _LibraryScreenState extends State { ], ), ), + if (item.type == 'Playlist') + ValueListenableBuilder>( + valueListenable: OfflineService().downloadedPlaylistIds, + builder: (context, downloaded, _) { + return ValueListenableBuilder>( + valueListenable: OfflineService().queuedPlaylistIds, + builder: (context, queued, _) { + if (downloaded.contains(item.id)) { + 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(); + }, + ); + }, + ), ], ), ), @@ -621,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 9e8473e..811ac29 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -28,15 +28,39 @@ 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 = {}; + bool _allDownloaded = false; + bool _isQueued = false; + @override void initState() { super.initState(); _loadPlaylist(); + OfflineService().downloadedPlaylistIds.addListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.addListener(_updateDownloadState); + } + + @override + void dispose() { + OfflineService().downloadedPlaylistIds.removeListener(_updateDownloadState); + OfflineService().queuedPlaylistIds.removeListener(_updateDownloadState); + super.dispose(); + } + + void _updateDownloadState() { + if (!mounted) return; + final offline = OfflineService(); + final allDown = offline.downloadedPlaylistIds.value.contains(widget.playlistId); + final queued = offline.queuedPlaylistIds.value.contains(widget.playlistId); + if (allDown != _allDownloaded || queued != _isQueued) { + setState(() { + _allDownloaded = allDown; + _isQueued = queued; + }); + } } Future _loadPlaylist() async { @@ -52,6 +76,7 @@ class _PlaylistScreenState extends State { _playlist = playlist; _isLoading = false; }); + _updateDownloadState(); } } catch (e) { if (mounted) { @@ -278,40 +303,73 @@ class _PlaylistScreenState extends State { Future _downloadPlaylist() async { 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); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Downloading ${songs.length} songs in background…'), + 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().cancelPlaylistDownload(widget.playlistId); + await OfflineService().deletePlaylistDownloads(songs); + } + } + + 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); @@ -359,24 +417,50 @@ 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().downloadedPlaylistIds, + builder: (context, downloaded, _) { + final allDownloaded = downloaded.contains(widget.playlistId); + 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( @@ -563,18 +647,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/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 2b5d145..c717aaa 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'; @@ -156,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); }, ), @@ -210,10 +212,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().downloadedPlaylistIds, + builder: (context, downloaded, _) { + return ValueListenableBuilder>( + valueListenable: OfflineService().queuedPlaylistIds, + builder: (context, queued, _) { + if (downloaded.contains(playlist.id)) { + 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, + ); + }, + ); + }, ), onTap: onTap, onLongPress: onLongPress, diff --git a/lib/screens/settings_storage_tab.dart b/lib/screens/settings_storage_tab.dart index db7e9d2..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}); @@ -137,6 +138,10 @@ class _SettingsStorageTabState extends State { _buildDivider(), _buildOfflineInfo(), _buildDivider(), + _buildActiveDownloadsRow(), + _buildDivider(), + _buildPlaylistStatusRow(), + _buildDivider(), _buildDownloadAllLibraryButton(), _buildDivider(), _buildDeleteDownloadsButton(), @@ -662,6 +667,94 @@ 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), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DownloadPlaylistStatusScreen(), + ), + ), + ); + }, + ); + } + Widget _buildDownloadAllLibraryButton() { return ValueListenableBuilder( valueListenable: _offlineService.downloadState, diff --git a/lib/services/offline_service.dart b/lib/services/offline_service.dart index 7fc1db6..f2f59d1 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,17 @@ class DownloadState { int? currentProgress, int? totalCount, int? downloadedCount, + Song? currentSong, + bool clearCurrentSong = false, + List? failedSongs, }) { return DownloadState( isDownloading: isDownloading ?? this.isDownloading, currentProgress: currentProgress ?? this.currentProgress, totalCount: totalCount ?? this.totalCount, downloadedCount: downloadedCount ?? this.downloadedCount, + currentSong: clearCurrentSong ? null : (currentSong ?? this.currentSong), + failedSongs: failedSongs ?? this.failedSongs, ); } } @@ -52,16 +71,49 @@ 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'; 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 _keyDownloadedPlaylists = 'offline_downloaded_playlists'; 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({}); + + /// 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 = {}; + + /// Sequential download queue: each entry is (playlistId, songs). + final List<({String playlistId, List songs, SubsonicService service})> + _downloadQueue = []; + bool _queueProcessorRunning = false; + String? _activePlaylistId; + Future initialize() async { _prefs ??= await SharedPreferences.getInstance(); final dir = await getApplicationDocumentsDirectory(); @@ -71,6 +123,170 @@ class OfflineService { if (!await offlineDirectory.exists()) { await offlineDirectory.create(recursive: true); } + + // 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(); + + // Load intent-based downloaded playlist tracking + final downloadedPlaylistList = _prefs?.getStringList(_keyDownloadedPlaylists) ?? []; + downloadedPlaylistIds.value = downloadedPlaylistList.toSet(); + + // Unmark any playlists that are now fully on disk + await _checkAndUnmarkCompleted(merged); + } + + /// Removes playlists from the queued set if all their songs are now present. + Future _checkAndUnmarkCompleted(Set presentIds) async { + 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); + downloadedPlaylistIds.value = {...downloadedPlaylistIds.value, ...nowDone}; + 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. + /// 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) { + await _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); + _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; + 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 (cancel may have already done this). + _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; + } + + /// 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) { @@ -85,6 +301,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); @@ -92,6 +312,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 { @@ -115,7 +342,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() { @@ -150,6 +378,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, { @@ -157,9 +393,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( @@ -172,18 +415,30 @@ 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); await _prefs?.setStringList(_keyDownloadedSongs, downloadedIds); } + // Notify reactive listeners (SongTile badges, playlist checkmarks) + downloadedSongIds.value = {...downloadedSongIds.value, song.id}; try { if (song.coverArt != null) { 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) { @@ -282,11 +537,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,48 +566,111 @@ 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); } } catch (e) { debugPrint('Error during background download: $e'); - } finally { - _isBackgroundDownloadActive = false; - downloadState.value = downloadState.value.copyWith(isDownloading: false); - - // 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'); - } + } + + // 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( + failedSongs: retryFailed, + ); + } + + if (!kIsWeb) { + try { + await WakelockPlus.disable(); + debugPrint('Wake lock disabled after download'); + } catch (e) { + debugPrint('Failed to disable wake lock: $e'); } } + + _isBackgroundDownloadActive = false; + downloadState.value = downloadState.value.copyWith( + isDownloading: false, + clearCurrentSong: true, + ); + } + + 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, + clearCurrentSong: true, + ); + } + + Future cancelPlaylistDownload(String playlistId) async { + _downloadQueue.removeWhere((e) => e.playlistId == playlistId); + // 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}); + 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)); + } + + Future deletePlaylistDownloads(List songs) async { + for (final song in songs) { + await deleteSong(song.id); + } + // 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; @@ -386,6 +710,9 @@ class OfflineService { final downloadedIds = getDownloadedSongIds(); 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) { @@ -397,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()) { @@ -408,6 +743,16 @@ class OfflineService { } await _prefs?.setStringList(_keyDownloadedSongs, []); + 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'); } @@ -476,7 +821,6 @@ class OfflineService { } String getPlayableUrl(Song song, SubsonicService subsonicService) { - if (song.isLocal == true && song.path != null) { return 'file://${song.path}'; } 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! diff --git a/lib/widgets/album_artwork.dart b/lib/widgets/album_artwork.dart index 6196599..acdcb83 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,18 @@ class AlbumArtwork extends StatelessWidget { ); } + 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: (ctx, err, stack) => _buildPlaceholder(isDark), + ); + } + } + return Builder( builder: (context) { final imageUrl = _ImageUrlCache.getUrl( @@ -229,6 +242,20 @@ class AlbumArtwork extends StatelessWidget { ); } + 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: (ctx, err, stack) => _buildPlaceholder(isDark), + ); + } + } + return Builder( builder: (context) { final imageUrl = _ImageUrlCache.getUrl( 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), - ), - ], + ], + ); + }, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 5adb7c8..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.13+1 +version: 1.0.13+4 environment: sdk: '>=3.0.0 <4.0.0'