From 8f30b8e5a81766687236f8a808f6954f4c85bfa9 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:01:19 +0500 Subject: [PATCH 01/12] Preview cache: one prune at a time Each new episode folder started a prune without waiting. Two running side by side picked the same oldest folders; the second delete threw, and the catch around the whole loop gave up on the rest, leaving 25 of 24. That was the flaky preview_grid_test that turned master's CI red. Prunes are chained now, and each delete is guarded on its own. --- lib/core/preview/frame_preview_service.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/core/preview/frame_preview_service.dart b/lib/core/preview/frame_preview_service.dart index 6f08f42d..e9ad3044 100644 --- a/lib/core/preview/frame_preview_service.dart +++ b/lib/core/preview/frame_preview_service.dart @@ -741,6 +741,7 @@ class PreviewDiskCache { } static final Set _pruned = {}; + static Future _pruning = Future.value(); static Future save(String key, int bucket, Uint8List bytes) async { try { @@ -749,7 +750,12 @@ class PreviewDiskCache { if (!await dir.exists()) { await dir.create(recursive: true); await _touch(dir); - if (_pruned.add(key)) unawaited(_prune(root)); + if (_pruned.add(key)) { + // One prune at a time. Run side by side, two of them picked the + // same oldest folders; the second delete threw, and the catch + // around the loop gave up on the rest, leaving one too many. + _pruning = _pruning.then((_) => _prune(root)); + } } await File('${dir.path}/$bucket.jpg').writeAsBytes(bytes, flush: false); } catch (_) {} @@ -779,7 +785,11 @@ class PreviewDiskCache { for (final d in dirs) (d, await _usedAt(d)), ]..sort((a, b) => a.$2.compareTo(b.$2)); for (final (d, _) in dated.take(dirs.length - maxEpisodes)) { - await d.delete(recursive: true); + // Each on its own: a folder already gone is no reason to keep the + // others. + try { + await d.delete(recursive: true); + } catch (_) {} } } catch (_) {} } From 754d91a3de10b1ca051f378d6f101eba30471843 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:51:23 +0500 Subject: [PATCH 02/12] Desktop player: a Telegram-style corner player inside the app On macOS, Windows and Linux the player can now dock into a small floating box in a corner of the app window. It keeps playing while the viewer moves around Home, Search, a detail page or anything else, because it lives in a layer above the Navigator (PlayerDockHost, in MaterialApp.router's builder). Nothing is handed over or restarted. /player now builds PlayerRoute, which gives PlayerPage a GlobalKey. Docking pops the route, and the same State moves into the host in the same frame. Expanding pushes /player again, and the route takes the key back. History ticks, auto-advance, subtitles, party sync and retries carry on as before, and closing the corner player disposes the page normally. - The corner box can be dragged, and on release it snaps to the nearest corner, taking the fling into account. Its inner edges resize it, between 240 px and 60% of the window. The corner and width are saved. - Hover controls: close, back to the full player, the title, previous and next episode, play/pause, 10-second steps when there is room, the time, and a timeline that seeks on click or drag. - I or the PiP button docks the player. Shift+I or a long press keeps the old whole-window always-on-top mini, which is still the only way to watch over other apps. - While docked, the keyboard stays with the page underneath. Clicking the corner player takes the keyboard. Esc or a click elsewhere gives it back without closing the video. I expands. - Opening another title closes the docked one first. Signing out, switching profile and onboarding close it too. - Fix: in the always-on-top mini window, regaining focus cleared _isPip and drew the full control bar into the 426-px window. Mobile and TV are unchanged. PlayerDock.supported is desktop-only, and the phone PiP paths are untouched. --- lib/app.dart | 11 +- lib/core/player/player_dock.dart | 312 ++++++++++++ lib/core/router/app_router.dart | 6 +- .../pages/player_page.controls.dart | 82 +++- .../presentation/pages/player_page.dart | 110 ++++- .../presentation/pages/player_page.pip.dart | 387 +++++++++++---- .../pages/player_page.widgets.dart | 92 +++- .../presentation/pages/player_route.dart | 70 +++ .../widgets/player_dock_host.dart | 390 +++++++++++++++ .../player/player_dock_geometry_test.dart | 176 +++++++ .../detail/player_dock_host_test.dart | 443 ++++++++++++++++++ .../detail/player_dock_source_test.dart | 92 ++++ 12 files changed, 2047 insertions(+), 124 deletions(-) create mode 100644 lib/core/player/player_dock.dart create mode 100644 lib/features/detail/presentation/pages/player_route.dart create mode 100644 lib/features/detail/presentation/widgets/player_dock_host.dart create mode 100644 test/core/player/player_dock_geometry_test.dart create mode 100644 test/features/detail/player_dock_host_test.dart create mode 100644 test/features/detail/player_dock_source_test.dart diff --git a/lib/app.dart b/lib/app.dart index 10c2d06e..60891803 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/localization/app_language.dart'; import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_dock_host.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/app_lock/presentation/widgets/app_lock_overlay.dart'; import 'package:soplay/features/notifications/data/services/notification_service.dart'; @@ -193,10 +195,17 @@ class _MyAppState extends State { // keeps the whole navigation stack — a redirect would have thrown // away the pages below, and the `extra` arguments pages like the // player are opened with. + final routed = child ?? const SizedBox.shrink(); final app = AppLockOverlay( child: ProfileGate( child: AchievementCelebrationHost( - child: child ?? const SizedBox.shrink(), + // Desktop: the corner player lives here, above the + // Navigator, so it keeps playing while pages change under + // it. Inside the lock, which covers it too; below the title + // bar, so its corners are the content area's. + child: PlayerDock.supported + ? PlayerDockHost(child: routed) + : routed, ), ), ); diff --git a/lib/core/player/player_dock.dart b/lib/core/player/player_dock.dart new file mode 100644 index 00000000..650f44f9 --- /dev/null +++ b/lib/core/player/player_dock.dart @@ -0,0 +1,312 @@ +import 'dart:math' as math; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; + +enum DockCorner { topLeft, topRight, bottomLeft, bottomRight } + +/// Where the corner player sits, as plain arithmetic so it can be tested +/// without a window. +class DockGeometry { + DockGeometry._(); + + /// Gap between the player and the edge of the app's content area. + static const double margin = 16; + static const double minWidth = 240; + static const double defaultWidth = 400; + + /// Past this share of the window the "mini" player covers the very page it + /// exists to leave usable. + static const double maxShare = 0.6; + + /// How far ahead a release is projected along its fling, in seconds: a + /// flick toward a corner lands there even when let go of mid-window. + static const double flingLookahead = 0.12; + + static double clampAspect(double a) => + a.isFinite && a > 0 ? a.clamp(0.5, 2.6).toDouble() : 16 / 9; + + /// The player's size for a wanted [width] at [aspect], kept between + /// [minWidth] and [maxShare] of [area] and never taller than the area. + static Size sizeFor(double width, double aspect, Size area) { + final a = clampAspect(aspect); + final maxW = math.max( + minWidth, + math.min(area.width * maxShare, (area.height - 2 * margin) * a), + ); + final w = (width.isFinite ? width : defaultWidth) + .clamp(minWidth, maxW) + .toDouble(); + return Size(w, (w / a).roundToDouble()); + } + + static Rect rectFor(DockCorner c, Size size, Size area) { + final left = c == DockCorner.topLeft || c == DockCorner.bottomLeft; + final top = c == DockCorner.topLeft || c == DockCorner.topRight; + return Rect.fromLTWH( + left ? margin : area.width - size.width - margin, + top ? margin : area.height - size.height - margin, + size.width, + size.height, + ); + } + + /// [rect] moved by [offset] but kept wholly inside [area], so a drag can not + /// lose the player past the edge of the window. + static Rect dragged(Rect rect, Offset offset, Size area) { + final r = rect.shift(offset); + final maxLeft = math.max(0.0, area.width - r.width); + final maxTop = math.max(0.0, area.height - r.height); + return Rect.fromLTWH( + r.left.clamp(0.0, maxLeft), + r.top.clamp(0.0, maxTop), + r.width, + r.height, + ); + } + + /// The corner a release snaps to: whichever quarter of the window the + /// player's centre would be in a moment later at the release [velocity]. + static DockCorner nearest(Rect rect, Offset velocity, Size area) { + final c = rect.center + velocity * flingLookahead; + final left = c.dx < area.width / 2; + final top = c.dy < area.height / 2; + if (top) return left ? DockCorner.topLeft : DockCorner.topRight; + return left ? DockCorner.bottomLeft : DockCorner.bottomRight; + } + + /// The grips sit on the edges facing the middle of the window: dragging + /// one away from the docked corner grows the player, toward it shrinks it. + /// The box keeps the picture's shape, so a vertical pull is converted to + /// width through [aspect], and of the two axes the larger pull wins. + static double resized( + double width, + Offset delta, + DockCorner c, + double aspect, + ) { + final left = c == DockCorner.topLeft || c == DockCorner.bottomLeft; + final top = c == DockCorner.topLeft || c == DockCorner.topRight; + final growX = left ? delta.dx : -delta.dx; + final growY = (top ? delta.dy : -delta.dy) * clampAspect(aspect); + return width + (growX.abs() >= growY.abs() ? growX : growY); + } +} + +enum _DockMode { none, docked, handingBack } + +/// The desktop corner player, Telegram-style. +/// +/// Owns no playback. It holds the [GlobalKey] of the PlayerPage that is in +/// the corner and where the corner window is. The page's State moves between +/// the /player route and PlayerDockHost under that key, so every timer, +/// subscription and the controller itself keep running across the move — +/// history ticks, auto-advance, subtitles, party sync and retries included. +/// +/// Two channels, because they change at very different rates: this notifier +/// says *which* page is docked (rare, and it rebuilds the host), [geometry] +/// says where it is (every frame of a drag, and only relays the box out). +class PlayerDock extends ChangeNotifier { + PlayerDock._(); + static final PlayerDock instance = PlayerDock._(); + + /// Desktop only. Phones already float over other apps through the system + /// picture-in-picture, and a television has no pointer to drag one with. + static bool get supported => isDesktopPlatform && !isTvPlatform; + + static const _cornerKey = 'player_dock_corner'; + static const _widthKey = 'player_dock_width'; + + _DockMode _mode = _DockMode.none; + GlobalKey? _key; + PlayerArgs? _args; + bool _prefsRead = false; + + final _Pulse _geometry = _Pulse(); + + /// Fires when the corner, size, aspect or drag offset changes. + Listenable get geometry => _geometry; + + /// Opens /player. Set by the host, so this file does not import the router. + void Function(PlayerArgs args)? openRoute; + + DockCorner corner = DockCorner.bottomRight; + double width = DockGeometry.defaultWidth; + double aspect = 16 / 9; + Offset drag = Offset.zero; + bool dragging = false; + + /// The host's last laid-out content area and player rect. Written during + /// layout, without notifying: a drag ends in screen space and needs to know + /// what it was dragged across. + Size area = Size.zero; + Rect? lastRect; + + bool get isDocked => _mode == _DockMode.docked; + GlobalKey? get dockedKey => isDocked ? _key : null; + PlayerArgs? get dockedArgs => isDocked ? _args : null; + + /// Where the player belongs in [area] right now, drag included. + Rect rectIn(Size area) { + final base = DockGeometry.rectFor( + corner, + DockGeometry.sizeFor(width, aspect, area), + area, + ); + return drag == Offset.zero ? base : DockGeometry.dragged(base, drag, area); + } + + void dock({ + required GlobalKey key, + required PlayerArgs args, + required Size videoSize, + }) { + _readPrefs(); + _key = key; + _args = args; + _mode = _DockMode.docked; + _setAspect(videoSize); + drag = Offset.zero; + dragging = false; + notifyListeners(); + } + + /// Back to the full player. Flip first, push second: the host stops + /// building the page in the same frame the new route starts building it, + /// which is what lets the GlobalKey carry the State across. A key that is + /// built nowhere at the end of a frame is disposed. + /// + /// False, and nothing changes, when there is no host to hand back from. + bool expand() { + final args = _args; + final open = openRoute; + if (!isDocked || args == null || open == null) return false; + _mode = _DockMode.handingBack; + notifyListeners(); + open(args); + return true; + } + + /// Called by PlayerRoute.initState: the key of the page being handed back, + /// or null for an ordinary open. No notify: the host already stopped + /// showing it when [expand] flipped the mode. + GlobalKey? takeHandBack() { + if (_mode != _DockMode.handingBack) return null; + final key = _key; + _clear(); + return key; + } + + /// Closes the corner player. Completes after the frame in which the page + /// was disposed, so its history is saved and its stream stopped by then. + Future close() async { + if (_mode == _DockMode.none) return; + _clear(); + notifyListeners(); + await SchedulerBinding.instance.endOfFrame; + } + + /// From PlayerPage.dispose. Silent: it runs while the tree is finalised. + void forget(GlobalKey key) { + if (_key == key) _clear(); + } + + /// A new episode or rendition can change the shape of the picture. + void reportVideoSize(Size size) { + if (!isDocked) return; + final before = aspect; + _setAspect(size); + if ((aspect - before).abs() > 0.01) _geometry.ping(); + } + + void dragStart() { + dragging = true; + drag = Offset.zero; + _geometry.ping(); + } + + void dragBy(Offset delta) { + drag += delta; + _geometry.ping(); + } + + void dragEnd(Offset velocity) { + if (!area.isEmpty) { + corner = DockGeometry.nearest(rectIn(area), velocity, area); + } + drag = Offset.zero; + dragging = false; + _savePrefs(); + _geometry.ping(); + } + + void resizeBy(Offset delta) { + if (area.isEmpty) return; + width = DockGeometry.sizeFor( + DockGeometry.resized(width, delta, corner, aspect), + aspect, + area, + ).width; + _geometry.ping(); + } + + void resizeEnd() => _savePrefs(); + + void _clear() { + _mode = _DockMode.none; + _key = null; + _args = null; + drag = Offset.zero; + dragging = false; + } + + void _setAspect(Size s) { + if (s.width > 0 && s.height > 0) { + aspect = DockGeometry.clampAspect(s.width / s.height); + } + } + + // The same box and the same direct access as DesktopWindow's geometry. + void _readPrefs() { + if (_prefsRead) return; + _prefsRead = true; + try { + final box = Hive.box(AppConstants.settingsBox); + final c = box.get(_cornerKey); + if (c is int && c >= 0 && c < DockCorner.values.length) { + corner = DockCorner.values[c]; + } + final w = box.get(_widthKey); + if (w is num && w > 0) width = w.toDouble(); + } catch (_) {} + } + + void _savePrefs() { + try { + final box = Hive.box(AppConstants.settingsBox); + box.put(_cornerKey, corner.index); + box.put(_widthKey, width); + } catch (_) {} + } + + /// Back to a fresh, empty dock between widget tests. + @visibleForTesting + void debugReset() { + _clear(); + openRoute = null; + corner = DockCorner.bottomRight; + width = DockGeometry.defaultWidth; + aspect = 16 / 9; + area = Size.zero; + lastRect = null; + _prefsRead = true; + } +} + +class _Pulse extends ChangeNotifier { + void ping() => notifyListeners(); +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index cf566077..e3b68b08 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -40,7 +40,7 @@ import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/presentation/pages/actor_page.dart'; import 'package:soplay/features/detail/presentation/pages/detail_page.dart'; import 'package:soplay/features/detail/presentation/pages/episodes_page.dart'; -import 'package:soplay/features/detail/presentation/pages/player_page.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; import 'package:soplay/features/download/presentation/pages/downloads_page.dart'; import 'package:soplay/features/history/presentation/pages/history_page.dart'; import 'package:soplay/features/home/domain/entities/view_all.dart'; @@ -261,7 +261,9 @@ class AppRouter { path: '/player', builder: (context, state) { final args = state.extra as PlayerArgs; - return PlayerPage(args: args); + // Through PlayerRoute, which keys the page: on the desktop it may be + // the one already playing in the corner, handed back with its state. + return PlayerRoute(args: args); }, ), GoRoute( diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index d580c97d..5d3723ce 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -230,6 +230,12 @@ extension _PlayerControls on _PlayerPageState { }); return; } + // In the corner there is no route of this page's to pop: the page under + // the corner player belongs to the viewer, not to the player. + if (_docked) { + _closeDock(); + return; + } if (context.canPop()) { context.pop(); } else { @@ -386,7 +392,62 @@ extension _PlayerControls on _PlayerPageState { !_isEncrypted && TorrentStreamUrl.parse(_videoUrl) == null; + Widget _buildCompactStatus() { + final error = _errorMessage; + return ColoredBox( + color: Colors.black, + child: Center( + child: error == null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.white70, + size: 22, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Text( + error, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + Widget _buildVideoLayer() { + // The small player has no room for the loading card or the error card, + // whose buttons alone are taller than the corner box. A spinner, or the + // reason in a line or two; Try again and the other sources are one click + // away in the full player. + if (_compact && (_initializing || _errorMessage != null)) { + return _buildCompactStatus(); + } if (_initializing) { return ColoredBox( color: Colors.black, @@ -1417,14 +1478,17 @@ extension _PlayerControls on _PlayerPageState { onTap: () => _openPanel(_SidePanel.episodes), ), ], - // The mini window: this player at 426 wide, above every - // other window. It had no button at all — the phone's PiP + // The mini player: a tap docks it in a corner of the app, + // a long press pops the whole window out above every other + // window. It had no button at all — the phone's PiP // control lives in the layout rows, which this row does // not draw — so only the I key could reach it. const SizedBox(width: 4), _IconButton( icon: Icons.picture_in_picture_alt_rounded, - onTap: _toggleDesktopMini, + onTap: _openMiniPlayer, + onLongPress: _toggleDesktopMini, + tooltip: 'player.mini_popout_hint'.tr(), ), const SizedBox(width: 4), _IconButton( @@ -1886,11 +1950,15 @@ extension _PlayerControls on _PlayerPageState { case 'pip': // `floating: ^6.0.0` ships an Android plugin and nothing else, and // _enterPip swallows the MissingPluginException, so on iOS this was a - // tap that did nothing. On the desktop it is the mini window. + // tap that did nothing. On the desktop a tap docks the player in a + // corner of the app, and a long press pops the window out above the + // others. if (isDesktopPlatform) { return _IconButton( icon: Icons.picture_in_picture_alt_rounded, - onTap: _toggleDesktopMini, + onTap: _openMiniPlayer, + onLongPress: _toggleDesktopMini, + tooltip: 'player.mini_popout_hint'.tr(), ); } if (!isAndroidPlatform && !isIosPlatform) return null; @@ -2082,7 +2150,9 @@ extension _PlayerControls on _PlayerPageState { /// Rebuilt from the controller's own notifier so the buffer and position /// rows move; everything else on the panel is steady between loads. Widget _buildPlayerInfoOverlay() { - if (!_showPlayerInfo) return const SizedBox.shrink(); + // A panel sized for the full player; in the small one it would cover the + // picture. + if (!_showPlayerInfo || _compact) return const SizedBox.shrink(); final c = _controller; if (c == null) return const SizedBox.shrink(); return ValueListenableBuilder( diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index a38d82b3..aa7bc8c5 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,4 +1,5 @@ import 'package:soplay/core/player/media_segment_relay.dart'; +import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/features/search/data/source_health_store.dart'; import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'package:share_plus/share_plus.dart'; @@ -221,6 +222,19 @@ class _PlayerPageState extends State /// Desktop picture-in-picture: the window is the small always-on-top /// player. [_isPip] is set with it, which hides the full controls. bool _desktopMini = false; + + /// Desktop picture-in-picture inside the app: this page is the corner + /// player over whatever screen the viewer moved on to (see [PlayerDock]). + /// [_isPip] is set with it, as for [_desktopMini], which hides the full + /// controls. + bool _docked = false; + + /// Either compact form: the always-on-top window or the corner player. + bool get _compact => _desktopMini || _docked; + + /// The desktop key handler's node, held so docking can let go of the + /// keyboard and a click on the corner player can take it back. + final FocusNode _keyFocus = FocusNode(debugLabel: 'desktopPlayerKeys'); final ValueNotifier _miniHover = ValueNotifier(false); bool _resumeAfterPause = false; bool _lastPipPlaying = false; @@ -788,7 +802,12 @@ class _PlayerPageState extends State _saveHistory(); break; case AppLifecycleState.resumed: - if (_isPip && mounted) { + // Back from Android's or iOS's floating window. On the desktop + // "resumed" only means the window got focus back — a click on the + // always-on-top mini window, or on the app around the corner player — + // and clearing _isPip there drew the full control bar into a 426-px + // window, under and over the mini controls. + if (_isPip && !_compact && mounted) { setState(() => _isPip = false); } if (_resumeAfterPause && c.value.isInitialized) { @@ -812,6 +831,11 @@ class _PlayerPageState extends State // Leaving the player from the mini window must not leave the app a // thumbnail above everything else. if (_desktopMini) unawaited(DesktopWindow.exitMini()); + // Closed from the corner, or disposed some other way while there: the + // dock must not keep pointing at a page that no longer exists. + final pageKey = widget.key; + if (pageKey is GlobalKey) PlayerDock.instance.forget(pageKey); + _keyFocus.dispose(); _miniHover.dispose(); // Leaving mid-play is a pause as far as Trakt's "watching now" goes. if (_traktPlaying) _syncTraktScrobble(false); @@ -893,6 +917,7 @@ class _PlayerPageState extends State @override Widget build(BuildContext context) { + if (_docked) _reportDockAspect(); return PopScope( // BACK is the *only* dismiss affordance on a remote, so on TV the player // intercepts it and unwinds one layer at a time (panel -> pending scrub -> @@ -902,6 +927,9 @@ class _PlayerPageState extends State canPop: !isTvPlatform || _tvPopAllowed, onPopInvokedWithResult: (didPop, _) { if (didPop || !isTvPlatform) { + // Docking pops the route too, but the page plays on in the corner: + // its wakelock and window state are still in use. + if (_docked) return; _restoreSystemUi(); return; } @@ -943,7 +971,7 @@ class _PlayerPageState extends State behavior: HitTestBehavior.opaque, onTap: _locked ? null - : _desktopMini + : _compact ? _togglePlay : _toggleControls, onDoubleTapDown: _locked || isDesktopPlatform @@ -951,6 +979,8 @@ class _PlayerPageState extends State : (d) => _onDoubleTapDown(d, constraints), onDoubleTap: _locked ? null + : _docked + ? _expandFromDock : _desktopMini ? _toggleDesktopMini : isDesktopPlatform @@ -961,20 +991,19 @@ class _PlayerPageState extends State // the old pan handlers — see _onScaleUpdate — so brightness, // volume and swipe-seek behave exactly as before, and two // fingers pinch-zoom the picture. - // In the mini window a drag moves the window instead. - onScaleStart: _locked || _desktopMini + // In the mini window a drag moves the window instead, and + // in the corner player it moves the corner player. + onScaleStart: _locked || _compact ? null : (d) => _onScaleStart(d, constraints), - onScaleUpdate: _locked || _desktopMini + onScaleUpdate: _locked || _compact ? null : (d) => _onScaleUpdate(d, constraints), - onScaleEnd: _locked || _desktopMini ? null : _onScaleEnd, - onLongPressStart: _locked || _desktopMini + onScaleEnd: _locked || _compact ? null : _onScaleEnd, + onLongPressStart: _locked || _compact ? null : _onLongPressStart, - onLongPressEnd: _locked || _desktopMini - ? null - : _onLongPressEnd, + onLongPressEnd: _locked || _compact ? null : _onLongPressEnd, child: Listener( // Seen before any recogniser claims the touch, and in the // order they happen: where the finger went down (the @@ -983,6 +1012,11 @@ class _PlayerPageState extends State onPointerDown: (e) { _pointerDownAt = e.localPosition; _pointerCancelled = false; + // The corner player gave the keyboard to the page under + // it; a click on the player takes it back. + if (_docked && !_keyFocus.hasFocus) { + _keyFocus.requestFocus(); + } }, onPointerCancel: (_) => _pointerCancelled = true, child: Stack( @@ -1017,7 +1051,7 @@ class _PlayerPageState extends State if (!_locked && _panel != _SidePanel.none) _buildSidePanel(), if (!_locked && _inParty) _buildPartyReactionsLayer(), - if (_desktopMini) _buildDesktopMiniOverlay(), + if (_compact) _buildDesktopMiniOverlay(), // Last, so it covers everything: while a television is // playing this episode the phone is a remote, and leaving the // local controls reachable underneath would let someone @@ -1050,14 +1084,21 @@ class _PlayerPageState extends State ); } if (!isDesktopPlatform) return child; - return Focus(autofocus: true, onKeyEvent: _onPlayerKey, child: child); + return Focus( + focusNode: _keyFocus, + // Not when the page arrives in the corner: the keyboard stays with the + // page the viewer went to. + autofocus: !_docked, + onKeyEvent: _onPlayerKey, + child: child, + ); } Widget _wrapHover(Widget child) { if (!isDesktopPlatform) return child; return MouseRegion( onHover: (_) => _revealControlsForHover(), - cursor: _controlsVisible || _desktopMini + cursor: _controlsVisible || _compact ? MouseCursor.defer : SystemMouseCursors.none, child: child, @@ -1080,6 +1121,18 @@ class _PlayerPageState extends State _toggleDesktopMini(); return KeyEventResult.handled; } + if (_docked) { + // In the corner, Esc hands the keyboard back to the page underneath + // instead of closing the video — PlayerDockHost takes it from here, as + // it knows where that page is — and I opens the full player again. + // Nothing that opens a sheet: there is no room for one. + if (k == LogicalKeyboardKey.escape) return KeyEventResult.ignored; + if (k == LogicalKeyboardKey.keyI) { + if (event is! KeyRepeatEvent) _expandFromDock(); + return KeyEventResult.handled; + } + if (!_kDockKeys.contains(k)) return KeyEventResult.ignored; + } for (final shortcut in _playerShortcuts) { if (!shortcut.keys.contains(k)) continue; // Swallowed rather than ignored when the party forbids it: the press was @@ -1230,14 +1283,19 @@ class _PlayerPageState extends State label: 'player.fullscreen'.tr(), onKey: (_) => _toggleFullscreen(), ), - // The mini window: above other windows, the same player at 426 wide. + // I: the corner player, which stays while the rest of the app is used. + // Shift+I: the whole window as a small always-on-top player, for watching + // over other apps — the only way above them, since a second window would + // need a second engine that cannot share this texture. // I rather than P, which is already the previous episode. _PlayerShortcut( keys: const [LogicalKeyboardKey.keyI], - hint: 'I', - label: 'player.pip'.tr(), + hint: 'I · ⇧ I', + label: 'player.shortcut_pip'.tr(), available: isDesktopPlatform, - onKey: (_) => _toggleDesktopMini(), + onKey: (_) => _desktopMini || HardwareKeyboard.instance.isShiftPressed + ? _toggleDesktopMini() + : _openMiniPlayer(), ), _PlayerShortcut( keys: const [LogicalKeyboardKey.keyS], @@ -1539,3 +1597,21 @@ const _kDecileKeys = [ /// (player_page.panels.dart): a key that landed between its rows would leave /// the sheet open with nothing selected. const _kKeyboardSpeeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +/// What the keyboard can still do while the player is in the corner: play, +/// seek, volume, mute and the episode keys. The rest open a sheet or change +/// the window, and the corner has room for neither. +const _kDockKeys = [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.mediaPlayPause, + LogicalKeyboardKey.keyK, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyJ, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyP, + LogicalKeyboardKey.keyN, +]; diff --git a/lib/features/detail/presentation/pages/player_page.pip.dart b/lib/features/detail/presentation/pages/player_page.pip.dart index 31dbce4d..a169955d 100644 --- a/lib/features/detail/presentation/pages/player_page.pip.dart +++ b/lib/features/detail/presentation/pages/player_page.pip.dart @@ -157,10 +157,31 @@ extension _PlayerPip on _PlayerPageState { } } - /// Desktop picture-in-picture: the whole window becomes a small player - /// above every other window, and back. + /// The PiP button and the I key: the corner player when this page can go + /// there, the always-on-top window otherwise. + void _openMiniPlayer() { + if (_canDock) { + unawaited(_dockToCorner()); + } else { + unawaited(_toggleDesktopMini()); + } + } + + /// Whether this page can move into the corner player: on the desktop, when + /// it was opened through PlayerRoute (which keys it), and not while a + /// television is playing — the corner would only show the "playing on" + /// card. + bool get _canDock => + PlayerDock.supported && widget.key is GlobalKey && !_cast.isCasting; + + /// Desktop picture-in-picture, the long way round: the whole window becomes + /// a small player above every other window, and back. The corner player + /// ([_dockToCorner]) cannot float over other apps; this is how to watch + /// over them. Future _toggleDesktopMini() async { if (!isDesktopPlatform) return; + // From the corner the way out is the full player first. + if (_docked) return; if (_desktopMini) { setState(() { _desktopMini = false; @@ -186,16 +207,90 @@ extension _PlayerPip on _PlayerPageState { await DesktopWindow.enterMini(aspect: aspect); } - /// The mini window's own controls, shown while the pointer is over it: - /// play or pause in the middle, back to the full window and a step either - /// way at the top, and how far along under it. The rest of the window is a - /// handle to move it by. + /// The corner player, Telegram-style. This page leaves the route stack and + /// carries on, State and all, in a small box over whatever the viewer opens + /// next. Nothing is handed over or rebuilt: the GlobalKey PlayerRoute gave + /// this page moves it into PlayerDockHost in the same frame, so history + /// ticks, auto-advance, subtitles, party sync and retries keep running as + /// they were. + Future _dockToCorner() async { + final key = widget.key; + if (!_canDock || _docked || key is! GlobalKey) return; + if (_desktopMini) await _toggleDesktopMini(); + if (!mounted) return; + if (_isFullscreen) { + _isFullscreen = false; + unawaited(DesktopWindow.setFullscreen(false)); + } + _hideTimer?.cancel(); + setState(() { + _docked = true; + _isPip = true; + _controlsVisible = false; + _panel = _SidePanel.none; + }); + _controlsAnimation.reverse(); + _miniHover.value = false; + _keyFocus.unfocus(); + // A save that notifies, unlike the five-second tick: Continue Watching on + // the page under the corner should show this title now, not when it is + // paused. + _saveHistory(); + PlayerDock.instance.dock( + key: key, + args: widget.args, + videoSize: _controller?.value.size ?? Size.zero, + ); + if (context.canPop()) { + context.pop(); + } else { + context.go('/main'); + } + } + + /// From the corner back to the full player, the same State again. + void _expandFromDock() { + if (!_docked || !PlayerDock.instance.expand()) return; + setState(() { + _docked = false; + _isPip = false; + }); + _miniHover.value = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _keyFocus.requestFocus(); + _revealControlsForHover(); + }); + } + + /// Closes the corner player; dispose saves history and stops the stream. + void _closeDock() => unawaited(PlayerDock.instance.close()); + + /// A new episode or rendition can change the picture's shape, and the + /// corner box follows it. After the frame: the dock notifies its host, + /// which must not happen during this page's build. + void _reportDockAspect() { + final size = _controller?.value.size; + if (size == null || size.isEmpty) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _docked) PlayerDock.instance.reportVideoSize(size); + }); + } + + /// The small player's own controls, shown while the pointer is over it — + /// in the always-on-top window and in the corner player alike. Play or + /// pause in the middle with the neighbouring episodes either side, the + /// title and the way back to the full player along the top (and, in the + /// corner, closing it), and a timeline to click along the bottom. The rest + /// is a handle: in the always-on-top window it moves the OS window, in the + /// corner it moves the corner player. Widget _buildDesktopMiniOverlay() { final c = _controller; + final inApp = _docked; Widget round( IconData icon, String tip, - VoidCallback onTap, { + VoidCallback? onTap, { double size = 18, }) => Tooltip( message: tip, @@ -207,114 +302,212 @@ extension _PlayerPip on _PlayerPageState { onTap: onTap, child: Padding( padding: const EdgeInsets.all(7), - child: Icon(icon, color: Colors.white, size: size), + child: Icon( + icon, + // Dimmed rather than absent at either end of a season, so the + // row does not shift under the pointer. + color: Colors.white.withValues(alpha: onTap == null ? 0.38 : 1), + size: size, + ), ), ), ), ); - return Positioned.fill( - child: MouseRegion( - onEnter: (_) => _miniHover.value = true, - onHover: (_) => _miniHover.value = true, - onExit: (_) => _miniHover.value = false, - child: ValueListenableBuilder( - valueListenable: _miniHover, - builder: (context, hover, _) => AnimatedOpacity( - opacity: hover ? 1 : 0, + const scrim = DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x99000000), Color(0x22000000), Color(0x99000000)], + ), + ), + ); + final dock = PlayerDock.instance; + final controls = MouseRegion( + onEnter: (_) => _miniHover.value = true, + onHover: (_) => _miniHover.value = true, + onExit: (_) => _miniHover.value = false, + child: ValueListenableBuilder( + valueListenable: _miniHover, + builder: (context, hover, _) { + // Loading or failed, the controls stay up: closing or going back to + // the full player — where Try again and the other sources are — are + // the useful moves then. + final show = hover || _initializing || _errorMessage != null; + return AnimatedOpacity( + opacity: show ? 1 : 0, duration: const Duration(milliseconds: 160), child: IgnorePointer( - ignoring: !hover, - child: Stack( - fit: StackFit.expand, - children: [ - const DragToMoveArea( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x99000000), - Color(0x22000000), - Color(0x99000000), + ignoring: !show, + child: LayoutBuilder( + builder: (context, box) { + final roomy = box.maxWidth >= 320; + final episodes = _affordances.hasEpisodes; + // Neighbouring episodes outrank the ten-second steps when + // there is room for only one pair; the arrow keys still step. + final steps = !episodes || box.maxWidth >= 360; + final gap = SizedBox(width: roomy ? 14 : 8); + return Stack( + fit: StackFit.expand, + children: [ + if (inApp) + GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => dock.dragStart(), + onPanUpdate: (d) => dock.dragBy(d.delta), + onPanEnd: (d) => + dock.dragEnd(d.velocity.pixelsPerSecond), + onPanCancel: () { + if (dock.dragging) dock.dragEnd(Offset.zero); + }, + child: scrim, + ) + else + const DragToMoveArea(child: scrim), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episodes) ...[ + round( + Icons.skip_previous_rounded, + 'player.previous'.tr(), + _hasPrevEpisode + ? () => _partyEpisodeNav(_episodeIndex - 1) + : null, + ), + gap, + ], + if (steps) ...[ + round( + Icons.fast_rewind_rounded, + 'player.rewind'.tr(), + () => _seekRelative(-_seekStep), + ), + gap, + ], + if (c != null) + ListenableBuilder( + listenable: c, + builder: (_, _) => round( + c.value.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + c.value.isPlaying + ? 'player.pause'.tr() + : 'player.play'.tr(), + _togglePlay, + size: 30, + ), + ), + if (steps) ...[ + gap, + round( + Icons.fast_forward_rounded, + 'player.forward'.tr(), + () => _seekRelative(_seekStep), + ), + ], + if (episodes) ...[ + gap, + round( + Icons.skip_next_rounded, + 'player.next_episode'.tr(), + _hasNextEpisode + ? () => _partyEpisodeNav(_episodeIndex + 1) + : null, + ), + ], ], ), ), - ), - ), - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - round( - Icons.fast_rewind_rounded, - 'player.rewind'.tr(), - () => _seekRelative(-_seekStep), + if (inApp) + Positioned( + top: 6, + left: 6, + child: round( + Icons.close_rounded, + 'general.close'.tr(), + _closeDock, + ), ), - const SizedBox(width: 14), - if (c != null) - ListenableBuilder( - listenable: c, - builder: (_, _) => round( - c.value.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - c.value.isPlaying - ? 'player.pause'.tr() - : 'player.play'.tr(), - _togglePlay, - size: 30, + if (roomy) + Positioned( + left: 48, + right: 48, + top: 14, + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, ), ), - const SizedBox(width: 14), - round( - Icons.fast_forward_rounded, - 'player.forward'.tr(), - () => _seekRelative(_seekStep), ), - ], - ), - ), - Positioned( - top: 6, - right: 6, - child: round( - Icons.open_in_full_rounded, - 'player.mini_restore'.tr(), - _toggleDesktopMini, - ), - ), - if (c != null) - Positioned( - left: 10, - right: 10, - bottom: 8, - child: ListenableBuilder( - listenable: c, - builder: (_, _) { - final d = c.value.duration.inMilliseconds; - final p = c.value.position.inMilliseconds; - return ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: d > 0 ? (p / d).clamp(0.0, 1.0) : 0, - minHeight: 3, - backgroundColor: Colors.white24, - valueColor: AlwaysStoppedAnimation( - AppColors.primary, - ), - ), - ); - }, + Positioned( + top: 6, + right: 6, + child: round( + Icons.open_in_full_rounded, + 'player.mini_restore'.tr(), + inApp ? _expandFromDock : _toggleDesktopMini, + ), ), - ), - ], + if (c != null && !_isLive) + Positioned( + left: 10, + right: 10, + bottom: 6, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (roomy) + ListenableBuilder( + listenable: c, + builder: (_, _) => Text( + '${_formatDuration(c.value.position)}' + ' / ${_formatDuration(c.value.duration)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w600, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + shadows: _kControlShadow, + ), + ), + ), + _MiniSeekBar(controller: c, onSeek: _seekTo), + ], + ), + ), + ], + ); + }, ), ), - ), - ), + ); + }, ), ); + return Positioned.fill( + // In the corner the page's text is scaled down with the box (see + // PlayerDockHost); these few labels are the controls' own, and they + // stay readable at any size. + child: inApp + ? MediaQuery.withClampedTextScaling( + minScaleFactor: 1, + child: controls, + ) + : controls, + ); } Future _startup() async { diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index d0c206de..5dbee7f9 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -264,10 +264,14 @@ class _IconButton extends StatelessWidget { this.onLongPress, this.color, this.enabled = true, + this.tooltip, }); final IconData icon; final VoidCallback onTap; + /// For a button whose long press is not obvious from its icon. + final String? tooltip; + /// The long way round, for a control whose tap is a shortcut. /// /// Speed and aspect ratio both used to open a full sheet over the video for @@ -312,7 +316,7 @@ class _IconButton extends StatelessWidget { // pitch was already 44; the gap simply moves inside the button as // transparent padding. Callers therefore drop their SizedBox(width: 6) — // keeping both would double the spacing. - return _tvRing( + final Widget button = _tvRing( circle: true, SizedBox( width: 44, @@ -344,6 +348,92 @@ class _IconButton extends StatelessWidget { ), ), ); + final tip = tooltip; + return tip == null ? button : Tooltip(message: tip, child: button); + } +} + +/// The small player's timeline: thin until the pointer is over it, and a +/// click or a drag anywhere along it seeks. The full bar's frame preview and +/// chapter marks have no room here. +class _MiniSeekBar extends StatefulWidget { + const _MiniSeekBar({required this.controller, required this.onSeek}); + + final PlayerController controller; + final ValueChanged onSeek; + + @override + State<_MiniSeekBar> createState() => _MiniSeekBarState(); +} + +class _MiniSeekBarState extends State<_MiniSeekBar> { + bool _hover = false; + + /// Where the pointer holds the bar, 0–1, while it is down. + double? _drag; + + void _at(double dx, double width) => + setState(() => _drag = width > 0 ? (dx / width).clamp(0.0, 1.0) : 0.0); + + void _commit() { + final f = _drag; + final d = widget.controller.value.duration; + if (f != null && d > Duration.zero) widget.onSeek(d * f); + setState(() => _drag = null); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, box) => MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Claim the click for the bar. Without these the player's own tap + // (play/pause) and double tap (back to the full player) would fire + // under it as well. + onTap: () {}, + onDoubleTap: () {}, + // Raw pointer events for the seek itself, so it lands on release + // rather than after the double-tap timeout the claim above holds. + child: Listener( + onPointerDown: (e) => _at(e.localPosition.dx, box.maxWidth), + onPointerMove: (e) => _at(e.localPosition.dx, box.maxWidth), + onPointerUp: (_) => _commit(), + onPointerCancel: (_) => setState(() => _drag = null), + child: SizedBox( + height: 16, + child: Align( + alignment: Alignment.bottomCenter, + child: ListenableBuilder( + listenable: widget.controller, + builder: (_, _) { + final v = widget.controller.value; + final ms = v.duration.inMilliseconds; + final f = + _drag ?? + (ms > 0 + ? (v.position.inMilliseconds / ms).clamp(0.0, 1.0) + : 0.0); + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: f, + minHeight: _hover || _drag != null ? 5 : 3, + backgroundColor: Colors.white24, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); } } diff --git a/lib/features/detail/presentation/pages/player_route.dart b/lib/features/detail/presentation/pages/player_route.dart new file mode 100644 index 00000000..8db8c3eb --- /dev/null +++ b/lib/features/detail/presentation/pages/player_route.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_page.dart'; + +/// Builds the player for a key and its arguments. Injectable so the hand-off +/// between the route and the corner can be tested without a real player. +typedef PlayerPageBuilder = Widget Function(GlobalKey key, PlayerArgs args); + +Widget defaultPlayerPageBuilder(GlobalKey key, PlayerArgs args) => + PlayerPage(key: key, args: args); + +/// What /player builds. It gives the page a [GlobalKey]: that key is how a +/// page can leave this route for the desktop corner player and come back +/// with all of its state, and how a route recognises a page being handed +/// back to it. +class PlayerRoute extends StatefulWidget { + const PlayerRoute({ + super.key, + required this.args, + this.pageBuilder = defaultPlayerPageBuilder, + }); + + final PlayerArgs args; + final PlayerPageBuilder pageBuilder; + + @override + State createState() => _PlayerRouteState(); +} + +class _PlayerRouteState extends State { + final PlayerDock _dock = PlayerDock.instance; + late final GlobalKey _pageKey; + bool _ready = true; + + @override + void initState() { + super.initState(); + final handed = _dock.takeHandBack(); + _pageKey = handed ?? GlobalKey(debugLabel: 'player'); + if (handed == null && _dock.isDocked) { + // Another title is in the corner. It goes first: its dispose saves its + // history and also clears page-wide singletons (PlayerLog, the frame + // preview service, Discord, the PiP channel handler). Disposed after + // this page's initState, it would tear down what this page had just + // set up. + _ready = false; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _dock.close(); + if (mounted) setState(() => _ready = true); + }); + } + } + + @override + Widget build(BuildContext context) { + if (!_ready) return const ColoredBox(color: Colors.black); + return ListenableBuilder( + listenable: _dock, + // While this route animates away after docking it draws nothing: the + // page is in the corner now, under the same key, and building it here + // as well would be the same GlobalKey in two places. Empty rather than + // black, so the page underneath shows through the exit transition while + // the picture shrinks into its corner. + builder: (context, _) => _dock.dockedKey == _pageKey + ? const SizedBox.expand() + : widget.pageBuilder(_pageKey, widget.args), + ); + } +} diff --git a/lib/features/detail/presentation/widgets/player_dock_host.dart b/lib/features/detail/presentation/widgets/player_dock_host.dart new file mode 100644 index 00000000..7f27e525 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_dock_host.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/core/router/app_router.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; + +/// Where the corner player belongs to the session that started it: signing +/// out, switching profile or starting over closes it. +bool playerDockClosesOn(String path) => + path == '/login' || + path == '/splash' || + path == '/profiles' || + path == '/onboarding' || + path.startsWith('/onboarding/'); + +/// The desktop corner player's home: a layer above the app's Navigator, so +/// the player stays while the pages under it change. +/// +/// The docked PlayerPage is the same element, with the same State, that was +/// playing in /player a frame earlier — see [PlayerDock]. Here it gets a +/// rounded, shadowed box in one corner of the content area, which it can be +/// dragged out of (it snaps to the nearest corner on release) and resized +/// from the edges that face the middle of the window. +class PlayerDockHost extends StatefulWidget { + const PlayerDockHost({ + super.key, + required this.child, + this.router, + this.pageBuilder = defaultPlayerPageBuilder, + }); + + final Widget child; + + /// The app's router; [AppRouter.router] unless a test brings its own. + final GoRouter? router; + final PlayerPageBuilder pageBuilder; + + /// Wraps the docked page's box; widget tests find the corner player by it. + static const Key frameKey = ValueKey('player-dock-frame'); + + @override + State createState() => _PlayerDockHostState(); +} + +class _PlayerDockHostState extends State + with SingleTickerProviderStateMixin { + final PlayerDock _dock = PlayerDock.instance; + late final GoRouter _router = widget.router ?? AppRouter.router; + + /// Drives both moves the box makes on its own: shrinking from the full + /// window into its corner when a page docks, and gliding to the corner it + /// snapped to after a drag. + late final AnimationController _move = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + + /// Where the current move started, given the area: the full area for a + /// page arriving, the drop point for a snap. Null when nothing is moving. + Rect Function(Size area)? _from; + + GlobalKey? _shown; + bool _wasDragging = false; + + /// Owns the keyboard while the viewer is using the corner player, and gives + /// it back to the page underneath on a click anywhere else. + final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'playerDock'); + + void _open(PlayerArgs args) => _router.push('/player', extra: args); + + @override + void initState() { + super.initState(); + _dock.openRoute = _open; + _dock.addListener(_onDock); + _dock.geometry.addListener(_onGeometry); + _move.addStatusListener((s) { + if (s == AnimationStatus.completed) _from = null; + }); + _router.routerDelegate.addListener(_closeOnSessionChange); + _shown = _dock.dockedKey; + } + + @override + void dispose() { + _dock.removeListener(_onDock); + _dock.geometry.removeListener(_onGeometry); + if (_dock.openRoute == _open) _dock.openRoute = null; + _router.routerDelegate.removeListener(_closeOnSessionChange); + _move.dispose(); + _scope.dispose(); + super.dispose(); + } + + /// Synchronous with the notify on purpose: the route that gave the page up + /// rebuilds in the same frame, and the GlobalKey has to land here in that + /// frame or the page is disposed. + void _onDock() { + final key = _dock.dockedKey; + if (key == _shown) return; + _shown = key; + if (key != null) { + // The Telegram move, and the visible half of the hand-off: the picture + // starts where the full player was and shrinks into its corner. + _from = (area) => Offset.zero & area; + _move.forward(from: 0); + } else { + _from = null; + _move.stop(); + } + setState(() {}); + } + + void _onGeometry() { + final dragging = _dock.dragging; + if (_wasDragging && !dragging) { + final start = _dock.lastRect; + if (start != null) { + _from = (_) => start; + _move.forward(from: 0); + } + } else if (dragging && _move.isAnimating) { + _move.stop(); + _from = null; + } + _wasDragging = dragging; + } + + void _closeOnSessionChange() { + if (!_dock.isDocked) return; + final paths = []; + try { + final config = _router.routerDelegate.currentConfiguration; + paths.add(config.uri.path); + // A push leaves `uri` at the location it was pushed from; the top + // match is where the viewer actually is. + if (config.isNotEmpty) paths.add(config.last.matchedLocation); + } catch (_) { + return; + } + if (!paths.any(playerDockClosesOn)) return; + // Not from inside the router's own notification, which can arrive while + // the tree is being built. + scheduleMicrotask(() => unawaited(_dock.close())); + } + + /// The box this frame should occupy in [area]. Called during layout only. + Rect _rectIn(Size area) { + _dock.area = area; + final target = _dock.rectIn(area); + final from = _from; + final rect = from != null && _move.isAnimating + ? Rect.lerp( + from(area), + target, + Curves.easeOutCubic.transform(_move.value), + )! + : target; + _dock.lastRect = rect; + return rect; + } + + @override + Widget build(BuildContext context) { + final key = _dock.dockedKey; + final args = _dock.dockedArgs; + return Stack( + fit: StackFit.passthrough, + children: [ + widget.child, + if (key != null && args != null) + // Empty space in this layer takes no hits, so the page underneath + // stays fully usable all around the player. + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _DockLayout(this), + child: _frame(key, args), + ), + ) + else + const SizedBox.shrink(), + ], + ); + } + + /// Gives the keyboard back to whatever had it before the player took it: + /// the page the viewer is using under the corner. + void _releaseKeyboard() { + if (_scope.hasFocus) { + _scope.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + } + } + + Widget _frame(GlobalKey key, PlayerArgs args) { + return TapRegion( + onTapOutside: (_) => _releaseKeyboard(), + child: FocusScope( + node: _scope, + // The page lets Esc through while it is in the corner. Here it means + // back to the page underneath; closing the player would surprise. + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + _scope.hasFocus) { + _releaseKeyboard(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: DecoratedBox( + key: PlayerDockHost.frameKey, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x99000000), + blurRadius: 24, + offset: Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + _DockMedia( + dock: _dock, + // Its own messenger, so a player toast does not land on the + // page underneath. Its own Navigator, so a Tooltip or + // Navigator.of(context) inside the page finds one: the + // app's Navigator is below this layer, not above it. + child: ScaffoldMessenger( + child: HeroControllerScope.none( + child: InheritedGoRouter( + // The router is below this layer too. With it here, + // anything in the page that navigates the app does so + // rather than throwing. + goRouter: _router, + child: Navigator( + key: ValueKey(key), + // The keyboard stays with the page the viewer + // moved on to; a click on the player takes it. + requestFocus: false, + onGenerateRoute: (_) => PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => + widget.pageBuilder(key, args), + ), + ), + ), + ), + ), + ), + _ResizeGrips(dock: _dock), + ], + ), + ), + ), + ), + ); + } +} + +class _DockLayout extends SingleChildLayoutDelegate { + _DockLayout(this.host) + : super(relayout: Listenable.merge([host._dock.geometry, host._move])); + + final _PlayerDockHostState host; + + @override + Size getSize(BoxConstraints constraints) => constraints.biggest; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + BoxConstraints.tight(host._rectIn(constraints.biggest).size); + + @override + Offset getPositionForChild(Size size, Size childSize) => + host._rectIn(size).topLeft; + + @override + bool shouldRelayout(_DockLayout oldDelegate) => oldDelegate.host != host; +} + +/// What the page sees of the world while it is in the corner: a screen the +/// size of the box, and text scaled down with it. Subtitles and the page's +/// cards are sized for a full window; at a quarter of the width they read +/// like a small player's. +/// +/// From the settled size rather than the frame-by-frame one, so a drag or the +/// docking glide rebuilds nothing inside the page. +class _DockMedia extends StatelessWidget { + const _DockMedia({required this.dock, required this.child}); + + final PlayerDock dock; + final Widget child; + + /// The window width the player's text is designed for. + static const double _designWidth = 960; + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return ListenableBuilder( + listenable: dock.geometry, + child: child, + builder: (context, child) { + final area = dock.area.isEmpty ? mq.size : dock.area; + final size = DockGeometry.sizeFor(dock.width, dock.aspect, area); + final scale = (size.width / _designWidth).clamp(0.45, 1.0); + return MediaQuery( + data: mq.copyWith( + size: size, + padding: EdgeInsets.zero, + viewPadding: EdgeInsets.zero, + viewInsets: EdgeInsets.zero, + textScaler: TextScaler.linear(mq.textScaler.scale(1) * scale), + ), + child: child!, + ); + }, + ); + } +} + +/// Resize handles on the two edges that face the middle of the window. The +/// box keeps the picture's shape, so either edge changes the width. +class _ResizeGrips extends StatelessWidget { + const _ResizeGrips({required this.dock}); + + final PlayerDock dock; + + /// Thin enough to stay clear of the corner buttons the page draws 6 px in. + static const double _thickness = 5; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: dock.geometry, + builder: (context, _) { + if (dock.dragging) return const SizedBox.shrink(); + final c = dock.corner; + final onRight = c == DockCorner.topRight || c == DockCorner.bottomRight; + final onBottom = + c == DockCorner.bottomLeft || c == DockCorner.bottomRight; + Widget grip(MouseCursor cursor, Offset Function(Offset) axis) => + MouseRegion( + cursor: cursor, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (d) => dock.resizeBy(axis(d.delta)), + onPanEnd: (_) => dock.resizeEnd(), + ), + ); + return Stack( + children: [ + Positioned( + top: 0, + bottom: 0, + width: _thickness, + left: onRight ? 0 : null, + right: onRight ? null : 0, + child: grip( + SystemMouseCursors.resizeLeftRight, + (d) => Offset(d.dx, 0), + ), + ), + Positioned( + left: 0, + right: 0, + height: _thickness, + top: onBottom ? 0 : null, + bottom: onBottom ? null : 0, + child: grip( + SystemMouseCursors.resizeUpDown, + (d) => Offset(0, d.dy), + ), + ), + ], + ); + }, + ); + } +} diff --git a/test/core/player/player_dock_geometry_test.dart b/test/core/player/player_dock_geometry_test.dart new file mode 100644 index 00000000..b00b553f --- /dev/null +++ b/test/core/player/player_dock_geometry_test.dart @@ -0,0 +1,176 @@ +// Where the desktop corner player sits is plain arithmetic, so it is pinned +// here without a window: the four corners at the margin, the size clamped to +// what leaves the page under it usable, the snap on release, and the resize +// grips growing the box toward the middle of the window. +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/player_dock.dart'; + +void main() { + const area = Size(1200, 800); + const m = DockGeometry.margin; + + group('rectFor', () { + const size = Size(400, 225); + + test('puts each corner at the margin', () { + expect( + DockGeometry.rectFor(DockCorner.topLeft, size, area), + const Rect.fromLTWH(m, m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.topRight, size, area), + const Rect.fromLTWH(1200 - 400 - m, m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.bottomLeft, size, area), + const Rect.fromLTWH(m, 800 - 225 - m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.bottomRight, size, area), + const Rect.fromLTWH(1200 - 400 - m, 800 - 225 - m, 400, 225), + ); + }); + }); + + group('sizeFor', () { + test('keeps the picture shape', () { + expect(DockGeometry.sizeFor(400, 16 / 9, area), const Size(400, 225)); + }); + + test('never smaller than the minimum', () { + expect(DockGeometry.sizeFor(10, 16 / 9, area).width, 240); + }); + + test('never more than the share of the window', () { + expect(DockGeometry.sizeFor(5000, 16 / 9, area).width, 1200 * 0.6); + }); + + test('a short window caps it by height instead', () { + const short = Size(1600, 300); + final s = DockGeometry.sizeFor(5000, 16 / 9, short); + expect(s.height, lessThanOrEqualTo(300 - 2 * m + 1)); + }); + + test('a window narrower than the minimum still gets the minimum', () { + expect( + DockGeometry.sizeFor(400, 16 / 9, const Size(200, 150)).width, + 240, + ); + }); + }); + + group('clampAspect', () { + test('maps nonsense to 16:9 and extremes into range', () { + expect(DockGeometry.clampAspect(0), 16 / 9); + expect(DockGeometry.clampAspect(double.nan), 16 / 9); + expect(DockGeometry.clampAspect(-2), 16 / 9); + expect(DockGeometry.clampAspect(10), 2.6); + expect(DockGeometry.clampAspect(0.1), 0.5); + expect(DockGeometry.clampAspect(4 / 3), 4 / 3); + }); + }); + + group('nearest', () { + Rect at(Offset centre) => + Rect.fromCenter(center: centre, width: 300, height: 170); + + test('picks the quarter the centre is in', () { + expect( + DockGeometry.nearest(at(const Offset(200, 150)), Offset.zero, area), + DockCorner.topLeft, + ); + expect( + DockGeometry.nearest(at(const Offset(1000, 150)), Offset.zero, area), + DockCorner.topRight, + ); + expect( + DockGeometry.nearest(at(const Offset(200, 700)), Offset.zero, area), + DockCorner.bottomLeft, + ); + expect( + DockGeometry.nearest(at(const Offset(1000, 700)), Offset.zero, area), + DockCorner.bottomRight, + ); + }); + + test('a flick from the middle lands where it was thrown', () { + // Released just right of and below the middle, but flung up-left. + final r = at(const Offset(620, 420)); + expect( + DockGeometry.nearest(r, const Offset(-1500, -1500), area), + DockCorner.topLeft, + ); + expect( + DockGeometry.nearest(r, Offset.zero, area), + DockCorner.bottomRight, + ); + }); + }); + + group('resized', () { + test('grows toward the middle of the window from every corner', () { + // Left corners: the grip is on the right, so dragging right grows. + expect( + DockGeometry.resized(400, const Offset(50, 0), DockCorner.topLeft, 1), + 450, + ); + expect( + DockGeometry.resized( + 400, + const Offset(50, 0), + DockCorner.bottomLeft, + 1, + ), + 450, + ); + // Right corners: the grip is on the left, so dragging left grows. + expect( + DockGeometry.resized(400, const Offset(-50, 0), DockCorner.topRight, 1), + 450, + ); + expect( + DockGeometry.resized( + 400, + const Offset(-50, 0), + DockCorner.bottomRight, + 1, + ), + 450, + ); + }); + + test('a vertical pull changes the width through the aspect', () { + // Bottom corners: the grip is on top, so dragging up grows. + expect( + DockGeometry.resized( + 400, + const Offset(0, -10), + DockCorner.bottomRight, + 2, + ), + 420, + ); + expect( + DockGeometry.resized(400, const Offset(0, 10), DockCorner.topLeft, 2), + 420, + ); + }); + }); + + test('dragged keeps the box inside the window', () { + const r = Rect.fromLTWH(100, 100, 300, 170); + expect( + DockGeometry.dragged(r, const Offset(-500, -500), area), + const Rect.fromLTWH(0, 0, 300, 170), + ); + expect( + DockGeometry.dragged(r, const Offset(5000, 5000), area), + const Rect.fromLTWH(900, 630, 300, 170), + ); + expect( + DockGeometry.dragged(r, const Offset(10, 20), area), + const Rect.fromLTWH(110, 120, 300, 170), + ); + }); +} diff --git a/test/features/detail/player_dock_host_test.dart b/test/features/detail/player_dock_host_test.dart new file mode 100644 index 00000000..0e5df3ec --- /dev/null +++ b/test/features/detail/player_dock_host_test.dart @@ -0,0 +1,443 @@ +// The desktop corner player moves the live player page — its State, timers, +// controller and all — out of /player and into a layer above the Navigator, +// and back, under one GlobalKey. Nothing is copied or restarted, so the one +// thing that must never happen is the State being disposed and created again +// on the way. These tests use a probe in place of the real PlayerPage (which +// needs the service locator, Hive and a video engine) and count exactly that. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_dock_host.dart'; + +final List _events = []; + +/// Stands in for PlayerPage: records its lifecycle, holds a piece of state +/// that must survive, and docks the way the page does. +class _Probe extends StatefulWidget { + const _Probe({super.key, required this.args}); + + final PlayerArgs args; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + int ticks = 0; + final FocusNode keys = FocusNode(debugLabel: 'probeKeys'); + + @override + void initState() { + super.initState(); + _events.add('init:${widget.args.title}'); + } + + @override + void dispose() { + _events.add('dispose:${widget.args.title}'); + keys.dispose(); + super.dispose(); + } + + /// What PlayerPage._dockToCorner does, minus the player. + void dock() { + PlayerDock.instance.dock( + key: widget.key! as GlobalKey, + args: widget.args, + videoSize: const Size(1920, 1080), + ); + context.pop(); + } + + @override + Widget build(BuildContext context) { + final dock = PlayerDock.instance; + // As PlayerPage: a key handler that lets Esc through while docked, and a + // click on the player that takes the keyboard. + return Focus( + focusNode: keys, + onKeyEvent: (_, e) => KeyEventResult.ignored, + child: Listener( + onPointerDown: (_) => keys.requestFocus(), + child: _body(dock), + ), + ); + } + + Widget _body(PlayerDock dock) { + return ColoredBox( + color: Colors.black, + // The real page's corner overlay drives the drag the same way. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => dock.dragStart(), + onPanUpdate: (d) => dock.dragBy(d.delta), + onPanEnd: (d) => dock.dragEnd(d.velocity.pixelsPerSecond), + child: Center( + child: Text( + 'probe ${widget.args.title} $ticks', + style: const TextStyle(fontSize: 10), + ), + ), + ), + ); + } +} + +/// Something on the page under the player that holds the keyboard. +FocusNode? _homeField; + +class _Screen extends StatelessWidget { + const _Screen(this.name); + + final String name; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Focus( + focusNode: name == 'home' ? _homeField : null, + child: Text('screen $name'), + ), + ), + ); +} + +PlayerArgs _args(String title) => + PlayerArgs(title: title, provider: 'test', headers: const {}); + +Widget _probe(GlobalKey key, PlayerArgs args) => _Probe(key: key, args: args); + +GoRouter _router() => GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const _Screen('home')), + GoRoute(path: '/other', builder: (_, _) => const _Screen('other')), + GoRoute(path: '/login', builder: (_, _) => const _Screen('login')), + GoRoute( + path: '/player', + builder: (_, state) => + PlayerRoute(args: state.extra! as PlayerArgs, pageBuilder: _probe), + ), + ], +); + +const _area = Size(1200, 800); +const _m = DockGeometry.margin; + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = _area; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = _router(); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + builder: (context, child) => + PlayerDockHost(router: router, pageBuilder: _probe, child: child!), + ), + ); + await tester.pumpAndSettle(); + return router; +} + +/// The location on top of the stack. Not `uri`: go_router keeps that at the +/// base location for an imperative push. +String _path(GoRouter router) => + router.routerDelegate.currentConfiguration.last.matchedLocation; + +Finder get _frame => find.byKey(PlayerDockHost.frameKey); + +Finder get _inFrame => + find.descendant(of: _frame, matching: find.byType(_Probe)); + +_ProbeState _state(WidgetTester tester) => + tester.state<_ProbeState>(find.byType(_Probe)); + +/// Opens "A" in /player, gives it some state, and docks it. +Future _openAndDock(WidgetTester tester, GoRouter router) async { + unawaited(router.push('/player', extra: _args('A'))); + await tester.pumpAndSettle(); + final s = _state(tester); + // ignore: invalid_use_of_protected_member + s.setState(() => s.ticks = 3); + s.dock(); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + _events.clear(); + _homeField = null; + PlayerDock.instance.debugReset(); + }); + tearDown(PlayerDock.instance.debugReset); + + testWidgets('nothing in the corner until a page docks', (tester) async { + final router = await _pumpApp(tester); + expect(_frame, findsNothing); + unawaited(router.push('/player', extra: _args('A'))); + await tester.pumpAndSettle(); + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsOneWidget); + }); + + testWidgets( + 'docking moves the same State into the corner and leaves the route', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + expect(_path(router), '/'); + expect(find.text('screen home'), findsOneWidget); + expect(_inFrame, findsOneWidget); + expect(find.byType(_Probe), findsOneWidget); + // Created once, never disposed, and still holding what it held. + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + expect(PlayerDock.instance.isDocked, isTrue); + + // Settled in the default corner, at the picture's shape. + expect( + tester.getRect(_frame), + const Rect.fromLTWH( + 1200 - DockGeometry.defaultWidth - _m, + 800 - 225 - _m, + DockGeometry.defaultWidth, + 225, + ), + ); + + // The page finds a Navigator and the router where it now lives, so a + // Tooltip, a dialog or an app navigation from inside it does not throw. + final ctx = tester.element(find.byType(_Probe)); + expect(Navigator.maybeOf(ctx), isNotNull); + expect(GoRouter.maybeOf(ctx), same(router)); + expect(ScaffoldMessenger.maybeOf(ctx), isNotNull); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('it keeps playing while the pages under it change', ( + tester, + ) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + router.go('/other'); + await tester.pumpAndSettle(); + expect(find.text('screen other'), findsOneWidget); + expect(_inFrame, findsOneWidget); + + unawaited(router.push('/')); + await tester.pumpAndSettle(); + router.pop(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(_inFrame, findsOneWidget); + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + }); + + testWidgets( + 'expanding hands the same State back to a /player route', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + router.go('/other'); + await tester.pumpAndSettle(); + + PlayerDock.instance.expand(); + await tester.pump(); + expect(tester.takeException(), isNull); + // Already in the new route in that same frame — offstage while the + // hero controller measures it, but mounted — and not recreated. + expect(_frame, findsNothing); + expect(find.byType(_Probe, skipOffstage: false), findsOneWidget); + expect(_events, ['init:A']); + await tester.pumpAndSettle(); + + expect(_path(router), '/player'); + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsOneWidget); + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + expect(PlayerDock.instance.isDocked, isFalse); + + // And back to the corner again from there. + _state(tester).dock(); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + expect(_path(router), '/other'); + expect(_inFrame, findsOneWidget); + expect(_events, ['init:A']); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('Esc in the corner gives the keyboard back to the page', ( + tester, + ) async { + _homeField = FocusNode(debugLabel: 'home'); + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + _homeField!.requestFocus(); + await tester.pump(); + expect(_homeField!.hasFocus, isTrue); + + // A click on the player takes the keyboard… + await tester.tapAt(tester.getRect(_frame).center); + await tester.pumpAndSettle(); + final keys = _state(tester).keys; + expect(keys.hasFocus, isTrue); + + // …and Esc hands it back, without closing the player or popping the + // page underneath. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(keys.hasFocus, isFalse); + expect(_homeField!.hasFocus, isTrue); + expect(_inFrame, findsOneWidget); + expect(_path(router), '/'); + + // A click elsewhere does the same. + await tester.tapAt(tester.getRect(_frame).center); + await tester.pump(); + expect(keys.hasFocus, isTrue); + await tester.tapAt(const Offset(40, 40)); + await tester.pump(); + expect(keys.hasFocus, isFalse); + expect(_events, ['init:A']); + }); + + testWidgets('closing disposes the page', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(PlayerDock.instance.close()); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsNothing); + expect(_events, ['init:A', 'dispose:A']); + expect(PlayerDock.instance.isDocked, isFalse); + }); + + testWidgets('a drag moves it and the release snaps to the nearest corner', ( + tester, + ) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + final start = tester.getRect(_frame); + + final gesture = await tester.startGesture(start.center); + await gesture.moveBy(const Offset(-30, 0)); + await gesture.moveBy(const Offset(-200, -100)); + await tester.pump(); + // It follows the pointer while held. + final held = tester.getRect(_frame); + expect(held.left, lessThan(start.left - 150)); + expect(held.top, lessThan(start.top - 50)); + + // Let go well into the top-left quarter. + await gesture.moveBy(const Offset(-500, -300)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(PlayerDock.instance.corner, DockCorner.topLeft); + expect( + tester.getRect(_frame), + const Rect.fromLTWH(_m, _m, DockGeometry.defaultWidth, 225), + ); + expect(_events, ['init:A']); + }); + + testWidgets('the inner edge resizes it within bounds', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + final start = tester.getRect(_frame); + + // Docked bottom-right, so the grip is the left edge. + await tester.dragFrom( + Offset(start.left + 2, start.center.dy), + const Offset(-100, 0), + ); + await tester.pumpAndSettle(); + final grown = tester.getRect(_frame); + expect(grown.width, closeTo(start.width + 100, 1)); + // Still anchored to its corner. + expect(grown.right, closeTo(1200 - _m, 0.5)); + expect(grown.bottom, closeTo(800 - _m, 0.5)); + + // Far past the limit stops at the share of the window. + await tester.dragFrom( + Offset(grown.left + 2, grown.center.dy), + const Offset(-2000, 0), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(_frame).width, 1200 * DockGeometry.maxShare); + expect(_events, ['init:A']); + }); + + testWidgets( + 'opening another title closes the docked one before the new one starts', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(router.push('/player', extra: _args('B'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + // The old page's dispose runs first: it tears down page-wide + // singletons the new one would otherwise have just set up. + expect(_events, ['init:A', 'dispose:A', 'init:B']); + expect(_frame, findsNothing); + expect(find.text('probe B 0'), findsOneWidget); + }, + ); + + testWidgets('signing out closes it', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + router.go('/login'); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(_events, ['init:A', 'dispose:A']); + }); + + testWidgets('a pushed sign-in page closes it too', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(router.push('/login')); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(_events, ['init:A', 'dispose:A']); + }); + + test('the routes that close it', () { + expect(playerDockClosesOn('/login'), isTrue); + expect(playerDockClosesOn('/splash'), isTrue); + expect(playerDockClosesOn('/profiles'), isTrue); + expect(playerDockClosesOn('/onboarding'), isTrue); + expect(playerDockClosesOn('/onboarding/genres'), isTrue); + expect(playerDockClosesOn('/main'), isFalse); + expect(playerDockClosesOn('/detail'), isFalse); + expect(playerDockClosesOn('/profiles/manage'), isFalse); + }); +} diff --git a/test/features/detail/player_dock_source_test.dart b/test/features/detail/player_dock_source_test.dart new file mode 100644 index 00000000..012eafab --- /dev/null +++ b/test/features/detail/player_dock_source_test.dart @@ -0,0 +1,92 @@ +// PlayerPage needs the service locator, Hive and a video engine, so it is not +// pumped here. These pin the few lines of it that the desktop corner player +// and the always-on-top mini window depend on, in the same source-scan style +// as player_controls_visibility_test.dart. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const dir = 'lib/features/detail/presentation/pages'; + String read(String name) => File('$dir/$name').readAsStringSync(); + + /// The body of the first `case