A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819
269 lines
9.2 KiB
Dart
269 lines
9.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/focus/input_mode_tracker.dart';
|
|
import 'package:plezy/i18n/strings.g.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_item.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/providers/multi_server_provider.dart';
|
|
import 'package:plezy/screens/music/now_playing_screen.dart';
|
|
import 'package:plezy/services/music/music_playback_service.dart';
|
|
import 'package:plezy/theme/mono_theme.dart';
|
|
import 'package:plezy/utils/platform_detector.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../test_helpers/media_items.dart';
|
|
import '../../test_helpers/multi_server_fixtures.dart';
|
|
import '../../test_helpers/stub_music_playback_service.dart';
|
|
|
|
MediaItem _track({required String id, required String title, required String album, required int year}) {
|
|
return testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.track,
|
|
title: title,
|
|
parentId: 'album_$id',
|
|
parentTitle: album,
|
|
grandparentId: 'artist_$id',
|
|
grandparentTitle: 'Artist $id',
|
|
year: year,
|
|
durationMs: const Duration(minutes: 3).inMilliseconds,
|
|
serverId: 'server_1',
|
|
);
|
|
}
|
|
|
|
class _FakeMusicService extends StubMusicPlaybackService {
|
|
MediaItem track;
|
|
final MusicPlayContext context;
|
|
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
|
|
final StreamController<Duration?> _playheadJumpController = StreamController<Duration?>.broadcast(sync: true);
|
|
final List<Duration> seeks = [];
|
|
Duration _position = Duration.zero;
|
|
|
|
_FakeMusicService({required this.track, required this.context});
|
|
|
|
void advanceTo(MediaItem next) {
|
|
track = next;
|
|
_position = Duration.zero;
|
|
notifyListeners();
|
|
}
|
|
|
|
void emitPosition(Duration position) {
|
|
_position = position;
|
|
_positionController.add(position);
|
|
}
|
|
|
|
/// Something outside the screen — OS media controls, a headset, the lock
|
|
/// screen — moved the playhead.
|
|
void emitPlayheadJump(Duration position) {
|
|
_position = position;
|
|
_playheadJumpController.add(position);
|
|
}
|
|
|
|
@override
|
|
MediaItem get currentTrack => track;
|
|
|
|
@override
|
|
MusicPlaybackStatus get status => MusicPlaybackStatus.playing;
|
|
|
|
@override
|
|
Duration get position => _position;
|
|
|
|
@override
|
|
Stream<Duration> get positionStream => _positionController.stream;
|
|
|
|
@override
|
|
Stream<Duration?> get playheadJumpStream => _playheadJumpController.stream;
|
|
|
|
@override
|
|
Duration get duration => const Duration(minutes: 3);
|
|
|
|
@override
|
|
List<MediaItem> get queue => [track];
|
|
|
|
@override
|
|
int get currentIndex => 0;
|
|
|
|
@override
|
|
MusicPlayContext get playContext => context;
|
|
|
|
@override
|
|
Future<void> seek(Duration position) async {
|
|
seeks.add(position);
|
|
_position = position;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_playheadJumpController.close();
|
|
_positionController.close();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
TvDetectionService.debugSetAppleTVOverride(false);
|
|
PlatformDetector.debugSetIsDesktopOSOverride(null);
|
|
});
|
|
|
|
tearDown(() {
|
|
TvDetectionService.debugSetAppleTVOverride(null);
|
|
PlatformDetector.debugSetIsDesktopOSOverride(null);
|
|
});
|
|
|
|
Future<void> pumpNowPlaying(WidgetTester tester, _FakeMusicService service, {required bool isTv}) async {
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.physicalSize = const Size(1200, 700);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
|
|
TvDetectionService.debugSetAppleTVOverride(isTv);
|
|
PlatformDetector.debugSetIsDesktopOSOverride(!isTv);
|
|
|
|
addTearDown(service.dispose);
|
|
final multiServerProvider = testMultiServer().provider;
|
|
|
|
await tester.pumpWidget(
|
|
InputModeTracker(
|
|
child: TranslationProvider(
|
|
child: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
|
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
|
|
],
|
|
child: MaterialApp(
|
|
theme: monoTheme(dark: true).copyWith(platform: isTv ? TargetPlatform.android : TargetPlatform.windows),
|
|
home: const NowPlayingScreen(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
}
|
|
|
|
for (final isTv in [false, true]) {
|
|
testWidgets('${isTv ? 'TV' : 'desktop'} follows the current album when an album queue crosses albums', (
|
|
tester,
|
|
) async {
|
|
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
|
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
|
|
final service = _FakeMusicService(
|
|
track: first,
|
|
context: const MusicPlayContext(id: 'album_one', title: 'First Album', kind: MusicPlayContextKind.album),
|
|
);
|
|
|
|
await pumpNowPlaying(tester, service, isTv: isTv);
|
|
|
|
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsOneWidget);
|
|
|
|
service.advanceTo(second);
|
|
await tester.pump();
|
|
|
|
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsNothing);
|
|
expect(find.text(t.music.playingFrom(title: 'Second Album · 1999')), findsOneWidget);
|
|
});
|
|
}
|
|
|
|
testWidgets('playlist playback retains its queue provenance label', (tester) async {
|
|
final service = _FakeMusicService(
|
|
track: _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973),
|
|
context: const MusicPlayContext(id: 'playlist_1', title: 'Road Trip', kind: MusicPlayContextKind.playlist),
|
|
);
|
|
|
|
await pumpNowPlaying(tester, service, isTv: false);
|
|
|
|
expect(find.text(t.music.playingFrom(title: 'Road Trip')), findsOneWidget);
|
|
expect(find.text(t.music.playingFrom(title: 'First Album · 1973')), findsNothing);
|
|
});
|
|
|
|
testWidgets('pending d-pad seek is discarded when the track changes', (tester) async {
|
|
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
|
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
|
|
final service = _FakeMusicService(
|
|
track: first,
|
|
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
|
|
);
|
|
|
|
await pumpNowPlaying(tester, service, isTv: true);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pump();
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
|
|
service.advanceTo(second);
|
|
await tester.pump();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
|
|
expect(service.seeks, isEmpty);
|
|
});
|
|
|
|
testWidgets('a d-pad seek after an outside jump starts from where the jump landed', (tester) async {
|
|
// The seek bar pins its coalesced target so a slow backend cannot make the
|
|
// next press rebase off a stale position. OS media controls, a headset and
|
|
// the lock screen seek straight through the service, so that pin has to be
|
|
// retired when one of them moves the playhead (#1819).
|
|
final track = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
|
final service = _FakeMusicService(
|
|
track: track,
|
|
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
|
|
);
|
|
|
|
await pumpNowPlaying(tester, service, isTv: true);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pump();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
expect(service.seeks, hasLength(1));
|
|
final step = service.seeks.single;
|
|
expect(step, greaterThan(Duration.zero));
|
|
|
|
service.emitPlayheadJump(const Duration(minutes: 2));
|
|
await tester.pump();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
|
|
expect(
|
|
service.seeks.last,
|
|
const Duration(minutes: 2) + step,
|
|
reason: 'the step must build on the outside jump, not on the superseded pin',
|
|
);
|
|
});
|
|
|
|
testWidgets('seek progress resets immediately when the track changes', (tester) async {
|
|
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
|
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
|
|
final service = _FakeMusicService(
|
|
track: first,
|
|
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
|
|
);
|
|
|
|
await pumpNowPlaying(tester, service, isTv: false);
|
|
service.emitPosition(const Duration(minutes: 2));
|
|
await tester.pump();
|
|
Slider seekSlider() => tester
|
|
.widgetList<Slider>(find.byType(Slider))
|
|
.singleWhere((slider) => slider.max == const Duration(minutes: 3).inMilliseconds);
|
|
|
|
expect(seekSlider().value, 120000);
|
|
|
|
service.advanceTo(second);
|
|
await tester.pump();
|
|
|
|
expect(seekSlider().value, 0);
|
|
});
|
|
}
|