fix(ui): harden settings focus and semantics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user