mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
fix(library): sync local album play and pause controls
This commit is contained in:
1 parent
c7b1d0b0ec
commit
c92e1a61fe
4 files changed
+338
-45
No files matched your search
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user