diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml index c9b1eff8..0ff1066f 100644 --- a/.forgejo/workflows/flutter.yml +++ b/.forgejo/workflows/flutter.yml @@ -74,7 +74,15 @@ jobs: - name: Build release APK if: startsWith(github.ref, 'refs/tags/v') - run: flutter build apk --release + # Inject the tag (sans leading 'v') as the build's --build-name + # so PackageInfo.version matches what /api/client/version + # reports — otherwise the in-app update banner would always + # think the user is behind because pubspec.yaml's static + # version drifts from the actual release tag. + shell: bash + run: | + TAG="${GITHUB_REF#refs/tags/v}" + flutter build apk --release --build-name="${TAG}" - name: Upload debug APK artifact if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart index 49bbe7dd..ba19be86 100644 --- a/flutter_client/lib/cache/adapters.dart +++ b/flutter_client/lib/cache/adapters.dart @@ -34,12 +34,18 @@ extension ArtistRefDriftWrite on ArtistRef { extension CachedAlbumAdapter on CachedAlbum { /// `artistName` is supplied by the joined CachedArtists row at query time. + /// `coverUrl` is reconstructed deterministically from the album id — + /// the server emits the same shape (see internal/api/convert.go:69). + /// We don't need to persist it, so AlbumRef.coverUrl is non-empty + /// even when the row was populated from a sync that didn't carry the + /// derived URL. AlbumRef toRef({String artistName = ''}) => AlbumRef( id: id, title: title, sortTitle: sortTitle, artistId: artistId, artistName: artistName, + coverUrl: '/api/albums/$id/cover', ); } diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart index bc724b03..ad4e3beb 100644 --- a/flutter_client/lib/cache/cache_first.dart +++ b/flutter_client/lib/cache/cache_first.dart @@ -5,12 +5,20 @@ // - empty + online → fetch via REST, populate drift, await re-emission // - empty + offline → yield mapped empty result (UI shows empty state) // +// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh +// in the background after the first non-empty emission. Use for +// aggregate lists (playlists, etc.) where the server may have rows +// the local sync didn't pick up — yields cache immediately, refreshes +// silently, and drift watch() re-emits with whatever new rows landed. +// // The pattern lets every read provider trust drift as the source of // truth. SyncController keeps drift fresh in the background; widget // rebuilds happen automatically as drift writes propagate via watch(). import 'dart:async'; +import 'package:flutter/foundation.dart' show debugPrint; + /// Wraps the watch + cold-cache fallback pattern. Generic over: /// D — the drift row type (or TypedResult for joins) /// T — the result type the caller wants (e.g. `List`) @@ -28,22 +36,55 @@ Stream cacheFirst({ required Future Function() fetchAndPopulate, required T Function(List) toResult, required Future Function() isOnline, + bool alwaysRefresh = false, + String? tag, }) async* { + // Tracks whether we've already kicked off a stale-while-revalidate + // refresh for this stream subscription, so we don't fire one on every + // drift re-emission (otherwise the populate cycles forever). + var revalidated = false; + void log(String msg) { + if (tag != null) debugPrint('cacheFirst[$tag]: $msg'); + } + await for (final rows in driftStream) { if (rows.isNotEmpty) { + log('drift hit (${rows.length} rows)'); yield toResult(rows); + // Stale-while-revalidate: yield cache immediately, then kick off + // a REST refresh in the background. Drift watch() picks up the + // resulting writes and re-emits via this same stream loop. + // Useful for aggregate lists (e.g. playlists) where the server + // may have rows the local sync didn't pick up. + if (alwaysRefresh && !revalidated && await isOnline()) { + revalidated = true; + unawaited(_safeFetch(fetchAndPopulate)); + } continue; } + log('drift miss; checking connectivity'); if (await isOnline()) { + log('online; calling fetchAndPopulate'); try { await fetchAndPopulate(); + log('fetchAndPopulate done; awaiting drift re-emit'); // The drift watch() stream re-emits with the populated rows on // the next loop iteration. Don't yield here. - } catch (_) { + } catch (e, st) { + log('fetchAndPopulate failed: $e\n$st'); yield toResult(rows); // empty result; caller surfaces error } } else { + log('offline; yielding empty'); yield toResult(rows); // empty result; offline } } } + +Future _safeFetch(Future Function() fn) async { + try { + await fn(); + } catch (_) { + // Background revalidate — swallow; UI already showed cached state. + } +} diff --git a/flutter_client/lib/cache/connectivity_provider.dart b/flutter_client/lib/cache/connectivity_provider.dart index d3cf4d71..06fa3e6c 100644 --- a/flutter_client/lib/cache/connectivity_provider.dart +++ b/flutter_client/lib/cache/connectivity_provider.dart @@ -5,9 +5,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator /// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and /// pull/cache". -final connectivityProvider = StreamProvider((ref) { +/// +/// IMPORTANT: onConnectivityChanged only emits on *changes*, not on +/// subscription. Without seeding an initial value via checkConnectivity(), +/// any consumer using `ref.read(connectivityProvider.future)` would +/// block until the OS happened to report a connectivity flip — which +/// is exactly what made album/artist/playlist detail screens spin +/// forever for tiles tapped before the first event landed. +final connectivityProvider = StreamProvider((ref) async* { final c = Connectivity(); - return c.onConnectivityChanged.map( - (results) => results.any((r) => r != ConnectivityResult.none), - ); + bool isOnline(List rs) => + rs.any((r) => r != ConnectivityResult.none); + + // checkConnectivity() goes over a platform channel and on some + // Android builds it can stall. Fall back to "assume online" after + // 2s so consumers waiting on .future never block forever — being + // wrong about connectivity costs at most one failed request, but + // being stuck spins the UI indefinitely. + try { + final initial = await c.checkConnectivity().timeout( + const Duration(seconds: 2), + onTimeout: () => const [ConnectivityResult.wifi], + ); + yield isOnline(initial); + } catch (_) { + yield true; + } + yield* c.onConnectivityChanged.map(isOnline); }); diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart index 90825b90..a7bc694a 100644 --- a/flutter_client/lib/library/album_detail_screen.dart +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -6,6 +6,7 @@ import '../cache/audio_cache_manager.dart'; import '../cache/db.dart'; import '../likes/like_button.dart'; import '../player/player_provider.dart'; +import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/track_row.dart'; @@ -27,7 +28,18 @@ class AlbumDetailScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16), child: Row(children: [ - Container(width: 96, height: 96, color: fs.slate), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 96, + height: 96, + child: ServerImage( + url: '/api/albums/$id/cover', + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ), + ), + ), const SizedBox(width: 16), Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart index 32d49809..497d24a8 100644 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -6,6 +6,7 @@ import '../api/endpoints/likes.dart'; import '../likes/like_button.dart'; import '../models/album.dart'; import '../player/player_provider.dart'; +import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/album_card.dart'; @@ -30,9 +31,21 @@ class ArtistDetailScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16), child: Row(children: [ - Container( - width: 96, height: 96, - decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle), + ClipOval( + child: SizedBox( + width: 96, + height: 96, + // Server derives artist cover from a representative + // album. Drift cache doesn't persist that pointer, so + // mirror the trick client-side: reuse the first album + // returned by artistAlbumsProvider. Falls back to + // slate while albums are loading or empty. + child: _ArtistAvatar( + serverCoverUrl: a.coverUrl, + albums: albums, + fs: fs, + ), + ), ), const SizedBox(width: 16), Expanded( @@ -68,23 +81,78 @@ class ArtistDetailScreen extends ConsumerWidget { albums.when( error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), - data: (list) => GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.8, - ), - itemCount: list.length, - itemBuilder: (_, i) { - final AlbumRef album = list[i]; - return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}')); - }, - ), + data: (list) => LayoutBuilder(builder: (ctx, constraints) { + const cols = 3; + const sidePad = 8.0; + const gap = 8.0; + final cellW = + (constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) / + cols; + // Card content: cover (cellW - 16) + 8 + title (≤2 lines + // ≈ 36) + small fudge. Artist line is suppressed in this + // grid (showArtist: false) since the page header already + // names the artist. + final cellH = (cellW - 16) + 8 + 36 + 4; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisExtent: cellH, + mainAxisSpacing: gap, + crossAxisSpacing: gap, + ), + itemCount: list.length, + itemBuilder: (_, i) { + final AlbumRef album = list[i]; + return AlbumCard( + album: album, + width: cellW, + titleMaxLines: 2, + showArtist: false, + onTap: () => context.push('/albums/${album.id}'), + ); + }, + ); + }), ), ]), ), ); } } + +/// Renders the artist's avatar. Server-emitted coverUrl wins when +/// non-empty; otherwise we mirror the server's "use the first album's +/// cover" rule client-side via the loaded album list. +class _ArtistAvatar extends StatelessWidget { + const _ArtistAvatar({ + required this.serverCoverUrl, + required this.albums, + required this.fs, + }); + final String serverCoverUrl; + final AsyncValue> albums; + final FabledSwordTheme fs; + + @override + Widget build(BuildContext context) { + if (serverCoverUrl.isNotEmpty) { + return ServerImage( + url: serverCoverUrl, + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ); + } + final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null; + if (firstId == null) { + return Container(color: fs.slate); + } + return ServerImage( + url: '/api/albums/$firstId/cover', + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ); + } +} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index c714414e..cbf87bd7 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -58,20 +58,30 @@ class HomeScreen extends ConsumerWidget { ), data: (h) => RefreshIndicator( onRefresh: () async => ref.refresh(homeProvider.future), - child: ListView(children: [ - _PlaylistsSection( - playlists: allPlaylists.value?.owned ?? const [], - status: status.value ?? SystemPlaylistsStatus.empty(), - ), - _RecentlyAddedSection(albums: h.recentlyAddedAlbums), - _RediscoverSection( - albums: h.rediscoverAlbums, - artists: h.rediscoverArtists, - ), - _MostPlayedSection(tracks: h.mostPlayedTracks), - _LastPlayedSection(artists: h.lastPlayedArtists), - const SizedBox(height: 96), - ]), + child: ListView( + // ClampingScrollPhysics: no bounce overscroll past the + // content. Combined with the bottom padding below, this + // makes "scroll to end" land the last item just above the + // player bar — no empty void. + physics: const ClampingScrollPhysics(), + children: [ + _PlaylistsSection( + playlists: allPlaylists.value?.owned ?? const [], + status: status.value ?? SystemPlaylistsStatus.empty(), + ), + _RecentlyAddedSection(albums: h.recentlyAddedAlbums), + _RediscoverSection( + albums: h.rediscoverAlbums, + artists: h.rediscoverArtists, + ), + _MostPlayedSection(tracks: h.mostPlayedTracks), + _LastPlayedSection(artists: h.lastPlayedArtists), + // Bottom padding ≈ player bar height (top row 80 + seek + // 30 + padding 16 ≈ ~140) so the last section's bottom + // edge lands right above the player when scrolled. + const SizedBox(height: 140), + ], + ), ), ), ), diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index 2a5b7908..25fbec9b 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/client.dart'; @@ -60,8 +61,10 @@ final artistProvider = toResult: (rows) => rows.isEmpty ? const ArtistRef(id: '', name: '') : rows.first.toRef(), - isOnline: () async => - (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + tag: 'artist($id)', ); }); @@ -89,8 +92,10 @@ final artistAlbumsProvider = final artist = r.readTableOrNull(db.cachedArtists); return album.toRef(artistName: artist?.name ?? ''); }).toList(), - isOnline: () async => - (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + tag: 'artistAlbums($artistId)', ); }); @@ -152,33 +157,106 @@ final albumProvider = StreamProvider.family< db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), ]); - await for (final albumRows in albumQuery.watch()) { - if (albumRows.isEmpty) { - // Cold cache fallback - if (await ref.read(connectivityProvider.future)) { - try { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getAlbum(albumId); - await db.batch((b) { - b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]); - b.insertAllOnConflictUpdate(db.cachedTracks, - fresh.tracks.map((t) => t.toDrift()).toList()); - }); - // watch() re-emits with the populated rows; loop continues. - } catch (_) { - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], + // Once-per-subscription guard so we don't re-fetch in a loop if the + // server genuinely returns zero tracks (or if the fetch fails). + var fetchAttempted = false; + + Future isOnline() async { + try { + return await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true); + } catch (_) { + return true; + } + } + + // Cold-cache fetch: pulls the album + its tracks + any unique + // artists referenced and writes all three tables in one batch. + // Returns true on success (drift watch will re-emit), false on + // failure so the caller can yield an empty result. + Future fetchAndPopulate() async { + try { + final api = await ref.read(libraryApiProvider.future); + debugPrint('albumProvider($albumId): calling getAlbum'); + final fresh = await api + .getAlbum(albumId) + .timeout(const Duration(seconds: 10)); + debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift'); + // Collect every artist mentioned by the album + its tracks so + // the JOINs that drive both the album header and the track rows + // have something to bind to. Without this, drift returns null + // for the artist row and artistName surfaces empty — visible + // as a missing artist line in the mini player and row list. + final artists = {}; + if (fresh.album.artistId.isNotEmpty && + fresh.album.artistName.isNotEmpty) { + artists[fresh.album.artistId] = ArtistRef( + id: fresh.album.artistId, + name: fresh.album.artistName, + ); + } + for (final t in fresh.tracks) { + if (t.artistId.isNotEmpty && + t.artistName.isNotEmpty && + !artists.containsKey(t.artistId)) { + artists[t.artistId] = ArtistRef( + id: t.artistId, + name: t.artistName, ); } - } else { + } + await db.batch((b) { + if (artists.isNotEmpty) { + b.insertAllOnConflictUpdate( + db.cachedArtists, artists.values.map((a) => a.toDrift()).toList()); + } + b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]); + b.insertAllOnConflictUpdate(db.cachedTracks, + fresh.tracks.map((t) => t.toDrift()).toList()); + }); + debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit'); + return true; + } catch (e, st) { + debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st'); + return false; + } + } + + await for (final albumRows in albumQuery.watch()) { + // Case 1: no album row at all → cold-fetch. + if (albumRows.isEmpty) { + debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch'); + if (fetchAttempted) { + // Already tried and got nothing back. + yield ( + album: const AlbumRef(id: '', title: '', artistId: ''), + tracks: const [], + ); + continue; + } + fetchAttempted = true; + final online = await isOnline(); + debugPrint('albumProvider($albumId): online=$online'); + if (!online) { + debugPrint('albumProvider($albumId): offline, yielding empty'); + yield ( + album: const AlbumRef(id: '', title: '', artistId: ''), + tracks: const [], + ); + continue; + } + final ok = await fetchAndPopulate(); + if (!ok) { yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], ); } + // On success, drift watch re-emits with rows; loop continues. continue; } + debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)'); final albumRow = albumRows.first; final album = albumRow.readTable(db.cachedAlbums).toRef( @@ -186,6 +264,23 @@ final albumProvider = StreamProvider.family< ); final trackRows = await tracksQuery.get(); + + // Case 2: album row exists but no tracks. This happens when an + // upstream provider (artistAlbumsProvider, sync, etc.) populated + // album rows without their track lists. Trigger the same + // fetchAndPopulate so the album becomes complete; drift watch + // re-emits and we land in the populated branch on the next pass. + if (trackRows.isEmpty && !fetchAttempted) { + debugPrint('albumProvider($albumId): album hit but no tracks; fetching'); + fetchAttempted = true; + if (await isOnline()) { + final ok = await fetchAndPopulate(); + if (ok) continue; // wait for watch re-emit + } + // Fall through and yield with the album + empty tracks if the + // fetch failed or we're offline. + } + final tracks = trackRows.map((r) { final track = r.readTable(db.cachedTracks); final artist = r.readTableOrNull(db.cachedArtists); diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart index 7f844ef2..24698634 100644 --- a/flutter_client/lib/library/widgets/album_card.dart +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -6,45 +6,76 @@ import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; class AlbumCard extends StatelessWidget { - const AlbumCard({required this.album, required this.onTap, super.key}); + const AlbumCard({ + required this.album, + required this.onTap, + this.width = 140, + this.titleMaxLines = 1, + this.showArtist = true, + super.key, + }); final AlbumRef album; final VoidCallback onTap; + /// Outer width of the card. Cover is square at width - 16 (8dp + /// horizontal padding either side). Default suits horizontal lists; + /// grids should pass the cell width so the card scales down. + final double width; + + /// Lets callers (e.g. the artist detail grid) allow the title to + /// wrap to a second line so it isn't truncated to a single + /// ellipsized character at narrower widths. + final int titleMaxLines; + + /// Suppress the artist name. Useful on surfaces where the artist is + /// already implied by the page header (e.g. artist detail). + final bool showArtist; + @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; - return GestureDetector( - onTap: onTap, - child: SizedBox( - width: 140, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 124, - height: 124, - color: fs.slate, - child: album.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : ServerImage(url: album.coverUrl, fit: BoxFit.cover), - ), + final coverSize = width - 16; + return SizedBox( + width: width, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: coverSize, + height: coverSize, + color: fs.slate, + child: album.coverUrl.isEmpty + ? SvgPicture.asset('assets/svg/album-fallback.svg', + fit: BoxFit.cover) + : ServerImage(url: album.coverUrl, fit: BoxFit.cover), + ), + ), + const SizedBox(height: 8), + Text( + album.title, + maxLines: titleMaxLines, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + if (showArtist && album.artistName.isNotEmpty) + Text( + album.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], ), - const SizedBox(height: 8), - Text( - album.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Text( - album.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ]), + ), ), ), ); diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart index 526dff9f..218a30ba 100644 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -13,31 +13,35 @@ class ArtistCard extends StatelessWidget { @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; - return GestureDetector( - onTap: onTap, - child: SizedBox( - width: 140, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipOval( - child: Container( - width: 124, - height: 124, - color: fs.slate, - child: artist.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), + return SizedBox( + width: 140, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClipOval( + child: Container( + width: 124, + height: 124, + color: fs.slate, + child: artist.coverUrl.isEmpty + ? SvgPicture.asset('assets/svg/album-fallback.svg', + fit: BoxFit.cover) + : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), + ), ), - ), - const SizedBox(height: 8), - Text( - artist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - ]), + const SizedBox(height: 8), + Text( + artist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + ]), + ), ), ), ); diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart index cc73089d..1c930a99 100644 --- a/flutter_client/lib/library/widgets/track_row.dart +++ b/flutter_client/lib/library/widgets/track_row.dart @@ -30,31 +30,39 @@ class TrackRow extends StatelessWidget { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row(children: [ if (track.trackNumber != null) SizedBox( - width: 28, + width: 22, child: Text( track.trackNumber.toString(), style: TextStyle(color: fs.ash, fontSize: 13), ), ), Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + // Skip the artist line entirely when empty so the row + // height collapses to a single line of title — keeps + // dense album views from looking padded. + if (track.artistName.isNotEmpty) + Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), CachedIndicator(trackId: track.id), Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index d1ecfefe..d859769f 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -14,6 +14,10 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl MinstrelAudioHandler() { _player.playbackEventStream.listen(_broadcastState); _player.currentIndexStream.listen(_onCurrentIndexChanged); + // Re-broadcast on shuffle/repeat changes so the PlaybackState's + // shuffleMode + repeatMode fields stay current for UI subscribers. + _player.shuffleModeEnabledStream.listen((_) => _broadcastState(null)); + _player.loopModeStream.listen((_) => _broadcastState(null)); } final AudioPlayer _player = AudioPlayer(); @@ -22,6 +26,19 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl AlbumCoverCache? _coverCache; AudioCacheManager? _audioCacheManager; + /// Volume stream for UI subscribers. Mirrors the just_audio player's + /// volume directly; set via setVolume(double). + Stream get volumeStream => _player.volumeStream; + double get volume => _player.volume; + + /// Position stream for UI subscribers. just_audio emits roughly every + /// 200ms while playing, which is what makes the seek bar advance + /// smoothly. PlaybackState.updatePosition only changes on event + /// transitions (play/pause/buffer), so it's too coarse for live + /// scrubbing UI. + Stream get positionStream => _player.positionStream; + Duration get position => _player.position; + void configure({ required String baseUrl, required String? token, @@ -198,7 +215,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl @override Future skipToPrevious() => _player.seekToPrevious(); - void _broadcastState(PlaybackEvent event) { + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + await _player + .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none); + // _broadcastState picks up the change via shuffleModeEnabledStream. + } + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + final loop = switch (repeatMode) { + AudioServiceRepeatMode.none => LoopMode.off, + AudioServiceRepeatMode.one => LoopMode.one, + AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all, + }; + await _player.setLoopMode(loop); + } + + /// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie + /// page audio to system volume — this is the in-app slider for parity + /// with desktop/web; on mobile the system volume is the real control. + Future setVolume(double v) async { + await _player.setVolume(v.clamp(0.0, 1.0)); + } + + // _broadcastState accepts a nullable event because the shuffle/repeat + // listeners don't have one — we just want to re-emit PlaybackState + // with up-to-date shuffleMode/repeatMode fields. + void _broadcastState(PlaybackEvent? event) { final playing = _player.playing; playbackState.add(PlaybackState( controls: [ @@ -206,7 +250,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, ], - systemActions: const {MediaAction.seek}, + // androidCompactActionIndices tells the system which controls + // appear in the collapsed/lock-screen view. Without this, some + // Android versions render the player without working buttons. + androidCompactActionIndices: const [0, 1, 2], + // systemActions enumerates which actions the system can invoke + // on us — without play/pause/skip in here, taps on lock-screen + // controls don't route back to the handler on Android 13+. + systemActions: const { + MediaAction.play, + MediaAction.pause, + MediaAction.skipToNext, + MediaAction.skipToPrevious, + MediaAction.seek, + }, processingState: switch (_player.processingState) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, @@ -218,7 +275,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, - queueIndex: event.currentIndex, + queueIndex: event?.currentIndex ?? _player.currentIndex, + shuffleMode: _player.shuffleModeEnabled + ? AudioServiceShuffleMode.all + : AudioServiceShuffleMode.none, + repeatMode: switch (_player.loopMode) { + LoopMode.off => AudioServiceRepeatMode.none, + LoopMode.one => AudioServiceRepeatMode.one, + LoopMode.all => AudioServiceRepeatMode.all, + }, )); } } diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index db9db86e..5cdc2e11 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -1,17 +1,59 @@ +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../api/endpoints/likes.dart' show LikeKind; +import '../likes/like_button.dart'; import '../models/track.dart'; +import '../shared/widgets/server_image.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'player_provider.dart'; -class NowPlayingScreen extends ConsumerWidget { +/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up +/// transition (see routing.dart). Dismisses three ways: +/// +/// - System back / leading button → Navigator.pop +/// - Vertical drag down past threshold → Navigator.pop (matches the +/// slide-down animation users expect from a "pull down to close" +/// sheet) +/// - Tap of the mini player ascends here; reverse motion descends back. +class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _NowPlayingScreenState(); +} + +class _NowPlayingScreenState extends ConsumerState { + // Cumulative drag distance used to decide if a vertical drag should + // dismiss. Reset on each drag start. + double _dragOffset = 0; + + void _onDragStart(DragStartDetails _) { + _dragOffset = 0; + } + + void _onDragUpdate(DragUpdateDetails d) { + _dragOffset += d.delta.dy; + } + + void _onDragEnd(DragEndDetails d) { + // Pop when either the user has dragged > 80px down OR flicked + // downward at speed (>500 px/s). Either should feel responsive + // without dismissing on accidental drags. + final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500; + if (_dragOffset > 80 || flicked) { + Navigator.of(context).maybePop(); + } + _dragOffset = 0; + } + + @override + Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final media = ref.watch(mediaItemProvider).value; final playback = ref.watch(playbackStateProvider).value; @@ -20,87 +62,337 @@ class NowPlayingScreen extends ConsumerWidget { return const Scaffold(body: Center(child: Text('Nothing playing.'))); } - final pos = playback?.updatePosition ?? Duration.zero; + // Use positionProvider (just_audio's positionStream, ~200ms) for + // the seek bar so it scrubs live; PlaybackState.updatePosition + // only fires on state transitions and would leave the bar frozen + // between them. + final pos = ref.watch(positionProvider).value ?? Duration.zero; final dur = media.duration ?? Duration.zero; + final isPlaying = playback?.playing == true; + final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; + final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; + final actions = ref.read(playerActionsProvider); + final albumId = (media.extras?['album_id'] as String?) ?? ''; return Scaffold( backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - leading: IconButton( - icon: Icon(Icons.expand_more, color: fs.parchment), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - IconButton( - icon: Icon(Icons.queue_music, color: fs.parchment), - tooltip: 'Queue', - onPressed: () => GoRouter.of(context).push('/queue'), + // The whole screen accepts vertical drag for dismissal. Buttons + // and the seek slider are still tappable since gesture arena + // gives priority to their child gesture detectors. + body: GestureDetector( + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + behavior: HitTestBehavior.translucent, + child: SafeArea( + child: Column( + children: [ + _TopBar(fs: fs), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Spacer(), + _AlbumArt(media: media, albumId: albumId, fs: fs), + const SizedBox(height: 28), + _TitleRow(media: media, fs: fs), + const SizedBox(height: 4), + Text( + media.artist ?? '', + style: TextStyle(color: fs.ash, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if ((media.album ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + media.album!, + style: TextStyle(color: fs.ash, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 32), + _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), + const SizedBox(height: 24), + _PrimaryControls( + fs: fs, + ref: ref, + isPlaying: isPlaying, + ), + const SizedBox(height: 24), + _SecondaryControls( + fs: fs, + actions: actions, + shuffleOn: shuffleOn, + repeatMode: repeatMode, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(children: [ - const Spacer(), - Container( - width: 280, height: 280, color: fs.slate, - ), - const SizedBox(height: 24), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Flexible( - child: Text( - media.title, - style: TextStyle(color: fs.parchment, fontSize: 22), - overflow: TextOverflow.ellipsis, - ), - ), - TrackActionsButton( - track: TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - artistId: '', // not stashed in MediaItem.extras today - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ), - hideQueueActions: true, - ), - ]), - Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)), - const SizedBox(height: 16), - Slider( - activeColor: fs.accent, - min: 0, - max: dur.inMilliseconds.toDouble().clamp(1, double.infinity), - value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()), - onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())), - ), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - IconButton( - iconSize: 56, - icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, - ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), - const Spacer(), - ]), ), ), ); } } + +class _TopBar extends StatelessWidget { + const _TopBar({required this.fs}); + final FabledSwordTheme fs; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row(children: [ + IconButton( + icon: Icon(Icons.expand_more, color: fs.parchment), + tooltip: 'Close', + onPressed: () => Navigator.of(context).maybePop(), + ), + const Spacer(), + IconButton( + icon: Icon(Icons.queue_music, color: fs.parchment), + tooltip: 'Queue', + onPressed: () => GoRouter.of(context).push('/queue'), + ), + ]), + ), + ); + } +} + +class _AlbumArt extends StatelessWidget { + const _AlbumArt({required this.media, required this.albumId, required this.fs}); + final MediaItem media; + final String albumId; + final FabledSwordTheme fs; + + @override + Widget build(BuildContext context) { + // Pick the best available source. AlbumCoverCache writes a file:// + // URI to media.artUri once the cover lands on disk; before then, + // fall back to fetching via ServerImage from /api/albums//cover + // (which carries the auth header and falls back gracefully on + // failure). + Widget cover; + final artUri = media.artUri; + if (artUri != null && artUri.isScheme('file')) { + cover = Image( + image: FileImage(File.fromUri(artUri)), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: fs.slate), + ); + } else if (albumId.isNotEmpty) { + cover = ServerImage( + url: '/api/albums/$albumId/cover', + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ); + } else { + cover = Container(color: fs.slate); + } + return AspectRatio( + aspectRatio: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), + ), + ); + } +} + +class _TitleRow extends StatelessWidget { + const _TitleRow({required this.media, required this.fs}); + final MediaItem media; + final FabledSwordTheme fs; + + @override + Widget build(BuildContext context) { + return Row(children: [ + Expanded( + child: Text( + media.title, + style: TextStyle(color: fs.parchment, fontSize: 22), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + LikeButton(kind: LikeKind.track, id: media.id, size: 22), + TrackActionsButton( + track: TrackRef( + id: media.id, + title: media.title, + albumId: (media.extras?['album_id'] as String?) ?? '', + albumTitle: media.album ?? '', + artistId: '', + artistName: media.artist ?? '', + durationSec: media.duration?.inSeconds ?? 0, + streamUrl: '', + ), + hideQueueActions: true, + ), + ]); + } +} + +class _SeekRow extends StatelessWidget { + const _SeekRow({ + required this.position, + required this.duration, + required this.fs, + required this.ref, + }); + final Duration position; + final Duration duration; + final FabledSwordTheme fs; + final WidgetRef ref; + + String _fmt(Duration d) { + final m = d.inMinutes.remainder(60).toString(); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); + return Column(children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), + ), + child: Slider( + activeColor: fs.accent, + inactiveColor: fs.slate, + min: 0, + max: maxMs, + value: position.inMilliseconds + .toDouble() + .clamp(0.0, duration.inMilliseconds.toDouble()), + onChanged: (v) => ref + .read(audioHandlerProvider) + .seek(Duration(milliseconds: v.toInt())), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)), + Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)), + ], + ), + ), + ]); + } +} + +class _PrimaryControls extends StatelessWidget { + const _PrimaryControls({ + required this.fs, + required this.ref, + required this.isPlaying, + }); + final FabledSwordTheme fs; + final WidgetRef ref; + final bool isPlaying; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + iconSize: 36, + icon: Icon(Icons.skip_previous, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), + ), + IconButton( + iconSize: 72, + icon: Icon( + isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, + color: fs.accent, + ), + onPressed: () { + final h = ref.read(audioHandlerProvider); + if (isPlaying) { + h.pause(); + } else { + h.play(); + } + }, + ), + IconButton( + iconSize: 36, + icon: Icon(Icons.skip_next, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToNext(), + ), + ], + ); + } +} + +class _SecondaryControls extends StatelessWidget { + const _SecondaryControls({ + required this.fs, + required this.actions, + required this.shuffleOn, + required this.repeatMode, + }); + final FabledSwordTheme fs; + final PlayerActions actions; + final bool shuffleOn; + final AudioServiceRepeatMode repeatMode; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', + icon: Icon( + Icons.shuffle, + color: shuffleOn ? fs.accent : fs.ash, + ), + onPressed: actions.toggleShuffle, + ), + IconButton( + tooltip: switch (repeatMode) { + AudioServiceRepeatMode.none => 'Repeat off', + AudioServiceRepeatMode.one => 'Repeat one', + _ => 'Repeat all', + }, + icon: Icon( + repeatMode == AudioServiceRepeatMode.one + ? Icons.repeat_one + : Icons.repeat, + color: repeatMode == AudioServiceRepeatMode.none + ? fs.ash + : fs.accent, + ), + onPressed: actions.cycleRepeat, + ), + IconButton( + tooltip: 'Queue', + icon: Icon(Icons.queue_music, color: fs.ash), + onPressed: () => GoRouter.of(context).push('/queue'), + ), + ], + ); + } +} diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 810b3e7e..e068103e 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -1,53 +1,293 @@ +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../api/endpoints/likes.dart' show LikeKind; +import '../likes/like_button.dart'; +import '../models/track.dart'; +import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'player_provider.dart'; +/// Compact player bar mounted at the bottom of the app shell. Mini +/// view only — the heavyweight shuffle/repeat/queue/volume controls +/// live in NowPlayingScreen, accessible by tap or swipe-up. +/// +/// ┌─────────────────────────────────────┬──────────┐ +/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ +/// │ Artist │ │ +/// ├─────────────────────────────────────────────────┤ +/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ +/// └─────────────────────────────────────────────────┘ +/// +/// Tap anywhere on the bar (including art/title) ascends to the full +/// player. A vertical drag upward also ascends, so the gesture mirrors +/// the drag-down dismissal on the full screen. class PlayerBar extends ConsumerWidget { const PlayerBar({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final mediaItem = ref.watch(mediaItemProvider).value; + final media = ref.watch(mediaItemProvider).value; final playback = ref.watch(playbackStateProvider).value; - if (mediaItem == null) return const SizedBox.shrink(); + if (media == null) return const SizedBox.shrink(); + + // positionProvider is just_audio's positionStream (~200ms cadence) + // so the mini bar's seek crawls forward smoothly. PlaybackState + // only updates on event transitions and would leave it frozen. + final pos = ref.watch(positionProvider).value ?? Duration.zero; + final dur = media.duration ?? Duration.zero; return Material( color: fs.iron, - child: InkWell( + child: GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () => context.push('/now-playing'), + // Swipe up anywhere on the bar to expand into the full player. + // We only act on a clear upward flick (>200 px/s) so a slow + // tap-with-tiny-jitter doesn't accidentally open the screen. + onVerticalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + if (v < -200) context.push('/now-playing'); + }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row(children: [ - Container(width: 48, height: 48, color: fs.slate), - const SizedBox(width: 12), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - )), - IconButton( - icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, - ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _TrackInfo(media: media)), + const SizedBox(width: 8), + _PlayControls(playback: playback, ref: ref), + ], + ), + ), + const SizedBox(height: 4), + _SeekRow(position: pos, duration: dur), + ], + ), ), ), ); } } + +class _TrackInfo extends StatelessWidget { + const _TrackInfo({required this.media}); + final MediaItem media; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final artistName = (media.artist ?? '').trim(); + + return Row( + // Default centering vertically aligns the like/kebab buttons + // against the album art (48dp) — visually they span the title + + // artist block instead of sitting on the title baseline. + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (media.artUri != null) + Image( + image: media.artUri!.isScheme('file') + ? FileImage(File.fromUri(media.artUri!)) as ImageProvider + : NetworkImage(media.artUri.toString()), + width: 48, + height: 48, + // Without a fit, Image paints the source at its intrinsic + // resolution inside the 48dp box — a thumbnail-sized cover + // would render as a tiny inset. Cover stretches/crops to + // fill the box uniformly. + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(width: 48, height: 48, color: fs.slate), + ) + else + Container(width: 48, height: 48, color: fs.slate), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + media.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + if (artistName.isNotEmpty) + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), + ), + // Like + kebab as siblings to the title/artist column rather + // than nested inside the title row. Width stays 32dp each so + // horizontal footprint matches what we had; height stretches + // to the row's full 48dp so the icons sit at vertical center + // against the title+artist block. + SizedBox( + width: 32, + height: 48, + child: LikeButton( + kind: LikeKind.track, + id: media.id, + size: 20, + ), + ), + SizedBox( + width: 32, + height: 48, + child: TrackActionsButton( + track: _trackRefFromMediaItem(media), + hideQueueActions: true, + ), + ), + ], + ); + } +} + +class _PlayControls extends StatelessWidget { + const _PlayControls({required this.playback, required this.ref}); + final PlaybackState? playback; + final WidgetRef ref; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final isPlaying = playback?.playing == true; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 22, + icon: Icon(Icons.skip_previous, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), + ), + ), + SizedBox( + width: 44, + height: 44, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 32, + icon: Icon( + isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, + color: fs.accent, + ), + onPressed: () { + final h = ref.read(audioHandlerProvider); + if (isPlaying) { + h.pause(); + } else { + h.play(); + } + }, + ), + ), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 22, + icon: Icon(Icons.skip_next, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToNext(), + ), + ), + ], + ); + } +} + +class _SeekRow extends ConsumerWidget { + const _SeekRow({required this.position, required this.duration}); + final Duration position; + final Duration duration; + + String _fmt(Duration d) { + final m = d.inMinutes.remainder(60).toString(); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final maxMs = + duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); + return Row( + children: [ + SizedBox( + width: 36, + child: Text( + _fmt(position), + textAlign: TextAlign.right, + style: TextStyle(color: fs.ash, fontSize: 10), + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + activeColor: fs.accent, + inactiveColor: fs.slate, + min: 0, + max: maxMs, + value: position.inMilliseconds + .toDouble() + .clamp(0.0, duration.inMilliseconds.toDouble()), + onChanged: (v) => ref + .read(audioHandlerProvider) + .seek(Duration(milliseconds: v.toInt())), + ), + ), + ), + SizedBox( + width: 36, + child: Text( + _fmt(duration), + style: TextStyle(color: fs.ash, fontSize: 10), + ), + ), + ], + ); + } +} + +/// Reconstructs a minimal TrackRef from a MediaItem so the +/// TrackActionsButton has the fields its sheet expects. Mirrors the +/// pattern used by NowPlayingScreen — extras['album_id'] is what +/// audio_handler stashed at queue-build time. +TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef( + id: media.id, + title: media.title, + albumId: (media.extras?['album_id'] as String?) ?? '', + albumTitle: media.album ?? '', + artistId: '', + artistName: media.artist ?? '', + durationSec: media.duration?.inSeconds ?? 0, + streamUrl: '', + ); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 1c00eac1..eafcb9a7 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -30,6 +30,23 @@ final queueProvider = StreamProvider>( (ref) => ref.watch(audioHandlerProvider).queue, ); +/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume; +/// modify via PlayerActions.setVolume(). Surfaced separately from +/// PlaybackState because audio_service's PlaybackState doesn't carry +/// volume — it's a player-side concern. +final volumeProvider = StreamProvider( + (ref) => ref.watch(audioHandlerProvider).volumeStream, +); + +/// Live playback position. just_audio emits at ~200ms cadence so seek +/// bars driven by this provider scrub smoothly. Don't use +/// PlaybackState.updatePosition for that — it only changes on state +/// transitions (play/pause/buffer/seek) and the bar would appear +/// frozen between events. +final positionProvider = StreamProvider( + (ref) => ref.watch(audioHandlerProvider).positionStream, +); + class PlayerActions { PlayerActions(this._ref); final Ref _ref; @@ -59,6 +76,32 @@ class PlayerActions { final h = _ref.read(audioHandlerProvider); await h.enqueue(track); } + + /// Toggles shuffle on/off. Pre-existing audio_service convention. + Future toggleShuffle() async { + final h = _ref.read(audioHandlerProvider); + final state = await h.playbackState.first; + final next = state.shuffleMode == AudioServiceShuffleMode.none + ? AudioServiceShuffleMode.all + : AudioServiceShuffleMode.none; + await h.setShuffleMode(next); + } + + /// Cycles repeat mode: none → all → one → none. + Future cycleRepeat() async { + final h = _ref.read(audioHandlerProvider); + final state = await h.playbackState.first; + final next = switch (state.repeatMode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }; + await h.setRepeatMode(next); + } + + Future setVolume(double v) async { + await _ref.read(audioHandlerProvider).setVolume(v); + } } final playerActionsProvider = Provider((ref) => PlayerActions(ref)); diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index 56b9175c..4bc23268 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart' as drift; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/me.dart'; @@ -36,6 +37,11 @@ final playlistsListProvider = fetchAndPopulate: () async { final api = await ref.read(playlistsApiProvider.future); final fresh = await api.list(kind: kind); + final ownedSysCount = + fresh.owned.where((p) => p.systemVariant != null).length; + debugPrint( + 'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} ' + '(system=$ownedSysCount) public=${fresh.public.length}'); await db.batch((b) { for (final p in fresh.all) { b.insert(db.cachedPlaylists, p.toDrift(), @@ -49,7 +55,7 @@ final playlistsListProvider = if (kind == 'user') return r.systemVariant == null; if (kind == 'system') return r.systemVariant != null; return true; // 'all' - }); + }).toList(); final owned = filtered .where((r) => r.userId == user.id) .map((r) => r.toRef()) @@ -58,9 +64,23 @@ final playlistsListProvider = .where((r) => r.userId != user.id && r.isPublic) .map((r) => r.toRef()) .toList(); + final ownedSys = owned.where((p) => p.isSystem).length; + final driftSys = filtered.where((r) => r.systemVariant != null).length; + debugPrint( + 'playlistsListProvider($kind): drift rows=${rows.length} ' + 'filtered=${filtered.length} (system=$driftSys) ' + 'owned=${owned.length} (system=$ownedSys) pub=${pub.length} ' + 'userId=${user.id}'); return PlaylistsList(owned: owned, public: pub); }, - isOnline: () async => (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // Aggregate list — server may add system playlists (For-You / + // Discover) that the per-user delta sync doesn't always emit. + // Stale-while-revalidate ensures the home tile row reflects the + // current server state on every screen visit. + alwaysRefresh: true, ); }); diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index f4b5d9da..e6781f37 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -18,45 +18,49 @@ class PlaylistCard extends StatelessWidget { final fs = Theme.of(context).extension()!; return SizedBox( width: 176, - child: GestureDetector( - onTap: () => context.push('/playlists/${playlist.id}'), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 144, - height: 144, - color: fs.slate, - child: playlist.coverUrl.isEmpty - ? Icon(Icons.queue_music, color: fs.ash, size: 56) - : ServerImage(url: playlist.coverUrl, fit: BoxFit.cover), - ), - ), - const SizedBox(height: 8), - Text( - playlist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (playlist.isSystem) - Padding( - padding: const EdgeInsets.only(top: 2), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.push('/playlists/${playlist.id}'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - playlist.systemVariant!.replaceAll('_', ' '), - style: TextStyle(color: fs.accent, fontSize: 11), - ), + width: 144, + height: 144, + color: fs.slate, + child: playlist.coverUrl.isEmpty + ? Icon(Icons.queue_music, color: fs.ash, size: 56) + : ServerImage(url: playlist.coverUrl, fit: BoxFit.cover), ), ), - ]), + const SizedBox(height: 8), + Text( + playlist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + if (playlist.isSystem) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fs.iron, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + playlist.systemVariant!.replaceAll('_', ' '), + style: TextStyle(color: fs.accent, fontSize: 11), + ), + ), + ), + ]), + ), ), ), ); diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 9d99b5df..9d8b55fe 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -18,6 +18,7 @@ import '../playlists/playlists_list_screen.dart'; import '../requests/requests_screen.dart'; import '../search/search_screen.dart'; import '../settings/settings_screen.dart'; +import '../update/client_update_provider.dart'; import '../update/update_banner.dart'; import '../admin/admin_landing_screen.dart'; import '../admin/admin_requests_screen.dart'; @@ -54,6 +55,34 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/', redirect: (_, __) => '/home'), GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + // /now-playing lives outside the ShellRoute on purpose. The full + // player IS the player UI when active, and we don't want the mini + // bar from the shell underneath fighting for the bottom strip. + // Pushing this route unmounts the shell entirely; the slide-up + // transition still feels right because the shell stays painted + // for the duration of the animation. + GoRoute( + path: '/now-playing', + pageBuilder: (_, __) => CustomTransitionPage( + child: const NowPlayingScreen(), + transitionDuration: const Duration(milliseconds: 280), + reverseTransitionDuration: const Duration(milliseconds: 240), + transitionsBuilder: (_, anim, __, child) { + final eased = CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(eased), + child: child, + ); + }, + ), + ), ShellRoute( builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), routes: [ @@ -66,7 +95,6 @@ GoRouter buildRouter(Ref ref) { path: '/albums/:id', builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), ), - GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), @@ -88,15 +116,30 @@ GoRouter buildRouter(Ref ref) { ); } -class _ShellWithPlayerBar extends StatelessWidget { +class _ShellWithPlayerBar extends ConsumerWidget { const _ShellWithPlayerBar({required this.child}); final Widget child; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // The banner sits above the routed child and uses SafeArea to clear + // the system status bar. MediaQuery.padding is screen-relative + // though, so the child's Scaffold/AppBar would still apply its own + // top status-bar inset on top of the banner — doubling the gap. + // When the banner is showing, strip that inset from the child so + // its AppBar lands directly under the banner. + final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; return Column( children: [ const UpdateBanner(), - Expanded(child: child), + Expanded( + child: hasBanner + ? MediaQuery.removePadding( + context: context, + removeTop: true, + child: child, + ) + : child, + ), const PlayerBar(), ], ); diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart index 2b8ca417..be44538e 100644 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -35,11 +35,31 @@ class ServerImage extends ConsumerWidget { // doesn't carry cookies/Bearer tokens automatically the way the // browser's tag does, so we forward the session token as a // header. Without this, the server returns 401 and the image fails. - final token = ref.watch(sessionTokenProvider).value; - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : null; - return Image.network(resolved, fit: fit, headers: headers); + // + // sessionTokenProvider is a FutureProvider — on first read after a + // hot restart its .value is null until the secure-storage read + // resolves. If we fired Image.network with headers:null at that + // moment, the network image cache would lock in the 401 response + // and never retry. Instead, hold the fallback until the token is + // available, then mount the Image with the auth header attached. + final tokenAsync = ref.watch(sessionTokenProvider); + return tokenAsync.when( + loading: () => empty, + error: (_, __) => empty, + data: (token) { + final headers = (token != null && token.isNotEmpty) + ? {'Authorization': 'Bearer $token'} + : null; + return Image.network( + resolved, + fit: fit, + headers: headers, + // Keep failures local — a single 401/timeout shouldn't dump + // a stack trace or replace the parent Container's background. + errorBuilder: (_, __, ___) => empty, + ); + }, + ); } static String? _resolve(String? baseUrl, String url) { diff --git a/flutter_client/lib/update/client_update_provider.dart b/flutter_client/lib/update/client_update_provider.dart index 940f9333..514cd2d8 100644 --- a/flutter_client/lib/update/client_update_provider.dart +++ b/flutter_client/lib/update/client_update_provider.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:pub_semver/pub_semver.dart'; import '../library/library_providers.dart' show dioProvider; import 'installer.dart'; @@ -50,20 +49,52 @@ class ClientUpdateController extends AsyncNotifier { } /// True when `serverVersion` is strictly newer than `installedVersion`. -/// Both strings are normalized (leading 'v' stripped) and parsed as -/// semver. On parse failure, falls back to string inequality (treats -/// any difference as "newer" — operator can dismiss if wrong). +/// +/// Comparison strategy: split both strings on `.`, parse each component +/// as an int (non-numeric = 0), pad the shorter list with zeros, then +/// compare component-wise. This handles our date-style versions +/// (2026.05.10.1) which exceed the 3-part semver shape that +/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as +/// equal to "2026.05.10.0" rather than "different = newer". +/// +/// Falls back to pub_semver as a secondary attempt when the components +/// look semver-like (handles pre-release suffixes, build metadata, etc). /// /// Exposed for testing; the polling logic in ClientUpdateController /// is the only production caller. bool isVersionNewer(String serverVersion, String installedVersion) { - final svr = serverVersion.replaceFirst(RegExp(r'^v'), ''); - final ins = installedVersion.replaceFirst(RegExp(r'^v'), ''); - try { - return Version.parse(svr) > Version.parse(ins); - } catch (_) { - return svr != ins; + List parts(String v) => v + .replaceFirst(RegExp(r'^v'), '') + .split('.') + .map((p) => int.tryParse(p) ?? 0) + .toList(); + + final svr = parts(serverVersion); + final ins = parts(installedVersion); + + // Safety net for non-numeric versions (e.g. branch-name builds like + // "main" vs "dev"): if neither side parsed any non-zero component, + // fall back to string inequality so an operator on a dev build + // still gets the banner instead of silently matching everything. + final svrAllZero = svr.every((c) => c == 0); + final insAllZero = ins.every((c) => c == 0); + if (svrAllZero && insAllZero) { + return serverVersion.replaceFirst(RegExp(r'^v'), '') != + installedVersion.replaceFirst(RegExp(r'^v'), ''); } + + final n = svr.length > ins.length ? svr.length : ins.length; + while (svr.length < n) { + svr.add(0); + } + while (ins.length < n) { + ins.add(0); + } + for (var i = 0; i < n; i++) { + if (svr[i] > ins[i]) return true; + if (svr[i] < ins[i]) return false; + } + return false; } final clientUpdateProvider = diff --git a/flutter_client/lib/update/update_banner.dart b/flutter_client/lib/update/update_banner.dart index 3f2b80df..6605dc28 100644 --- a/flutter_client/lib/update/update_banner.dart +++ b/flutter_client/lib/update/update_banner.dart @@ -62,11 +62,12 @@ class _UpdateBannerState extends ConsumerState { child: SafeArea( bottom: false, child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Icons.system_update, color: fs.parchment, size: 18), - const SizedBox(width: 10), + Icon(Icons.system_update, color: fs.parchment, size: 16), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -76,11 +77,11 @@ class _UpdateBannerState extends ConsumerState { _stage == _Stage.error ? 'Update failed' : 'Update Minstrel · ${info.version} available', - style: TextStyle(color: fs.parchment, fontSize: 13), + style: TextStyle(color: fs.parchment, fontSize: 12), overflow: TextOverflow.ellipsis, ), if (_stage == _Stage.downloading) ...[ - const SizedBox(height: 4), + const SizedBox(height: 2), LinearProgressIndicator( value: _progress > 0 ? _progress : null, minHeight: 2, @@ -89,11 +90,11 @@ class _UpdateBannerState extends ConsumerState { ), ], if (_stage == _Stage.error && _error != null) ...[ - const SizedBox(height: 2), + const SizedBox(height: 1), Text( _error!, style: TextStyle(color: fs.ash, fontSize: 11), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, ), ], @@ -105,20 +106,35 @@ class _UpdateBannerState extends ConsumerState { onPressed: () => _onInstall(info), style: TextButton.styleFrom( foregroundColor: fs.accent, - minimumSize: const Size(64, 36), + minimumSize: const Size(56, 28), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, ), - child: const Text('Install'), + child: const Text('Install', style: TextStyle(fontSize: 13)), ), if (_stage == _Stage.error) TextButton( onPressed: () => _onInstall(info), - style: TextButton.styleFrom(foregroundColor: fs.accent), - child: const Text('Retry'), + style: TextButton.styleFrom( + foregroundColor: fs.accent, + minimumSize: const Size(56, 28), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: const Text('Retry', style: TextStyle(fontSize: 13)), + ), + SizedBox( + width: 32, + height: 32, + child: IconButton( + tooltip: 'Dismiss', + padding: EdgeInsets.zero, + iconSize: 16, + icon: Icon(Icons.close, color: fs.ash), + onPressed: () => _onDismiss(info), ), - IconButton( - tooltip: 'Dismiss', - icon: Icon(Icons.close, size: 18, color: fs.ash), - onPressed: () => _onDismiss(info), ), ], ), diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index 22e4a936..95704394 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -73,6 +73,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" characters: dependency: transitive description: @@ -81,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -89,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -113,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -145,6 +233,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" dio: dependency: "direct main" description: @@ -161,6 +265,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 + url: "https://pub.dev" + source: hosted + version: "0.2.8" fake_async: dependency: transitive description: @@ -320,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: @@ -384,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" just_audio: dependency: "direct main" description: @@ -496,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: @@ -553,7 +705,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -640,6 +792,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" record_use: dependency: transitive description: @@ -701,6 +869,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" source_map_stack_trace: dependency: transitive description: @@ -765,6 +941,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" + url: "https://pub.dev" + source: hosted + version: "0.43.1" stack_trace: dependency: transitive description: @@ -789,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 8ba9d235..68d04ddf 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -1,7 +1,7 @@ name: minstrel description: Minstrel mobile client publish_to: 'none' -version: 0.1.0+1 +version: 2026.5.11+1 environment: sdk: '>=3.5.0 <4.0.0' diff --git a/flutter_client/test/update/client_update_provider_test.dart b/flutter_client/test/update/client_update_provider_test.dart index 3c362944..9433c915 100644 --- a/flutter_client/test/update/client_update_provider_test.dart +++ b/flutter_client/test/update/client_update_provider_test.dart @@ -24,17 +24,19 @@ void main() { expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse); }); - test('honors prerelease ordering', () { - // 0.1.0 > 0.1.0-rc.1 per semver - expect(isVersionNewer('0.1.0', '0.1.0-rc.1'), isTrue); - expect(isVersionNewer('0.1.0-rc.1', '0.1.0'), isFalse); + test('zero-pads shorter version when comparing', () { + // "1.2" treated as "1.2.0" — shorter side gets zero-padded so + // comparison is component-wise. + expect(isVersionNewer('1.2.1', '1.2'), isTrue); + expect(isVersionNewer('1.2', '1.2.1'), isFalse); + expect(isVersionNewer('1.2', '1.2.0'), isFalse); }); }); - group('isVersionNewer (date-style versions parse as semver)', () { - // pub_semver is permissive with leading zeros — '2026.05.10' - // parses as 2026.5.10. So date-style tags get strict ordering, - // not the unparseable-fallback path. + group('isVersionNewer (date-style versions)', () { + // Date tags are 3- or 4-part: 2026.05.10 or 2026.05.10.1. The + // numeric comparator parses each component as int (so "05" == 5) + // and zero-pads the shorter side. test('newer date → newer', () { expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue); }); @@ -44,6 +46,18 @@ void main() { test('equal date → not newer', () { expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse); }); + test('leading zeros do not affect ordering', () { + // "2026.5.11" and "2026.05.11" parse to the same components. + // 12 > 5 numerically so month rollover stays correct. + expect(isVersionNewer('2026.5.11', '2026.05.11'), isFalse); + expect(isVersionNewer('2026.12.1', '2026.5.31'), isTrue); + }); + test('4-part build suffix breaks the tie', () { + // 2026.05.10 vs 2026.05.10.1 — first three equal, server has a + // 4th component (1), client zero-pads → server is newer. + expect(isVersionNewer('2026.05.10.1', '2026.05.10'), isTrue); + expect(isVersionNewer('2026.05.10', '2026.05.10.0'), isFalse); + }); }); group('isVersionNewer (truly non-semver fallback)', () { diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index a3c90121..5f22633b 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -15,7 +15,6 @@ import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; - import PlayerOverflowMenu from './PlayerOverflowMenu.svelte'; const current = $derived(player.current); @@ -29,7 +28,6 @@ player.repeat === 'all' ? 'Repeat all' : 'Repeat one' ); - // Volume icon tracks level so the right-edge anchor reads at a glance. const VolumeIcon = $derived( player.volume === 0 ? VolumeX : player.volume < 0.5 ? Volume1 : Volume2 @@ -49,9 +47,187 @@ {#if current} -
+ +
+ {#if player.state === 'error'} + + {:else} + +
+ + + +
+
{current.title}
+
{current.artist_name}
+
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + +
+ +
+
+ + +
+ + {formatDuration(player.position)} + + + + {formatDuration(player.duration)} + +
+ {/if} +
+ + + {:else}
- +
- + {formatDuration(player.position)} - + {formatDuration(player.duration)}
{/if} - -