Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main

This commit was merged in pull request #38.
This commit is contained in:
2026-05-11 15:21:58 +00:00
28 changed files with 1851 additions and 491 deletions
+9 -1
View File
@@ -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/')
+6
View File
@@ -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',
);
}
+42 -1
View File
@@ -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<ArtistRef>`)
@@ -28,22 +36,55 @@ Stream<T> cacheFirst<D, T>({
required Future<void> Function() fetchAndPopulate,
required T Function(List<D>) toResult,
required Future<bool> 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<void> _safeFetch(Future<void> Function() fn) async {
try {
await fn();
} catch (_) {
// Background revalidate — swallow; UI already showed cached state.
}
}
+26 -4
View File
@@ -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<bool>((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<bool>((ref) async* {
final c = Connectivity();
return c.onConnectivityChanged.map(
(results) => results.any((r) => r != ConnectivityResult.none),
);
bool isOnline(List<ConnectivityResult> 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);
});
@@ -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,
@@ -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<List<AlbumRef>> 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),
);
}
}
+24 -14
View File
@@ -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),
],
),
),
),
),
+117 -22
View File
@@ -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 <TrackRef>[],
// 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<bool> 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<bool> 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 = <String, ArtistRef>{};
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 <TrackRef>[],
);
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 <TrackRef>[],
);
continue;
}
final ok = await fetchAndPopulate();
if (!ok) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
// 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);
@@ -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<FabledSwordTheme>()!;
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),
),
]),
),
),
),
);
@@ -13,31 +13,35 @@ class ArtistCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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),
),
]),
),
),
),
);
@@ -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)),
+68 -3
View File
@@ -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<double> 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<Duration> 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<void> skipToPrevious() => _player.seekToPrevious();
void _broadcastState(PlaybackEvent event) {
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _player
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
// _broadcastState picks up the change via shuffleModeEnabledStream.
}
@override
Future<void> 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<void> 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,
},
));
}
}
+368 -76
View File
@@ -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<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// 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<FabledSwordTheme>()!;
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/<id>/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'),
),
],
);
}
}
+268 -28
View File
@@ -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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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: '',
);
@@ -30,6 +30,23 @@ final queueProvider = StreamProvider<List<MediaItem>>(
(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<double>(
(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<Duration>(
(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<void> 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<void> 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<void> setVolume(double v) async {
await _ref.read(audioHandlerProvider).setVolume(v);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
@@ -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,
);
});
@@ -18,45 +18,49 @@ class PlaylistCard extends StatelessWidget {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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),
),
),
),
]),
),
),
),
);
+47 -4
View File
@@ -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(),
],
);
@@ -35,11 +35,31 @@ class ServerImage extends ConsumerWidget {
// doesn't carry cookies/Bearer tokens automatically the way the
// browser's <img> 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) {
@@ -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<UpdateInfo?> {
}
/// 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<int> 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 =
+31 -15
View File
@@ -62,11 +62,12 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
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<UpdateBanner> {
_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<UpdateBanner> {
),
],
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<UpdateBanner> {
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),
),
],
),
+209 -1
View File
@@ -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:
+1 -1
View File
@@ -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'
@@ -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)', () {
+188 -18
View File
@@ -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 @@
</script>
{#if current}
<div class="flex flex-col md:flex-row md:min-h-[108px] min-h-[88px] md:items-center gap-2 md:gap-4 border-t border-border bg-surface px-3 md:px-4 py-2 md:py-1.5">
<!--
COMPACT view (below md). Layout per operator design:
┌─────────┬───────────┬───────────────────┐
│ art │ ♥ ⋮ │ 🔀 🔁 ☰ │ ← short sub-row
│ title │ │ │
│ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play row at full size
├─────────┴───────────┴───────────────────┤
│ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
└─────────────────────────────────────────┘
Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows
are shorter so column 2's bottom edge aligns with column 3's bottom.
-->
<div
data-testid="player-bar-compact"
class="md:hidden flex flex-col gap-1.5 border-t border-border bg-surface px-3 py-2"
>
{#if player.state === 'error'}
<div role="alert" class="flex items-center justify-center gap-3 text-sm text-text-primary">
<span>{player.error ?? 'Playback failed.'}</span>
<button
type="button"
class="rounded bg-accent px-3 py-1.5 text-sm text-text-primary"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
</button>
</div>
{:else}
<!-- Top row: 3 columns -->
<div class="flex items-stretch gap-3">
<!-- Column 1: art + title + artist -->
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
class="flex min-w-0 items-center gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style="flex: 1 1 35%;"
>
<img
src={coverUrl(current.album_id)}
alt=""
class="h-12 w-12 shrink-0 rounded-md object-cover"
onerror={onCoverError}
/>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
<div class="truncate text-xs text-text-secondary">{current.artist_name}</div>
</div>
</a>
<!-- Column 2: like+kebab (top), play controls (bottom) -->
<div class="flex flex-col items-center justify-between gap-1 shrink-0">
<div class="flex items-center gap-0.5">
<LikeButton entityType="track" entityId={current.id} />
<TrackMenu track={current} direction="up" hideQueueActions />
</div>
<div class="flex items-center gap-1.5">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipBack size={18} strokeWidth={1.5} fill="currentColor" />
</button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
{:else if player.isPlaying}
<Pause size={22} strokeWidth={1.5} fill="currentColor" />
{:else}
<Play size={22} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipForward size={18} strokeWidth={1.5} fill="currentColor" />
</button>
</div>
</div>
<!-- Column 3: shuffle/repeat/queue (top), volume slider (bottom) -->
<div class="flex min-w-0 flex-col items-stretch justify-between gap-1" style="flex: 1 1 30%;">
<div class="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.shuffle
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<Shuffle size={16} strokeWidth={1.5} />
</button>
<button
type="button"
aria-label={repeatLabel}
onclick={cycleRepeat}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{#if player.repeat === 'one'}
<Repeat1 size={16} strokeWidth={1.5} />
{:else}
<Repeat size={16} strokeWidth={1.5} />
{/if}
</button>
<button
type="button"
onclick={() => toggleQueueDrawer()}
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
aria-pressed={player.queueDrawerOpen}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<ListMusic size={16} strokeWidth={1.5} />
</button>
</div>
<label class="flex items-center gap-1">
<span class="sr-only">Volume</span>
<VolumeIcon size={14} strokeWidth={1.5} class="shrink-0 text-text-secondary" />
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="min-w-0 flex-1 accent-accent"
/>
</label>
</div>
</div>
<!-- Bottom row: full-width seek slider + time labels -->
<div class="flex items-center gap-2">
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
{formatDuration(player.position)}
</span>
<input
type="range"
aria-label="Seek"
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
<span class="w-9 shrink-0 text-[10px] tabular-nums text-text-secondary">
{formatDuration(player.duration)}
</span>
</div>
{/if}
</div>
<!--
DESKTOP view (md+). Original 3-column layout — unchanged except that
the redundant PlayerOverflowMenu kebab is no longer mounted (volume,
shuffle, repeat, queue all live inline in the right cluster).
-->
<div
data-testid="player-bar-desktop"
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
>
<!-- Left: cover + title + artist + like + menu -->
<div class="flex w-full md:w-72 min-w-0 items-center gap-3">
<div class="flex w-72 min-w-0 items-center gap-3">
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
@@ -60,7 +236,7 @@
<img
src={coverUrl(current.album_id)}
alt=""
class="h-10 w-10 md:h-24 md:w-24 rounded-md object-cover"
class="h-24 w-24 rounded-md object-cover"
onerror={onCoverError}
/>
</a>
@@ -74,14 +250,13 @@
</a>
{#if player.queue.length > player.index + 1}
{@const nextTrack = player.queue[player.index + 1]}
<span class="text-text-secondary text-xs truncate hidden md:block">
<span class="block truncate text-xs text-text-secondary">
Next · {nextTrack.title}{nextTrack.artist_name}
</span>
{/if}
</div>
<LikeButton entityType="track" entityId={current.id} />
<TrackMenu track={current} direction="up" hideQueueActions />
<PlayerOverflowMenu direction="up" />
</div>
<!-- Center: seek row + transport row -->
@@ -98,7 +273,7 @@
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<!-- Transport row: prev / play / next, play is the accent focal point -->
<!-- Transport row -->
<div class="flex items-center justify-center gap-3">
<button
type="button"
@@ -116,12 +291,7 @@
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2
data-testid="play-spinner"
size={24}
strokeWidth={1.5}
class="animate-spin"
/>
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
{:else if player.isPlaying}
<Pause size={24} strokeWidth={1.5} fill="currentColor" />
{:else}
@@ -140,7 +310,7 @@
</div>
<!-- Seek row -->
<div class="flex items-center gap-3">
<span class="w-10 md:w-12 shrink-0 text-right text-[10px] md:text-xs tabular-nums text-text-secondary">
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(player.position)}
</span>
<input
@@ -153,15 +323,15 @@
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
<span class="w-10 md:w-12 shrink-0 text-[10px] md:text-xs tabular-nums text-text-secondary">
<span class="w-12 shrink-0 text-xs tabular-nums text-text-secondary">
{formatDuration(player.duration)}
</span>
</div>
</div>
{/if}
<!-- Right: shuffle + repeat + volume -->
<div class="hidden md:flex w-60 items-center justify-end gap-2">
<!-- Right: shuffle + repeat + queue + volume -->
<div class="flex w-60 items-center justify-end gap-2">
<button
type="button"
aria-label="Shuffle"
+41 -24
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { render, screen, fireEvent, within } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
@@ -66,6 +66,15 @@ function track(): TrackRef {
});
}
// PlayerBar renders both a compact (mobile, md:hidden) and desktop
// (hidden md:flex) block. jsdom doesn't apply CSS media queries, so
// both DOM trees are present in tests. Scope every query to the
// compact block — that's the user-facing change in #358; desktop
// behavior is covered structurally by the same store + handlers.
function compact() {
return within(screen.getByTestId('player-bar-compact'));
}
beforeEach(() => {
state.queue = [track()];
state.current = state.queue[0];
@@ -85,9 +94,11 @@ afterEach(() => vi.clearAllMocks());
describe('PlayerBar', () => {
test('renders title, linked artist, linked cover', () => {
render(PlayerBar);
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md');
const cover = screen.getByRole('link', { name: /open album/i });
expect(compact().getByText('So What')).toBeInTheDocument();
// The artist label sits under the title in compact (no link wrapper);
// look for it as plain text.
expect(compact().getByText('Miles Davis')).toBeInTheDocument();
const cover = compact().getByRole('link', { name: /open album/i });
expect(cover).toHaveAttribute('href', '/albums/xyz');
});
@@ -95,14 +106,14 @@ describe('PlayerBar', () => {
render(PlayerBar);
// Anchored regex — "Player options" (the new overflow ⋮) also matches
// /play|pause/i otherwise.
await fireEvent.click(screen.getByRole('button', { name: /^(play|pause)$/i }));
await fireEvent.click(compact().getByRole('button', { name: /^(play|pause)$/i }));
expect(togglePlay).toHaveBeenCalledTimes(1);
});
test('skip-next click calls skipNext; skip-prev click calls skipPrev', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /next/i }));
await fireEvent.click(screen.getByRole('button', { name: /previous/i }));
await fireEvent.click(compact().getByRole('button', { name: /next/i }));
await fireEvent.click(compact().getByRole('button', { name: /previous/i }));
expect(skipNext).toHaveBeenCalledTimes(1);
expect(skipPrev).toHaveBeenCalledTimes(1);
});
@@ -111,7 +122,7 @@ describe('PlayerBar', () => {
state.index = 0;
state.position = 1;
render(PlayerBar);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(compact().getByRole('button', { name: /previous/i })).toBeDisabled();
});
test('skip-next disabled at end with repeat=off', () => {
@@ -119,19 +130,19 @@ describe('PlayerBar', () => {
state.index = 0;
state.repeat = 'off';
render(PlayerBar);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
expect(compact().getByRole('button', { name: /next/i })).toBeDisabled();
});
test('seek slider input calls seekTo(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/seek/i) as HTMLInputElement;
const slider = compact().getByLabelText(/seek/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '120' } });
expect(seekTo).toHaveBeenCalledWith(120);
});
test('volume slider input calls setVolume(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/volume/i) as HTMLInputElement;
const slider = compact().getByLabelText(/volume/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '0.3' } });
expect(setVolume).toHaveBeenCalledWith(0.3);
});
@@ -139,28 +150,28 @@ describe('PlayerBar', () => {
test('shuffle button click calls toggleShuffle; active class reflects shuffle', () => {
state.shuffle = true;
render(PlayerBar);
const btn = screen.getByRole('button', { name: /shuffle/i });
const btn = compact().getByRole('button', { name: /shuffle/i });
expect(btn.getAttribute('aria-pressed')).toBe('true');
});
test('repeat button aria-label reflects current mode', () => {
state.repeat = 'one';
render(PlayerBar);
expect(screen.getByRole('button', { name: /repeat one/i })).toBeInTheDocument();
expect(compact().getByRole('button', { name: /repeat one/i })).toBeInTheDocument();
});
test('loading state shows spinner in place of play icon', () => {
state.state = 'loading';
render(PlayerBar);
expect(screen.getByTestId('play-spinner')).toBeInTheDocument();
expect(compact().getByTestId('play-spinner')).toBeInTheDocument();
});
test('error state renders retry card; retry click calls playQueue(queue, index)', async () => {
state.state = 'error';
state.error = 'Playback failed.';
render(PlayerBar);
expect(screen.getByText(/playback failed/i)).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(compact().getByText(/playback failed/i)).toBeInTheDocument();
await fireEvent.click(compact().getByRole('button', { name: /try again/i }));
expect(playQueue).toHaveBeenCalledWith(state.queue, state.index);
});
@@ -168,21 +179,21 @@ describe('PlayerBar', () => {
state.position = 65;
state.duration = 245;
render(PlayerBar);
expect(screen.getByText('1:05')).toBeInTheDocument();
expect(screen.getByText('4:05')).toBeInTheDocument();
expect(compact().getByText('1:05')).toBeInTheDocument();
expect(compact().getByText('4:05')).toBeInTheDocument();
});
test('renders the TrackMenu kebab button when a track is current', () => {
render(PlayerBar);
expect(
screen.getByRole('button', { name: /track actions for/i })
compact().getByRole('button', { name: /track actions for/i })
).toBeInTheDocument();
});
describe('queue toggle button', () => {
test('clicking the queue toggle calls toggleQueueDrawer', async () => {
render(PlayerBar);
const btn = screen.getByLabelText(/(open|close) queue/i);
const btn = compact().getByLabelText(/(open|close) queue/i);
await fireEvent.click(btn);
expect(toggleQueueDrawer).toHaveBeenCalled();
});
@@ -190,12 +201,18 @@ describe('PlayerBar', () => {
test('aria-pressed reflects queueDrawerOpen state', () => {
state.queueDrawerOpen = true;
render(PlayerBar);
const btn = screen.getByLabelText(/close queue/i);
const btn = compact().getByLabelText(/close queue/i);
expect(btn).toHaveAttribute('aria-pressed', 'true');
});
});
describe('"Up next" line', () => {
describe('"Up next" line (desktop only)', () => {
// The "Next · …" copy was dropped from compact per #358; only the
// desktop block surfaces it. Scope queries to desktop.
function desktop() {
return within(screen.getByTestId('player-bar-desktop'));
}
test('renders the next track title when index + 1 < queue.length', () => {
state.queue = [
{ id: 'a', title: 'Track A', artist_name: 'Art A' } as TrackRef,
@@ -204,7 +221,7 @@ describe('PlayerBar', () => {
state.index = 0;
state.current = state.queue[0];
render(PlayerBar);
expect(screen.getByText(/track b/i)).toBeInTheDocument();
expect(desktop().getByText(/track b/i)).toBeInTheDocument();
});
test('omits the line on the last track in queue', () => {
@@ -214,7 +231,7 @@ describe('PlayerBar', () => {
state.index = 0;
state.current = state.queue[0];
render(PlayerBar);
expect(screen.queryByText(/^Next ·/i)).not.toBeInTheDocument();
expect(desktop().queryByText(/^Next ·/i)).not.toBeInTheDocument();
});
test('omits the line when queue is empty', () => {
@@ -1,97 +0,0 @@
<script lang="ts">
import { MoreVertical, Shuffle, Repeat, Repeat1, ListMusic, Volume2 } from 'lucide-svelte';
import {
player, toggleShuffle, cycleRepeat, toggleQueueDrawer, setVolume
} from '$lib/player/store.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
let { direction = 'up' }: { direction?: 'up' | 'down' } = $props();
let menuOpen = $state(false);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
menuOpen = !menuOpen;
}
const repeatLabel = $derived(
player.repeat === 'off' ? 'Repeat off' :
player.repeat === 'all' ? 'Repeat all' : 'Repeat one'
);
const repeatIcon = $derived(player.repeat === 'one' ? Repeat1 : Repeat);
function onShuffle() {
toggleShuffle();
}
function onRepeat() {
cycleRepeat();
}
function onQueue() {
toggleQueueDrawer();
menuOpen = false;
}
function onVolume(e: Event) {
setVolume(Number((e.currentTarget as HTMLInputElement).value));
}
</script>
<svelte:window onclick={() => { menuOpen = false; }} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
<div class="relative inline-block">
<button
type="button"
aria-label="Player options"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={toggleMenu}
class="rounded p-1 text-text-muted hover:text-text-primary min-h-[44px] min-w-[44px]
flex items-center justify-center"
>
<MoreVertical size={18} strokeWidth={1} />
</button>
{#if menuOpen}
<div
role="menu"
tabindex="-1"
class="absolute right-0 z-20 w-56 rounded-md border border-border bg-surface p-1 shadow-lg
{direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'}"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<TrackMenuItem
icon={Shuffle}
label={player.shuffle ? 'Shuffle on' : 'Shuffle off'}
onclick={onShuffle}
/>
<TrackMenuItem
icon={repeatIcon}
label={repeatLabel}
onclick={onRepeat}
/>
<TrackMenuItem
icon={ListMusic}
label={player.queueDrawerOpen ? 'Hide queue' : 'Show queue'}
onclick={onQueue}
/>
<TrackMenuDivider />
<div class="flex items-center gap-2 px-2 py-1.5">
<Volume2 size={14} strokeWidth={1} class="shrink-0 text-text-secondary" />
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolume}
class="flex-1 accent-accent"
/>
</div>
</div>
{/if}
</div>
@@ -1,31 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import PlayerOverflowMenu from './PlayerOverflowMenu.svelte';
describe('PlayerOverflowMenu', () => {
it('renders the trigger button closed by default', () => {
render(PlayerOverflowMenu);
const trigger = screen.getByLabelText('Player options');
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('opens the menu on click and shows shuffle/repeat/queue/volume', async () => {
render(PlayerOverflowMenu);
const trigger = screen.getByLabelText('Player options');
await fireEvent.click(trigger);
expect(trigger.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByText(/shuffle/i)).toBeInTheDocument();
expect(screen.getByText(/repeat/i)).toBeInTheDocument();
expect(screen.getByText(/queue/i)).toBeInTheDocument();
expect(screen.getByLabelText('Volume')).toBeInTheDocument();
});
it('Escape key closes the menu', async () => {
render(PlayerOverflowMenu);
await fireEvent.click(screen.getByLabelText('Player options'));
expect(screen.getByLabelText('Player options').getAttribute('aria-expanded')).toBe('true');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await Promise.resolve();
expect(screen.getByLabelText('Player options').getAttribute('aria-expanded')).toBe('false');
});
});