mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Queue the full available Library collection from the selected row instead of replacing playback with one track. Preserve playlist order and map the selected position after skipping unavailable and virtual CUE entries, so Next and Previous retain their context. Cover both themes, repeated tracks, list boundaries, unavailable audio, and external-player behavior. Refs #592.
283 lines
8.6 KiB
Dart
283 lines
8.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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/models/settings.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
|
import 'package:spotiflac_android/theme/app_theme.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/widgets/track_card.dart';
|
|
|
|
final _tracks = [
|
|
for (final name in ['First', 'Unavailable', 'Selected', 'Last'])
|
|
Track(
|
|
id: name,
|
|
name: name,
|
|
artistName: 'Artist',
|
|
albumName: 'Album',
|
|
duration: 180,
|
|
),
|
|
];
|
|
|
|
class _Settings extends SettingsNotifier {
|
|
@override
|
|
AppSettings build() => const AppSettings(playerMode: 'internal');
|
|
|
|
@override
|
|
void setPlayerMode(String mode) => state = state.copyWith(playerMode: mode);
|
|
}
|
|
|
|
class _Handler extends Fake implements MusicPlayerHandler {}
|
|
|
|
class _Player extends MusicPlayerController {
|
|
List<PlayableMedia> queue = [];
|
|
int? index;
|
|
|
|
@override
|
|
Future<MusicPlayerHandler?> ensureInitialized() async => _Handler();
|
|
|
|
@override
|
|
Future<void> playAll(
|
|
List<PlayableMedia> items, {
|
|
int initialIndex = 0,
|
|
}) async {
|
|
queue = items;
|
|
index = initialIndex;
|
|
}
|
|
}
|
|
|
|
class _Playback extends PlaybackController {
|
|
final Map<String, String?> paths = {
|
|
'First': 'content://library/first',
|
|
'Selected': 'content://library/selected',
|
|
'Last': 'content://library/last',
|
|
};
|
|
|
|
@override
|
|
Future<List<String?>> resolveTrackFilePaths(List<Track> tracks) async => [
|
|
for (final track in tracks) paths[track.id],
|
|
];
|
|
}
|
|
|
|
class _Collections extends LibraryCollectionsNotifier {
|
|
@override
|
|
LibraryCollectionsState build() => LibraryCollectionsState(
|
|
isLoaded: true,
|
|
playlists: [
|
|
UserPlaylistCollection(
|
|
id: 'playlist',
|
|
name: 'Playlist',
|
|
createdAt: DateTime(2026),
|
|
updatedAt: DateTime(2026),
|
|
tracks: [
|
|
for (final track in _tracks)
|
|
CollectionTrackEntry(
|
|
key: track.id,
|
|
track: track,
|
|
addedAt: DateTime(2026),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
|
|
@override
|
|
Future<void> ensurePlaylistLoaded(String playlistId) async {}
|
|
}
|
|
|
|
class _Library extends LocalLibraryNotifier {
|
|
@override
|
|
LocalLibraryState build() => LocalLibraryState();
|
|
}
|
|
|
|
class _History extends DownloadHistoryNotifier {
|
|
@override
|
|
DownloadHistoryState build() => DownloadHistoryState();
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
|
|
|
for (final mornye in [false, true]) {
|
|
testWidgets('playlist row queues its neighbors (Mornye: $mornye)', (
|
|
tester,
|
|
) async {
|
|
final player = _Player();
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
settingsProvider.overrideWith(_Settings.new),
|
|
musicPlayerControllerProvider.overrideWithValue(player),
|
|
playbackProvider.overrideWith(_Playback.new),
|
|
libraryCollectionsProvider.overrideWith(_Collections.new),
|
|
localLibraryProvider.overrideWith(_Library.new),
|
|
downloadHistoryProvider.overrideWith(_History.new),
|
|
downloadHistoryVisibleBatchExistsProvider.overrideWith(
|
|
(ref, request) => {
|
|
for (final track in request.tracks)
|
|
if (track.spotifyId != 'Unavailable') track.lookupKey,
|
|
},
|
|
),
|
|
localLibraryCoverProvider.overrideWith(
|
|
(ref, request) async => null,
|
|
),
|
|
localLibraryFirstCoverProvider.overrideWith(
|
|
(ref, request) async => null,
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
theme: mornye
|
|
? MornyeTheme.build(Brightness.dark)
|
|
: AppTheme.light(),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: const LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.playlist,
|
|
playlistId: 'playlist',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final row = find.ancestor(
|
|
of: find.text('Selected'),
|
|
matching: find.byType(TrackCard),
|
|
);
|
|
await tester.scrollUntilVisible(row, 200);
|
|
await tester.tap(
|
|
find.descendant(of: row, matching: find.byIcon(Icons.play_arrow)),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(player.queue.map((item) => item.title), [
|
|
'First',
|
|
'Selected',
|
|
'Last',
|
|
]);
|
|
expect(player.index, 1);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
|
|
late ProviderContainer container;
|
|
late _Player player;
|
|
late _Playback playback;
|
|
setUp(() {
|
|
player = _Player();
|
|
playback = _Playback();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
settingsProvider.overrideWith(_Settings.new),
|
|
musicPlayerControllerProvider.overrideWithValue(player),
|
|
playbackProvider.overrideWith(() => playback),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
});
|
|
|
|
test(
|
|
'starting at the last track does not rotate earlier tracks after it',
|
|
() async {
|
|
await container
|
|
.read(playbackProvider.notifier)
|
|
.playTrackList(_tracks, startIndex: 3);
|
|
expect(player.queue.map((item) => item.title), [
|
|
'First',
|
|
'Selected',
|
|
'Last',
|
|
]);
|
|
expect(player.index, player.queue.length - 1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'missing and virtual CUE tracks do not shift the selected track',
|
|
() async {
|
|
playback.paths['Unavailable'] = '/album.cue#track02';
|
|
await container
|
|
.read(playbackProvider.notifier)
|
|
.playTrackList(_tracks, startIndex: 2);
|
|
expect(player.queue.map((item) => item.title), [
|
|
'First',
|
|
'Selected',
|
|
'Last',
|
|
]);
|
|
expect(player.index, 1);
|
|
},
|
|
);
|
|
|
|
test('repeated tracks retain the selected occurrence', () async {
|
|
await container.read(playbackProvider.notifier).playTrackList([
|
|
_tracks.first,
|
|
_tracks.last,
|
|
_tracks.first,
|
|
_tracks[2],
|
|
], startIndex: 2);
|
|
expect(player.queue.map((item) => item.title), [
|
|
'First',
|
|
'Last',
|
|
'First',
|
|
'Selected',
|
|
]);
|
|
expect(player.index, 2);
|
|
});
|
|
|
|
test('unavailable selection starts the next playable track', () async {
|
|
await container
|
|
.read(playbackProvider.notifier)
|
|
.playTrackList(_tracks, startIndex: 1);
|
|
expect(player.queue[player.index!].title, 'Selected');
|
|
});
|
|
|
|
test('empty playlist leaves existing playback alone', () async {
|
|
await container.read(playbackProvider.notifier).playTrackList([]);
|
|
expect(player.index, isNull);
|
|
});
|
|
|
|
test(
|
|
'no playable files reports an error without replacing playback',
|
|
() async {
|
|
playback.paths.clear();
|
|
await expectLater(
|
|
container.read(playbackProvider.notifier).playTrackList(_tracks),
|
|
throwsA(isA<Exception>()),
|
|
);
|
|
expect(player.index, isNull);
|
|
},
|
|
);
|
|
|
|
test('external mode opens only the selected available file', () async {
|
|
container.read(settingsProvider.notifier).setPlayerMode('external');
|
|
final calls = <MethodCall>[];
|
|
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
return null;
|
|
});
|
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
|
|
|
await container
|
|
.read(playbackProvider.notifier)
|
|
.playTrackList(_tracks, startIndex: 2);
|
|
expect(calls.single.method, 'openContentUri');
|
|
expect(
|
|
calls.single.arguments,
|
|
containsPair('uri', 'content://library/selected'),
|
|
);
|
|
expect(player.index, isNull);
|
|
});
|
|
}
|