yaml
This commit is contained in:
parent
d205277cdd
commit
d1cab09a4f
9 changed files with 850 additions and 140 deletions
113
codemagic.yaml
Normal file
113
codemagic.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -279,6 +279,12 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
return;
|
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);
|
final tmp = File(tmpPath);
|
||||||
await tmp.rename(finalPath);
|
await tmp.rename(finalPath);
|
||||||
final size = await File(finalPath).length();
|
final size = await File(finalPath).length();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import 'downloads_screen.dart';
|
||||||
import 'favorites_screen.dart';
|
import 'favorites_screen.dart';
|
||||||
import 'playlists_screen.dart';
|
import 'playlists_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
|
||||||
|
|
||||||
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
|
||||||
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
|
||||||
|
|
@ -66,13 +65,6 @@ class BrowserScreen extends ConsumerWidget {
|
||||||
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
|
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Action(
|
|
||||||
icon: Icons.settings,
|
|
||||||
label: 'Settings',
|
|
||||||
onTap: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: RatuneSpacing.md),
|
const SizedBox(height: RatuneSpacing.md),
|
||||||
|
|
@ -337,6 +329,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
: 'No tracks indexed yet.'),
|
: 'No tracks indexed yet.'),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
final downloads = ref.watch(downloadManagerProvider);
|
||||||
body = ListView.builder(
|
body = ListView.builder(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
itemCount: index.songs.length,
|
itemCount: index.songs.length,
|
||||||
|
|
@ -345,9 +338,16 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
return BrowseRow(
|
return BrowseRow(
|
||||||
title: song.title ?? 'Untitled',
|
title: song.title ?? 'Untitled',
|
||||||
trailing: song.artist,
|
trailing: song.artist,
|
||||||
|
downloadStatus: downloads.byId[song.id]?.status,
|
||||||
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
||||||
onPlayNext: () => playback.playNext(song),
|
onPlayNext: () => playback.playNext(song),
|
||||||
onAddToQueue: () => playback.addToQueue(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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final album = ref.watch(albumProvider(id));
|
final album = ref.watch(albumProvider(id));
|
||||||
|
final downloads = ref.watch(downloadManagerProvider);
|
||||||
|
final playback = ref.read(playbackProvider.notifier);
|
||||||
|
final songs = album.valueOrNull?.songs ?? const <Song>[];
|
||||||
|
|
||||||
return _DetailScaffold(
|
return _DetailScaffold(
|
||||||
title: album.valueOrNull?.name ?? 'Album',
|
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(
|
child: album.when(
|
||||||
loading: () => const _Centered(child: _Loading()),
|
loading: () => const _Centered(child: _Loading()),
|
||||||
error: (e, _) => _Centered(child: _ErrorText('$e')),
|
error: (e, _) => _Centered(child: _ErrorText('$e')),
|
||||||
|
|
@ -461,14 +489,20 @@ class AlbumScreen extends ConsumerWidget {
|
||||||
itemCount: a.songs.length,
|
itemCount: a.songs.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final song = a.songs[i];
|
final song = a.songs[i];
|
||||||
final playback = ref.read(playbackProvider.notifier);
|
|
||||||
return BrowseRow(
|
return BrowseRow(
|
||||||
leading: song.track?.toString(),
|
leading: song.track?.toString(),
|
||||||
title: song.title ?? 'Untitled',
|
title: song.title ?? 'Untitled',
|
||||||
trailing: _fmtDuration(song.duration),
|
trailing: _fmtDuration(song.duration),
|
||||||
|
downloadStatus: downloads.byId[song.id]?.status,
|
||||||
onTap: () => playback.playSongs(a.songs, startIndex: i),
|
onTap: () => playback.playSongs(a.songs, startIndex: i),
|
||||||
onPlayNext: () => playback.playNext(song),
|
onPlayNext: () => playback.playNext(song),
|
||||||
onAddToQueue: () => playback.addToQueue(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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../history/play_history.dart';
|
||||||
|
import '../subsonic/models.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/block_progress_bar.dart';
|
||||||
import 'browser_screen.dart';
|
import 'browser_screen.dart';
|
||||||
|
|
||||||
/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover,
|
/// Home tab — a "Resume" hero bound to live playback, then horizontal art
|
||||||
/// all derived from the local play history (mirrors Ratune's home tab).
|
/// 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 {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -15,8 +18,14 @@ class HomeScreen extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
final recentAlbums = ref.watch(recentAlbumsProvider);
|
final recentAlbums = ref.watch(recentAlbumsProvider);
|
||||||
final recentSongs = ref.watch(recentSongsProvider);
|
|
||||||
final rediscover = ref.watch(rediscoverProvider);
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
|
@ -26,123 +35,408 @@ class HomeScreen extends ConsumerWidget {
|
||||||
RatuneSpacing.lg,
|
RatuneSpacing.lg,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
HairlinePanel(
|
const _HeroCard(),
|
||||||
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 SizedBox(height: RatuneSpacing.xl),
|
const SizedBox(height: RatuneSpacing.xl),
|
||||||
HairlinePanel(
|
|
||||||
title: 'Recent Tracks',
|
// Recently Played — from local history.
|
||||||
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
|
if (recentAlbums.isNotEmpty)
|
||||||
child: recentSongs.isEmpty
|
_Shelf(
|
||||||
? const Padding(
|
title: 'Recently Played',
|
||||||
padding: EdgeInsets.all(RatuneSpacing.lg),
|
cards: [
|
||||||
child: _Empty('Nothing played recently.'),
|
for (final rec in recentAlbums)
|
||||||
)
|
_ArtCard(
|
||||||
: Column(
|
artUri: artFor(rec.coverArt),
|
||||||
children: [
|
title: rec.album ?? 'Unknown album',
|
||||||
for (final rec in recentSongs.take(8))
|
subtitle: rec.artist,
|
||||||
BrowseRow(
|
onTap: rec.albumId == null
|
||||||
title: rec.title,
|
? null
|
||||||
trailing: rec.artist,
|
: () => _pushAlbum(context, rec.albumId!),
|
||||||
onTap: () => ref
|
|
||||||
.read(playbackProvider.notifier)
|
|
||||||
.playSongs([rec.toSong()]),
|
|
||||||
onPlayNext: () => ref
|
|
||||||
.read(playbackProvider.notifier)
|
|
||||||
.playNext(rec.toSong()),
|
|
||||||
onAddToQueue: () => ref
|
|
||||||
.read(playbackProvider.notifier)
|
|
||||||
.addToQueue(rec.toSong()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
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<Widget> 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 {
|
/// An async album shelf backed by a FutureProvider. Renders a placeholder row
|
||||||
const _Empty(this.message);
|
/// while loading and disappears when empty / errored / offline.
|
||||||
final String message;
|
class _AlbumShelf extends StatelessWidget {
|
||||||
|
const _AlbumShelf({
|
||||||
|
required this.title,
|
||||||
|
required this.albums,
|
||||||
|
required this.artFor,
|
||||||
|
this.onShuffle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final AsyncValue<List<Album>> albums;
|
||||||
|
final String? Function(String? coverArt, {int size}) artFor;
|
||||||
|
final VoidCallback? onShuffle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Align(
|
Widget build(BuildContext context) {
|
||||||
alignment: Alignment.centerLeft,
|
return albums.when(
|
||||||
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart' show LoopMode;
|
import 'package:just_audio/just_audio.dart' show LoopMode;
|
||||||
|
|
||||||
|
import '../downloads/download_manager.dart';
|
||||||
import '../playback/playback_engine.dart';
|
import '../playback/playback_engine.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/block_progress_bar.dart';
|
import '../widgets/block_progress_bar.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/hairline_panel.dart';
|
||||||
|
import 'add_to_playlist_sheet.dart';
|
||||||
|
|
||||||
/// Now Playing tab — album art + info strip + transport, bound to the live
|
/// 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
|
/// playback engine. The top region shows the full-size album art by default and
|
||||||
|
|
@ -249,6 +251,12 @@ class _FavRating extends ConsumerWidget {
|
||||||
final rating =
|
final rating =
|
||||||
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../playlists/playlists.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../screens/browser_screen.dart';
|
||||||
import '../screens/connect_sheet.dart';
|
import '../screens/connect_sheet.dart';
|
||||||
import '../screens/home_screen.dart';
|
import '../screens/home_screen.dart';
|
||||||
import '../screens/now_playing_screen.dart';
|
import '../screens/now_playing_screen.dart';
|
||||||
|
import '../screens/settings_screen.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/mini_player.dart';
|
import '../widgets/mini_player.dart';
|
||||||
|
|
@ -52,6 +53,12 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||||
// Keep favorites alive from launch so its connect/disconnect listener runs
|
// Keep favorites alive from launch so its connect/disconnect listener runs
|
||||||
// and hydrates stars/ratings as soon as a server connects.
|
// and hydrates stars/ratings as soon as a server connects.
|
||||||
ref.watch(favoritesProvider);
|
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);
|
final index = ref.watch(selectedTabProvider);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
|
|
@ -189,27 +196,53 @@ class _StatusBar extends ConsumerWidget {
|
||||||
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
|
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
|
||||||
};
|
};
|
||||||
|
|
||||||
return InkWell(
|
return Container(
|
||||||
onTap: () => showConnectSheet(context),
|
height: 22,
|
||||||
child: Container(
|
color: RatuneColors.surface,
|
||||||
height: 22,
|
child: Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
children: [
|
||||||
color: RatuneColors.surface,
|
// Left: connection status — tap to open the connect sheet.
|
||||||
child: Row(
|
Expanded(
|
||||||
children: [
|
child: InkWell(
|
||||||
Text('$glyph ', style: TextStyle(color: color)),
|
onTap: () => showConnectSheet(context),
|
||||||
Expanded(
|
child: Padding(
|
||||||
child: Text(
|
padding:
|
||||||
label,
|
const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
||||||
maxLines: 1,
|
child: Row(
|
||||||
overflow: TextOverflow.ellipsis,
|
children: [
|
||||||
style: TextStyle(color: color),
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,23 @@ final libraryIndexProvider =
|
||||||
return controller;
|
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<List<Album>>((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<List<Album>>((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<Artist, String>((ref, id) async {
|
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
if (client == null) throw StateError('Not connected');
|
if (client == null) throw StateError('Not connected');
|
||||||
|
|
|
||||||
169
test/features_test.dart
Normal file
169
test/features_test.dart
Normal file
|
|
@ -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<Playlist> playlists = [];
|
||||||
|
final Map<String, PlaylistDetail> details = {};
|
||||||
|
bool failNext = false;
|
||||||
|
int _seq = 0;
|
||||||
|
|
||||||
|
void _maybeThrow() {
|
||||||
|
if (failNext) {
|
||||||
|
failNext = false;
|
||||||
|
throw Exception('boom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Playlist>> getPlaylists() async => List.of(playlists);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlaylistDetail> getPlaylist(String id) async =>
|
||||||
|
details[id] ?? PlaylistDetail(id: id, name: 'x');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlaylistDetail?> 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<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
|
||||||
|
_maybeThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
|
||||||
|
_maybeThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> renamePlaylist(String playlistId, String name) async {
|
||||||
|
_maybeThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue