-
Notifications
You must be signed in to change notification settings - Fork 32
feat: offline playlist & album downloader #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6278e27
de6b898
1a41b13
30fc506
437e4e6
34c34e4
79f27c9
3caf86f
b325811
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError | ||
| android.useAndroidX=true | ||
| android.newDsl=false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <output-dir> (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" | ||
|
Comment on lines
+9
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a unique container name to avoid cross-run collisions. A fixed Proposed change-CONTAINER_NAME="musly-apk-build"
+CONTAINER_NAME="musly-apk-build-$$-$(date +%s)"
+cleanup() { sudo docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true; }
+trap cleanup EXIT
...
-sudo docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
sudo docker create --name "$CONTAINER_NAME" "$IMAGE_TAG" echo done
...
-sudo docker rm -f "$CONTAINER_NAME"🧰 Tools🪛 Shellcheck (0.11.0)[warning] 18-18: Use semicolon or linefeed before 'done' (or quote to make it literal). (SC1010) 🤖 Prompt for AI Agents |
||
|
|
||
| echo "" | ||
| echo "==> APK ready: $OUTPUT_DIR/musly-release.apk" | ||
| ls -lh "$OUTPUT_DIR/musly-release.apk" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,12 +24,36 @@ class _AlbumScreenState extends State<AlbumScreen> { | |
| Album? _album; | ||
| List<Song> _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<void> _loadAlbum() async { | ||
|
|
@@ -62,6 +86,7 @@ class _AlbumScreenState extends State<AlbumScreen> { | |
| _songs = songs; | ||
| _isLoading = false; | ||
| }); | ||
| _updateDownloadState(); | ||
| } | ||
| } catch (e) { | ||
| if (mounted) { | ||
|
|
@@ -84,41 +109,74 @@ class _AlbumScreenState extends State<AlbumScreen> { | |
| } | ||
|
|
||
| Future<void> _downloadAlbum() async { | ||
| if (_songs.isEmpty) return; | ||
|
|
||
| final subsonicService = Provider.of<SubsonicService>( | ||
| context, | ||
| listen: false, | ||
| ); | ||
| if (_songs.isEmpty || _album == null) return; | ||
| final offlineService = OfflineService(); | ||
| final subsonicService = Provider.of<SubsonicService>(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<void> _cancelDownload() async { | ||
| if (_album == null) return; | ||
| await OfflineService().cancelPlaylistDownload(_album!.id); | ||
| } | ||
|
|
||
| Future<void> _removeDownloads() async { | ||
| if (_songs.isEmpty || _album == null) return; | ||
| final confirmed = await showDialog<bool>( | ||
| 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), | ||
| ); | ||
| } | ||
|
Comment on lines
111
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Localize the new album download strings. The queued snackbar, remove-downloads dialog, and tooltips are all hardcoded English. Since this screen already depends on 🤖 Prompt for AI Agents |
||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final theme = Theme.of(context); | ||
|
|
@@ -224,39 +282,50 @@ class _AlbumScreenState extends State<AlbumScreen> { | |
| 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<Set<String>>( | ||
| 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
What do Flutter release docs recommend for reproducible CI builds: pinning a specific Flutter version/tag vs using the moving "stable" branch?💡 Result:
For reproducible CI builds, Flutter best practices strongly recommend pinning a specific Flutter version or tag rather than using the moving "stable" branch [1][2]. While the "stable" channel is the recommended choice for production app releases [3], it is a moving target that receives updates. Relying on it in CI can lead to non-deterministic builds where your environment changes unexpectedly between pipeline runs, potentially causing build failures or regressions due to underlying SDK changes [1][2]. To ensure reproducibility and stability in CI/CD pipelines, the following approaches are recommended: 1. Pinning a Specific Version: Explicitly define the Flutter version (e.g., 3.41.5) in your CI configuration or a version file [2][4]. This ensures that every build uses the exact same SDK environment, regardless of when the pipeline runs [1]. 2. Using Version Management Tools: Tools like FVM (Flutter Version Manager) are widely used to manage Flutter SDK versions per project [5][6]. You can pin your project to a specific version in a configuration file (such as.fvmrc), which your CI pipeline can then read to install the correct SDK version automatically [2]. 3. Matrix Testing: If you need to ensure compatibility across different versions, use a CI matrix strategy [2]. This allows you to test your application against multiple specific versions (e.g., current stable, next stable, and beta) in a single pipeline run, rather than relying on a single moving branch [2]. By pinning your version, you gain control over when to upgrade your SDK, allowing you to test changes in a controlled manner before updating your production pipeline [7][1].
Citations:
Pin Flutter to an explicit version for reproducible release artifacts.
Using
--branch stablecreates non-deterministic builds since the stable branch updates over time. Flutter best practices explicitly recommend pinning to a specific version in CI/CD to ensure reproducibility and gain control over when to upgrade the SDK.Proposed change
📝 Committable suggestion
🤖 Prompt for AI Agents