fix(ui): harden settings focus and semantics

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent f8bfecf57d
commit b41fb4fe75
159 changed files with 12054 additions and 2050 deletions
+287 -1
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
@@ -13,9 +14,11 @@ import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
import 'package:plezy/widgets/video_controls/mobile_video_controls.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
@@ -27,6 +30,7 @@ import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart';
import 'package:plezy/widgets/video_controls/widgets/sync_offset_control.dart';
import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart';
import 'package:plezy/widgets/video_controls/video_control_button.dart';
import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart';
import '../test_helpers/watch_together_fakes.dart';
@@ -1479,6 +1483,85 @@ void main() {
});
});
group('subtitle visibility', () {
testWidgets('rolls back a failed latest toggle to the preceding successful mutation', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final player = _FakeSubtitleVisibilityPlayer(writes: [firstWrite, secondWrite]);
final volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
final playbackState = PlaybackStateProvider();
final watchTogether = WatchTogetherProvider();
final chrome = PlayerChromeController();
final toast = PlayerToastController();
addTearDown(volume.dispose);
addTearDown(playbackState.dispose);
addTearDown(watchTogether.dispose);
addTearDown(chrome.dispose);
addTearDown(toast.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 1200,
height: 800,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'subtitle-visibility'),
toastController: toast,
canNavigateMediaItems: false,
chromeController: chrome,
isLive: true,
),
),
),
),
),
);
await tester.pump();
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no']);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no'], reason: 'the latest toggle must wait for the in-flight native write');
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(player.propertyValues, ['no', 'yes']);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
});
group('SyncOffsetControl', () {
testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
@@ -1594,16 +1677,165 @@ void main() {
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
await tester.pump();
expect(persistedOffsets, [200]);
expect(persistedOffsets, isEmpty);
staleWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
expect(persistedOffsets, [200]);
});
testWidgets('rolls back a failed latest write to the preceding successful offset', (tester) async {
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final propertyValues = <String>[];
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(
onSetProperty: (_, value) {
propertyValues.add(value);
return propertyValues.length == 1 ? firstWrite.future : secondWrite.future;
},
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async {
persistedOffsets.add(offset);
},
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pump();
expect(propertyValues, ['0.1']);
tester.widget<Slider>(find.byType(Slider)).onChanged!(200);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
expect(propertyValues, ['0.1']);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(propertyValues, ['0.1', '0.2']);
expect(persistedOffsets, [100]);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 100);
expect(persistedOffsets, [100]);
});
testWidgets('persists an accepted offset after the control is disposed', (tester) async {
final propertyWrite = Completer<void>();
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(onSetProperty: (_, _) => propertyWrite.future);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async => persistedOffsets.add(offset),
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pumpWidget(const SizedBox.shrink());
propertyWrite.complete();
await tester.pump();
expect(persistedOffsets, [100]);
});
});
group('VideoControlButton semantics', () {
testWidgets('exposes one operable node with value and checked state', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_video_control');
addTearDown(focusNode.dispose);
var activations = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VideoControlButton(
icon: Icons.settings,
tooltip: 'Player settings',
semanticValue: '720p',
checked: true,
focusNode: focusNode,
onPressed: () => activations++,
),
),
),
);
final finder = find.bySemanticsLabel('Player settings');
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, '720p');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isChecked, ui.CheckedState.isTrue);
expect(data.hasAction(ui.SemanticsAction.tap), isTrue);
final node = tester.getSemantics(finder);
node.owner!.performAction(node.id, ui.SemanticsAction.tap);
await tester.pump();
expect(activations, 1);
semantics.dispose();
});
testWidgets('keeps disabled controls discoverable without a tap action', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: VideoControlButton(icon: Icons.skip_next, semanticLabel: 'Next item', onPressed: null),
),
),
);
final data = tester.getSemantics(find.bySemanticsLabel('Next item')).getSemanticsData();
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
semantics.dispose();
});
});
}
@@ -1670,3 +1902,57 @@ class _FakeSyncPlayer implements Player {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeSubtitleVisibilityPlayer implements Player {
_FakeSubtitleVisibilityPlayer({required this.writes});
final List<Completer<void>> writes;
final List<String> propertyValues = [];
@override
String get playerType => 'mpv';
@override
PlayerState get state => PlayerState(
duration: const Duration(minutes: 45),
seekable: true,
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 'subtitle-1', language: 'eng')],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 'subtitle-1', language: 'eng'),
),
);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
Future<void> setProperty(String name, String value) {
expect(name, 'sub-visibility');
propertyValues.add(value);
return writes[propertyValues.length - 1].future;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}