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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Dockerfile.release
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
Comment on lines +41 to +43
Copy link
Copy Markdown

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 stable creates 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
+ARG FLUTTER_VERSION=3.29.3
-RUN git clone --depth 1 --branch stable \
-        https://github.com/flutter/flutter.git /opt/flutter \
+RUN git clone --depth 1 --branch ${FLUTTER_VERSION} \
+        https://github.com/flutter/flutter.git /opt/flutter \
     && git config --global --add safe.directory /opt/flutter
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RUN git clone --depth 1 --branch stable \
https://github.com/flutter/flutter.git /opt/flutter \
&& git config --global --add safe.directory /opt/flutter
ARG FLUTTER_VERSION=3.29.3
RUN git clone --depth 1 --branch ${FLUTTER_VERSION} \
https://github.com/flutter/flutter.git /opt/flutter \
&& git config --global --add safe.directory /opt/flutter
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile.release` around lines 41 - 43, Replace the non-deterministic clone
of Flutter that uses --branch stable with a pinned SDK tag or commit so releases
are reproducible: update the RUN that clones into /opt/flutter to fetch a
specific release (e.g., use --branch vX.Y.Z or clone then git checkout
<tag_or_commit>) and keep the git config --global --add safe.directory
/opt/flutter; choose and document the exact Flutter tag/commit to pin (replace
"stable" with the chosen tag in the RUN command or add an explicit git checkout
after clone).


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
1 change: 1 addition & 0 deletions android/gradle.properties
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
27 changes: 27 additions & 0 deletions build-release-apk.sh
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a unique container name to avoid cross-run collisions.

A fixed CONTAINER_NAME can break concurrent builds and may delete another run’s container.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build-release-apk.sh` around lines 9 - 23, The script uses a fixed
CONTAINER_NAME ("musly-apk-build") which can collide across concurrent runs;
change CONTAINER_NAME in build-release-apk.sh to a unique value (e.g., include
$$, a timestamp, or a generated UUID) and use that variable consistently for
docker create, docker cp and docker rm; also add a trap cleanup that removes the
container name on exit to avoid leftover containers (references: CONTAINER_NAME,
docker create --name, docker cp, docker rm -f).


echo ""
echo "==> APK ready: $OUTPUT_DIR/musly-release.apk"
ls -lh "$OUTPUT_DIR/musly-release.apk"
6 changes: 6 additions & 0 deletions lib/providers/player_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand Down
179 changes: 124 additions & 55 deletions lib/screens/album_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -62,6 +86,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
_songs = songs;
_isLoading = false;
});
_updateDownloadState();
}
} catch (e) {
if (mounted) {
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the new album download strings.

The queued snackbar, remove-downloads dialog, and tooltips are all hardcoded English. Since this screen already depends on AppLocalizations, these new states should use localized strings too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/screens/album_screen.dart` around lines 111 - 178, Replace hardcoded
English UI strings in _downloadAlbum, _removeDownloads, and _buildDownloadButton
with localized values from AppLocalizations (e.g. call
AppLocalizations.of(context) inside these methods/builders and use resource keys
instead of literal text); specifically localize the SnackBar message in
_downloadAlbum, the AlertDialog title/content and button labels in
_removeDownloads, and all IconButton tooltip texts in _buildDownloadButton,
adding new localization keys
(queuedSongs/removeDownloadsTitle/removeDownloadsContent/cancelLabel/removeLabel/downloadedTooltip/downloadingTooltip/downloadAlbumTooltip
or similar) to your ARB/locale files and use those keys via AppLocalizations in
the referenced methods.


@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand Down Expand Up @@ -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(
Expand Down
Loading