From 40792e27792db494719754951b5b0ef0378f77ae Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:48:37 +0200 Subject: [PATCH] fix: restore focus and interaction behavior --- lib/focus/focusable_action_bar.dart | 4 +- lib/focus/focusable_slider.dart | 5 +- lib/screens/livetv/tabs/guide_tab.dart | 36 +++- lib/screens/media_detail_screen.dart | 19 +- lib/screens/music/queue_sheet.dart | 3 + .../settings/external_player_screen.dart | 13 +- .../settings/hotkey_recorder_widget.dart | 13 +- .../music/music_playback_service_impl.dart | 63 +++++- .../widgets/watch_together_overlay.dart | 6 +- lib/widgets/collapsible_text.dart | 43 +++- lib/widgets/episode_card.dart | 1 + lib/widgets/hotkey_recorder.dart | 26 ++- lib/widgets/library_management_sheet.dart | 6 +- lib/widgets/music/mini_player.dart | 121 +++++------ lib/widgets/overlay_sheet.dart | 21 +- test/focus/focusable_slider_test.dart | 33 ++- test/screens/livetv/guide_tab_test.dart | 96 +++++++++ test/screens/media_detail_screen_test.dart | 74 ++++++- test/screens/music/queue_sheet_test.dart | 25 ++- .../settings/external_player_screen_test.dart | 70 ++++++ .../music/music_playback_service_test.dart | 108 +++++++++- .../watch_together_overlay_test.dart | 137 ++++++++++++ test/widgets/collapsible_text_test.dart | 41 ++++ test/widgets/episode_card_test.dart | 110 ++++++++++ test/widgets/hotkey_recorder_test.dart | 171 +++++++++++++++ .../library_management_sheet_test.dart | 157 ++++++++++++++ test/widgets/music/mini_player_test.dart | 201 +++++++++++++++++- test/widgets/overlay_sheet_test.dart | 141 ++++++++++++ 28 files changed, 1624 insertions(+), 120 deletions(-) create mode 100644 test/screens/livetv/guide_tab_test.dart create mode 100644 test/screens/settings/external_player_screen_test.dart create mode 100644 test/watch_together/watch_together_overlay_test.dart create mode 100644 test/widgets/episode_card_test.dart create mode 100644 test/widgets/hotkey_recorder_test.dart create mode 100644 test/widgets/library_management_sheet_test.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart index 8c8afdae..15d425a8 100644 --- a/lib/focus/focusable_action_bar.dart +++ b/lib/focus/focusable_action_bar.dart @@ -29,6 +29,7 @@ class FocusableAction { final IconData icon; final Color? iconColor; final double iconFill; + final double iconSize; final String? debugLabel; final FocusNode? focusNode; @@ -43,6 +44,7 @@ class FocusableAction { this.icon = Icons.circle, this.iconColor, this.iconFill = 1.0, + this.iconSize = 24, this.debugLabel, this.focusNode, this.autofocus = false, @@ -246,7 +248,7 @@ class FocusableActionBarState extends State { child: action.child ?? IconButton( - icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor), + icon: AppIcon(action.icon, size: action.iconSize, fill: action.iconFill, color: action.iconColor), tooltip: action.tooltip, onPressed: action.onPressed, ), diff --git a/lib/focus/focusable_slider.dart b/lib/focus/focusable_slider.dart index d434e1e1..aee54286 100644 --- a/lib/focus/focusable_slider.dart +++ b/lib/focus/focusable_slider.dart @@ -70,6 +70,7 @@ class _FocusableSliderState extends State { @override Widget build(BuildContext context) { + final sliderTheme = SliderTheme.of(context); return FocusableWrapper( focusNode: widget.focusNode, autofocus: widget.autofocus, @@ -79,8 +80,8 @@ class _FocusableSliderState extends State { onFocusChange: (focused) => setState(() => _isFocused = focused), onKeyEvent: _handleKeyEvent, child: SliderTheme( - data: SliderTheme.of(context).copyWith( - overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + data: sliderTheme.copyWith( + overlayShape: sliderTheme.overlayShape ?? const RoundSliderOverlayShape(overlayRadius: 0), thumbSize: WidgetStatePropertyAll( (!InputModeTracker.isKeyboardMode(context) || _isFocused) ? const Size(4, 20) : Size.zero, ), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index feb89d3a..50ff4673 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -55,6 +55,30 @@ class GuideTab extends StatefulWidget { State createState() => GuideTabState(); } +@visibleForTesting +({String channelScopeKey, ({String kind, String value})? programId, int? beginsAt, int? endsAt}) guideAiringIdentity( + LiveTvChannel channel, + LiveTvProgram program, +) { + ({String kind, String value})? programId; + if (program.ratingKey case final ratingKey? when ratingKey.isNotEmpty) { + programId = (kind: 'ratingKey', value: ratingKey); + } else if (program.guid case final guid? when guid.isNotEmpty) { + programId = (kind: 'guid', value: guid); + } else if (program.key case final key? when key.isNotEmpty) { + programId = (kind: 'key', value: key); + } + + // Keep the slot even with an ID so repeated airings cannot inherit each + // other's hold; with no ID, channel scope plus timing is the fallback. + return ( + channelScopeKey: liveTvChannelScopeKey(channel), + programId: programId, + beginsAt: program.beginsAt, + endsAt: program.endsAt, + ); +} + enum _GuideZone { timeNav, grid } sealed class _GuideRow { @@ -578,9 +602,19 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final target = _focusedProgramTarget(); if (target == null) return KeyEventResult.ignored; + final ownerChannelIndex = _gridChannelIndex; + final targetIdentity = guideAiringIdentity(target.channel, target.program); return _programSelectController.handleKeyEvent( event, - isOwnerActive: () => mounted && _focusedProgramTarget() == target, + isOwnerActive: () { + if (!mounted || _focusZone != _GuideZone.grid || _gridColumn != 1 || _gridChannelIndex != ownerChannelIndex) { + return false; + } + + final activeTarget = _focusedProgramTarget(); + return activeTarget != null && + guideAiringIdentity(activeTarget.channel, activeTarget.program) == targetIdentity; + }, onShortPress: () => _activateProgram(target.channel, target.program), onLongPress: () { _programSelectController.reset(); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9be1b2f9..99739910 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2149,7 +2149,7 @@ class _MediaDetailScreenState extends State if (!widget.isOffline) _ratingChipFocusNode.requestFocus(); } - /// Focus the first available section below the primary action row. + /// Focus the overview, or the first available content section when there is no overview. void _focusBelowActionRow() { final metadata = _fullMetadata ?? _metadata; @@ -2158,13 +2158,20 @@ class _MediaDetailScreenState extends State return; } - // DOWN order: overview → seasons → cast → extras - if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) { + if (metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); return; } + _focusBelowOverview(); + } + + /// Focus the first available content section after the overview. + void _focusBelowOverview() { + final metadata = _fullMetadata ?? _metadata; + + // DOWN order: season tabs → episodes → cast → extras → related hubs → info rows. if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { // Focus the selected season tab chip _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); @@ -2194,6 +2201,10 @@ class _MediaDetailScreenState extends State _relatedHubKeys.first.currentState?.requestFocusFromMemory(); return; } + + if (_hasInfoRows) { + _focusInfoRows(); + } } /// Get the responsive card width used by seasons/extras/cast rows. @@ -3155,7 +3166,7 @@ class _MediaDetailScreenState extends State ); _playButtonFocusNode.requestFocus(); }, - onNavigateDown: _focusBelowActionRow, + onNavigateDown: _focusBelowOverview, onNavigateLeft: () {}, onNavigateRight: () {}, ), diff --git a/lib/screens/music/queue_sheet.dart b/lib/screens/music/queue_sheet.dart index 39ab4ff3..d18606ff 100644 --- a/lib/screens/music/queue_sheet.dart +++ b/lib/screens/music/queue_sheet.dart @@ -54,18 +54,21 @@ class QueueSheet extends StatelessWidget { FocusableAction( icon: Symbols.shuffle_rounded, iconColor: service.shuffled ? colorScheme.primary : tk.textMuted, + iconSize: 20, tooltip: t.common.shuffle, onPressed: service.toggleShuffle, ), FocusableAction( icon: repeatModeIcon(service.repeatMode), iconColor: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary, + iconSize: 20, tooltip: repeatModeLabel(service.repeatMode), onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), ), FocusableAction( icon: Symbols.clear_all_rounded, iconColor: tk.textMuted, + iconSize: 20, tooltip: t.music.clearQueue, onPressed: service.clearUpcoming, ), diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 050e743a..c6b0c2e4 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -113,14 +113,15 @@ class _PlayerTile extends StatelessWidget { trailing: Row( mainAxisSize: .min, children: [ - FocusableButton( - onPressed: () => svc.removeCustomExternalPlayer(player.id), - autoScroll: false, - child: IconButton( - icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20), + if (isCustom) + FocusableButton( onPressed: () => svc.removeCustomExternalPlayer(player.id), + autoScroll: false, + child: IconButton( + icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20), + onPressed: () => svc.removeCustomExternalPlayer(player.id), + ), ), - ), AppIcon( isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, diff --git a/lib/screens/settings/hotkey_recorder_widget.dart b/lib/screens/settings/hotkey_recorder_widget.dart index b1e7d108..47e2b4da 100644 --- a/lib/screens/settings/hotkey_recorder_widget.dart +++ b/lib/screens/settings/hotkey_recorder_widget.dart @@ -113,13 +113,12 @@ class _HotKeyRecorderWidgetState extends State { border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), borderRadius: const BorderRadius.all(Radius.circular(6)), ), - child: hasShortcut - ? HotKeyRecorder( - initalHotKey: _recordedHotKey, - enabled: _isCapturing, - onHotKeyRecorded: _handleHotKeyRecorded, - ) - : Text(recordLabel), + child: HotKeyRecorder( + initalHotKey: _recordedHotKey, + enabled: _isCapturing, + placeholder: Text(recordLabel), + onHotKeyRecorded: _handleHotKeyRecorded, + ), ), ), ), diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 50fcc12a..65f1fd61 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/foundation.dart' show ValueListenable, visibleForTesting; import 'package:flutter/widgets.dart'; import 'package:os_media_controls/os_media_controls.dart'; @@ -66,10 +66,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO this._mediaControlsFactory = MediaControlsManager.new, this._completedConfirmDelay = const Duration(milliseconds: 400), PlaybackCoordinator? coordinator, + @visibleForTesting Future Function(double)? volumePersistenceWriter, }) : assert(resolver != null || database != null, 'database is required to build the default resolver'), _serverManager = serverManager, _resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!), - _coordinator = coordinator ?? PlaybackCoordinator.instance { + _coordinator = coordinator ?? PlaybackCoordinator.instance, + _volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume { _coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim); // tvOS has no background-audio session in v1 — pause on backgrounding so // audio doesn't play over other apps / the home screen. Other platforms @@ -96,6 +98,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO final Player Function() _audioPlayerFactory; final MediaControlsManager Function() _mediaControlsFactory; final PlaybackCoordinator _coordinator; + final Future Function(double) _volumePersistenceWriter; final MusicQueueController _queue = MusicQueueController(); @@ -104,6 +107,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO /// volume when settings aren't bootstrapped (tests). double _volume = SettingsService.instanceOrNull?.read(SettingsService.musicVolume) ?? 100.0; late final ValueNotifier _volumeNotifier = ValueNotifier(_volume); + // One settings write stays in flight while rapid updates replace the single + // pending slot. The drain intentionally survives service disposal. + double? _pendingVolumeWrite; + Future? _volumeWriteDrain; Player? _player; final List> _playerSubs = []; @@ -850,14 +857,58 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO @override Future setVolume(double volume, {bool persist = true}) async { final clamped = volume.clamp(0.0, 100.0); + Future? playerUpdate; if (clamped != _volume) { _volume = clamped; _volumeNotifier.value = clamped; - await _player?.setVolume(clamped); + playerUpdate = _player?.setVolume(clamped); } - if (persist) { - final settings = SettingsService.instanceOrNull; - if (settings != null) await settings.write(SettingsService.musicVolume, clamped); + + final persistence = persist ? _persistVolume(clamped) : null; + if (playerUpdate != null && persistence != null) { + await Future.wait([playerUpdate, persistence]); + } else { + await playerUpdate; + await persistence; + } + } + + static Future _writePersistedVolume(double volume) async { + final settings = SettingsService.instanceOrNull; + if (settings != null) await settings.write(SettingsService.musicVolume, volume); + } + + Future _persistVolume(double volume) { + _pendingVolumeWrite = volume; + final activeDrain = _volumeWriteDrain; + if (activeDrain != null) return activeDrain; + + final completer = Completer(); + _volumeWriteDrain = completer.future; + unawaited(_drainVolumeWrites(completer)); + return completer.future; + } + + Future _drainVolumeWrites(Completer completer) async { + Object? firstError; + StackTrace? firstStackTrace; + + while (_pendingVolumeWrite != null) { + final volume = _pendingVolumeWrite!; + _pendingVolumeWrite = null; + try { + await _volumePersistenceWriter(volume); + } catch (error, stackTrace) { + firstError ??= error; + firstStackTrace ??= stackTrace; + } + } + + _volumeWriteDrain = null; + if (firstError != null) { + completer.completeError(firstError, firstStackTrace); + } else { + completer.complete(); } } diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 4275a082..c662e673 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -268,8 +268,10 @@ class _SessionMenuSheet extends StatelessWidget { isDestructive: true, ); - if (!confirmed || !context.mounted) return; - OverlaySheetController.closeAdaptive(context); + if (!confirmed) return; + if (context.mounted) { + OverlaySheetController.closeAdaptive(context); + } unawaited(provider.leaveSession()); onLeaveSession?.call(); } diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index b04fe2d2..3b14cd83 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -17,6 +17,10 @@ class CollapsibleText extends StatefulWidget { final ValueChanged? onOverflowChanged; final bool skipTraversal; + /// Hides this widget's expand/collapse label and semantic tap action while + /// preserving the semantics of the text itself. + final bool suppressExpandSemantics; + const CollapsibleText({ super.key, required this.text, @@ -30,6 +34,7 @@ class CollapsibleText extends StatefulWidget { this.onNavigateRight, this.onOverflowChanged, this.skipTraversal = true, + this.suppressExpandSemantics = false, }); @override @@ -70,7 +75,31 @@ class _CollapsibleTextState extends State { if (!overflows) { textPainter.dispose(); - return Text(widget.text, style: style); + Widget result = Text(widget.text, style: style); + final hasFocusBehavior = + widget.focusNode != null || + widget.onNavigateUp != null || + widget.onNavigateDown != null || + widget.onNavigateLeft != null || + widget.onNavigateRight != null; + if (!hasFocusBehavior) return result; + + result = FocusableWrapper( + focusNode: widget.focusNode, + onNavigateUp: widget.onNavigateUp, + onNavigateDown: widget.onNavigateDown, + onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: widget.onNavigateRight, + descendantsAreFocusable: false, + disableScale: true, + useBackgroundFocus: true, + borderRadius: 8, + child: result, + ); + if (widget.skipTraversal) { + result = ExcludeFocusTraversal(child: result); + } + return result; } String displayText = widget.text; @@ -102,7 +131,11 @@ class _CollapsibleTextState extends State { onNavigateDown: widget.onNavigateDown, onNavigateLeft: widget.onNavigateLeft, onNavigateRight: widget.onNavigateRight, - semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText, + semanticLabel: widget.suppressExpandSemantics + ? null + : _expanded + ? t.accessibility.collapseText + : t.accessibility.expandText, descendantsAreFocusable: false, disableScale: true, useBackgroundFocus: true, @@ -114,7 +147,11 @@ class _CollapsibleTextState extends State { } return ClickableCursor( - child: GestureDetector(onTap: _toggleExpanded, child: result), + child: GestureDetector( + onTap: _toggleExpanded, + excludeFromSemantics: widget.suppressExpandSemantics, + child: result, + ), ); }, ); diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 1209269d..c5557e78 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -307,6 +307,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin onHotKeyRecorded; final bool enabled; + final Widget? placeholder; @override State createState() => _HotKeyRecorderState(); @@ -52,16 +60,22 @@ class _HotKeyRecorderState extends State { .where((m) => !m.physicalKeys.contains(key)) .toList(); - setState(() { - _hotKey = HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null); - }); - widget.onHotKeyRecorded(_hotKey!); + final hotKey = HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null); + setState(() => _hotKey = hotKey); + + final isModifierOnly = HotKeyModifier.values.any((modifier) => modifier.physicalKeys.contains(key)); + if (isModifierOnly) return true; + + if (keyEvent.logicalKey.isSelectKey) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + } + widget.onHotKeyRecorded(hotKey); return true; } @override Widget build(BuildContext context) { - if (_hotKey == null) return const SizedBox.shrink(); + if (_hotKey == null) return widget.placeholder ?? const SizedBox.shrink(); return HotKeyVirtualView(hotKey: _hotKey!); } } diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index b6e806a2..6b490b62 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -508,11 +508,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { for (final item in menuItems) AppMenuItem(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive), ], - closeOnSelected: false, - onSelected: (value) { - OverlaySheetController.popAdaptive(context, value); - widget.onLibraryMenuAction(value, library); - }, + onSelected: (value) => widget.onLibraryMenuAction(value, library), ), ); } diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart index 9258d4ae..de3cecd3 100644 --- a/lib/widgets/music/mini_player.dart +++ b/lib/widgets/music/mini_player.dart @@ -274,35 +274,35 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix color: tk.surface, clipBehavior: Clip.antiAlias, borderRadius: BorderRadius.circular(tk.radiusLg), - child: SizedBox( - height: _MusicMiniPlayerOverlayState._cardHeight, - child: Stack( - children: [ - const Positioned.fill(child: _MiniPlayerProgress()), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - Expanded( - child: FocusableWrapper( - focusNode: _detailsFocusNode, - onSelect: () => unawaited(openNowPlaying(context)), - enableLongPress: true, - onLongPress: showContextMenuFromTap, - onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(), - semanticLabel: widget.track.title, - descendantsAreFocusable: false, - disableScale: true, - useBackgroundFocus: true, - borderRadius: tk.radiusLg, - child: InkWell( - canRequestFocus: false, - mouseCursor: SystemMouseCursors.click, - onTap: () => unawaited(openNowPlaying(context)), - onTapDown: storeTapPosition, - onLongPress: showContextMenuFromTap, - onSecondaryTapDown: storeTapPosition, - onSecondaryTap: showContextMenuFromTap, + child: InkWell( + canRequestFocus: false, + mouseCursor: SystemMouseCursors.click, + onTap: () => unawaited(openNowPlaying(context)), + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, + child: SizedBox( + height: _MusicMiniPlayerOverlayState._cardHeight, + child: Stack( + children: [ + const Positioned.fill(child: _MiniPlayerProgress()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: FocusableWrapper( + focusNode: _detailsFocusNode, + onSelect: () => unawaited(openNowPlaying(context)), + enableLongPress: true, + onLongPress: showContextMenu, + onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(), + semanticLabel: widget.track.title, + descendantsAreFocusable: false, + disableScale: true, + useBackgroundFocus: true, + borderRadius: tk.radiusLg, child: Row( children: [ ClipRRect( @@ -342,43 +342,44 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix ), ), ), - ), - FocusableActionBar( - key: _transportKey, - onNavigateLeft: _detailsFocusNode.requestFocus, - actions: [ - if (widget.desktop) + FocusableActionBar( + key: _transportKey, + onNavigateLeft: _detailsFocusNode.requestFocus, + actions: [ + if (widget.desktop) + FocusableAction( + icon: Symbols.skip_previous_rounded, + iconColor: tk.text, + tooltip: t.music.previousTrack, + onPressed: () => unawaited(service.previous()), + ), FocusableAction( - icon: Symbols.skip_previous_rounded, + icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconColor: tk.text, - tooltip: t.music.previousTrack, - onPressed: () => unawaited(service.previous()), + tooltip: isPlaying ? t.common.pause : t.common.play, + onPressed: () => unawaited(service.togglePlayPause()), ), - FocusableAction( - icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, - iconColor: tk.text, - tooltip: isPlaying ? t.common.pause : t.common.play, - onPressed: () => unawaited(service.togglePlayPause()), - ), - FocusableAction( - icon: Symbols.skip_next_rounded, - iconColor: tk.text, - tooltip: t.music.nextTrack, - onPressed: () => unawaited(service.next()), - ), - if (widget.desktop) FocusableAction( - icon: Symbols.close_rounded, - iconColor: tk.textMuted, - tooltip: t.music.stopPlayback, - onPressed: widget.onDismissed, + icon: Symbols.skip_next_rounded, + iconColor: tk.text, + tooltip: t.music.nextTrack, + onPressed: () => unawaited(service.next()), ), - ], - ), - ], + if (widget.desktop) + FocusableAction( + icon: Symbols.close_rounded, + iconColor: tk.textMuted, + iconSize: 20, + tooltip: t.music.stopPlayback, + onPressed: widget.onDismissed, + ), + ], + ), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 3c67ef53..78470c5e 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -157,8 +157,13 @@ class OverlaySheetController { /// Push a sub-page using the overlay system if available, otherwise fall /// back to [showModalBottomSheet]. Returns the result from the page. /// - /// Presentation options apply only to the modal fallback. A hosted push - /// retains the root sheet's presentation and changes only its page content. + /// When a hosted sheet is already open, this pushes a nested page and + /// retains the root sheet's presentation. When a host is available but + /// idle, this opens [builder] as its root sheet using the supplied hosted + /// presentation options. Without a host, the modal fallback is used. + /// + /// [isScrollControlled] applies only to the modal fallback; hosted sheets + /// use their explicit or default constraints. static Future pushAdaptive( BuildContext context, { required WidgetBuilder builder, @@ -171,7 +176,17 @@ class OverlaySheetController { }) async { final controller = maybeOf(context); if (controller != null) { - return controller.push(builder: builder, initialFocusNode: initialFocusNode); + if (controller.isOpen) { + return controller.push(builder: builder, initialFocusNode: initialFocusNode); + } + return controller.show( + builder: builder, + constraints: constraints, + backgroundColor: backgroundColor, + barrierDismissible: barrierDismissible, + initialFocusNode: initialFocusNode, + showDragHandle: showDragHandle, + ); } final effectiveConstraints = constraints ?? diff --git a/test/focus/focusable_slider_test.dart b/test/focus/focusable_slider_test.dart index 211fd28e..18ebcfd2 100644 --- a/test/focus/focusable_slider_test.dart +++ b/test/focus/focusable_slider_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_slider.dart'; void main() { - testWidgets('D-pad adjustment reports a complete persisted change', (tester) async { + testWidgets('each D-pad arrow reports a complete persisted change', (tester) async { final focusNode = FocusNode(debugLabel: 'slider'); addTearDown(focusNode.dispose); final starts = []; @@ -38,11 +38,34 @@ void main() { await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); - expect(starts, [0.0]); - expect(changes, [1.0]); - expect(ends, [1.0]); - expect(value, 1.0); + expect(starts, [0.0, 1.0]); + expect(changes, [1.0, 0.0]); + expect(ends, [1.0, 0.0]); + expect(value, 0.0); + }); + + testWidgets('preserves an inherited 12px slider overlay', (tester) async { + const inheritedOverlay = RoundSliderOverlayShape(overlayRadius: 12); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SliderTheme( + data: const SliderThemeData(overlayShape: inheritedOverlay), + child: FocusableSlider(value: 0, onChanged: (_) {}), + ), + ), + ), + ); + + final sliderContext = tester.element(find.byType(Slider)); + final overlay = SliderTheme.of(sliderContext).overlayShape; + + expect(overlay, same(inheritedOverlay)); + expect(overlay!.getPreferredSize(false, false), const Size.square(24)); }); testWidgets('SELECT invokes the slider action once', (tester) async { diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart new file mode 100644 index 00000000..1da33d88 --- /dev/null +++ b/test/screens/livetv/guide_tab_test.dart @@ -0,0 +1,96 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; +import 'package:plezy/focus/dpad_select_long_press_controller.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/models/livetv_program.dart'; +import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; + +const _selectDown = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.enter, + logicalKey: LogicalKeyboardKey.enter, + timeStamp: Duration.zero, +); + +LiveTvChannel _channel({String key = 'channel/7'}) => + LiveTvChannel(key: key, identifier: 'station-7', callSign: 'SEVEN', serverId: 'server-a', liveDvrKey: 'dvr-a'); + +LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_000_000, int endsAt = 1_800_003_600}) => + LiveTvProgram( + ratingKey: ratingKey, + title: 'Evening News', + beginsAt: beginsAt, + endsAt: endsAt, + channelIdentifier: 'station-7', + serverId: 'server-a', + liveDvrKey: 'dvr-a', + ); + +void main() { + tearDown(SelectKeyUpSuppressor.clearSuppression); + + test('SELECT hold survives equivalent fresh guide objects and opens details once', () { + fakeAsync((async) { + final controller = DpadSelectLongPressController(); + var focusedChannel = _channel(); + var focusedProgram = _program(); + final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram); + var detailsOpened = 0; + + controller.handleKeyEvent( + _selectDown, + isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity, + onShortPress: () {}, + onLongPress: () { + controller.reset(); + detailsOpened++; + }, + ); + + async.elapse(const Duration(milliseconds: 250)); + final replacementChannel = _channel(); + final replacementProgram = _program(); + expect(identical(replacementChannel, focusedChannel), isFalse); + expect(identical(replacementProgram, focusedProgram), isFalse); + focusedChannel = replacementChannel; + focusedProgram = replacementProgram; + + async.elapse(const Duration(milliseconds: 249)); + expect(detailsOpened, 0); + async.elapse(const Duration(milliseconds: 1)); + expect(detailsOpened, 1); + + async.elapse(const Duration(seconds: 1)); + expect(detailsOpened, 1); + controller.dispose(); + }); + }); + + test('SELECT hold does not open details after focus moves to a different airing', () { + fakeAsync((async) { + final controller = DpadSelectLongPressController(); + final focusedChannel = _channel(); + var focusedProgram = _program(); + final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram); + var detailsOpened = 0; + + controller.handleKeyEvent( + _selectDown, + isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity, + onShortPress: () {}, + onLongPress: () => detailsOpened++, + ); + + async.elapse(const Duration(milliseconds: 250)); + focusedProgram = _program(beginsAt: 1_800_003_600, endsAt: 1_800_007_200); + expect(guideAiringIdentity(focusedChannel, focusedProgram), isNot(pressedIdentity)); + + async.elapse(const Duration(milliseconds: 250)); + expect(detailsOpened, 0); + async.elapse(const Duration(seconds: 1)); + expect(detailsOpened, 0); + controller.dispose(); + }); + }); +} diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index ed9cb593..5a9fdc2c 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -36,6 +36,7 @@ import 'package:plezy/utils/layout_constants.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/widgets/collapsible_text.dart'; import 'package:plezy/widgets/episode_card.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; @@ -657,11 +658,12 @@ void main() { }); group('watch state freshness (phone layout)', () { - MediaItem buildShow() => testMediaItem( + MediaItem buildShow({String? summary}) => testMediaItem( id: 'show_1', backend: MediaBackend.jellyfin, kind: MediaKind.show, title: 'The Show', + summary: summary, leafCount: 4, viewedLeafCount: 0, serverId: 'server_1', @@ -784,6 +786,76 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } + _FakeMediaServerClient singleSeasonClient(MediaItem show) { + final season = buildSeason(show, 1); + return _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season], + season.id: [buildEpisode(show, season, 1)], + }, + ); + } + + FocusNode overviewFocusNode(WidgetTester tester) { + final overviewFocus = find.byWidgetPredicate( + (widget) => widget is Focus && widget.focusNode?.debugLabel == 'overview', + description: 'overview focus widget', + ); + expect(overviewFocus, findsOneWidget); + return tester.widget(overviewFocus).focusNode!; + } + + testWidgets('overflowing overview DOWN reaches the first real section', (tester) async { + const summary = + 'A deliberately extensive overview repeats enough concrete detail to exceed the collapsed line limit. ' + 'It describes the setting, the characters, the central conflict, and the consequences in full. ' + 'A second passage adds more background, more context, and more narrative detail for the viewer. ' + 'A third passage ensures the overview remains overflowing even across a wide phone test viewport. ' + 'Finally, another complete passage keeps the text beyond four generous lines without relying on font timing.'; + final show = buildShow(summary: summary); + + await pumpPhoneDetail(tester, singleSeasonClient(show), show); + + final overviewText = tester.widget( + find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first, + ); + expect(overviewText.textSpan, isNotNull); + expect(overviewText.textSpan!.toPlainText(), isNot(summary)); + + final overviewNode = overviewFocusNode(tester); + overviewNode.requestFocus(); + await tester.pump(); + expect(overviewNode.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'first_episode'); + }); + + testWidgets('short overview accepts focus and preserves DOWN then episode UP navigation', (tester) async { + const summary = 'A short overview.'; + final show = buildShow(summary: summary); + + await pumpPhoneDetail(tester, singleSeasonClient(show), show); + + expect(find.text(summary), findsOneWidget); + final overviewNode = overviewFocusNode(tester); + expect(overviewNode.context, isNotNull); + overviewNode.requestFocus(); + await tester.pump(); + expect(overviewNode.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'first_episode'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'overview'); + }); + testWidgets('phone detail focuses requested season tab', (tester) async { final show = buildShow(); final season1 = buildSeason(show, 1); diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index b3c16573..ac548bfe 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -11,11 +12,12 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/music/track_row.dart'; import 'package:provider/provider.dart'; -import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; MediaItem _track(String id, String title) => testMediaItem( id: id, @@ -73,7 +75,11 @@ void main() { Widget wrap(MusicPlaybackService service) { final manager = MultiServerManager(); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(multiServerProvider.dispose); + addTearDown(service.dispose); + addTearDown(() { + multiServerProvider.dispose(); + manager.dispose(); + }); return TranslationProvider( child: MultiProvider( @@ -109,6 +115,21 @@ void main() { expect(find.text('Gamma'), findsOneWidget); }); + testWidgets('renders all queue header action icons at 20px', (tester) async { + final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]); + + await tester.pumpWidget(wrap(service)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final headerIcons = tester + .widgetList(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(AppIcon))) + .toList(); + + expect(headerIcons, hasLength(3)); + expect(headerIcons.map((icon) => icon.size), everyElement(20)); + }); + testWidgets('tapping a played or upcoming row jumps to its queue index', (tester) async { final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]); diff --git a/test/screens/settings/external_player_screen_test.dart b/test/screens/settings/external_player_screen_test.dart new file mode 100644 index 00000000..171ff3a4 --- /dev/null +++ b/test/screens/settings/external_player_screen_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_button.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/models/external_player_models.dart'; +import 'package:plezy/screens/settings/external_player_screen.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; + +import '../../test_helpers/prefs.dart'; + +void main() { + late SettingsService settings; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + settings = await SettingsService.getInstance(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + tearDown(() { + SettingsService.resetForTesting(); + resetSharedPreferencesForTest(); + }); + + testWidgets('only custom players expose a focusable delete action', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1400); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final customPlayer = ExternalPlayer.custom( + id: 'custom-test-player', + name: 'Custom Test Player', + value: 'custom-player', + type: CustomPlayerType.command, + ); + await settings.write(SettingsService.useExternalPlayer, true); + await settings.write(SettingsService.customExternalPlayers, [customPlayer]); + await settings.write(SettingsService.selectedExternalPlayer, customPlayer); + + await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen())); + await tester.pumpAndSettle(); + + for (final player in KnownPlayers.getForCurrentPlatform()) { + final title = player.id == KnownPlayers.systemDefault.id ? 'System Default' : player.name; + final row = find.widgetWithText(FocusableListTile, title); + expect(row, findsOneWidget); + expect(find.descendant(of: row, matching: find.byType(FocusableButton)), findsNothing); + expect(find.descendant(of: row, matching: find.byType(IconButton)), findsNothing); + } + + final customRow = find.widgetWithText(FocusableListTile, customPlayer.name); + expect(customRow, findsOneWidget); + final focusableDelete = find.descendant(of: customRow, matching: find.byType(FocusableButton)); + final deleteControl = find.descendant(of: customRow, matching: find.byType(IconButton)); + expect(focusableDelete, findsOneWidget); + expect(deleteControl, findsOneWidget); + expect(tester.widget(focusableDelete).onPressed, isNotNull); + + await tester.tap(deleteControl); + await tester.pumpAndSettle(); + + expect(settings.read(SettingsService.customExternalPlayers), isEmpty); + expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault); + expect(find.text(customPlayer.name), findsNothing); + }); +} diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 41f4c832..35e427bc 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -485,6 +485,30 @@ class FakeMediaControlsManager extends MediaControlsManager { } } +class _GatedVolumeWriter { + final writes = []; + final gates = >[]; + final _writeWaiters = >{}; + var inFlight = 0; + var maxInFlight = 0; + + Future write(double volume) { + writes.add(volume); + inFlight++; + if (inFlight > maxInFlight) maxInFlight = inFlight; + + final gate = Completer(); + gates.add(gate); + _writeWaiters.remove(writes.length)?.complete(); + return gate.future.whenComplete(() => inFlight--); + } + + Future waitForWrites(int count) { + if (writes.length >= count) return Future.value(); + return (_writeWaiters[count] ??= Completer()).future; + } +} + class _Harness { _Harness._(this.service, this.resolver, this.client, this.controls, this.players); @@ -500,7 +524,7 @@ class _Harness { FakePlayer get player => players.last; - factory _Harness.create() { + factory _Harness.create({Future Function(double)? volumePersistenceWriter}) { final client = FakeMediaServerClient(); final resolver = FakeMusicSourceResolver(client: client); final controls = FakeMediaControlsManager(); @@ -519,6 +543,7 @@ class _Harness { // Collapse the boundary-pulse confirmation window so completed-driven // paths resolve within pumpEventQueue. completedConfirmDelay: Duration.zero, + volumePersistenceWriter: volumePersistenceWriter, ); harness = _Harness._(service, resolver, client, controls, players); return harness; @@ -541,9 +566,11 @@ void main() { final t3 = _track('t3'); late _Harness h; + late Future Function(double) persistenceWriter; setUp(() { - h = _Harness.create(); + persistenceWriter = (_) async {}; + h = _Harness.create(volumePersistenceWriter: (volume) => persistenceWriter(volume)); }); tearDown(() { @@ -569,6 +596,83 @@ void main() { expect(serviceNotifications, 0); }); + test('volume persistence keeps one write in flight and coalesces a burst to the latest value', () async { + final writer = _GatedVolumeWriter(); + persistenceWriter = writer.write; + var settled = 0; + + final callers = [ + h.service.setVolume(10).whenComplete(() => settled++), + h.service.setVolume(20).whenComplete(() => settled++), + h.service.setVolume(30).whenComplete(() => settled++), + ]; + + await writer.waitForWrites(1); + expect(writer.writes, [10]); + expect(writer.inFlight, 1); + expect(writer.maxInFlight, 1); + expect(settled, 0); + + writer.gates[0].complete(); + await writer.waitForWrites(2); + expect(writer.writes, [10, 30]); + expect(writer.inFlight, 1); + expect(writer.maxInFlight, 1); + expect(settled, 0); + + writer.gates[1].complete(); + await Future.wait(callers); + expect(writer.inFlight, 0); + expect(settled, 3); + }); + + test('volume persistence propagates a drain error and accepts a later write', () async { + final writer = _GatedVolumeWriter(); + persistenceWriter = writer.write; + final persistenceError = StateError('persistence failed'); + var firstSettled = false; + var secondSettled = false; + + final first = h.service + .setVolume(40) + .then( + (_) => fail('first caller unexpectedly succeeded'), + onError: (Object error, StackTrace stackTrace) { + firstSettled = true; + expect(error, same(persistenceError)); + }, + ); + final second = h.service + .setVolume(50) + .then( + (_) => fail('second caller unexpectedly succeeded'), + onError: (Object error, StackTrace stackTrace) { + secondSettled = true; + expect(error, same(persistenceError)); + }, + ); + + await writer.waitForWrites(1); + writer.gates[0].completeError(persistenceError); + await writer.waitForWrites(2); + expect(writer.writes, [40, 50]); + expect(firstSettled, isFalse); + expect(secondSettled, isFalse); + + writer.gates[1].complete(); + await Future.wait([first, second]); + expect(firstSettled, isTrue); + expect(secondSettled, isTrue); + expect(writer.inFlight, 0); + + final recovered = h.service.setVolume(60); + await writer.waitForWrites(3); + expect(writer.writes, [40, 50, 60]); + writer.gates[2].complete(); + await recovered; + expect(writer.inFlight, 0); + }); + test('playFromList opens the first track and arms the second', () async { await h.playTracks([t1, t2, t3]); diff --git a/test/watch_together/watch_together_overlay_test.dart b/test/watch_together/watch_together_overlay_test.dart new file mode 100644 index 00000000..266264ba --- /dev/null +++ b/test/watch_together/watch_together_overlay_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/watch_together/models/watch_session.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; +import 'package:plezy/watch_together/widgets/watch_together_overlay.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:provider/provider.dart'; + +void main() { + setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + for (final isHost in [false, true]) { + final role = isHost ? 'host' : 'guest'; + + testWidgets('$role confirmation survives the session sheet closing', (tester) async { + final harness = _OverlayHarness(isHost: isHost); + addTearDown(harness.dispose); + await tester.pumpWidget(harness.build()); + + await _openLeaveConfirmation(tester, harness); + harness.sheetController.close(); + await tester.pumpAndSettle(); + + expect(find.text(t.watchTogether.title), findsNothing); + expect( + find.text(isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion), + findsOneWidget, + ); + + await tester.tap(find.text(isHost ? t.watchTogether.endSession : t.watchTogether.leave)); + await tester.pumpAndSettle(); + + expect(harness.provider.leaveCalls, 1); + expect(harness.onLeaveSessionCalls, 1); + }); + + testWidgets('$role cancellation remains a no-op after the session sheet closes', (tester) async { + final harness = _OverlayHarness(isHost: isHost); + addTearDown(harness.dispose); + await tester.pumpWidget(harness.build()); + + await _openLeaveConfirmation(tester, harness); + harness.sheetController.close(); + await tester.pumpAndSettle(); + + expect( + find.text(isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion), + findsOneWidget, + ); + await tester.tap(find.text(t.common.cancel)); + await tester.pumpAndSettle(); + + expect(harness.provider.leaveCalls, 0); + expect(harness.onLeaveSessionCalls, 0); + }); + } +} + +Future _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async { + await tester.tap(find.byKey(_OverlayHarness.indicatorKey)); + await tester.pumpAndSettle(); + + expect(harness.sheetController.isOpen, isTrue); + await tester.tap(find.text(harness.provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession)); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); +} + +class _OverlayHarness { + _OverlayHarness({required bool isHost}) : provider = _FakeWatchTogetherProvider(isHostValue: isHost); + + static const indicatorKey = Key('watch-together-session-indicator'); + + final _FakeWatchTogetherProvider provider; + late OverlaySheetController sheetController; + int onLeaveSessionCalls = 0; + + Widget build() { + return ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + home: OverlaySheetHost( + child: Builder( + builder: (context) { + sheetController = OverlaySheetController.of(context); + return Scaffold( + body: Center( + child: WatchTogetherSessionIndicator(key: indicatorKey, onLeaveSession: () => onLeaveSessionCalls++), + ), + ); + }, + ), + ), + ), + ); + } + + void dispose() => provider.dispose(); +} + +class _FakeWatchTogetherProvider extends WatchTogetherProvider { + _FakeWatchTogetherProvider({required this.isHostValue}); + + final bool isHostValue; + var leaveCalls = 0; + var _isDisposing = false; + + @override + bool get isHost => isHostValue; + + @override + String? get sessionId => 'ROOM42'; + + @override + ControlMode get controlMode => ControlMode.hostOnly; + + @override + List get participants => [ + Participant(peerId: 'local', displayName: 'Local viewer', isHost: isHostValue), + ]; + + @override + int get participantCount => participants.length; + + @override + Future leaveSession() async { + if (!_isDisposing) leaveCalls++; + } + + @override + void dispose() { + _isDisposing = true; + super.dispose(); + } +} diff --git a/test/widgets/collapsible_text_test.dart b/test/widgets/collapsible_text_test.dart index cc30a5ff..6dfa1cbe 100644 --- a/test/widgets/collapsible_text_test.dart +++ b/test/widgets/collapsible_text_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/widgets/collapsible_text.dart'; @@ -35,6 +36,46 @@ void main() { expect(focusNode.skipTraversal, isTrue); }); + testWidgets('short focused text keeps navigation without an expand action', (tester) async { + final semantics = tester.ensureSemantics(); + final focusNode = FocusNode(debugLabel: 'short_collapsible_text'); + addTearDown(focusNode.dispose); + var downCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + child: CollapsibleText( + text: 'Short overview', + maxLines: 2, + focusNode: focusNode, + skipTraversal: false, + onNavigateDown: () => downCount++, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(focusNode.context, isNotNull); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + final node = tester.getSemantics(find.text('Short overview')); + expect(node.label, 'Short overview'); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(downCount, 1); + semantics.dispose(); + }); + testWidgets('reports whether text overflows', (tester) async { bool? overflows; diff --git a/test/widgets/episode_card_test.dart b/test/widgets/episode_card_test.dart new file mode 100644 index 00000000..285067d4 --- /dev/null +++ b/test/widgets/episode_card_test.dart @@ -0,0 +1,110 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/providers/download_provider.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/collapsible_text.dart'; +import 'package:plezy/widgets/episode_card.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + TvDetectionService.debugSetAppleTVOverride(false); + LocaleSettings.setLocaleSync(AppLocale.en); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('overflowing summary stays in card semantics without an Expand label', (tester) async { + final semantics = tester.ensureSemantics(); + const summary = + 'The expedition follows a careful team through an unfamiliar landscape while each discovery changes their plans.'; + final episode = testMediaItem( + id: 'semantic_episode', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'A Difficult Crossing', + index: 3, + summary: summary, + durationMs: 42 * 60 * 1000, + ); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + final downloadManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + ); + downloadManager.recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + addTearDown(() async { + downloadProvider.dispose(); + downloadManager.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: downloadProvider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 360, + child: EpisodeCard(episode: episode, isOffline: true, onTap: () {}), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final summaryText = tester.widget( + find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first, + ); + expect(summaryText.textSpan, isNotNull); + expect(summaryText.textSpan!.toPlainText(), isNot(summary)); + + final semanticNodes = []; + void collectSemantics(SemanticsNode node) { + semanticNodes.add(node); + node.visitChildren((child) { + collectSemantics(child); + return true; + }); + } + + collectSemantics(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!); + final cardSemantics = semanticNodes.singleWhere((node) => node.label.contains('A Difficult Crossing')); + expect(cardSemantics.label, contains('The expedition follows a careful team')); + expect(cardSemantics.label, isNot(contains('Expand'))); + expect(cardSemantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); + semantics.dispose(); + }); +} diff --git a/test/widgets/hotkey_recorder_test.dart b/test/widgets/hotkey_recorder_test.dart new file mode 100644 index 00000000..b20ba38b --- /dev/null +++ b/test/widgets/hotkey_recorder_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/models/hotkey_model.dart'; +import 'package:plezy/screens/settings/hotkey_recorder_widget.dart'; +import 'package:plezy/widgets/dialog_action_button.dart'; +import 'package:plezy/widgets/hotkey_recorder.dart'; + +void main() { + tearDown(SelectKeyUpSuppressor.clearSuppression); + + testWidgets('initially unbound shortcut captures from a tap and saves', (tester) async { + final saved = []; + await _pumpRecorder(tester, saved: saved); + + expect(_recorder(tester).enabled, isFalse); + expect(find.text(t.hotkeys.pressToRecord), findsNWidgets(2)); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record'); + expect(_saveAction(tester).onPressed, isNull); + + await tester.tap(find.byType(HotKeyRecorder)); + await tester.pump(); + + expect(_recorder(tester).enabled, isTrue); + expect(find.text(t.hotkeys.recordingShortcut), findsNWidgets(2)); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record'); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyK, physicalKey: PhysicalKeyboardKey.keyK); + await _pumpFocusChange(tester); + + expect(_recorder(tester).enabled, isFalse); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyK)), findsOneWidget); + expect(find.text(t.hotkeys.pressToRecord), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save'); + expect(saved, isEmpty); + + await tester.tap(find.widgetWithText(FilledButton, t.common.save)); + + expect(saved, hasLength(1)); + expect(saved.single.key, PhysicalKeyboardKey.keyK); + expect(saved.single.modifiers, isNull); + }); + + testWidgets('cleared shortcut can capture and save a replacement', (tester) async { + final saved = []; + await _pumpRecorder( + tester, + saved: saved, + currentHotKey: const HotKey(key: PhysicalKeyboardKey.keyJ, modifiers: [HotKeyModifier.shift]), + ); + + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsOneWidget); + expect(_saveAction(tester).onPressed, isNotNull); + + await tester.tap(find.byTooltip(t.hotkeys.clearShortcut)); + await tester.pump(); + + expect(_recorder(tester).enabled, isFalse); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsNothing); + expect(find.text(t.hotkeys.pressToRecord), findsNWidgets(2)); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record'); + expect(_saveAction(tester).onPressed, isNull); + + await tester.tap(find.byType(HotKeyRecorder)); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyL, physicalKey: PhysicalKeyboardKey.keyL); + await _pumpFocusChange(tester); + + expect(_recorder(tester).enabled, isFalse); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyL)), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save'); + + await tester.tap(find.widgetWithText(FilledButton, t.common.save)); + + expect(saved, hasLength(1)); + expect(saved.single.key, PhysicalKeyboardKey.keyL); + expect(saved.single.modifiers, isNull); + }); + + testWidgets('modifier-first Control+P completes with the held modifier', (tester) async { + final saved = []; + await _pumpRecorder(tester, saved: saved); + await tester.tap(find.byType(HotKeyRecorder)); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft, physicalKey: PhysicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(_recorder(tester).enabled, isTrue); + expect(saved, isEmpty); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.controlLeft)), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyP, physicalKey: PhysicalKeyboardKey.keyP); + await _pumpFocusChange(tester); + + expect(_recorder(tester).enabled, isFalse); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save'); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.controlLeft)), findsOneWidget); + expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyP)), findsOneWidget); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyP, physicalKey: PhysicalKeyboardKey.keyP); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft, physicalKey: PhysicalKeyboardKey.controlLeft); + await tester.tap(find.widgetWithText(FilledButton, t.common.save)); + + expect(saved, hasLength(1)); + expect(saved.single.key, PhysicalKeyboardKey.keyP); + expect(saved.single.modifiers, [HotKeyModifier.control]); + }); + + for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[ + ('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter), + ('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select), + ]) { + testWidgets('${entry.$1} completion does not rearm capture or activate Save on key-up', (tester) async { + final saved = []; + await _pumpRecorder(tester, saved: saved); + await tester.tap(find.byType(HotKeyRecorder)); + await tester.pump(); + + await tester.sendKeyDownEvent(entry.$2, physicalKey: entry.$3); + await _pumpFocusChange(tester); + + expect(_recorder(tester).enabled, isFalse); + expect(find.text(t.hotkeys.recordingShortcut), findsNothing); + expect(find.text(physicalKeyLabel(entry.$3)), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save'); + expect(saved, isEmpty); + + await tester.sendKeyUpEvent(entry.$2, physicalKey: entry.$3); + await tester.pump(); + + expect(_recorder(tester).enabled, isFalse); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save'); + expect(saved, isEmpty); + + await tester.tap(find.widgetWithText(FilledButton, t.common.save)); + + expect(saved, hasLength(1)); + expect(saved.single.key, entry.$3); + expect(saved.single.modifiers, isNull); + }); + } +} + +Future _pumpRecorder(WidgetTester tester, {required List saved, HotKey? currentHotKey}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: HotKeyRecorderWidget( + actionName: 'Play/Pause', + currentHotKey: currentHotKey, + onHotKeyRecorded: saved.add, + onCancel: () {}, + ), + ), + ), + ); + await tester.pump(); +} + +Future _pumpFocusChange(WidgetTester tester) async { + await tester.pump(); + await tester.pump(); +} + +HotKeyRecorder _recorder(WidgetTester tester) => tester.widget(find.byType(HotKeyRecorder)); + +DialogActionButton _saveAction(WidgetTester tester) => + tester.widget(find.widgetWithText(DialogActionButton, t.common.save)); diff --git a/test/widgets/library_management_sheet_test.dart b/test/widgets/library_management_sheet_test.dart new file mode 100644 index 00000000..61125e9a --- /dev/null +++ b/test/widgets/library_management_sheet_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.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_kind.dart'; +import 'package:plezy/media/media_library.dart'; +import 'package:plezy/providers/hidden_libraries_provider.dart'; +import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/library_management_sheet.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementLauncher(WidgetTester tester) async { + final librariesProvider = LibrariesProvider(); + await librariesProvider.updateLibraryOrder([ + const MediaLibrary(id: 'movies', backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie), + ]); + addTearDown(librariesProvider.dispose); + + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + await hiddenLibrariesProvider.ensureInitialized(); + addTearDown(hiddenLibrariesProvider.dispose); + + var underlyingSelects = 0; + var underlyingBacks = 0; + + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey.isSelectKey) underlyingSelects++; + if (event is KeyDownEvent && event.logicalKey.isBackKey) underlyingBacks++; + return KeyEventResult.ignored; + }, + child: OverlaySheetHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + autofocus: true, + onPressed: () => showLibraryManagementSheet(context), + child: const Text('Open library management'), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return (selects: () => underlyingSelects, backs: () => underlyingBacks); +} + +Future _openScanConfirmation(WidgetTester tester) async { + // Switch from the desktop pointer default to keyboard mode, then activate the + // focused launcher using the same key path as a keyboard/remote user. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(t.libraries.manageLibraries), findsOneWidget); + + // The sheet owns one focus node for its virtual row/column navigation. Move + // from the row to its options column and open the real AppMenuSheet. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(t.libraries.scanLibraryFiles), findsOneWidget); + + // The hosted menu focuses its first entry in keyboard mode. Selecting it + // must close the whole hosted sheet before presenting the confirmation. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.libraries.manageLibraries), findsNothing); + expect(find.text(t.libraries.scanLibraryFiles), findsNothing); + expect(OverlaySheetController.openSheetCount.value, 0); + + final dialogElement = tester.element(find.byType(AlertDialog)); + final primaryFocusContext = FocusManager.instance.primaryFocus?.context; + var dialogOwnsPrimaryFocus = false; + primaryFocusContext?.visitAncestorElements((element) { + if (identical(element, dialogElement)) { + dialogOwnsPrimaryFocus = true; + return false; + } + return true; + }); + expect(dialogOwnsPrimaryFocus, isTrue); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(false); + TvDetectionService.setForceTVSync(false); + PlatformDetector.debugSetIsDesktopOSOverride(false); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); + PlatformDetector.debugSetIsDesktopOSOverride(null); + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic; + }); + + for (final interaction in [ + (name: 'Enter', key: LogicalKeyboardKey.enter), + (name: 'Back', key: LogicalKeyboardKey.escape), + ]) { + testWidgets('${interaction.name} is handled by confirmation after the hosted action sheet closes', (tester) async { + final underlyingActions = await _pumpLibraryManagementLauncher(tester); + await _openScanConfirmation(tester); + + // Ignore launcher/menu navigation. From this point onward, neither key + // may reach the underlying page while the modal confirmation has focus. + final selectsBeforeDialogAction = underlyingActions.selects(); + final backsBeforeDialogAction = underlyingActions.backs(); + + await tester.sendKeyEvent(interaction.key); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Open library management'), findsOneWidget); + expect(underlyingActions.selects(), selectsBeforeDialogAction); + expect(underlyingActions.backs(), backsBeforeDialogAction); + expect(OverlaySheetController.openSheetCount.value, 0); + }); + } +} diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 056f4234..74cafee4 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -1,21 +1,36 @@ +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/focus/focusable_wrapper.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/models/download_models.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/music_navigation.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/music/mini_player.dart'; import 'package:provider/provider.dart'; -import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; final _track = testMediaItem( id: 'track_1', @@ -31,9 +46,33 @@ final _track = testMediaItem( serverName: 'Server', ); +class _RecordingNavigatorObserver extends NavigatorObserver { + final List> pushedRoutes = []; + + @override + void didPush(Route route, Route? previousRoute) { + pushedRoutes.add(route); + } +} + +class _FakeDownloadProvider extends ChangeNotifier implements DownloadProvider { + @override + DownloadProgress? getProgress(String globalKey) => null; + + @override + bool hasSyncRule(String globalKey) => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// Fixed-state fake: reports a playing session with a single-track queue. class _FakeMusicService extends StubMusicPlaybackService { MediaItem? track; + int previousCalls = 0; + int toggleCalls = 0; + int nextCalls = 0; + int stopCalls = 0; _FakeMusicService({this.track}); @@ -54,6 +93,26 @@ class _FakeMusicService extends StubMusicPlaybackService { @override int get currentIndex => track == null ? -1 : 0; + + @override + Future previous() async { + previousCalls++; + } + + @override + Future togglePlayPause() async { + toggleCalls++; + } + + @override + Future next() async { + nextCalls++; + } + + @override + Future stop() async { + stopCalls++; + } } void main() { @@ -70,10 +129,22 @@ void main() { TvDetectionService.debugSetAppleTVOverride(null); }); - Widget wrap({required MusicPlaybackService service, required MusicUiRouteObserver observer}) { + Widget wrap({ + required MusicPlaybackService service, + required MusicUiRouteObserver observer, + TargetPlatform platform = TargetPlatform.android, + NavigatorObserver? navigatorObserver, + ActiveProfileProvider? activeProfileProvider, + DownloadProvider? downloadProvider, + }) { final manager = MultiServerManager(); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(multiServerProvider.dispose); + addTearDown(service.dispose); + addTearDown(observer.suppress.dispose); + addTearDown(() { + multiServerProvider.dispose(); + manager.dispose(); + }); return TranslationProvider( child: MultiProvider( @@ -82,9 +153,13 @@ void main() { ChangeNotifierProvider.value(value: service), ChangeNotifierProvider(create: (_) => MiniPlayerInsetController()), Provider.value(value: observer), + if (activeProfileProvider != null) + ChangeNotifierProvider.value(value: activeProfileProvider), + if (downloadProvider != null) ChangeNotifierProvider.value(value: downloadProvider), ], child: MaterialApp( - theme: monoTheme(dark: true), + theme: monoTheme(dark: true).copyWith(platform: platform), + navigatorObservers: [?navigatorObserver], home: const Stack( children: [ SizedBox.expand(), @@ -108,6 +183,124 @@ void main() { expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout) }); + testWidgets('tapping the card edge opens the named Now Playing route', (tester) async { + final service = _FakeMusicService(track: _track); + final observer = MusicUiRouteObserver(); + final navigatorObserver = _RecordingNavigatorObserver(); + + await tester.pumpWidget(wrap(service: service, observer: observer, navigatorObserver: navigatorObserver)); + await tester.pumpAndSettle(); + + final cardRect = tester.getRect(find.byKey(const ValueKey('mini_player_dismiss'))); + await tester.tapAt(Offset(cardRect.left + 2, cardRect.center.dy)); + + final route = navigatorObserver.pushedRoutes.last; + expect(route.settings.name, kNowPlayingRouteName); + expect(service.previousCalls, 0); + expect(service.toggleCalls, 0); + expect(service.nextCalls, 0); + expect(service.stopCalls, 0); + + route.navigator!.removeRoute(route); + }); + + testWidgets('desktop transport buttons own their actions without opening Now Playing', (tester) async { + final service = _FakeMusicService(track: _track); + final observer = MusicUiRouteObserver(); + final navigatorObserver = _RecordingNavigatorObserver(); + + await tester.pumpWidget( + wrap(service: service, observer: observer, platform: TargetPlatform.macOS, navigatorObserver: navigatorObserver), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(t.music.previousTrack)); + await tester.tap(find.byTooltip(t.common.pause)); + await tester.tap(find.byTooltip(t.music.nextTrack)); + await tester.tap(find.byTooltip(t.music.stopPlayback)); + + expect(service.previousCalls, 1); + expect(service.toggleCalls, 1); + expect(service.nextCalls, 1); + expect(service.stopCalls, 1); + expect(navigatorObserver.pushedRoutes.where((route) => route.settings.name == kNowPlayingRouteName), isEmpty); + }); + + testWidgets('desktop close icon alone is 20px while transport icons remain 24px', (tester) async { + final service = _FakeMusicService(track: _track); + final observer = MusicUiRouteObserver(); + + await tester.pumpWidget(wrap(service: service, observer: observer, platform: TargetPlatform.macOS)); + await tester.pumpAndSettle(); + + final icons = tester + .widgetList(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(AppIcon))) + .toList(); + + expect(icons, hasLength(4)); + expect(icons.singleWhere((icon) => icon.icon == Symbols.close_rounded).size, 20); + expect(icons.where((icon) => icon.icon != Symbols.close_rounded).map((icon) => icon.size), everyElement(24)); + }); + + testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', ( + tester, + ) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + final downloadProvider = _FakeDownloadProvider(); + addTearDown(() async { + activeProfileProvider.dispose(); + downloadProvider.dispose(); + await plexHome.dispose(); + await db.close(); + }); + final service = _FakeMusicService(track: _track); + final observer = MusicUiRouteObserver(); + + await tester.pumpWidget( + wrap( + service: service, + observer: observer, + activeProfileProvider: activeProfileProvider, + downloadProvider: downloadProvider, + ), + ); + await tester.pumpAndSettle(); + + final card = find.byKey(const ValueKey('mini_player_dismiss')); + final cardRect = tester.getRect(card); + final gesture = await tester.startGesture(Offset(cardRect.left + 4, cardRect.center.dy)); + await tester.pump(const Duration(milliseconds: 150)); + await gesture.cancel(); + await tester.pump(); + + tester.widget(find.byType(FocusableWrapper)).focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 550)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(t.common.play), findsOneWidget); + final menuSurface = find.byWidgetPredicate((widget) => widget is Material && widget.elevation == 3); + expect(menuSurface, findsOneWidget); + expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1)); + + await tester.tapAt(const Offset(4, 4)); + await tester.pumpAndSettle(); + }); + testWidgets('stays hidden while the route observer suppresses it', (tester) async { final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver(); diff --git a/test/widgets/overlay_sheet_test.dart b/test/widgets/overlay_sheet_test.dart index 8267da4c..5be3dcea 100644 --- a/test/widgets/overlay_sheet_test.dart +++ b/test/widgets/overlay_sheet_test.dart @@ -140,6 +140,147 @@ void main() { expect(screenBacks, 0); }); + testWidgets('pushAdaptive opens a root page on an idle host and returns its result', (tester) async { + final result = ValueNotifier('pending'); + addTearDown(result.dispose); + + await tester.pumpWidget( + MaterialApp( + home: OverlaySheetHost( + child: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () async { + final value = await OverlaySheetController.pushAdaptive( + context, + builder: (sheetContext) => SizedBox( + height: 120, + child: Column( + children: [ + const Text('Adaptive root page'), + ElevatedButton( + onPressed: () => OverlaySheetController.of(sheetContext).close('root result'), + child: const Text('Close adaptive root'), + ), + ], + ), + ), + ); + result.value = value ?? 'null'; + }, + child: const Text('Push adaptive root'), + ), + ValueListenableBuilder( + valueListenable: result, + builder: (_, value, _) => Text('Root result: $value'), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push adaptive root')); + await tester.pumpAndSettle(); + + expect(find.text('Adaptive root page'), findsOneWidget); + expect(find.text('Root result: pending'), findsOneWidget); + + await tester.tap(find.text('Close adaptive root')); + await tester.pumpAndSettle(); + + expect(find.text('Adaptive root page'), findsNothing); + expect(find.text('Root result: root result'), findsOneWidget); + }); + + testWidgets('pushAdaptive pushes a nested page on an open host and pop restores the root', (tester) async { + final nestedResult = ValueNotifier('pending'); + addTearDown(nestedResult.dispose); + + await tester.pumpWidget( + MaterialApp( + home: OverlaySheetHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + OverlaySheetController.of(context).show( + builder: (rootContext) => SizedBox( + height: 160, + child: Column( + children: [ + const Text('Existing root page'), + ElevatedButton( + onPressed: () async { + final value = await OverlaySheetController.pushAdaptive( + rootContext, + builder: (nestedContext) => SizedBox( + height: 120, + child: Column( + children: [ + const Text('Adaptive nested page'), + ElevatedButton( + onPressed: () => + OverlaySheetController.of(nestedContext).pop('nested result'), + child: const Text('Pop adaptive nested'), + ), + ], + ), + ), + ); + nestedResult.value = value ?? 'null'; + }, + child: const Text('Push adaptive nested'), + ), + ValueListenableBuilder( + valueListenable: nestedResult, + builder: (_, value, _) => Text('Nested result: $value'), + ), + ElevatedButton( + onPressed: () => OverlaySheetController.of(rootContext).close(), + child: const Text('Close existing root'), + ), + ], + ), + ), + ); + }, + child: const Text('Open existing root'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open existing root')); + await tester.pumpAndSettle(); + expect(find.text('Existing root page'), findsOneWidget); + + await tester.tap(find.text('Push adaptive nested')); + await tester.pumpAndSettle(); + + expect(find.text('Adaptive nested page'), findsOneWidget); + expect(find.text('Existing root page'), findsNothing); + + await tester.tap(find.text('Pop adaptive nested')); + await tester.pumpAndSettle(); + + expect(find.text('Adaptive nested page'), findsNothing); + expect(find.text('Existing root page'), findsOneWidget); + expect(find.text('Nested result: nested result'), findsOneWidget); + + await tester.tap(find.text('Close existing root')); + await tester.pumpAndSettle(); + expect(find.text('Existing root page'), findsNothing); + }); + group('opt-in canPop / onSystemBack', () { // Pushes an OverlaySheetHost route on top of a home route so we can observe // whether a simulated system back pops the route. The host's child has an