fix(library): sync local album play and pause controls

This commit is contained in:
zarzet committed 2026-10-09 13:48:53 +07:00
1 parent c7b1d0b0ec
commit c92e1a61fe
4 files changed
+338 -45

No files matched your search

+85 -41
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:math';
import 'package:audio_service/audio_service.dart' show MediaItem;
import 'package:spotiflac_android/services/deleted_library_files.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
@@ -199,6 +200,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
);
}
bool _isCurrentTrack(MediaItem? item, LocalLibraryItem track) {
if (item == null) return false;
// Playlist playback can use the file path rather than the Library row ID.
final source = item.extras?['source'];
return source is String && source.isNotEmpty
? source == track.filePath
: item.id == track.id || item.id == track.filePath;
}
@override
Widget build(BuildContext context) {
if (context.isMornye) {
@@ -352,11 +362,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
overflow: TextOverflow.ellipsis,
),
meta: _buildLocalHeaderMeta(context, qualityLabelMode),
actions: AlbumPlayActions(
playLabel: context.l10n.tooltipPlay,
shuffleTooltip: context.l10n.actionShuffle,
onPlay: _playAll,
onShuffle: _shuffleAll,
actions: Consumer(
builder: (context, ref, _) {
final isCurrent = ref.watch(
currentMediaItemProvider.select(
(state) => _sortedTracksCache.any(
(track) => _isCurrentTrack(state.value, track),
),
),
);
final isPlaying = isCurrent && ref.watch(playbackPlayingProvider);
return AlbumPlayActions(
isPlaying: isPlaying,
playLabel: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
shuffleTooltip: context.l10n.actionShuffle,
onPlay: isCurrent
? () => ref
.read(musicPlayerControllerProvider)
.togglePlayPause(isPlaying)
: _playAll,
onShuffle: _shuffleAll,
);
},
),
);
}
@@ -469,44 +498,59 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
ColorScheme colorScheme,
LocalLibraryItem track,
) {
return AlbumTrackTile(
trackNumber: track.trackNumber,
trackName: track.trackName,
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
return Consumer(
builder: (context, ref, _) {
final isCurrent = ref.watch(
currentMediaItemProvider.select(
(state) => _isCurrentTrack(state.value, track),
),
if (track.format != null) ...[
Text(
' • ',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
);
final isPlaying = isCurrent && ref.watch(playbackPlayingProvider);
return AlbumTrackTile(
isPlaying: isPlaying,
trackNumber: track.trackNumber,
trackName: track.trackName,
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(track.id),
colorScheme: colorScheme,
onToggleSelection: () => toggleSelection(track.id),
onOpen: () => _openMetadata(track),
onEnterSelectionMode: () => enterSelectionMode(track.id),
onPlay: () => _openFile(track),
if (track.format != null) ...[
Text(
' • ',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(track.id),
colorScheme: colorScheme,
onToggleSelection: () => toggleSelection(track.id),
onOpen: () => _openMetadata(track),
onEnterSelectionMode: () => enterSelectionMode(track.id),
onPlay: isCurrent
? () => ref
.read(musicPlayerControllerProvider)
.togglePlayPause(isPlaying)
: () => _openFile(track),
);
},
);
}
+6 -2
View File
@@ -531,12 +531,14 @@ class AlbumPlayActions extends StatelessWidget {
required this.shuffleTooltip,
required this.onPlay,
required this.onShuffle,
this.isPlaying = false,
});
final String playLabel;
final String shuffleTooltip;
final VoidCallback onPlay;
final VoidCallback onShuffle;
final bool isPlaying;
@override
Widget build(BuildContext context) {
@@ -547,7 +549,9 @@ class AlbumPlayActions extends StatelessWidget {
children: [
Expanded(
child: HeaderFilledButton(
icon: CupertinoIcons.play_fill,
icon: isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill,
label: playLabel,
onPressed: onPlay,
),
@@ -569,7 +573,7 @@ class AlbumPlayActions extends StatelessWidget {
Flexible(
child: ExpressiveButton(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow, size: 20),
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow, size: 20),
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
+10 -2
View File
@@ -23,6 +23,7 @@ class AlbumTrackTile extends StatelessWidget {
required this.onOpen,
required this.onEnterSelectionMode,
required this.onPlay,
this.isPlaying = false,
});
final int? trackNumber;
@@ -35,6 +36,7 @@ class AlbumTrackTile extends StatelessWidget {
final VoidCallback onOpen;
final VoidCallback onEnterSelectionMode;
final VoidCallback onPlay;
final bool isPlaying;
@override
Widget build(BuildContext context) {
@@ -58,10 +60,16 @@ class AlbumTrackTile extends StatelessWidget {
title: trackName,
subtitle: subtitle,
trailing: IconButton(
tooltip: context.l10n.tooltipPlay,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
onPressed: onPlay,
icon: Icon(
context.isMornye ? CupertinoIcons.play_fill : Icons.play_arrow,
context.isMornye
? (isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill)
: (isPlaying ? Icons.pause : Icons.play_arrow),
color: colorScheme.primary,
),
style: IconButton.styleFrom(
@@ -0,0 +1,237 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart' show MediaItem;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
class _Playing extends Notifier<bool> {
@override
bool build() => false;
void emit(bool playing) => state = playing;
}
final _playingProvider = NotifierProvider<_Playing, bool>(_Playing.new);
class _Player extends MusicPlayerController {
_Player(this._emit);
final void Function(bool) _emit;
final _toggles = <bool>[];
@override
Future<void> setShuffle(bool enabled) async {}
@override
Future<void> togglePlayPause(bool isPlaying) async {
_toggles.add(isPlaying);
_emit(!isPlaying);
}
}
class _Playback extends PlaybackController {
_Playback(this._start);
final void Function(LocalLibraryItem) _start;
final _started = <String>[];
@override
Future<void> playLocalLibraryQueue(
List<LocalLibraryItem> items, {
required LocalLibraryItem startItem,
}) async {
_started.add(startItem.id);
_start(startItem);
}
}
void main() {
for (final mornye in [false, true]) {
testWidgets(
'local album controls pause and resume without restarting ($mornye)',
(tester) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(430, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final media = StreamController<MediaItem?>();
addTearDown(media.close);
late final ProviderContainer container;
void emitPlaying(bool playing) =>
container.read(_playingProvider.notifier).emit(playing);
final player = _Player(emitPlaying);
final playback = _Playback((track) {
media.add(
MediaItem(
id: track.id,
title: track.trackName,
extras: {'source': track.filePath},
),
);
emitPlaying(true);
});
final tracks = [
for (final number in [1, 2])
LocalLibraryItem(
id: '$number',
trackName: 'Song $number',
artistName: 'Artist',
albumName: 'Album',
filePath: 'content://library/song-$number.flac',
trackNumber: number,
scannedAt: DateTime(2026),
),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith((ref) => media.stream),
playbackPlayingProvider.overrideWith(
(ref) => ref.watch(_playingProvider),
),
musicPlayerControllerProvider.overrideWithValue(player),
playbackProvider.overrideWith(() => playback),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
tracks: tracks,
),
),
),
);
container = ProviderScope.containerOf(
tester.element(find.byType(LocalAlbumScreen)),
);
await tester.pumpAndSettle();
final header = find.byType(AlbumPlayActions);
Finder row(int number) => find.byWidgetPredicate(
(widget) =>
widget is AlbumTrackTile && widget.trackName == 'Song $number',
);
Finder rowButton(int number) =>
find.descendant(of: row(number), matching: find.byType(IconButton));
final pauseIcon = mornye ? CupertinoIcons.pause_fill : Icons.pause;
final playIcon = mornye ? CupertinoIcons.play_fill : Icons.play_arrow;
await tester.tap(
find.descendant(of: header, matching: find.text('Play')),
);
await tester.pumpAndSettle();
expect(playback._started, ['1']);
expect(
find.descendant(of: header, matching: find.text('Pause')),
findsOneWidget,
);
expect(
find.descendant(of: header, matching: find.byIcon(pauseIcon)),
findsOneWidget,
);
expect(
find.descendant(of: row(1), matching: find.byIcon(pauseIcon)),
findsOneWidget,
);
expect(
find.descendant(of: row(2), matching: find.byIcon(playIcon)),
findsOneWidget,
);
await tester.tap(
find.descendant(of: header, matching: find.text('Pause')),
);
await tester.pumpAndSettle();
expect(player._toggles, [true]);
expect(
find.descendant(of: header, matching: find.text('Play')),
findsOneWidget,
);
expect(
find.descendant(of: row(1), matching: find.byTooltip('Play')),
findsOneWidget,
);
await tester.ensureVisible(rowButton(1));
await tester.tap(rowButton(1));
await tester.pumpAndSettle();
expect(player._toggles, [true, false]);
expect(playback._started, ['1']);
expect(
find.descendant(of: row(1), matching: find.byTooltip('Pause')),
findsOneWidget,
);
await tester.ensureVisible(rowButton(2));
await tester.tap(rowButton(2));
await tester.pumpAndSettle();
expect(playback._started, ['1', '2']);
expect(
find.descendant(of: row(1), matching: find.byIcon(playIcon)),
findsOneWidget,
);
expect(
find.descendant(of: row(2), matching: find.byIcon(pauseIcon)),
findsOneWidget,
);
await tester.tap(rowButton(2));
await tester.pumpAndSettle();
expect(player._toggles, [true, false, true]);
expect(playback._started, ['1', '2']);
// A queue started from a playlist uses its file path as the media ID.
media.add(MediaItem(id: tracks.first.filePath, title: 'Song 1'));
emitPlaying(true);
await tester.pumpAndSettle();
expect(
find.descendant(of: row(1), matching: find.byIcon(pauseIcon)),
findsOneWidget,
);
await tester.tap(rowButton(1));
await tester.pumpAndSettle();
expect(player._toggles.last, isTrue);
expect(playback._started, ['1', '2']);
// A matching row ID must not pause a different file in another album.
media.add(
const MediaItem(
id: '1',
title: 'Other',
extras: {'source': '/other.flac'},
),
);
emitPlaying(true);
await tester.pumpAndSettle();
expect(
find.descendant(of: header, matching: find.text('Play')),
findsOneWidget,
);
expect(find.byIcon(pauseIcon), findsNothing);
await tester.ensureVisible(header);
await tester.tap(
find.descendant(of: header, matching: find.text('Play')),
);
await tester.pumpAndSettle();
expect(playback._started, ['1', '2', '1']);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}