diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 0000000..602844f --- /dev/null +++ b/codemagic.yaml @@ -0,0 +1,113 @@ +# Codemagic CI/CD for ratune_mobile (Flutter). +# The repo root IS the Flutter project, so scripts run from the app directory +# with no `working_directory` override. Two workflows: Android (Linux) and +# iOS (mac). Docs: https://docs.codemagic.io/yaml/yaml-getting-started/ + +definitions: + # Shared quality gate — anchored per-step (YAML can't splice a list into a + # list, only reference individual map items). Toolchain is pinned inline in + # each workflow to Flutter 3.44.8 / Dart 3.12.2 (the local dev version); + # change to `stable` for the latest, or bump when you upgrade. + scripts: + - &flutter_version + name: Flutter version + script: flutter --version + - &get_deps + name: Get dependencies + script: flutter pub get + - &analyze + name: Analyze + script: flutter analyze + - &unit_tests + name: Unit tests + script: | + mkdir -p test-results + flutter test --machine --coverage > test-results/flutter.json + test_report: test-results/flutter.json + +workflows: + # --------------------------------------------------------------------------- + android: + name: Android (APK + AAB) + instance_type: linux_x2 + max_build_duration: 60 + environment: + flutter: 3.44.8 + java: 17 + # Uncomment once you add a keystore to Codemagic (Teams → Code signing + # identities → Android) and reference its group here to sign releases. + # groups: + # - android_keystore # CM_KEYSTORE, CM_KEYSTORE_PASSWORD, CM_KEY_ALIAS, CM_KEY_PASSWORD + cache: + cache_paths: + - $HOME/.pub-cache + - $HOME/.gradle/caches + # triggering: + # events: [push, tag] + # branch_patterns: + # - pattern: main + # include: true + scripts: + - *flutter_version + - *get_deps + - *analyze + - *unit_tests + # NOTE: android/app/build.gradle.kts currently signs `release` with the + # DEBUG key (Flutter template default), so these build & install but are + # not upload-ready. To ship: add a real signingConfig backed by + # key.properties / the CI env vars above — this step then needs no change. + - name: Build APK (release) + script: flutter build apk --release + - name: Build App Bundle (release) + script: flutter build appbundle --release + artifacts: + - build/app/outputs/flutter-apk/*.apk + - build/app/outputs/bundle/**/*.aab + - build/app/outputs/**/mapping.txt + - flutter_drive.log + # publishing: + # email: + # recipients: + # - you@conversionpath.com + # notify: + # success: true + # failure: true + # # google_play: + # # credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS + # # track: internal + + # --------------------------------------------------------------------------- + ios: + name: iOS (unsigned build) + instance_type: mac_mini_m2 + max_build_duration: 60 + environment: + flutter: 3.44.8 + java: 17 + cache: + cache_paths: + - $HOME/.pub-cache + - $HOME/.gradle/caches + scripts: + - *flutter_version + - *get_deps + - *analyze + - *unit_tests + # Compiles the iOS app without code signing — verifies the build on CI. + # Produces a .app (not an installable/uploadable .ipa). + - name: Build iOS (no codesign) + script: flutter build ios --release --no-codesign + artifacts: + - build/ios/iphoneos/*.app + - flutter_drive.log + + # --- To produce a signed, uploadable IPA instead: configure App Store + # --- Connect + signing in Codemagic (Teams → Integrations → App Store + # --- Connect), then add `ios_signing` to `environment` and replace the + # --- build step with: + # - name: Set up provisioning profiles + # script: xcode-project use-profiles + # - name: Build IPA + # script: flutter build ipa --release --export-options-plist=/Users/builder/export_options.plist + # --- with artifacts: build/ios/ipa/*.ipa and + # --- publishing.app_store_connect.submit_to_testflight: true diff --git a/lib/downloads/download_manager.dart b/lib/downloads/download_manager.dart index 64874be..d7b65a2 100644 --- a/lib/downloads/download_manager.dart +++ b/lib/downloads/download_manager.dart @@ -279,6 +279,12 @@ class DownloadController extends StateNotifier { return; } + // Honor a removal that happened mid-download: don't resurrect the entry. + if (!state.byId.containsKey(song.id)) { + await File(tmpPath).delete().catchError((_) => File(tmpPath)); + return; + } + final tmp = File(tmpPath); await tmp.rename(finalPath); final size = await File(finalPath).length(); diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index a5cb72a..fe13fd1 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -11,7 +11,6 @@ import 'downloads_screen.dart'; import 'favorites_screen.dart'; import 'playlists_screen.dart'; import 'search_screen.dart'; -import 'settings_screen.dart'; /// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic /// server. Artists and Albums drill down by pushing onto the (nested) navigator; @@ -66,13 +65,6 @@ class BrowserScreen extends ConsumerWidget { MaterialPageRoute(builder: (_) => const DownloadsScreen()), ), ), - _Action( - icon: Icons.settings, - label: 'Settings', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), - ), - ), ], ), const SizedBox(height: RatuneSpacing.md), @@ -337,6 +329,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { : 'No tracks indexed yet.'), ); } else { + final downloads = ref.watch(downloadManagerProvider); body = ListView.builder( padding: EdgeInsets.zero, itemCount: index.songs.length, @@ -345,9 +338,16 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { return BrowseRow( title: song.title ?? 'Untitled', trailing: song.artist, + downloadStatus: downloads.byId[song.id]?.status, onTap: () => playback.playSongs(index.songs, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), + onAddToPlaylist: () => + showAddToPlaylistSheet(context, songs: [song]), + onDownload: () => + ref.read(downloadManagerProvider.notifier).download(song), + onRemoveDownload: () => + ref.read(downloadManagerProvider.notifier).remove(song.id), ); }, ); @@ -451,8 +451,36 @@ class AlbumScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(albumProvider(id)); + final downloads = ref.watch(downloadManagerProvider); + final playback = ref.read(playbackProvider.notifier); + final songs = album.valueOrNull?.songs ?? const []; + return _DetailScaffold( title: album.valueOrNull?.name ?? 'Album', + actions: songs.isEmpty + ? null + : [ + IconButton( + tooltip: 'Add to playlist', + onPressed: () => + showAddToPlaylistSheet(context, songs: songs), + icon: const Icon(Icons.playlist_add), + ), + IconButton( + tooltip: 'Download album', + onPressed: () { + ref + .read(downloadManagerProvider.notifier) + .downloadAll(songs); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Downloading album…'), + duration: Duration(seconds: 2)), + ); + }, + icon: const Icon(Icons.download), + ), + ], child: album.when( loading: () => const _Centered(child: _Loading()), error: (e, _) => _Centered(child: _ErrorText('$e')), @@ -461,14 +489,20 @@ class AlbumScreen extends ConsumerWidget { itemCount: a.songs.length, itemBuilder: (context, i) { final song = a.songs[i]; - final playback = ref.read(playbackProvider.notifier); return BrowseRow( leading: song.track?.toString(), title: song.title ?? 'Untitled', trailing: _fmtDuration(song.duration), + downloadStatus: downloads.byId[song.id]?.status, onTap: () => playback.playSongs(a.songs, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), + onAddToPlaylist: () => + showAddToPlaylistSheet(context, songs: [song]), + onDownload: () => + ref.read(downloadManagerProvider.notifier).download(song), + onRemoveDownload: () => + ref.read(downloadManagerProvider.notifier).remove(song.id), ); }, ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 52e956c..9a04f98 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../history/play_history.dart'; +import '../subsonic/models.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; -import '../widgets/hairline_panel.dart'; +import '../widgets/block_progress_bar.dart'; import 'browser_screen.dart'; -/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover, -/// all derived from the local play history (mirrors Ratune's home tab). +/// Home tab — a "Resume" hero bound to live playback, then horizontal art +/// shelves. Reworked from the terminal-style dense list into a touch-first +/// mobile layout while keeping the app's angular, hairline aesthetic. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -15,8 +18,14 @@ class HomeScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(subsonicClientProvider); final recentAlbums = ref.watch(recentAlbumsProvider); - final recentSongs = ref.watch(recentSongsProvider); final rediscover = ref.watch(rediscoverProvider); + final newest = ref.watch(newestAlbumsProvider); + final random = ref.watch(randomAlbumsProvider); + + String? artFor(String? coverArt, {int size = 300}) => + (client != null && coverArt != null) + ? client.coverArtUri(coverArt, size: size).toString() + : null; return ListView( padding: const EdgeInsets.fromLTRB( @@ -26,123 +35,408 @@ class HomeScreen extends ConsumerWidget { RatuneSpacing.lg, ), children: [ - HairlinePanel( - title: 'Recently Played', - child: SizedBox( - height: 120, - child: recentAlbums.isEmpty - ? const _Empty('No listening history yet.') - : ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: recentAlbums.length, - separatorBuilder: (_, _) => - const SizedBox(width: RatuneSpacing.md), - itemBuilder: (_, i) { - final rec = recentAlbums[i]; - final art = (client != null && rec.coverArt != null) - ? client.coverArtUri(rec.coverArt!, size: 240).toString() - : null; - return GestureDetector( - onTap: rec.albumId == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - AlbumScreen(id: rec.albumId!), - ), - ), - child: SizedBox( - width: 120, - child: ColoredBox( - color: RatuneColors.surface, - child: art != null - ? Image.network(art, - fit: BoxFit.cover, gaplessPlayback: true) - : const Icon(Icons.album_outlined, - color: RatuneColors.dimmed), - ), - ), - ); - }, - ), - ), - ), + const _HeroCard(), const SizedBox(height: RatuneSpacing.xl), - HairlinePanel( - title: 'Recent Tracks', - padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), - child: recentSongs.isEmpty - ? const Padding( - padding: EdgeInsets.all(RatuneSpacing.lg), - child: _Empty('Nothing played recently.'), - ) - : Column( - children: [ - for (final rec in recentSongs.take(8)) - BrowseRow( - title: rec.title, - trailing: rec.artist, - onTap: () => ref - .read(playbackProvider.notifier) - .playSongs([rec.toSong()]), - onPlayNext: () => ref - .read(playbackProvider.notifier) - .playNext(rec.toSong()), - onAddToQueue: () => ref - .read(playbackProvider.notifier) - .addToQueue(rec.toSong()), - ), - ], + + // Recently Played — from local history. + if (recentAlbums.isNotEmpty) + _Shelf( + title: 'Recently Played', + cards: [ + for (final rec in recentAlbums) + _ArtCard( + artUri: artFor(rec.coverArt), + title: rec.album ?? 'Unknown album', + subtitle: rec.artist, + onTap: rec.albumId == null + ? null + : () => _pushAlbum(context, rec.albumId!), ), - ), - const SizedBox(height: RatuneSpacing.xl), - HairlinePanel( - title: 'Rediscover', - padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (rediscover.isEmpty) - const Padding( - padding: EdgeInsets.all(RatuneSpacing.lg), - child: _Empty('Listen to more music to unlock suggestions.'), - ) - else - for (final a in rediscover) - BrowseRow( - title: a.name, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ArtistScreen(id: a.artistId), - ), - ), - ), - InkWell( - onTap: () => - ref.read(rediscoverSeedProvider.notifier).state++, - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: RatuneSpacing.lg, - vertical: RatuneSpacing.md, - ), - child: Text('↻ re-roll', - style: TextStyle(color: RatuneColors.dimmed)), - ), - ), ], ), + + // Recently Added — server discovery shelf. + _AlbumShelf( + title: 'Recently Added', + albums: newest, + artFor: artFor, ), + + // Made For You — rediscover artists you're neglecting. + if (rediscover.isNotEmpty) + _Shelf( + title: 'Made For You', + onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++, + cards: [ + for (final a in rediscover) + _ArtCard( + artUri: null, + title: a.name, + subtitle: 'Artist', + fallbackIcon: Icons.person_outline, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ArtistScreen(id: a.artistId), + ), + ), + ), + ], + ), + + // Random — server discovery shelf, shares the shuffle affordance. + _AlbumShelf( + title: 'Random', + albums: random, + artFor: artFor, + onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++, + ), + + if (recentAlbums.isEmpty && client == null) + const Padding( + padding: EdgeInsets.only(top: RatuneSpacing.xl), + child: Text( + 'Connect to a server and start listening — your home fills in as you play.', + style: TextStyle(color: RatuneColors.dimmed), + ), + ), + ], + ); + } + + static void _pushAlbum(BuildContext context, String albumId) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)), + ); + } +} + +/// Live "Resume" hero. Isolated so playback position ticks rebuild only this +/// card, not the whole Home list. Tapping jumps to Now Playing (or plays the +/// most recent track when nothing is loaded). +class _HeroCard extends ConsumerWidget { + const _HeroCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accent = Theme.of(context).colorScheme.primary; + final client = ref.watch(subsonicClientProvider); + final current = ref.watch(playbackProvider.select((s) => s.current)); + final progress = ref.watch(playbackProvider.select((s) => s.progress)); + + // Fall back to the most recent track so the hero is useful before playback. + final recent = ref.watch(recentSongsProvider); + final PlayRecord? fallback = recent.isEmpty ? null : recent.first; + + final String? coverArt = current?.coverArt ?? fallback?.coverArt; + final artUri = (client != null && coverArt != null) + ? client.coverArtUri(coverArt, size: 240).toString() + : null; + + final title = current?.title ?? fallback?.title; + final subtitle = current?.artist ?? fallback?.artist; + final hasCurrent = current != null; + + if (title == null) { + return _HeroShell( + accent: accent, + onTap: () => ref.read(selectedTabProvider.notifier).state = 1, + child: const Row( + children: [ + Icon(Icons.library_music_outlined, + color: RatuneColors.dimmed, size: 40), + SizedBox(width: RatuneSpacing.lg), + Expanded( + child: Text('Browse your library to start listening', + style: TextStyle(color: RatuneColors.foreground)), + ), + ], + ), + ); + } + + return _HeroShell( + accent: accent, + onTap: () { + if (hasCurrent) { + ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; + } else if (fallback != null) { + ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]); + ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; + } + }, + child: Row( + children: [ + SizedBox( + width: 64, + height: 64, + child: ColoredBox( + color: RatuneColors.surface, + child: artUri != null + ? Image.network(artUri, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const Icon( + Icons.album_outlined, color: RatuneColors.dimmed)) + : const Icon(Icons.album_outlined, + color: RatuneColors.dimmed), + ), + ), + const SizedBox(width: RatuneSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(hasCurrent ? Icons.play_arrow : Icons.history, + size: 14, color: accent), + const SizedBox(width: RatuneSpacing.xs), + Text(hasCurrent ? 'NOW PLAYING' : 'RESUME', + style: TextStyle( + color: accent, + fontSize: 11, + letterSpacing: 1, + fontWeight: FontWeight.w700)), + ], + ), + const SizedBox(height: RatuneSpacing.xs), + Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: RatuneColors.foreground, + fontWeight: FontWeight.w700)), + if (subtitle != null) + Text(subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: RatuneColors.dimmed)), + if (hasCurrent) ...[ + const SizedBox(height: RatuneSpacing.md), + BlockProgressBar(progress: progress, cells: 32, height: 6), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _HeroShell extends StatelessWidget { + const _HeroShell( + {required this.child, required this.accent, required this.onTap}); + + final Widget child; + final Color accent; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(RatuneSpacing.lg), + decoration: BoxDecoration( + color: RatuneColors.surface, + border: Border.all(color: RatuneColors.borderActive), + ), + child: child, + ), + ); + } +} + +/// A horizontal shelf: a header (with an optional shuffle action) over a +/// scrolling row of art cards. +class _Shelf extends StatelessWidget { + const _Shelf({required this.title, required this.cards, this.onShuffle}); + + final String title; + final List cards; + final VoidCallback? onShuffle; + + @override + Widget build(BuildContext context) { + if (cards.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: RatuneSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ShelfHeader(title: title, onShuffle: onShuffle), + const SizedBox(height: RatuneSpacing.md), + SizedBox( + height: 182, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: cards.length, + separatorBuilder: (_, _) => + const SizedBox(width: RatuneSpacing.md), + itemBuilder: (_, i) => cards[i], + ), + ), + ], + ), + ); + } +} + +class _ShelfHeader extends StatelessWidget { + const _ShelfHeader({required this.title, this.onShuffle}); + + final String title; + final VoidCallback? onShuffle; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(title, + style: const TextStyle( + color: RatuneColors.foreground, + fontWeight: FontWeight.w700, + letterSpacing: 0.5)), + const Spacer(), + if (onShuffle != null) + InkWell( + onTap: onShuffle, + customBorder: const CircleBorder(), + child: const SizedBox( + width: RatuneSpacing.minTouchTarget, + height: 28, + child: Icon(Icons.shuffle, size: 18, color: RatuneColors.dimmed), + ), + ), ], ); } } -class _Empty extends StatelessWidget { - const _Empty(this.message); - final String message; +/// An async album shelf backed by a FutureProvider. Renders a placeholder row +/// while loading and disappears when empty / errored / offline. +class _AlbumShelf extends StatelessWidget { + const _AlbumShelf({ + required this.title, + required this.albums, + required this.artFor, + this.onShuffle, + }); + + final String title; + final AsyncValue> albums; + final String? Function(String? coverArt, {int size}) artFor; + final VoidCallback? onShuffle; + @override - Widget build(BuildContext context) => Align( - alignment: Alignment.centerLeft, - child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)), - ); + Widget build(BuildContext context) { + return albums.when( + loading: () => _Shelf( + title: title, + onShuffle: onShuffle, + cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()], + ), + error: (_, _) => const SizedBox.shrink(), + data: (list) => _Shelf( + title: title, + onShuffle: onShuffle, + cards: [ + for (final a in list) + _ArtCard( + artUri: artFor(a.coverArt), + title: a.name ?? 'Unknown album', + subtitle: a.artist, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), + ), + ), + ], + ), + ); + } +} + +/// A fixed-width art tile with a caption — the shelf's building block. +class _ArtCard extends StatelessWidget { + const _ArtCard({ + required this.title, + required this.artUri, + this.subtitle, + this.onTap, + this.fallbackIcon = Icons.album_outlined, + }); + + final String title; + final String? artUri; + final String? subtitle; + final VoidCallback? onTap; + final IconData fallbackIcon; + + static const double _size = 132; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _size, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: _size, + height: _size, + child: ColoredBox( + color: RatuneColors.surface, + child: artUri != null + ? Image.network(artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => + Icon(fallbackIcon, color: RatuneColors.dimmed)) + : Icon(fallbackIcon, color: RatuneColors.dimmed), + ), + ), + ), + const SizedBox(height: RatuneSpacing.sm), + Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: RatuneColors.foreground)), + if (subtitle != null) + Text(subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: RatuneColors.dimmed, fontSize: 12)), + ], + ), + ), + ); + } +} + +class _ArtCardSkeleton extends StatelessWidget { + const _ArtCardSkeleton(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _ArtCard._size, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: const SizedBox( + width: _ArtCard._size, + height: _ArtCard._size, + child: ColoredBox(color: RatuneColors.surface), + ), + ), + ); + } } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index dee5f51..3f24a33 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart' show LoopMode; +import '../downloads/download_manager.dart'; import '../playback/playback_engine.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/block_progress_bar.dart'; import '../widgets/hairline_panel.dart'; +import 'add_to_playlist_sheet.dart'; /// Now Playing tab — album art + info strip + transport, bound to the live /// playback engine. The top region shows the full-size album art by default and @@ -249,6 +251,12 @@ class _FavRating extends ConsumerWidget { final rating = fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0); + final downloadStatus = + ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status)); + final isDownloaded = downloadStatus == DownloadStatus.done; + final isDownloading = downloadStatus == DownloadStatus.queued || + downloadStatus == DownloadStatus.downloading; + return Row( children: [ InkWell( @@ -278,6 +286,41 @@ class _FavRating extends ConsumerWidget { ), ), ), + const Spacer(), + InkWell( + onTap: () => showAddToPlaylistSheet(context, songs: [song]), + customBorder: const CircleBorder(), + child: const Padding( + padding: EdgeInsets.all(RatuneSpacing.sm), + child: Icon(Icons.playlist_add, + size: 22, color: RatuneColors.dimmed), + ), + ), + InkWell( + onTap: isDownloading + ? null + : () { + final dm = ref.read(downloadManagerProvider.notifier); + isDownloaded ? dm.remove(song.id) : dm.download(song); + }, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(RatuneSpacing.sm), + child: isDownloading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + isDownloaded + ? Icons.download_done + : Icons.download_outlined, + size: 22, + color: isDownloaded ? accent : RatuneColors.dimmed, + ), + ), + ), ], ); } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 8204540..b0376f9 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../playlists/playlists.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index c8cfee3..063d8a8 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -6,6 +6,7 @@ import '../screens/browser_screen.dart'; import '../screens/connect_sheet.dart'; import '../screens/home_screen.dart'; import '../screens/now_playing_screen.dart'; +import '../screens/settings_screen.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; import '../widgets/mini_player.dart'; @@ -52,6 +53,12 @@ class _AppShellState extends ConsumerState { // Keep favorites alive from launch so its connect/disconnect listener runs // and hydrates stars/ratings as soon as a server connects. ref.watch(favoritesProvider); + // Instantiate the download + playlist stores at launch too, so their + // per-server manifests load from disk before the user can trigger + // playback — otherwise the first play right after a cold (offline) boot + // races the async manifest load and misses a downloaded file. + ref.watch(downloadManagerProvider); + ref.watch(playlistsProvider); final index = ref.watch(selectedTabProvider); return PopScope( @@ -189,27 +196,53 @@ class _StatusBar extends ConsumerWidget { ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed), }; - return InkWell( - onTap: () => showConnectSheet(context), - child: Container( - height: 22, - padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg), - color: RatuneColors.surface, - child: Row( - children: [ - Text('$glyph ', style: TextStyle(color: color)), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: color), + return Container( + height: 22, + color: RatuneColors.surface, + child: Row( + children: [ + // Left: connection status — tap to open the connect sheet. + Expanded( + child: InkWell( + onTap: () => showConnectSheet(context), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg), + child: Row( + children: [ + Text('$glyph ', style: TextStyle(color: color)), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color), + ), + ), + ], + ), ), ), - const SizedBox(width: RatuneSpacing.md), - const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)), - ], - ), + ), + // Right: settings — pushes over the whole shell (root navigator). + InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.settings, size: 12, color: RatuneColors.dimmed), + SizedBox(width: RatuneSpacing.xs), + Text('settings', + style: TextStyle(color: RatuneColors.dimmed)), + ], + ), + ), + ), + ], ), ); } diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 5f0ab63..b55581d 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -195,6 +195,23 @@ final libraryIndexProvider = return controller; }); +/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on +/// the Home tab. Small page; the Home shelf shows the first handful. +final newestAlbumsProvider = FutureProvider>((ref) async { + final client = ref.watch(subsonicClientProvider); + if (client == null) return const []; + return client.getAlbumList2(type: 'newest', size: 20); +}); + +/// A random album shelf (`getAlbumList2` type `random`). Re-rolls when +/// [rediscoverSeedProvider] bumps so it shares the Home "shuffle" affordance. +final randomAlbumsProvider = FutureProvider>((ref) async { + ref.watch(rediscoverSeedProvider); + final client = ref.watch(subsonicClientProvider); + if (client == null) return const []; + return client.getAlbumList2(type: 'random', size: 20); +}); + final artistProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); if (client == null) throw StateError('Not connected'); diff --git a/test/features_test.dart b/test/features_test.dart new file mode 100644 index 0000000..f95987d --- /dev/null +++ b/test/features_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ratune_mobile/playlists/playlists.dart'; +import 'package:ratune_mobile/settings/settings_store.dart'; +import 'package:ratune_mobile/subsonic/models.dart'; +import 'package:ratune_mobile/subsonic/subsonic_client.dart'; + +/// Fake client so playlist mutations can be tested without a server. Methods +/// are overridden; the (unused) real Dio is created but never hit. +class _FakeClient extends SubsonicClient { + _FakeClient() : super(baseUrl: 'http://x', username: 'u', password: 'p'); + + final List playlists = []; + final Map details = {}; + bool failNext = false; + int _seq = 0; + + void _maybeThrow() { + if (failNext) { + failNext = false; + throw Exception('boom'); + } + } + + @override + Future> getPlaylists() async => List.of(playlists); + + @override + Future getPlaylist(String id) async => + details[id] ?? PlaylistDetail(id: id, name: 'x'); + + @override + Future createPlaylist(String name) async { + _maybeThrow(); + final d = PlaylistDetail(id: 'pl-${_seq++}', name: name); + playlists.add(d.toSummary()); + details[d.id] = d; + return d; + } + + @override + Future addTracksToPlaylist(String playlistId, List songIds) async { + _maybeThrow(); + } + + @override + Future removeTrackFromPlaylist(String playlistId, int index) async { + _maybeThrow(); + } + + @override + Future renamePlaylist(String playlistId, String name) async { + _maybeThrow(); + } + + @override + Future deletePlaylist(String id) async { + _maybeThrow(); + } +} + +Song _song(String id) => Song(id: id, title: 'Song $id'); + +void main() { + group('model round-trips', () { + test('Playlist toJson/fromJson', () { + final p = Playlist(id: 'p1', name: 'Mix', songCount: 3, duration: 600); + final back = Playlist.fromJson(p.toJson()); + expect(back.id, 'p1'); + expect(back.name, 'Mix'); + expect(back.songCount, 3); + expect(back.duration, 600); + }); + + test('PlaylistDetail parses tracks from the `entry` key', () { + final detail = PlaylistDetail.fromJson({ + 'id': 'p1', + 'name': 'Mix', + 'entry': [ + {'id': 's1', 'title': 'One'}, + {'id': 's2', 'title': 'Two'}, + ], + }); + expect(detail.songs.length, 2); + expect(detail.songs.first.id, 's1'); + // toJson emits back under `entry` so the offline mirror round-trips. + expect(PlaylistDetail.fromJson(detail.toJson()).songs.length, 2); + }); + }); + + group('AppSettings', () { + test('copyWith preserves format when the arg is omitted', () { + const s = AppSettings(downloadFormat: 'mp3'); + expect(s.copyWith(streamMaxBitRate: 320).downloadFormat, 'mp3'); + }); + + test('copyWith(downloadFormat: null) clears to Original', () { + const s = AppSettings(downloadFormat: 'mp3'); + expect(s.copyWith(downloadFormat: null).downloadFormat, isNull); + }); + + test('toJson/fromJson round-trip', () { + const s = AppSettings( + streamMaxBitRate: 192, downloadMaxBitRate: 0, downloadFormat: 'opus'); + final back = AppSettings.fromJson(s.toJson()); + expect(back.streamMaxBitRate, 192); + expect(back.downloadMaxBitRate, 0); + expect(back.downloadFormat, 'opus'); + }); + }); + + group('PlaylistsController optimistic mutations', () { + late _FakeClient client; + late PlaylistsController controller; + + setUp(() { + client = _FakeClient(); + // Null server key => no filesystem access (persist/reload are no-ops). + controller = PlaylistsController( + clientGetter: () => client, + serverKeyGetter: () => null, + ); + }); + + test('create adds a playlist and returns its id', () async { + final id = await controller.create('Roadtrip'); + expect(id, isNotNull); + expect(controller.state.playlists.map((p) => p.name), contains('Roadtrip')); + expect(controller.state.details[id!]!.name, 'Roadtrip'); + }); + + test('addTracks appends optimistically and bumps the count', () async { + final id = (await controller.create('Mix'))!; + await controller.addTracks(id, [_song('a'), _song('b')]); + expect(controller.state.details[id]!.songs.length, 2); + final summary = controller.state.playlists.firstWhere((p) => p.id == id); + expect(summary.songCount, 2); + }); + + test('removeAt drops the track optimistically', () async { + final id = (await controller.create('Mix'))!; + await controller.addTracks(id, [_song('a'), _song('b')]); + await controller.removeAt(id, 0); + final songs = controller.state.details[id]!.songs; + expect(songs.length, 1); + expect(songs.single.id, 'b'); + }); + + test('rename updates the summary and cached detail', () async { + final id = (await controller.create('Old'))!; + await controller.rename(id, 'New'); + expect(controller.state.playlists.single.name, 'New'); + expect(controller.state.details[id]!.name, 'New'); + }); + + test('delete removes the playlist', () async { + final id = (await controller.create('Temp'))!; + await controller.delete(id); + expect(controller.state.playlists, isEmpty); + expect(controller.state.details.containsKey(id), isFalse); + }); + + test('rename reverts when the server call fails', () async { + final id = (await controller.create('Keep'))!; + client.failNext = true; + await controller.rename(id, 'Nope'); + expect(controller.state.playlists.single.name, 'Keep'); + }); + }); +}