From 7174620b1a7f91ad74552efb75c3000dc35a20df Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:22:30 +0500 Subject: [PATCH 01/21] Player: hold the controls' glass to its tint while they fade On an iPhone the controls stand on refracting glass. A FadeTransition over it composites the glass and the picture under it on every frame of the 220 ms fade, each time the controls show or hide. GlassHoldWhile puts the glass down to its tint for the fade, and the group cross-fades the refraction back once it has settled. Android, TV and desktop draw no glass over video and are unchanged. --- .../pages/player_page.controls.dart | 316 +++++++++--------- .../presentation/pages/player_page.dart | 1 + 2 files changed, 163 insertions(+), 154 deletions(-) diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index fb27d12c..c1ca0ef1 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -1108,183 +1108,191 @@ extension _PlayerControls on _PlayerPageState { final canDownload = a.canDownload; final isBuffering = c != null && c.value.isBuffering; + // While the controls fade, their glass is held to its tint: an opacity + // over refracting glass composites the glass and the picture under it on + // every frame of the fade. The group gives the refraction back once the + // fade has settled. final overlay = FadeTransition( opacity: _controlsAnimation, - child: IgnorePointer( - ignoring: !_controlsVisible, - child: Stack( - children: [ - const Positioned.fill(child: _ControlsScrim()), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - // One read of the picture for every button along the top, - // not one each (MediaGlass). - child: MediaGlassLayer( - child: Row( - children: [ - _IconButton( - icon: Icons.arrow_back_ios_new_rounded, - onTap: _exit, - ), - const SizedBox(width: 3), - Expanded( - flex: 2, - // Text takes the hit but has no gesture, so a tap on - // the title did nothing while the same tap anywhere - // else on the dimmed video hides the controls. - child: IgnorePointer( - child: Text( - _episodeTitle(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w700, - shadows: _kControlShadow, + child: GlassHoldWhile( + source: _controlsAnimation, + moving: () => _controlsAnimation.isAnimating, + child: IgnorePointer( + ignoring: !_controlsVisible, + child: Stack( + children: [ + const Positioned.fill(child: _ControlsScrim()), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + // One read of the picture for every button along the top, + // not one each (MediaGlass). + child: MediaGlassLayer( + child: Row( + children: [ + _IconButton( + icon: Icons.arrow_back_ios_new_rounded, + onTap: _exit, + ), + const SizedBox(width: 3), + Expanded( + flex: 2, + // Text takes the hit but has no gesture, so a tap on + // the title did nothing while the same tap anywhere + // else on the dimmed video hides the controls. + child: IgnorePointer( + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, + ), ), ), ), - ), - // The action set outgrows a portrait phone. It used to - // take half the bar and scroll the surplus off the left, - // where nobody found it — orientation and Watch Party were - // simply invisible. Now the actions get the larger share - // and scale a few percent if they still do not fit, so - // every one of them is on screen. - Expanded( - flex: 5, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // TV and desktop only. A phone draws its bar - // from the viewer's arrangement below, which - // has the language pill in it — adding this one - // as well put two SUB pills side by side. - if (hasLangSwitcher && - (isTvPlatform || isDesktopPlatform)) ...[ - _LangPill( - label: (_currentLang ?? _kSubLang) - .toUpperCase(), - onTap: _openLangSheet, - ), - const SizedBox(width: 5), - ], - // CloudStream (cs:) sources are excluded from Watch2Gether - // for now (they need an on-device plugin per peer). - // - if ((isTvPlatform || isDesktopPlatform) && - (_inParty || - !widget.args.provider.startsWith( - 'cs:', - ))) ...[ - _IconButton( - icon: _inParty - ? Icons.groups_rounded - : Icons.groups_2_rounded, - color: _inParty ? AppColors.primary : null, - onTap: _openWatchParty, - ), - const SizedBox(width: 2), - ], - // TV drops three phone-only affordances outright rather - // than adapting them: orientation lock is meaningless on - // a fixed landscape panel, PiP semantics differ on - // leanback, and the touch lock swaps in an overlay whose - // only escape is a non-focusable tap target — on a remote - // that is an unrecoverable state. - if (isTvPlatform) ...[ - _IconButton( - icon: Icons.closed_caption_rounded, - onTap: _openSubtitleSheet, - ), - const SizedBox(width: 2), - if (canDownload) ...[ - _IconButton( - icon: Icons.download_rounded, - onTap: _startDownload, + // The action set outgrows a portrait phone. It used to + // take half the bar and scroll the surplus off the left, + // where nobody found it — orientation and Watch Party were + // simply invisible. Now the actions get the larger share + // and scale a few percent if they still do not fit, so + // every one of them is on screen. + Expanded( + flex: 5, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // TV and desktop only. A phone draws its bar + // from the viewer's arrangement below, which + // has the language pill in it — adding this one + // as well put two SUB pills side by side. + if (hasLangSwitcher && + (isTvPlatform || isDesktopPlatform)) ...[ + _LangPill( + label: (_currentLang ?? _kSubLang) + .toUpperCase(), + onTap: _openLangSheet, ), - const SizedBox(width: 2), + const SizedBox(width: 5), ], - _IconButton( - icon: Icons.settings_rounded, - onTap: _openSettingsSheet, - ), - if (hasServers) ...[ - const SizedBox(width: 2), + // CloudStream (cs:) sources are excluded from Watch2Gether + // for now (they need an on-device plugin per peer). + // + if ((isTvPlatform || isDesktopPlatform) && + (_inParty || + !widget.args.provider.startsWith( + 'cs:', + ))) ...[ _IconButton( - icon: Icons.dns_rounded, - onTap: _openServerSheet, + icon: _inParty + ? Icons.groups_rounded + : Icons.groups_2_rounded, + color: _inParty ? AppColors.primary : null, + onTap: _openWatchParty, ), - ], - if (hasQualities) ...[ const SizedBox(width: 2), + ], + // TV drops three phone-only affordances outright rather + // than adapting them: orientation lock is meaningless on + // a fixed landscape panel, PiP semantics differ on + // leanback, and the touch lock swaps in an overlay whose + // only escape is a non-focusable tap target — on a remote + // that is an unrecoverable state. + if (isTvPlatform) ...[ _IconButton( - icon: Icons.high_quality_rounded, - onTap: () => - _openPanel(_SidePanel.quality), + icon: Icons.closed_caption_rounded, + onTap: _openSubtitleSheet, ), - ], - if (hasEpisodes) ...[ const SizedBox(width: 2), + if (canDownload) ...[ + _IconButton( + icon: Icons.download_rounded, + onTap: _startDownload, + ), + const SizedBox(width: 2), + ], _IconButton( - icon: Icons.video_library_rounded, - onTap: () => - _openPanel(_SidePanel.episodes), + icon: Icons.settings_rounded, + onTap: _openSettingsSheet, + ), + if (hasServers) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.dns_rounded, + onTap: _openServerSheet, + ), + ], + if (hasQualities) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.high_quality_rounded, + onTap: () => + _openPanel(_SidePanel.quality), + ), + ], + if (hasEpisodes) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.video_library_rounded, + onTap: () => + _openPanel(_SidePanel.episodes), + ), + ], + ] else if (!isDesktopPlatform) ...[ + // Phone: the viewer's arrangement, in their + // order. Every control stays ON a bar — a + // previous pass filed six of them behind a ⋯ + // sheet to buy room and had to undo it, because + // a control one tap away is a control people + // use and one two taps away is a control they + // stop reaching for. What changed is only that + // WHICH controls sit here is now a preference + // instead of an argument about a Row. + // + // The capacity that PlayerControlsLayout + // enforces is this bar's: the group is wrapped + // in a FittedBox, so an extra button does not + // wrap or scroll, it shrinks all of them. + ..._controlsFor( + PlayerControlSlot.topBar, + topBar: true, ), ], - ] else if (!isDesktopPlatform) ...[ - // Phone: the viewer's arrangement, in their - // order. Every control stays ON a bar — a - // previous pass filed six of them behind a ⋯ - // sheet to buy room and had to undo it, because - // a control one tap away is a control people - // use and one two taps away is a control they - // stop reaching for. What changed is only that - // WHICH controls sit here is now a preference - // instead of an argument about a Row. - // - // The capacity that PlayerControlsLayout - // enforces is this bar's: the group is wrapped - // in a FittedBox, so an extra button does not - // wrap or scroll, it shrinks all of them. - ..._controlsFor( - PlayerControlSlot.topBar, - topBar: true, - ), ], - ], + ), ), ), - ), - if (isDesktopPlatform) - ValueListenableBuilder( - valueListenable: DesktopWindow.immersive, - builder: (_, imm, _) => imm - ? const WindowButtons() - : const SizedBox.shrink(), - ), - ], + if (isDesktopPlatform) + ValueListenableBuilder( + valueListenable: DesktopWindow.immersive, + builder: (_, imm, _) => imm + ? const WindowButtons() + : const SizedBox.shrink(), + ), + ], + ), ), ), ), ), - ), - if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), - if (initialized) - _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), - ], + if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), + if (initialized) + _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), + ], + ), ), ), ); diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 1cb3e79f..3a046445 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -73,6 +73,7 @@ import 'package:soplay/core/system/desktop_window.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/glass/glass_hold.dart'; import 'package:soplay/core/glass/media_glass.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; import 'package:soplay/features/mal/data/mal_tracker.dart'; From f788c942e3af4cf99137ea05fd452b1fe290317d Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:43:58 +0500 Subject: [PATCH 02/21] Player on iOS 26+: the phone's own clear Liquid Glass under the controls The player's controls stood on a Flutter shader that imitates iOS 26's clear glass. On an iPhone whose OS has UIGlassEffect with the clear style, each control now stands on the real material, hosted through the existing NativeGlass bridge. It refracts the video and the page alike, and follows Reduce Transparency and Increase Contrast as Apple maintains them. A layer that goes native draws no shader group, because hosted glass inside a refracting group would ruin it. Older iPhones keep the shader, not the frosted system blur. Android, TV and desktop are unchanged. Podfile.lock now includes the ML Kit pods added with page translation. --- ios/Podfile.lock | 62 +++++++++- lib/core/glass/media_glass.dart | 122 ++++++++++++++++--- test/core/glass/media_glass_native_test.dart | 100 +++++++++++++++ 3 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 test/core/glass/media_glass_native_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 40597af4..4f6cd384 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -187,6 +187,13 @@ PODS: - gal (1.0.0): - Flutter - FlutterMacOS + - google_mlkit_commons (0.12.0): + - Flutter + - MLKitVision (~> 10.0.0) + - google_mlkit_text_recognition (0.16.0): + - Flutter + - google_mlkit_commons + - GoogleMLKit/TextRecognition (~> 9.0.0) - google_sign_in_ios (0.0.1): - Flutter - FlutterMacOS @@ -221,11 +228,21 @@ PODS: - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) + - GoogleMLKit/MLKitCore (9.0.0): + - MLKitCommon (~> 14.0.0) + - GoogleMLKit/TextRecognition (9.0.0): + - GoogleMLKit/MLKitCore + - MLKitTextRecognition (~> 7.0.0) - GoogleSignIn (9.2.0): - AppAuth (~> 2.1) - AppCheckCore (~> 11.0) - GTMAppAuth (~> 5.0) - GTMSessionFetcher/Core (~> 3.3) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger @@ -270,6 +287,27 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - Flutter + - MLImage (1.0.0-beta8) + - MLKitCommon (14.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitTextRecognition (7.0.0): + - MLKitCommon (~> 14.0) + - MLKitTextRecognitionCommon (= 6.0.0) + - MLKitVision (~> 10.0) + - MLKitTextRecognitionCommon (6.0.0): + - MLKitCommon (~> 14.0) + - MLKitVision (~> 10.0) + - MLKitVision (10.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta8) + - MLKitCommon (~> 14.0) - mobile_scanner (7.0.0): - Flutter - FlutterMacOS @@ -335,6 +373,8 @@ DEPENDENCIES: - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - gal (from `.symlinks/plugins/gal/darwin`) + - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) @@ -381,10 +421,17 @@ SPEC REPOS: - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport + - GoogleMLKit - GoogleSignIn + - GoogleToolboxForMac - GoogleUtilities - GTMAppAuth - GTMSessionFetcher + - MLImage + - MLKitCommon + - MLKitTextRecognition + - MLKitTextRecognitionCommon + - MLKitVision - nanopb - OrderedSet - PromisesObjC @@ -428,6 +475,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_tts/ios" gal: :path: ".symlinks/plugins/gal/darwin" + google_mlkit_commons: + :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_text_recognition: + :path: ".symlinks/plugins/google_mlkit_text_recognition/ios" google_sign_in_ios: :path: ".symlinks/plugins/google_sign_in_ios/darwin" image_picker_ios: @@ -505,11 +556,15 @@ SPEC CHECKSUMS: flutter_timezone: ee50ce7786b5fde27e2fe5375bbc8c9661ffc13f flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113 gal: baecd024ebfd13c441269ca7404792a7152fde89 + google_mlkit_commons: b4dfe2f6ae4cbf24010dc6640a28984d01343c86 + google_mlkit_text_recognition: 9b164471713e3afdb4c129cabe6d1d64fef7e817 google_sign_in_ios: 000870aa06da9b28d1d0bf7ef70ff0213059dd28 GoogleAdsOnDeviceConversion: 2be6297a4f048459e0ae17fad9bfd2844e10cf64 GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 GoogleSignIn: e449a40a92e9f2eea56e98b5214d13725dc5a00a + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 @@ -517,6 +572,11 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb media_kit_video: f3b0d035d89def15cfbbcf7dc2ae278f201e2f83 + MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 + MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 + MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826 + MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2 + MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 @@ -538,6 +598,6 @@ SPEC CHECKSUMS: wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 workmanager_apple: 1fc2a64e6a63ed3540fbb91b65f0c0d501881248 -PODFILE CHECKSUM: 4e7d5b8d11a26e6afec4f744ef685a01591f7aad +PODFILE CHECKSUM: 8a71acd50773f2518d70978ba2cb565c248ff197 COCOAPODS: 1.17.0 diff --git a/lib/core/glass/media_glass.dart b/lib/core/glass/media_glass.dart index 956db30a..d3baa7f7 100644 --- a/lib/core/glass/media_glass.dart +++ b/lib/core/glass/media_glass.dart @@ -2,7 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'app_glass.dart'; +import 'glass_sense.dart'; import 'glass_surface.dart'; +import 'native_glass.dart'; /// The plate a control stands on over video — the player's buttons, its /// pills — as an iPhone draws controls over a picture: clear glass. @@ -45,30 +47,88 @@ class MediaGlass extends StatelessWidget { /// Whether controls over video are drawn as glass here. static bool get drawsGlass => defaultTargetPlatform == TargetPlatform.iOS; + /// The tint laid in the phone's own clear glass: lighter than [glyphDim], + /// because the system glass adapts its own dimming to the picture under + /// it and keeps a white glyph legible on its own. + static const double nativeDim = 0.22; + @override Widget build(BuildContext context) { final fill = dim ?? _plate.withValues(alpha: glyphDim); - final plate = Positioned.fill( - child: _Plate(radius: radius, color: fill, glass: drawsGlass), - ); if (!drawsGlass) { - return Stack(children: [plate, child]); + return Stack( + children: [ + Positioned.fill( + child: _Plate(radius: radius, color: fill, glass: false), + ), + child, + ], + ); + } + final layer = _InLayer.maybeOf(context); + if (layer == null) { + // A piece standing alone is a layer of its own; built again inside it + // so it knows which glass that layer draws. + return MediaGlassLayer( + child: MediaGlass(radius: radius, dim: dim, child: child), + ); } - final piece = Stack( + if (layer.native) { + // The phone's own clear glass (UIGlassEffect, iOS 26+): the real + // material the shader below imitates, refracting the video and the + // page alike. One hosted view per piece, with no Flutter glass in it. + final tint = dim ?? _plate.withValues(alpha: nativeDim); + return Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, box) => NativeGlass( + style: NativeGlassStyle.clear, + borderRadius: BorderRadius.circular( + radius ?? box.biggest.shortestSide / 2, + ), + tint: tint, + fallback: (_) => + _Plate(radius: radius, color: fill, glass: false), + ), + ), + ), + child, + ], + ); + } + return Stack( children: [ // Laid before the shape: the renderer paints its layer's glass first // whatever the order, so the dimming lands over the bent picture and // under the glass's tint and its lit rim — the rim stays a light. - plate, + Positioned.fill( + child: _Plate(radius: radius, color: fill, glass: true), + ), Positioned.fill(child: GlassShape(radius: radius)), child, ], ); - if (_InLayer.of(context)) return piece; - return MediaGlassLayer(child: piece); } } +/// Whether a [MediaGlassLayer] draws the phone's own clear glass rather than +/// the shader: an iPhone whose OS has `UIGlassEffect` with its clear style, +/// and Reduce Transparency off. An older iPhone keeps the shader, not the +/// system blur material, which frosts the picture the controls stand on. +bool mediaGlassGoesNative({ + required TargetPlatform platform, + required NativeGlassCapability capability, + required bool reduceTransparency, +}) => + nativeGlassDraw( + platform: platform, + capability: capability, + style: NativeGlassStyle.clear, + reduceTransparency: reduceTransparency, + ) == + NativeGlassDraw.systemGlass; + /// One read of the picture for the [MediaGlass] pieces of one row: on an /// iPhone a group of the clear glass ([GlassGroup], [GlassKind.clear]); /// elsewhere nothing at all. @@ -76,31 +136,61 @@ class MediaGlass extends StatelessWidget { /// The whole row fades as one: the renderer draws a group's glass through /// the group, not through an opacity between the group and a piece, so a /// piece faded on its own inside a row would leave its glass behind. -class MediaGlassLayer extends StatelessWidget { +class MediaGlassLayer extends StatefulWidget { const MediaGlassLayer({super.key, required this.child}); final Widget child; + @override + State createState() => _MediaGlassLayerState(); +} + +class _MediaGlassLayerState extends State { + NativeGlassCapability? _said; + + @override + void initState() { + super.initState(); + if (!MediaGlass.drawsGlass) return; + NativeGlass.capability().then((said) { + if (mounted) setState(() => _said = said); + }); + } + @override Widget build(BuildContext context) { - if (!MediaGlass.drawsGlass) return child; + if (!MediaGlass.drawsGlass) return widget.child; + final native = mediaGlassGoesNative( + platform: defaultTargetPlatform, + capability: + NativeGlassScope.of(context) ?? + _said ?? + NativeGlassCapability.absent, + reduceTransparency: GlassSense.reduceTransparencyOf(context), + ); + // Hosted glass never stands inside a refracting group: the shader would + // read a backdrop with the hosted view cut out of it (NativeGlass). + if (native) return _InLayer(native: true, child: widget.child); return GlassGroup( kind: GlassKind.clear, - child: _InLayer(child: child), + child: _InLayer(native: false, child: widget.child), ); } } /// Marks what stands in a [MediaGlassLayer], so a piece in it is a shape of -/// that layer rather than a layer — glass on glass — of its own. +/// that layer rather than a layer — glass on glass — of its own, and says +/// which glass the layer draws. class _InLayer extends InheritedWidget { - const _InLayer({required super.child}); + const _InLayer({required this.native, required super.child}); + + final bool native; - static bool of(BuildContext context) => - context.getInheritedWidgetOfExactType<_InLayer>() != null; + static _InLayer? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_InLayer>(); @override - bool updateShouldNotify(_InLayer oldWidget) => false; + bool updateShouldNotify(_InLayer oldWidget) => oldWidget.native != native; } /// The dimming: in the glass's own shape on an iPhone, and the plain circle diff --git a/test/core/glass/media_glass_native_test.dart b/test/core/glass/media_glass_native_test.dart new file mode 100644 index 00000000..23586184 --- /dev/null +++ b/test/core/glass/media_glass_native_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +NativeGlassCapability _phone({ + required bool systemGlass, + Set styles = const {'regular', 'clear'}, +}) => NativeGlassCapability( + systemVersion: systemGlass ? '26.0' : '18.5', + majorVersion: systemGlass ? 26 : 18, + systemGlass: systemGlass, + styles: systemGlass ? styles : const {}, + materials: const {'ultraThin', 'thin', 'regular', 'thick', 'chrome'}, + reduceTransparency: false, +); + +void main() { + test('an iPhone with the clear glass effect draws the system glass', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true), + reduceTransparency: false, + ), + isTrue, + ); + }); + + test('an older iPhone keeps the shader, not the blur material', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: false), + reduceTransparency: false, + ), + isFalse, + ); + }); + + test('Reduce Transparency and other platforms never host glass', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true), + reduceTransparency: true, + ), + isFalse, + ); + expect( + mediaGlassGoesNative( + platform: TargetPlatform.android, + capability: _phone(systemGlass: true), + reduceTransparency: false, + ), + isFalse, + ); + }); + + test('a glass effect without the clear style keeps the shader', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true, styles: const {'regular'}), + reduceTransparency: false, + ), + isFalse, + ); + }); + + testWidgets('on an iPhone with system glass each piece is the hosted glass', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _phone(systemGlass: true), + child: const Center( + child: MediaGlassLayer( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MediaGlass(child: SizedBox(width: 38, height: 38)), + MediaGlass(radius: 10, child: SizedBox(width: 80, height: 44)), + ], + ), + ), + ), + ), + ), + ); + expect(find.byType(NativeGlass), findsNWidgets(2)); + expect(find.byType(GlassGroup), findsNothing); + expect(find.byType(GlassShape), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); +} From c0392765b36c4700e4bc0eab3e352b7afa7e395e Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:43:44 +0500 Subject: [PATCH 03/21] Player on iOS 26+: the controls are the phone's own Liquid Glass buttons One hosted UIKit view per group of controls (soplay/native_player_bar): back, the top bar's actions, the cluster round play, the bottom row and the social rail, each real UIButtons in a UIGlassContainerEffect, as clear glass capsules or glass circles with play on prominent glass. Dart lays every slot out (RTL, label widths, text scale) and UIKit places them; hits land only on a slot, so the gaps stay the page's. The bars fade in UIKit and leave the frame once faded; only what Flutter draws keeps the Flutter fade. Speed, fit and sleep open a native menu on a hold, with VoiceOver's long press still reaching the Flutter sheet. Gated on a new glassControls capability, Reduce Transparency off, and an iPhone; iOS below 26, Android, TV, desktop and the small players keep the Flutter controls unchanged. --- integration_test/native_glass_test.dart | 52 + ios/Runner.xcodeproj/project.pbxproj | 8 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/GlassSense.swift | 2 + ios/Runner/NativeGlass.swift | 4 + ios/Runner/NativeMenuRows.swift | 35 + ios/Runner/NativePlayerBar.swift | 462 +++++++ lib/core/glass/native_glass.dart | 9 + lib/core/native/native_bar_layout.dart | 204 ++++ lib/core/native/native_controls_gate.dart | 90 ++ lib/core/native/native_glass_bar.dart | 491 ++++++++ lib/core/native/native_link.dart | 240 ++++ lib/core/native/sf_symbols.dart | 54 + .../pages/player_page.controls.dart | 1079 +++++++++++------ .../presentation/pages/player_page.dart | 113 +- .../pages/player_page.panels.dart | 5 +- .../pages/player_page.reactions.dart | 69 +- .../pages/player_page.widgets.dart | 52 + .../widgets/player_control_face.dart | 88 ++ lib/main.dart | 4 + test/core/native/bar_harness.dart | 64 + test/core/native/fake_native_host.dart | 100 ++ test/core/native/native_bar_layout_test.dart | 115 ++ .../native/native_controls_gate_test.dart | 60 + test/core/native/native_glass_bar_test.dart | 280 +++++ .../native/native_glass_capability_test.dart | 29 + test/core/native/native_link_test.dart | 91 ++ test/core/native/native_menu_select_test.dart | 81 ++ test/core/native/sf_symbols_test.dart | 38 + .../detail/player_native_controls_test.dart | 116 ++ 30 files changed, 3635 insertions(+), 401 deletions(-) create mode 100644 ios/Runner/NativeMenuRows.swift create mode 100644 ios/Runner/NativePlayerBar.swift create mode 100644 lib/core/native/native_bar_layout.dart create mode 100644 lib/core/native/native_controls_gate.dart create mode 100644 lib/core/native/native_glass_bar.dart create mode 100644 lib/core/native/native_link.dart create mode 100644 lib/core/native/sf_symbols.dart create mode 100644 lib/features/detail/presentation/widgets/player_control_face.dart create mode 100644 test/core/native/bar_harness.dart create mode 100644 test/core/native/fake_native_host.dart create mode 100644 test/core/native/native_bar_layout_test.dart create mode 100644 test/core/native/native_controls_gate_test.dart create mode 100644 test/core/native/native_glass_bar_test.dart create mode 100644 test/core/native/native_glass_capability_test.dart create mode 100644 test/core/native/native_link_test.dart create mode 100644 test/core/native/native_menu_select_test.dart create mode 100644 test/core/native/sf_symbols_test.dart create mode 100644 test/features/detail/player_native_controls_test.dart diff --git a/integration_test/native_glass_test.dart b/integration_test/native_glass_test.dart index ecdb5a71..e5b90ba8 100644 --- a/integration_test/native_glass_test.dart +++ b/integration_test/native_glass_test.dart @@ -28,6 +28,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:soplay/core/glass/glass_sense.dart'; import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; void say(String line) => debugPrint('NATIVE_GLASS $line'); @@ -120,4 +121,55 @@ void main() { 'not registered, or the effect could not be made', ); }); + + testWidgets('an iOS 26 phone builds the player\'s glass bar', (tester) async { + final said = await NativeGlass.capability(); + say('glassControls=${said.glassControls}'); + final before = + await GlassSense.channel.invokeMethod('glassControlsBuilt') ?? 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GlassSense( + child: Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(key: Key('barFallback')), + items: const [ + NativeBarItem( + id: 'settings', + symbol: 'gearshape', + semanticLabel: 'Settings', + ), + NativeBarItem( + id: 'speed', + symbol: 'gauge.with.dots.needle.67percent', + semanticLabel: 'Speed', + title: '1.25x', + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final after = + await GlassSense.channel.invokeMethod('glassControlsBuilt') ?? 0; + say('glassControlsBuilt=$before->$after'); + + if (said.majorVersion >= 26) { + expect(said.glassControls, isTrue, reason: 'iOS 26 has the bar factory'); + expect( + after - before, + 1, + reason: + 'UIKit built no bar: soplay/native_player_bar is not ' + 'registered', + ); + } else { + expect(said.glassControls, isFalse); + expect(find.byKey(const Key('barFallback')), findsOneWidget); + } + }); } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 69ac5087..a01501a5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ A1B2C3D40000000000000005 /* NativePip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000006 /* NativePip.swift */; }; A1B2C3D40000000000000007 /* GlassSense.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000008 /* GlassSense.swift */; }; A1B2C3D40000000000000009 /* NativeGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000000000000A /* NativeGlass.swift */; }; + A1B2C3D4000000000000000B /* NativePlayerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000000000000C /* NativePlayerBar.swift */; }; + A1B2C3D4000000000000000D /* NativeMenuRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000000000000E /* NativeMenuRows.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -66,6 +68,8 @@ A1B2C3D40000000000000006 /* NativePip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePip.swift; sourceTree = ""; }; A1B2C3D40000000000000008 /* GlassSense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassSense.swift; sourceTree = ""; }; A1B2C3D4000000000000000A /* NativeGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlass.swift; sourceTree = ""; }; + A1B2C3D4000000000000000C /* NativePlayerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerBar.swift; sourceTree = ""; }; + A1B2C3D4000000000000000E /* NativeMenuRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMenuRows.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -155,6 +159,8 @@ A1B2C3D40000000000000006 /* NativePip.swift */, A1B2C3D40000000000000008 /* GlassSense.swift */, A1B2C3D4000000000000000A /* NativeGlass.swift */, + A1B2C3D4000000000000000C /* NativePlayerBar.swift */, + A1B2C3D4000000000000000E /* NativeMenuRows.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -420,6 +426,8 @@ A1B2C3D40000000000000005 /* NativePip.swift in Sources */, A1B2C3D40000000000000007 /* GlassSense.swift in Sources */, A1B2C3D40000000000000009 /* NativeGlass.swift in Sources */, + A1B2C3D4000000000000000B /* NativePlayerBar.swift in Sources */, + A1B2C3D4000000000000000D /* NativeMenuRows.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4d3328e7..34ec9960 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -73,6 +73,7 @@ import UIKit // platform view, so its factory has to be registered on the registrar // and not only on the method channel GlassSense owns. NativeGlass.register(with: registrar) + NativePlayerBar.register(with: registrar) NativePip.register(messenger: registrar.messenger()) let previewChannel = FlutterMethodChannel( diff --git a/ios/Runner/GlassSense.swift b/ios/Runner/GlassSense.swift index a696cba3..25dd42a9 100644 --- a/ios/Runner/GlassSense.swift +++ b/ios/Runner/GlassSense.swift @@ -41,6 +41,8 @@ enum GlassSense { result(NativeGlass.capability()) case "glassViewsBuilt": result(NativeGlassView.built) + case "glassControlsBuilt": + result(NativePlayerBar.built) default: result(FlutterMethodNotImplemented) } diff --git a/ios/Runner/NativeGlass.swift b/ios/Runner/NativeGlass.swift index 246a3f73..a2314c6b 100644 --- a/ios/Runner/NativeGlass.swift +++ b/ios/Runner/NativeGlass.swift @@ -50,10 +50,13 @@ enum NativeGlass { static func capability() -> [String: Any] { let os = ProcessInfo.processInfo.operatingSystemVersion var styles: [String] = [] + var controls = false if #available(iOS 26.0, *) { // Both styles ship together; there is no partial adoption to probe // for, so the availability check *is* the probe. styles = ["regular", "clear"] + // The player's own glass buttons (NativePlayerBar.swift). + controls = true } return [ "systemVersion": UIDevice.current.systemVersion, @@ -63,6 +66,7 @@ enum NativeGlass { // the one fact Dart cannot work out for itself. "systemGlass": !styles.isEmpty, "glassStyles": styles, + "glassControls": controls, // The blur materials the fallback may ask for. All of these are // iOS 13+, so on anything that can run this app the list is the same // — it is sent anyway so that a phone which ever stops offering one diff --git a/ios/Runner/NativeMenuRows.swift b/ios/Runner/NativeMenuRows.swift new file mode 100644 index 00000000..e44fe2b2 --- /dev/null +++ b/ios/Runner/NativeMenuRows.swift @@ -0,0 +1,35 @@ +import UIKit + +/// The rows Dart sends for a hosted button's menu, as UIKit's menu elements +/// (lib/core/native/native_glass_bar.dart, `NativeMenuRow`). +/// +/// A row is `{title, symbol?, checked?, breakBefore, destructive}`. Each run +/// of rows between two breaks is an inline group of its own, so UIKit draws +/// the separator between them. +enum NativeMenuRows { + /// [choose] is told the row's index, in the list as it was sent. + static func elements( + _ rows: [[String: Any]], choose: @escaping (Int) -> Void + ) -> [UIMenuElement] { + var groups: [[UIMenuElement]] = [[]] + for (i, row) in rows.enumerated() { + if row["breakBefore"] as? Bool == true, !(groups.last?.isEmpty ?? true) { + groups.append([]) + } + groups[groups.count - 1].append(element(row, index: i, choose: choose)) + } + if groups.count == 1 { return groups[0] } + return groups.map { UIMenu(options: .displayInline, children: $0) } + } + + private static func element( + _ row: [String: Any], index: Int, choose: @escaping (Int) -> Void + ) -> UIMenuElement { + let title = row["title"] as? String ?? "" + let image = (row["symbol"] as? String).flatMap { UIImage(systemName: $0) } + let action = UIAction(title: title, image: image) { _ in choose(index) } + if row["destructive"] as? Bool == true { action.attributes.insert(.destructive) } + if let checked = row["checked"] as? Bool { action.state = checked ? .on : .off } + return action + } +} diff --git a/ios/Runner/NativePlayerBar.swift b/ios/Runner/NativePlayerBar.swift new file mode 100644 index 00000000..ba0fa072 --- /dev/null +++ b/ios/Runner/NativePlayerBar.swift @@ -0,0 +1,462 @@ +import Flutter +import UIKit + +/// The player's controls as the phone's own glass buttons, hosted in Flutter +/// (lib/core/native/native_glass_bar.dart). +/// +/// One view holds a whole group of controls — the top bar's actions, the +/// cluster round play, the bottom row — rather than one view per button: +/// each hosted view is a composition seam, and fifteen of them over 60 fps +/// video was the cost the shader path never had. +/// +/// Dart lays the bar out and sends every slot's rect; this side only places +/// buttons there. Two looks: +/// - `capsule`: one clear glass capsule per group, plain buttons on it, as an +/// iOS 26 toolbar groups its items; +/// - `cluster`: each button its own glass circle (`.glass()`, or +/// `.prominentGlass()` for an item sent `prominent`), melting into its +/// neighbour when they are close. +/// +/// Channel `soplay/native_player_bar/`: +/// - Flutter → here: `setItems {items, look, dir, axis, capsules}` (buttons +/// rebuilt only when the ids change), `setItem {id, …changed}`, +/// `setShown {shown, ms}`. +/// - here → Flutter: `press id`, `longPress id`, `select {id, row}` once the +/// menu has gone, `menuOpen id`, `menuClose id`. +/// +/// Flutter owns semantics: none of these buttons is an accessibility element. +enum NativePlayerBar { + static let viewType = "soplay/native_player_bar" + + static func register(with registrar: FlutterPluginRegistrar) { + registrar.register( + NativePlayerBarFactory(messenger: registrar.messenger()), withId: viewType) + } + + /// How many bars UIKit has really built since launch, for + /// integration_test/native_glass_test.dart (`glassControlsBuilt`). + static var built = 0 +} + +final class NativePlayerBarFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + guard #available(iOS 26.0, *) else { return EmptyBarView(frame: frame) } + let args = args as? [String: Any] ?? [:] + return MainActor.assumeIsolated { + NativePlayerBarView(frame: frame, id: viewId, args: args, messenger: messenger) + } + } +} + +/// Never reached: Dart asks only where `glassControls` is true. +private final class EmptyBarView: NSObject, FlutterPlatformView { + private let box: UIView + + init(frame: CGRect) { + box = UIView(frame: frame) + box.backgroundColor = .clear + box.isUserInteractionEnabled = false + } + + func view() -> UIView { box } +} + +/// Answers a finger only over a button; the gaps are Flutter's page. +@available(iOS 26.0, *) +private final class BarRoot: UIView { + var buttons: [BarButton] = [] + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard alpha > 0.01 else { return false } + return buttons.contains { + !$0.isHidden && $0.point(inside: convert(point, to: $0), with: event) + } + } +} + +@available(iOS 26.0, *) +private final class BarButton: UIButton { + var itemId = "" + let hold = UILongPressGestureRecognizer() + var onOpen: (() -> Void)? + var onClose: (() -> Void)? + var onSelect: ((Int) -> Void)? + + private var menuUp = false + private var chosen: Int? + + private let ring = CAShapeLayer() + + var busy = false { + didSet { + guard busy != oldValue else { return } + busy ? spin() : stop() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + ring.fillColor = UIColor.clear.cgColor + ring.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + ring.lineWidth = 2.5 + ring.lineCap = .round + ring.strokeEnd = 0.28 + ring.isHidden = true + layer.addSublayer(ring) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func layoutSubviews() { + super.layoutSubviews() + let inset = ring.lineWidth / 2 + // Over the glass the configuration lays in after it. + layer.addSublayer(ring) + ring.frame = bounds + ring.path = UIBezierPath(ovalIn: bounds.insetBy(dx: inset, dy: inset)).cgPath + } + + private func spin() { + ring.isHidden = false + let turn = CABasicAnimation(keyPath: "transform.rotation.z") + turn.fromValue = 0 + turn.toValue = CGFloat.pi * 2 + turn.duration = 0.9 + turn.repeatCount = .infinity + ring.add(turn, forKey: "spin") + } + + private func stop() { + ring.removeAnimation(forKey: "spin") + ring.isHidden = true + } + + /// A row chosen while the menu is still leaving is said once it has gone: + /// a sheet presented from it meanwhile is dropped by UIKit. + func choose(_ row: Int) { + if menuUp { chosen = row } else { onSelect?(row) } + } + + override func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willDisplayMenuFor configuration: UIContextMenuConfiguration, + animator: (any UIContextMenuInteractionAnimating)? + ) { + super.contextMenuInteraction( + interaction, willDisplayMenuFor: configuration, animator: animator) + menuUp = true + onOpen?() + } + + override func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willEndFor configuration: UIContextMenuConfiguration, + animator: (any UIContextMenuInteractionAnimating)? + ) { + super.contextMenuInteraction( + interaction, willEndFor: configuration, animator: animator) + if let animator { + // The row's action may run just after the dismissal's completion. + animator.addCompletion { [weak self] in + DispatchQueue.main.async { self?.menuGone() } + } + } else { + DispatchQueue.main.async { [weak self] in self?.menuGone() } + } + } + + private func menuGone() { + guard menuUp else { return } + menuUp = false + if let row = chosen { + chosen = nil + onSelect?(row) + } + onClose?() + } +} + +@available(iOS 26.0, *) +final class NativePlayerBarView: NSObject, FlutterPlatformView { + private let root: BarRoot + private let container: UIVisualEffectView + private let channel: FlutterMethodChannel + + private var look = "capsule" + private var ids: [String] = [] + private var items: [String: [String: Any]] = [:] + private var buttons: [String: BarButton] = [:] + private var capsules: [UIVisualEffectView] = [] + + init(frame: CGRect, id: Int64, args: [String: Any], messenger: FlutterBinaryMessenger) { + root = BarRoot(frame: frame) + let effect = UIGlassContainerEffect() + effect.spacing = 8 + container = UIVisualEffectView(effect: effect) + channel = FlutterMethodChannel( + name: "\(NativePlayerBar.viewType)/\(id)", binaryMessenger: messenger) + super.init() + root.backgroundColor = .clear + // The player is dark whatever the phone is. + root.overrideUserInterfaceStyle = .dark + root.accessibilityElementsHidden = true + container.frame = root.bounds + container.autoresizingMask = [.flexibleWidth, .flexibleHeight] + root.addSubview(container) + setItems(args) + let shown = args["shown"] as? Bool ?? true + root.alpha = shown ? 1 : 0 + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + NativePlayerBar.built += 1 + } + + deinit { + channel.setMethodCallHandler(nil) + } + + func view() -> UIView { root } + + private func handle(_ call: FlutterMethodCall, result: FlutterResult) { + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "setItems": + setItems(args) + result(nil) + case "setItem": + if let id = args["id"] as? String, var item = items[id] { + item.merge(args) { _, new in new } + items[id] = item + if let button = buttons[id] { apply(item, to: button, animated: true) } + } + result(nil) + case "setShown": + let shown = args["shown"] as? Bool ?? true + let ms = (args["ms"] as? NSNumber)?.doubleValue ?? 0 + let target: CGFloat = shown ? 1 : 0 + if ms <= 0 { + root.alpha = target + } else { + UIView.animate( + withDuration: ms / 1000, delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut, .allowUserInteraction] + ) { self.root.alpha = target } + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func setItems(_ args: [String: Any]) { + let list = args["items"] as? [[String: Any]] ?? [] + let newLook = args["look"] as? String ?? "capsule" + root.semanticContentAttribute = + args["dir"] as? String == "rtl" ? .forceRightToLeft : .forceLeftToRight + let newIds = list.compactMap { $0["id"] as? String } + items = Dictionary( + list.compactMap { item in (item["id"] as? String).map { ($0, item) } }, + uniquingKeysWith: { _, last in last }) + let rebuild = newIds != ids || newLook != look + look = newLook + ids = newIds + if rebuild { build() } + placeCapsules(args["capsules"] as? [[NSNumber]] ?? []) + for id in ids { + if let button = buttons[id], let item = items[id] { + apply(item, to: button, animated: !rebuild) + } + } + } + + private func build() { + buttons.values.forEach { $0.removeFromSuperview() } + buttons = [:] + capsules.forEach { $0.removeFromSuperview() } + capsules = [] + var all: [BarButton] = [] + for id in ids { + let button = BarButton(type: .system) + button.itemId = id + button.isAccessibilityElement = false + button.preferredMenuElementOrder = .fixed + button.showsMenuAsPrimaryAction = false + button.addAction( + UIAction { [weak self] _ in self?.say("press", id) }, for: .primaryActionTriggered) + button.onOpen = { [weak self] in self?.say("menuOpen", id) } + button.onClose = { [weak self] in self?.say("menuClose", id) } + button.onSelect = { [weak self] row in + self?.channel.invokeMethod("select", arguments: ["id": id, "row": row]) + } + button.hold.addTarget(self, action: #selector(held(_:))) + button.addGestureRecognizer(button.hold) + buttons[id] = button + all.append(button) + } + root.buttons = all + } + + @objc private func held(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began, + let button = recognizer.view as? BarButton + else { return } + say("longPress", button.itemId) + } + + private func say(_ method: String, _ id: String) { + channel.invokeMethod(method, arguments: id) + } + + /// One clear glass capsule per group in the capsule look; none in the + /// cluster look, where every button is its own glass. + private func placeCapsules(_ rects: [[NSNumber]]) { + guard look == "capsule" else { + for id in ids { + if let button = buttons[id], button.superview !== container.contentView { + container.contentView.addSubview(button) + } + } + return + } + while capsules.count < rects.count { + let glass = UIGlassEffect(style: .clear) + glass.isInteractive = false + glass.tintColor = UIColor.black.withAlphaComponent(0.22) + let capsule = UIVisualEffectView(effect: glass) + capsule.cornerConfiguration = .capsule() + container.contentView.addSubview(capsule) + capsules.append(capsule) + } + while capsules.count > rects.count { + capsules.removeLast().removeFromSuperview() + } + for (i, rect) in rects.enumerated() { + let r = cgRect(rect) + capsules[i].bounds = CGRect(origin: .zero, size: r.size) + capsules[i].center = CGPoint(x: r.midX, y: r.midY) + } + for id in ids { + guard let button = buttons[id] else { continue } + let group = (items[id]?["group"] as? NSNumber)?.intValue ?? 0 + let host = capsules.indices.contains(group) + ? capsules[group].contentView : container.contentView + if button.superview !== host { host.addSubview(button) } + } + } + + private func apply(_ item: [String: Any], to button: BarButton, animated: Bool) { + let enabled = item["enabled"] as? Bool ?? true + let active = item["active"] as? Bool ?? false + let accent = (item["accent"] as? NSNumber).map { color($0.uint32Value) } ?? .white + let base: UIColor = active ? accent : .white + let tint = enabled ? base : base.withAlphaComponent(0.38) + let glyph = CGFloat((item["glyph"] as? NSNumber)?.doubleValue ?? 20) + let fontSize = CGFloat((item["fontSize"] as? NSNumber)?.doubleValue ?? 12) + let title = item["title"] as? String + let prominent = item["prominent"] as? Bool ?? false + + var config: UIButton.Configuration + if look == "cluster" { + config = prominent ? .prominentGlass() : .glass() + if prominent { config.baseBackgroundColor = accent } + } else { + config = .plain() + } + config.cornerStyle = .capsule + config.contentInsets = .zero + // The colour is baked into the glyph: a glass configuration recolours a + // template image with its own foreground, which lost the active accent. + if let name = item["symbol"] as? String, !name.isEmpty { + let symbol = UIImage.SymbolConfiguration(pointSize: glyph, weight: .semibold) + if let image = UIImage(systemName: name, withConfiguration: symbol) { + config.image = image.withTintColor(tint, renderingMode: .alwaysOriginal) + } else { + assertionFailure("No SF Symbol named \(name)") + } + config.imagePlacement = .leading + config.imagePadding = 4 + } + if let title, !title.isEmpty { + let font = UIFont.systemFont(ofSize: fontSize, weight: .semibold) + config.attributedTitle = AttributedString( + title, attributes: AttributeContainer([.font: font, .foregroundColor: tint])) + config.titleLineBreakMode = .byClipping + } + config.baseForegroundColor = tint + + let titleChanged = button.configuration?.attributedTitle.map { String($0.characters) } + != title.flatMap { $0.isEmpty ? nil : $0 } + let set = { button.configuration = config } + if animated && titleChanged { + UIView.transition(with: button, duration: 0.18, options: .transitionCrossDissolve) { set() } + } else { + set() + } + button.isEnabled = enabled + button.semanticContentAttribute = root.semanticContentAttribute + button.isHidden = item["hidden"] as? Bool ?? false + button.busy = item["busy"] as? Bool ?? false + + if let rows = item["menu"] as? [[String: Any]], !rows.isEmpty { + button.menu = UIMenu(children: NativeMenuRows.elements(rows) { [weak button] row in + button?.choose(row) + }) + } else { + button.menu = nil + } + // A menu opens on its own hold; only a button without one hears this. + button.hold.isEnabled = button.menu == nil && item["longPress"] as? Bool == true + + place(button, item) + } + + /// Bounds and centre, never frame: the glass's swell under a finger is a + /// transform on the button. + private func place(_ button: BarButton, _ item: [String: Any]) { + guard let raw = item["rect"] as? [NSNumber] else { return } + var r = cgRect(raw) + if look == "capsule" { + let group = (item["group"] as? NSNumber)?.intValue ?? 0 + if capsules.indices.contains(group) { + let c = capsules[group] + r = r.offsetBy( + dx: -(c.center.x - c.bounds.width / 2), dy: -(c.center.y - c.bounds.height / 2)) + } + button.bounds = CGRect(origin: .zero, size: r.size) + } else { + let size = CGFloat((item["size"] as? NSNumber)?.doubleValue ?? Double(min(r.width, r.height))) + button.bounds = CGRect(x: 0, y: 0, width: min(size, r.width), height: min(size, r.height)) + } + button.center = CGPoint(x: r.midX, y: r.midY) + } + + private func cgRect(_ raw: [NSNumber]) -> CGRect { + guard raw.count == 4 else { return .zero } + return CGRect( + x: raw[0].doubleValue, y: raw[1].doubleValue, + width: raw[2].doubleValue, height: raw[3].doubleValue) + } + + private func color(_ argb: UInt32) -> UIColor { + UIColor( + red: CGFloat((argb >> 16) & 0xFF) / 255, + green: CGFloat((argb >> 8) & 0xFF) / 255, + blue: CGFloat(argb & 0xFF) / 255, + alpha: CGFloat((argb >> 24) & 0xFF) / 255) + } +} diff --git a/lib/core/glass/native_glass.dart b/lib/core/glass/native_glass.dart index aa909a3c..4a461456 100644 --- a/lib/core/glass/native_glass.dart +++ b/lib/core/glass/native_glass.dart @@ -294,6 +294,7 @@ final class NativeGlassCapability { required this.styles, required this.materials, required this.reduceTransparency, + this.glassControls = false, }); /// No hosted glass of any kind: not an iPhone, or a phone that did not @@ -325,6 +326,7 @@ final class NativeGlassCapability { styles: names(said['glassStyles']), materials: names(said['materials']), reduceTransparency: said['reduceTransparency'] as bool? ?? false, + glassControls: said['glassControls'] as bool? ?? false, ); } @@ -350,6 +352,11 @@ final class NativeGlassCapability { /// [GlassSense] for the live answer, not this. final bool reduceTransparency; + /// Whether the player's controls can be the phone's own glass buttons + /// (ios/Runner/NativePlayerBar.swift): iOS 26+, with the factory + /// registered. + final bool glassControls; + /// Compared by what it says and not by which object said it, because /// [NativeGlassScope.updateShouldNotify] asks this question and two /// probes of the same phone are the same answer — without this, a scope @@ -361,6 +368,7 @@ final class NativeGlassCapability { other.majorVersion == majorVersion && other.systemGlass == systemGlass && other.reduceTransparency == reduceTransparency && + other.glassControls == glassControls && setEquals(other.styles, styles) && setEquals(other.materials, materials); @@ -370,6 +378,7 @@ final class NativeGlassCapability { majorVersion, systemGlass, reduceTransparency, + glassControls, Object.hashAllUnordered(styles), Object.hashAllUnordered(materials), ); diff --git a/lib/core/native/native_bar_layout.dart b/lib/core/native/native_bar_layout.dart new file mode 100644 index 00000000..cf22a7cd --- /dev/null +++ b/lib/core/native/native_bar_layout.dart @@ -0,0 +1,204 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; + +/// One slot of a native bar, as the layout needs to know it. +class BarSlotSpec { + const BarSlotSpec({ + this.extent, + this.label, + this.fontSize = 12, + this.glyph = 20, + this.hasGlyph = true, + this.group = 0, + }); + + /// A square slot of this size; null is the bar's own slot size. + final double? extent; + + /// Words beside the glyph, measured; null is a glyph alone. + final String? label; + final double fontSize; + final double glyph; + + /// False for words alone (quality's "1080" on a compact row). + final bool hasGlyph; + + /// Which capsule the slot stands in. + final int group; +} + +/// How the slots share the room. +enum BarSpread { + /// Side by side, the bar as wide as they are. + packed, + + /// Spread evenly over all of it, as `MainAxisAlignment.spaceEvenly`. + even, +} + +class BarLayout { + const BarLayout({ + required this.size, + required this.slots, + required this.capsules, + }); + + final Size size; + final List slots; + + /// One per group, in group order; empty for a bar without capsules. + final List capsules; +} + +/// The room either side of a label, and between its glyph and its words, as +/// the Flutter bottom-row button draws them. +const double _labelInset = 8; +const double _labelGap = 4; + +/// Where every slot of a bar goes, or null when they do not fit in +/// [maxExtent] even tightened (slots 44→40, no gap between them). +/// +/// RTL mirrors a horizontal bar: the first group, and the first slot in it, +/// start at the right. A caller with physical order (transport) passes LTR. +BarLayout? layoutBar({ + required List slots, + required TextDirection dir, + Axis axis = Axis.horizontal, + required double maxExtent, + double slot = 44, + double gap = 4, + double padding = 6, + double groupGap = 16, + bool capsules = true, + BarSpread spread = BarSpread.packed, + TextScaler textScaler = TextScaler.noScaling, +}) { + if (slots.isEmpty) return null; + final labels = [ + for (final s in slots) + s.label == null ? 0.0 : _measure(s.label!, s.fontSize, dir, textScaler), + ]; + BarLayout? attempt(double slot, double gap) => _place( + slots: slots, + labels: labels, + dir: dir, + axis: axis, + maxExtent: maxExtent, + slot: slot, + gap: gap, + padding: capsules ? padding : 0, + groupGap: groupGap, + capsules: capsules, + spread: spread, + ); + return attempt(slot, gap) ?? attempt(math.min(slot, 40), 0); +} + +double _measure(String text, double size, TextDirection dir, TextScaler s) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontSize: size, fontWeight: FontWeight.w600), + ), + textDirection: dir, + textScaler: s, + maxLines: 1, + )..layout(); + final width = painter.width.ceilToDouble(); + painter.dispose(); + return width; +} + +BarLayout? _place({ + required List slots, + required List labels, + required TextDirection dir, + required Axis axis, + required double maxExtent, + required double slot, + required double gap, + required double padding, + required double groupGap, + required bool capsules, + required BarSpread spread, +}) { + final mains = []; + for (var i = 0; i < slots.length; i++) { + final s = slots[i]; + if (s.label != null) { + final words = s.hasGlyph ? labels[i] : math.max(labels[i], 20.0); + final glyph = s.hasGlyph ? s.glyph + _labelGap : 0.0; + mains.add(math.max(slot, glyph + words + _labelInset * 2)); + } else { + mains.add(s.extent ?? slot); + } + } + final cross = [slot, for (final s in slots) ?s.extent].reduce(math.max); + + final rects = []; + final groupRects = []; + double total; + + Rect rectAt(double main, double length, double crossLength) { + final c = (cross - crossLength) / 2; + return axis == Axis.horizontal + ? Rect.fromLTWH(main, c, length, crossLength) + : Rect.fromLTWH(c, main, crossLength, length); + } + + if (spread == BarSpread.even) { + final used = mains.fold(0, (a, b) => a + b); + if (used > maxExtent) return null; + final space = (maxExtent - used) / (slots.length + 1); + var at = space; + for (var i = 0; i < slots.length; i++) { + rects.add(rectAt(at, mains[i], slots[i].extent ?? slot)); + at += mains[i] + space; + } + total = maxExtent; + } else { + var at = 0.0; + int? group; + double groupStart = 0; + for (var i = 0; i < slots.length; i++) { + final g = slots[i].group; + if (g != group) { + if (group != null) { + at += padding; + groupRects.add(rectAt(groupStart, at - groupStart, slot)); + at += groupGap; + } + group = g; + groupStart = at; + at += padding; + } else { + at += gap; + } + rects.add(rectAt(at, mains[i], slots[i].extent ?? slot)); + at += mains[i]; + } + at += padding; + groupRects.add(rectAt(groupStart, at - groupStart, slot)); + total = at; + if (total > maxExtent) return null; + } + + final size = axis == Axis.horizontal + ? Size(total, cross) + : Size(cross, total); + if (axis == Axis.horizontal && dir == TextDirection.rtl) { + Rect mirror(Rect r) => + Rect.fromLTWH(total - r.right, r.top, r.width, r.height); + return BarLayout( + size: size, + slots: [for (final r in rects) mirror(r)], + capsules: capsules ? [for (final r in groupRects) mirror(r)] : const [], + ); + } + return BarLayout( + size: size, + slots: rects, + capsules: capsules ? groupRects : const [], + ); +} diff --git a/lib/core/native/native_controls_gate.dart b/lib/core/native/native_controls_gate.dart new file mode 100644 index 00000000..9a930d88 --- /dev/null +++ b/lib/core/native/native_controls_gate.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../glass/glass_sense.dart'; +import '../glass/native_glass.dart'; +import '../system/platform_utils.dart'; + +/// Whether the player's controls are the phone's own glass buttons +/// (ios/Runner/NativePlayerBar.swift) rather than Flutter's. +/// +/// In the order [nativeGlassDraw] asks: an iPhone, no Reduce Transparency, +/// the clear system glass, and then the bar factory itself. TV and desktop +/// never host, whatever they report. +bool nativeControlsDraw({ + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, +}) { + if (!isIOS || isTv || isDesktop) return false; + if (reduceTransparency) return false; + if (!cap.systemGlass || !cap.styles.contains(NativeGlassStyle.clear.name)) { + return false; + } + return cap.glassControls; +} + +/// Overrides [isDesktopPlatform] for the gate: `flutter test` runs on a +/// desktop host, so without this no test could reach the native path. +@visibleForTesting +bool? debugNativeControlsDesktopOverride; + +abstract final class NativeControls { + static NativeGlassCapability? _known; + + /// Asks the phone once, before the first frame, so the player can decide + /// on its first build instead of swapping Flutter for UIKit under the + /// viewer. A phone slower than [timeout] keeps Flutter until it answers. + static Future warm({ + Duration timeout = const Duration(milliseconds: 300), + }) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + final asked = NativeGlass.capability().then((c) => _known = c); + try { + await asked.timeout(timeout); + } on TimeoutException { + // Answered later; [_known] is set then. + } + } + + @visibleForTesting + static void debugReset() => _known = null; + + /// The live decision for [context]: a [NativeControlsScope] above wins, + /// so the player can latch one answer for the whole page. + static bool of(BuildContext context) { + final latched = NativeControlsScope.maybeOf(context); + if (latched != null) return latched; + return decide(context); + } + + /// The decision from the capability and the switches, ignoring any scope. + static bool decide(BuildContext context) => nativeControlsDraw( + isIOS: defaultTargetPlatform == TargetPlatform.iOS, + isTv: isTvPlatform, + isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, + cap: NativeGlassScope.of(context) ?? _known ?? NativeGlassCapability.absent, + reduceTransparency: GlassSense.reduceTransparencyOf(context), + ); +} + +/// One decision for everything under it. +class NativeControlsScope extends InheritedWidget { + const NativeControlsScope({ + super.key, + required this.native, + required super.child, + }); + + final bool native; + + static bool? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.native; + + @override + bool updateShouldNotify(NativeControlsScope old) => old.native != native; +} diff --git a/lib/core/native/native_glass_bar.dart b/lib/core/native/native_glass_bar.dart new file mode 100644 index 00000000..97c883e8 --- /dev/null +++ b/lib/core/native/native_glass_bar.dart @@ -0,0 +1,491 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'native_bar_layout.dart'; +import 'native_controls_gate.dart'; +import 'native_link.dart'; + +/// How a bar's buttons stand on the glass. +enum NativeBarLook { + /// One clear glass capsule per group, plain buttons on it. + capsule, + + /// Each button its own glass circle. + cluster, +} + +/// One row of a button's menu. +@immutable +class NativeMenuRow { + const NativeMenuRow({ + required this.title, + this.symbol, + this.checked, + this.breakBefore = false, + this.destructive = false, + this.key, + }); + + final String title; + final String? symbol; + + /// Null draws no check column; false an empty one. + final bool? checked; + final bool breakBefore; + final bool destructive; + + /// What the row stands for, so a choice made while the rows changed under + /// the open menu still lands on the row the viewer saw. The title without. + final Object? key; + + bool _same(NativeMenuRow other) => + key != null ? key == other.key : title == other.title; + + Map toWire() => { + 'title': title, + 'symbol': symbol, + 'checked': checked, + 'breakBefore': breakBefore, + 'destructive': destructive, + }; +} + +/// One button of a [NativeGlassBar]. +@immutable +class NativeBarItem { + const NativeBarItem({ + required this.id, + required this.symbol, + required this.semanticLabel, + this.title, + this.enabled = true, + this.active = false, + this.busy = false, + this.prominent = false, + this.hidden = false, + this.extent, + this.size, + this.glyph = 20, + this.fontSize = 12, + this.hasGlyph = true, + this.group = 0, + this.onPressed, + this.onLongPress, + this.menu, + this.onMenuSelected, + }); + + final String id; + + /// An SF Symbol name; empty for words alone. + final String symbol; + final String? title; + final String semanticLabel; + final bool enabled; + + /// Drawn in the accent, and said selected. + final bool active; + + /// A ring turning on the rim: buffering. + final bool busy; + + /// The tinted glass of the one action that matters most (play). + final bool prominent; + + /// Keeps its slot and draws nothing: the room an absent Previous leaves, + /// so play stays in the middle. + final bool hidden; + + /// The square slot the button stands in; null is the bar's own slot. + final double? extent; + + /// The drawn circle of a cluster button, centred in its slot; null fills it. + final double? size; + final double glyph; + final double fontSize; + final bool hasGlyph; + final int group; + final VoidCallback? onPressed; + + /// A hold on a button without a menu, and VoiceOver's long press on any: + /// the Flutter sheet behind a menu stays one gesture away. + final VoidCallback? onLongPress; + + /// Opened by a hold, as UIKit's own buttons open theirs. + final List? menu; + final ValueChanged? onMenuSelected; + + BarSlotSpec get _slot => BarSlotSpec( + extent: extent, + label: title, + fontSize: fontSize, + glyph: glyph, + hasGlyph: hasGlyph, + group: group, + ); +} + +/// A group of player controls as the phone's own glass buttons, in one +/// hosted view (ios/Runner/NativePlayerBar.swift), or [fallback] wherever the +/// gate says no ([NativeControls]) or the slots do not fit. +/// +/// Dart lays it out ([layoutBar]) and sends the rects; UIKit only places the +/// buttons. Hits land only on a slot ([SlotHitBox]), so the gaps stay the +/// page's. It fades itself, in UIKit, on [shown], and leaves the frame once +/// faded so a hidden bar costs no composition. +class NativeGlassBar extends StatefulWidget { + const NativeGlassBar({ + super.key, + required this.items, + required this.look, + required this.fallback, + this.shown = true, + this.axis = Axis.horizontal, + this.direction, + this.spread = BarSpread.packed, + this.maxExtent = double.infinity, + this.accent = const Color(0xFFFFFFFF), + this.onMenuOpen, + this.onMenuClose, + }); + + static const String viewType = 'soplay/native_player_bar'; + + /// How long the bar takes to fade, as the controls' own fade. + static const Duration fade = Duration(milliseconds: 220); + + final List items; + final NativeBarLook look; + final WidgetBuilder fallback; + final bool shown; + final Axis axis; + + /// Null is the ambient direction; transport passes LTR. + final TextDirection? direction; + final BarSpread spread; + + /// A cap under the room the parent gives. + final double maxExtent; + final Color accent; + final VoidCallback? onMenuOpen; + final VoidCallback? onMenuClose; + + @override + State createState() => _NativeGlassBarState(); +} + +class _NativeGlassBarState extends State + with NativeControlState { + /// One set for the view's life: a new one each build would replace the + /// recognizers mid-press. + static final _gestures = pressGestures(); + + BarLayout? _layout; + TextDirection _dir = TextDirection.ltr; + double _scale = 1; + bool _reduceMotion = false; + + /// Faded out and out of the frame. + late bool _gone = !widget.shown; + Timer? _fading; + + Animation? _route; + bool _routeMoving = false; + + /// The rows each menu was last sent, by item: a row is chosen by its index + /// in those. + final Map> _toldRows = {}; + + /// The rows of a menu as it was when it opened: UIKit keeps showing those + /// while it is up, whatever was sent since. + final Map> _openRows = {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + final route = ModalRoute.of(context)?.animation; + if (route != _route) { + _route?.removeStatusListener(_routeStatus); + _route = route; + route?.addStatusListener(_routeStatus); + _routeMoving = route != null && !route.isCompleted; + } + } + + void _routeStatus(AnimationStatus status) { + final moving = !status.isCompleted; + if (moving != _routeMoving && mounted) { + setState(() => _routeMoving = moving); + } + } + + @override + void didUpdateWidget(NativeGlassBar old) { + super.didUpdateWidget(old); + if (old.shown == widget.shown) return; + _fading?.cancel(); + if (widget.shown) { + _gone = false; + } else { + _fading = Timer(_reduceMotion ? Duration.zero : NativeGlassBar.fade, () { + if (mounted && !widget.shown) setState(() => _gone = true); + }); + } + } + + @override + void dispose() { + _fading?.cancel(); + _route?.removeStatusListener(_routeStatus); + super.dispose(); + } + + Map _shownWire() => { + 'shown': widget.shown, + 'ms': _reduceMotion ? 0 : NativeGlassBar.fade.inMilliseconds, + }; + + Map _itemWire(NativeBarItem item, Rect rect) => { + 'id': item.id, + 'symbol': item.hasGlyph ? item.symbol : '', + 'title': item.title, + 'enabled': item.enabled, + 'active': item.active, + 'busy': item.busy, + 'prominent': item.prominent, + 'hidden': item.hidden, + 'size': item.size ?? item.extent, + 'glyph': item.glyph, + 'fontSize': _scale * item.fontSize, + 'accent': widget.accent.toARGB32(), + 'rect': [rect.left, rect.top, rect.width, rect.height], + 'group': item.group, + 'menu': item.menu?.map((r) => r.toWire()).toList(), + 'longPress': item.onLongPress != null, + }; + + Map _payload(BarLayout layout) => { + 'items': [ + for (var i = 0; i < widget.items.length; i++) + _itemWire(widget.items[i], layout.slots[i]), + ], + 'look': widget.look.name, + 'dir': _dir == TextDirection.rtl ? 'rtl' : 'ltr', + 'axis': widget.axis.name, + 'capsules': [ + for (final r in layout.capsules) [r.left, r.top, r.width, r.height], + ], + }; + + /// What a full `setItems` is needed for: anything that moves a button. + static const _placing = {'id', 'rect', 'group'}; + + static Object? _structure(Map payload) => { + for (final e in payload.entries) + if (e.key != 'items') e.key: e.value, + 'items': [ + for (final item in payload['items']! as List) + {for (final k in _placing) k: (item as Map)[k]}, + ], + }; + + @override + void sync() { + final layout = _layout; + if (layout == null || layout.slots.length != widget.items.length) return; + final payload = _payload(layout); + final told = toldOf('setItems') as Map?; + if (told == null || !nativeSame(_structure(told), _structure(payload))) { + say('setItems', payload); + } else { + final before = told['items']! as List; + final now = payload['items']! as List; + for (var i = 0; i < now.length; i++) { + final was = before[i] as Map; + final item = now[i] as Map; + final changed = { + for (final e in item.entries) + if (!_placing.contains(e.key) && !nativeSame(was[e.key], e.value)) + e.key: e.value, + }; + if (changed.isNotEmpty) say('setItem', {'id': item['id'], ...changed}); + } + } + remember('setItems', payload); + for (final item in widget.items) { + final rows = item.menu; + if (rows == null) { + _toldRows.remove(item.id); + } else { + _toldRows[item.id] = rows; + } + } + tell('setShown', _shownWire()); + } + + NativeBarItem? _item(Object? id) { + for (final item in widget.items) { + if (item.id == id) return item; + } + return null; + } + + @override + void heard(String method, Object? arguments) { + switch (method) { + case 'press': + final item = _item(arguments); + if (item != null && item.enabled) item.onPressed?.call(); + case 'longPress': + final item = _item(arguments); + if (item != null && item.enabled) item.onLongPress?.call(); + case 'select': + if (arguments is! Map) return; + final item = _item(arguments['id']); + final row = arguments['row']; + final told = _openRows[item?.id] ?? _toldRows[item?.id]; + final rows = item?.menu; + if (item == null || row is! int || told == null || rows == null) return; + if (row < 0 || row >= told.length) return; + final index = rows.indexWhere((r) => r._same(told[row])); + if (index >= 0) item.onMenuSelected?.call(index); + case 'menuOpen': + final rows = _toldRows[arguments]; + if (arguments is String && rows != null) _openRows[arguments] = rows; + widget.onMenuOpen?.call(); + case 'menuClose': + _openRows.remove(arguments); + widget.onMenuClose?.call(); + } + } + + @override + Widget build(BuildContext context) { + if (!NativeControls.of(context)) return widget.fallback(context); + final scaler = MediaQuery.textScalerOf(context); + _dir = widget.direction ?? Directionality.of(context); + _scale = scaler.scale(1); + return LayoutBuilder( + builder: (context, box) { + final room = widget.axis == Axis.horizontal + ? box.maxWidth + : box.maxHeight; + final max = room < widget.maxExtent ? room : widget.maxExtent; + final layout = layoutBar( + slots: [for (final item in widget.items) item._slot], + dir: _dir, + axis: widget.axis, + maxExtent: max, + capsules: widget.look == NativeBarLook.capsule, + spread: widget.spread, + textScaler: scaler, + ); + if (layout == null) return widget.fallback(context); + final first = _layout == null; + _layout = layout; + if (!first) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && linked) sync(); + }); + } + return _hostBar(layout); + }, + ); + } + + Widget _hostBar(BarLayout layout) { + final hits = widget.look == NativeBarLook.capsule + ? layout.capsules + : [ + for (var i = 0; i < widget.items.length; i++) + if (!widget.items[i].hidden) layout.slots[i], + ]; + final payload = _payload(layout); + final shown = _shownWire(); + final interactive = widget.shown && !_gone && !_routeMoving; + return Offstage( + offstage: _gone || _routeMoving, + child: SizedBox.fromSize( + size: layout.size, + child: Stack( + children: [ + Positioned.fill( + child: SlotHitBox( + rects: hits, + enabled: interactive, + child: ExcludeSemantics( + child: hosted( + type: NativeGlassBar.viewType, + params: {...payload, 'shown': widget.shown}, + gestures: _gestures, + made: {'setItems': payload, 'setShown': shown}, + ), + ), + ), + ), + for (var i = 0; i < widget.items.length; i++) + if (!widget.items[i].hidden) + Positioned.fromRect( + rect: layout.slots[i], + child: _slotSemantics(widget.items[i]), + ), + ], + ), + ), + ); + } + + Widget _slotSemantics(NativeBarItem item) => Semantics( + container: true, + button: true, + label: item.semanticLabel, + value: item.title, + enabled: item.enabled, + selected: item.active, + onTap: item.enabled ? item.onPressed : null, + onLongPress: item.enabled ? item.onLongPress : null, + child: const SizedBox.expand(), + ); +} + +/// Answers a hit only inside [rects]: the gaps between a bar's buttons are +/// the page's, so a tap there still toggles the controls and a double tap +/// still seeks. +class SlotHitBox extends SingleChildRenderObjectWidget { + const SlotHitBox({ + super.key, + required this.rects, + this.enabled = true, + super.child, + }); + + final List rects; + final bool enabled; + + @override + RenderSlotHitBox createRenderObject(BuildContext context) => + RenderSlotHitBox(rects, enabled: enabled); + + @override + void updateRenderObject(BuildContext context, RenderSlotHitBox renderObject) { + renderObject + ..rects = rects + ..enabled = enabled; + } +} + +class RenderSlotHitBox extends RenderProxyBox { + RenderSlotHitBox(this.rects, {this.enabled = true}); + + List rects; + bool enabled; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!enabled || !rects.any((r) => r.contains(position))) return false; + return super.hitTest(result, position: position); + } +} diff --git a/lib/core/native/native_link.dart b/lib/core/native/native_link.dart new file mode 100644 index 00000000..046495e0 --- /dev/null +++ b/lib/core/native/native_link.dart @@ -0,0 +1,240 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../glass/glass_surface.dart' show PlatformViewMark; + +/// What a hosted view says back: the method and its arguments. +typedef NativeHeard = void Function(String method, Object? arguments); + +/// The way to one hosted view once it exists. The channel's in the app; a +/// test's fake records what is said and says things back. +abstract interface class NativeLink { + /// Tells UIKit [method], not waited on. Nothing once [dispose]d. + void say(String method, [Object? arguments]); + + /// Asks UIKit [method]; anything that goes wrong is null. + Future ask(String method, [Object? arguments]); + + void dispose(); +} + +/// A tap and a finger resting [hold]: a drag that starts on the view stays +/// the page's (swipe-seek), as it does over an `InkWell`. +Set> pressGestures({ + Duration hold = const Duration(milliseconds: 120), +}) => { + Factory(TapGestureRecognizer.new), + Factory( + () => LongPressGestureRecognizer(duration: hold), + ), +}; + +/// Where hosted views come from: `UiKitView`s and their channels in the app +/// ([ChannelNativeHost]), a fake in a test. +abstract interface class NativeHost { + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }); +} + +/// Hosted views as the phone makes them: a `UiKitView` of the type and the +/// view's own channel, `/`. +final class ChannelNativeHost implements NativeHost { + const ChannelNativeHost(); + + @override + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }) => PlatformViewMark( + child: UiKitView( + viewType: type, + creationParams: params, + creationParamsCodec: const StandardMessageCodec(), + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: gestures, + onPlatformViewCreated: (id) => + onLink(ChannelNativeLink(MethodChannel('$type/$id'), onHeard)), + ), + ); +} + +final class ChannelNativeLink implements NativeLink { + ChannelNativeLink(this._channel, NativeHeard onHeard) { + _channel.setMethodCallHandler((call) async { + if (!_disposed) onHeard(call.method, call.arguments); + }); + } + + final MethodChannel _channel; + bool _disposed = false; + + @override + void say(String method, [Object? arguments]) { + if (_disposed) return; + _channel.invokeMethod(method, arguments).catchError((Object _) {}); + } + + @override + Future ask(String method, [Object? arguments]) async { + if (_disposed) return null; + try { + return await _channel.invokeMethod(method, arguments); + } on Object { + return null; + } + } + + @override + void dispose() { + _disposed = true; + _channel.setMethodCallHandler(null); + } +} + +/// Hands the hosted views under it a [NativeHost] in place of the channels: +/// a test's fake. +class NativeHostScope extends InheritedWidget { + const NativeHostScope({super.key, required this.host, required super.child}); + + final NativeHost host; + + static NativeHost of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.host ?? + const ChannelNativeHost(); + + @override + bool updateShouldNotify(NativeHostScope old) => old.host != host; +} + +/// The state of a hosted control: the link to its view and what the view was +/// last told, so it is told only what changed. +/// +/// A control writes [sync] — every [tell] of what it shows, run once the view +/// exists and on the frame after each new configuration — and [heard], what +/// UIKit says back. It builds its view with [hosted]. +mixin NativeControlState on State { + NativeLink? _link; + final Map _told = {}; + Map _made = const {}; + + @protected + void sync(); + + @protected + void heard(String method, Object? arguments); + + @protected + bool get linked => _link != null; + + /// Tells the view [method] with [value] unless that is what [method] last + /// told it, compared list by list and key by key. + @protected + void tell(String method, Object? value) { + final link = _link; + if (link == null) return; + if (_told.containsKey(method) && nativeSame(_told[method], value)) return; + _told[method] = value; + link.say(method, value); + } + + /// What [method] last told the view. + @protected + Object? toldOf(String method) => _told[method]; + + /// Counts [value] as told by [method], for a control that said it its own + /// way (a diff of it). + @protected + void remember(String method, Object? value) => _told[method] = value; + + /// Says [method] whatever was said before: a move, not a state. + @protected + void say(String method, [Object? arguments]) => _link?.say(method, arguments); + + /// The view. [made] is what it is created with, keyed by the method that + /// tells each part later, and counts as told. + @protected + Widget hosted({ + required String type, + required Map params, + required Set> gestures, + Map made = const {}, + }) { + for (final MapEntry(:key, :value) in made.entries) { + _told.putIfAbsent(key, () => value); + } + _made = made; + return NativeHostScope.of(context).view( + type: type, + params: params, + gestures: gestures, + onLink: _linked, + onHeard: _heard, + ); + } + + void _linked(NativeLink link) { + if (!mounted) { + link.dispose(); + return; + } + final again = _link != null; + _link?.dispose(); + _link = link; + // A view made again was made with what the last build handed it. + if (again) { + _told + ..clear() + ..addAll(_made); + } + sync(); + } + + void _heard(String method, Object? arguments) { + if (mounted) heard(method, arguments); + } + + @override + void didUpdateWidget(covariant W oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && linked) sync(); + }); + } + + @override + void dispose() { + _link?.dispose(); + _link = null; + super.dispose(); + } +} + +/// Two things told alike: lists item by item, maps key by key. +bool nativeSame(Object? a, Object? b) { + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!nativeSame(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !nativeSame(a[key], b[key])) return false; + } + return true; + } + return a == b; +} diff --git a/lib/core/native/sf_symbols.dart b/lib/core/native/sf_symbols.dart new file mode 100644 index 00000000..91eb1ad0 --- /dev/null +++ b/lib/core/native/sf_symbols.dart @@ -0,0 +1,54 @@ +/// The SF Symbol each player control wears on the native glass bars, keyed by +/// the control's id (`PlayerControlCatalogue`) and the player's own pieces. +const Map playerSymbols = { + 'back': 'chevron.backward', + 'language': 'character.bubble', + 'subtitles': 'captions.bubble', + 'settings': 'gearshape', + 'orientation': 'rotate.right', + 'lock': 'lock.fill', + 'unlock': 'lock.fill', + 'stats': 'info.circle', + 'speed': 'gauge.with.dots.needle.67percent', + 'server': 'server.rack', + 'quality': 'slider.horizontal.3', + 'episodes': 'rectangle.stack', + 'previous': 'backward.end.fill', + 'next': 'forward.end.fill', + 'shader': 'sparkles', + 'fit': 'aspectratio', + 'sleep': 'moon.zzz', + 'cast': 'tv.badge.wifi', + 'party': 'shareplay', + 'pip': 'pip.enter', + 'download': 'arrow.down.circle', + 'rewind': 'gobackward', + 'forward': 'goforward', + 'play': 'play.fill', + 'pause': 'pause.fill', + 'replay': 'arrow.counterclockwise', + 'react': 'face.smiling', + 'comments': 'bubble.left.and.bubble.right', + 'skip': 'forward.fill', +}; + +/// The filled face of a control while it is on. +const Map activeSymbols = { + 'subtitles': 'captions.bubble.fill', + 'stats': 'info.circle.fill', + 'sleep': 'moon.zzz.fill', + 'download': 'arrow.down.circle.fill', +}; + +/// [id]'s symbol, its filled face when [active] and it has one. +String playerSymbol(String id, {bool active = false}) => + (active ? activeSymbols[id] : null) ?? playerSymbols[id] ?? 'circle'; + +/// The step lengths SF Symbols draws a number into. +const Set _numberedSteps = {5, 10, 15, 30, 45, 60, 75, 90}; + +String rewindSymbol(int s) => + _numberedSteps.contains(s) ? 'gobackward.$s' : 'gobackward'; + +String forwardSymbol(int s) => + _numberedSteps.contains(s) ? 'goforward.$s' : 'goforward'; diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index c1ca0ef1..dfaf94f9 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -1056,36 +1056,38 @@ extension _PlayerControls on _PlayerPageState { child: Padding( padding: const EdgeInsets.only(bottom: 24), child: Center( - child: GestureDetector( - onTap: _unlock, - // Words on it, so the deeper dimming: white 12pt text keeps - // AA over a white frame. - child: MediaGlass( - radius: 24, - dim: Colors.black.withValues(alpha: 0.6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 10, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.lock_rounded, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 8), - Text( - 'player.tap_to_unlock'.tr(), - style: const TextStyle( + child: _unlockPill( + GestureDetector( + onTap: _unlock, + // Words on it, so the deeper dimming: white 12pt text keeps + // AA over a white frame. + child: MediaGlass( + radius: 24, + dim: Colors.black.withValues(alpha: 0.6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lock_rounded, color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, + size: 16, ), - ), - ], + const SizedBox(width: 8), + Text( + 'player.tap_to_unlock'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ), ), @@ -1112,190 +1114,201 @@ extension _PlayerControls on _PlayerPageState { // over refracting glass composites the glass and the picture under it on // every frame of the fade. The group gives the refraction back once the // fade has settled. - final overlay = FadeTransition( - opacity: _controlsAnimation, - child: GlassHoldWhile( - source: _controlsAnimation, - moving: () => _controlsAnimation.isAnimating, - child: IgnorePointer( - ignoring: !_controlsVisible, - child: Stack( - children: [ - const Positioned.fill(child: _ControlsScrim()), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - // One read of the picture for every button along the top, - // not one each (MediaGlass). - child: MediaGlassLayer( - child: Row( - children: [ - _IconButton( - icon: Icons.arrow_back_ios_new_rounded, - onTap: _exit, - ), - const SizedBox(width: 3), - Expanded( - flex: 2, - // Text takes the hit but has no gesture, so a tap on - // the title did nothing while the same tap anywhere - // else on the dimmed video hides the controls. - child: IgnorePointer( - child: Text( - _episodeTitle(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w700, - shadows: _kControlShadow, - ), + // + // The native bars fade themselves, in UIKit: an opacity over a hosted + // view is a per-frame composite of it. So on that path the overlay is + // held at full, and only what Flutter draws fades ([_fadeFlutter]). + final native = _nativeBars; + final body = IgnorePointer( + ignoring: !_controlsVisible, + child: Stack( + children: [ + Positioned.fill(child: _fadeFlutter(const _ControlsScrim())), + if (native) + _buildNativeTopBar() + else + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + // One read of the picture for every button along the top, + // not one each (MediaGlass). + child: MediaGlassLayer( + child: Row( + children: [ + _IconButton( + icon: Icons.arrow_back_ios_new_rounded, + onTap: _exit, + ), + const SizedBox(width: 3), + Expanded( + flex: 2, + // Text takes the hit but has no gesture, so a tap on + // the title did nothing while the same tap anywhere + // else on the dimmed video hides the controls. + child: IgnorePointer( + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, ), ), ), - // The action set outgrows a portrait phone. It used to - // take half the bar and scroll the surplus off the left, - // where nobody found it — orientation and Watch Party were - // simply invisible. Now the actions get the larger share - // and scale a few percent if they still do not fit, so - // every one of them is on screen. - Expanded( - flex: 5, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // TV and desktop only. A phone draws its bar - // from the viewer's arrangement below, which - // has the language pill in it — adding this one - // as well put two SUB pills side by side. - if (hasLangSwitcher && - (isTvPlatform || isDesktopPlatform)) ...[ - _LangPill( - label: (_currentLang ?? _kSubLang) - .toUpperCase(), - onTap: _openLangSheet, - ), - const SizedBox(width: 5), - ], - // CloudStream (cs:) sources are excluded from Watch2Gether - // for now (they need an on-device plugin per peer). - // - if ((isTvPlatform || isDesktopPlatform) && - (_inParty || - !widget.args.provider.startsWith( - 'cs:', - ))) ...[ + ), + // The action set outgrows a portrait phone. It used to + // take half the bar and scroll the surplus off the left, + // where nobody found it — orientation and Watch Party were + // simply invisible. Now the actions get the larger share + // and scale a few percent if they still do not fit, so + // every one of them is on screen. + Expanded( + flex: 5, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // TV and desktop only. A phone draws its bar + // from the viewer's arrangement below, which + // has the language pill in it — adding this one + // as well put two SUB pills side by side. + if (hasLangSwitcher && + (isTvPlatform || isDesktopPlatform)) ...[ + _LangPill( + label: (_currentLang ?? _kSubLang) + .toUpperCase(), + onTap: _openLangSheet, + ), + const SizedBox(width: 5), + ], + // CloudStream (cs:) sources are excluded from Watch2Gether + // for now (they need an on-device plugin per peer). + // + if ((isTvPlatform || isDesktopPlatform) && + (_inParty || + !widget.args.provider.startsWith( + 'cs:', + ))) ...[ + _IconButton( + icon: _inParty + ? Icons.groups_rounded + : Icons.groups_2_rounded, + color: _inParty ? AppColors.primary : null, + onTap: _openWatchParty, + ), + const SizedBox(width: 2), + ], + // TV drops three phone-only affordances outright rather + // than adapting them: orientation lock is meaningless on + // a fixed landscape panel, PiP semantics differ on + // leanback, and the touch lock swaps in an overlay whose + // only escape is a non-focusable tap target — on a remote + // that is an unrecoverable state. + if (isTvPlatform) ...[ + _IconButton( + icon: Icons.closed_caption_rounded, + onTap: _openSubtitleSheet, + ), + const SizedBox(width: 2), + if (canDownload) ...[ _IconButton( - icon: _inParty - ? Icons.groups_rounded - : Icons.groups_2_rounded, - color: _inParty ? AppColors.primary : null, - onTap: _openWatchParty, + icon: Icons.download_rounded, + onTap: _startDownload, ), const SizedBox(width: 2), ], - // TV drops three phone-only affordances outright rather - // than adapting them: orientation lock is meaningless on - // a fixed landscape panel, PiP semantics differ on - // leanback, and the touch lock swaps in an overlay whose - // only escape is a non-focusable tap target — on a remote - // that is an unrecoverable state. - if (isTvPlatform) ...[ + _IconButton( + icon: Icons.settings_rounded, + onTap: _openSettingsSheet, + ), + if (hasServers) ...[ + const SizedBox(width: 2), _IconButton( - icon: Icons.closed_caption_rounded, - onTap: _openSubtitleSheet, + icon: Icons.dns_rounded, + onTap: _openServerSheet, ), + ], + if (hasQualities) ...[ const SizedBox(width: 2), - if (canDownload) ...[ - _IconButton( - icon: Icons.download_rounded, - onTap: _startDownload, - ), - const SizedBox(width: 2), - ], _IconButton( - icon: Icons.settings_rounded, - onTap: _openSettingsSheet, + icon: Icons.high_quality_rounded, + onTap: () => + _openPanel(_SidePanel.quality), ), - if (hasServers) ...[ - const SizedBox(width: 2), - _IconButton( - icon: Icons.dns_rounded, - onTap: _openServerSheet, - ), - ], - if (hasQualities) ...[ - const SizedBox(width: 2), - _IconButton( - icon: Icons.high_quality_rounded, - onTap: () => - _openPanel(_SidePanel.quality), - ), - ], - if (hasEpisodes) ...[ - const SizedBox(width: 2), - _IconButton( - icon: Icons.video_library_rounded, - onTap: () => - _openPanel(_SidePanel.episodes), - ), - ], - ] else if (!isDesktopPlatform) ...[ - // Phone: the viewer's arrangement, in their - // order. Every control stays ON a bar — a - // previous pass filed six of them behind a ⋯ - // sheet to buy room and had to undo it, because - // a control one tap away is a control people - // use and one two taps away is a control they - // stop reaching for. What changed is only that - // WHICH controls sit here is now a preference - // instead of an argument about a Row. - // - // The capacity that PlayerControlsLayout - // enforces is this bar's: the group is wrapped - // in a FittedBox, so an extra button does not - // wrap or scroll, it shrinks all of them. - ..._controlsFor( - PlayerControlSlot.topBar, - topBar: true, + ], + if (hasEpisodes) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.video_library_rounded, + onTap: () => + _openPanel(_SidePanel.episodes), ), ], + ] else if (!isDesktopPlatform) ...[ + // Phone: the viewer's arrangement, in their + // order. Every control stays ON a bar — a + // previous pass filed six of them behind a ⋯ + // sheet to buy room and had to undo it, because + // a control one tap away is a control people + // use and one two taps away is a control they + // stop reaching for. What changed is only that + // WHICH controls sit here is now a preference + // instead of an argument about a Row. + // + // The capacity that PlayerControlsLayout + // enforces is this bar's: the group is wrapped + // in a FittedBox, so an extra button does not + // wrap or scroll, it shrinks all of them. + ..._controlsFor( + PlayerControlSlot.topBar, + topBar: true, + ), ], - ), + ], ), ), - if (isDesktopPlatform) - ValueListenableBuilder( - valueListenable: DesktopWindow.immersive, - builder: (_, imm, _) => imm - ? const WindowButtons() - : const SizedBox.shrink(), - ), - ], - ), + ), + if (isDesktopPlatform) + ValueListenableBuilder( + valueListenable: DesktopWindow.immersive, + builder: (_, imm, _) => imm + ? const WindowButtons() + : const SizedBox.shrink(), + ), + ], ), ), ), ), - if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), - if (initialized) - _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), - ], - ), - ), + ), + if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), + if (initialized) + _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), + ], ), ); + final overlay = native + ? FadeTransition(opacity: kAlwaysCompleteAnimation, child: body) + : FadeTransition( + opacity: _controlsAnimation, + child: GlassHoldWhile( + source: _controlsAnimation, + moving: () => _controlsAnimation.isAnimating, + child: body, + ), + ); // IgnorePointer stops taps but leaves every button focusable, so on a // D-pad an invisible control would still take focus and fire on OK. @@ -1376,21 +1389,27 @@ extension _PlayerControls on _PlayerPageState { Widget _buildCenterPlayCluster(PlayerController c) { final step = _seekSeconds; final centered = _layout.of(PlayerControlSlot.center); - return MediaGlassLayer( + final previous = !_layoutDrivesBars || centered.contains('previous') + ? _centerControl('previous') + : null; + final next = !_layoutDrivesBars || centered.contains('next') + ? _centerControl('next') + : null; + void rewind() { + _seekRelative(Duration(seconds: -step)); + _showSeekRipple(-1); + } + + void forward() { + _seekRelative(Duration(seconds: step)); + _showSeekRipple(1); + } + + final flutter = MediaGlassLayer( child: PlayerTransportRow( - previous: !_layoutDrivesBars || centered.contains('previous') - ? _centerControl('previous') - : null, - next: !_layoutDrivesBars || centered.contains('next') - ? _centerControl('next') - : null, - rewind: _CenterIconButton( - icon: _rewindIconFor(step), - onTap: () { - _seekRelative(Duration(seconds: -step)); - _showSeekRipple(-1); - }, - ), + previous: previous, + next: next, + rewind: _CenterIconButton(icon: _rewindIconFor(step), onTap: rewind), playPause: ValueListenableBuilder( valueListenable: c, builder: (_, value, _) => _CenterIconButton( @@ -1403,17 +1422,205 @@ extension _PlayerControls on _PlayerPageState { focusNode: isTvPlatform ? _tvPlayFocus : null, ), ), - forward: _CenterIconButton( - icon: _forwardIconFor(step), - onTap: () { - _seekRelative(Duration(seconds: step)); - _showSeekRipple(1); - }, + forward: _CenterIconButton(icon: _forwardIconFor(step), onTap: forward), + ), + ); + if (!_nativeBars) return flutter; + + final episodes = previous != null || next != null; + NativeBarItem episode(String id, Widget? shown, bool enabled, int to) => + NativeBarItem( + id: id, + symbol: playerSymbol(id), + semanticLabel: _controlName(id), + hidden: shown == null, + enabled: enabled, + extent: 52, + glyph: 22, + onPressed: () => _partyEpisodeNav(to), + ); + return _Selected<(bool, bool)>( + listenable: c, + select: () => (c.value.isPlaying, c.value.isBuffering), + builder: (context, state) => Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: episodes ? 420 : 280), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: NativeGlassBar( + look: NativeBarLook.cluster, + // Transport has physical direction even in an RTL interface. + direction: TextDirection.ltr, + spread: BarSpread.even, + shown: _controlsVisible, + accent: AppColors.primary, + items: [ + if (episodes) + episode( + 'previous', + previous, + _hasPrevEpisode, + _episodeIndex - 1, + ), + NativeBarItem( + id: 'rewind', + symbol: rewindSymbol(step), + semanticLabel: 'player.rewind'.tr(), + extent: 52, + glyph: 24, + onPressed: rewind, + ), + NativeBarItem( + id: 'play', + symbol: playerSymbol(state.$1 ? 'pause' : 'play'), + semanticLabel: (state.$1 ? 'player.pause' : 'player.play') + .tr(), + extent: 72, + glyph: 32, + prominent: true, + busy: state.$2, + onPressed: _togglePlay, + ), + NativeBarItem( + id: 'forward', + symbol: forwardSymbol(step), + semanticLabel: 'player.forward'.tr(), + extent: 52, + glyph: 24, + onPressed: forward, + ), + if (episodes) + episode('next', next, _hasNextEpisode, _episodeIndex + 1), + ], + fallback: (_) => _fadeFlutter(flutter), + ), + ), ), ), ); } + /// What Flutter draws on the native path fades on its own; elsewhere the + /// whole overlay fades and this is a no-op. + Widget _fadeFlutter(Widget child) => _nativeBars + ? FadeTransition(opacity: _controlsAnimation, child: child) + : child; + + /// A native menu holds the controls up while it is open. + void _nativeMenuOpened() => _hideTimer?.cancel(); + + void _nativeMenuClosed() => _scheduleHide(); + + /// The phone's top bar on the native path: back and the actions as two + /// glass bars, the title between them in Flutter. No FittedBox: a scale + /// over a hosted view does not reach UIKit, so a bar that does not fit + /// draws its Flutter fallback instead. + Widget _buildNativeTopBar() { + final faces = _facesFor(PlayerControlSlot.topBar, topBar: true); + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + NativeGlassBar( + look: NativeBarLook.cluster, + shown: _controlsVisible, + items: [ + NativeBarItem( + id: 'back', + symbol: playerSymbol('back'), + semanticLabel: 'general.back'.tr(), + extent: 44, + size: 38, + onPressed: _exit, + ), + ], + fallback: (_) => _fadeFlutter( + MediaGlassLayer( + child: _IconButton( + icon: Icons.arrow_back_ios_new_rounded, + onTap: _exit, + ), + ), + ), + ), + const SizedBox(width: 3), + Expanded( + flex: 2, + child: _fadeFlutter( + IgnorePointer( + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, + ), + ), + ), + ), + ), + Expanded( + flex: 5, + child: Align( + alignment: Alignment.centerRight, + child: faces.isEmpty + ? const SizedBox.shrink() + : NativeGlassBar( + look: NativeBarLook.capsule, + shown: _controlsVisible, + accent: AppColors.primary, + onMenuOpen: _nativeMenuOpened, + onMenuClose: _nativeMenuClosed, + items: [for (final f in faces) f.toNative()], + fallback: (_) => _fadeFlutter( + MediaGlassLayer( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final f in faces) _flutterIcon(f), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// The unlock pill: the one control while the screen is locked, up + /// whatever the controls' fade is doing. + Widget _unlockPill(Widget flutter) => NativeGlassBar( + look: NativeBarLook.capsule, + items: [ + NativeBarItem( + id: 'unlock', + symbol: playerSymbol('unlock'), + title: 'player.tap_to_unlock'.tr(), + semanticLabel: 'player.tap_to_unlock'.tr(), + glyph: 16, + onPressed: _unlock, + ), + ], + fallback: (_) => flutter, + ); + Widget _buildDesktopControlRow( PlayerController c, bool hasEpisodes, @@ -1882,36 +2089,133 @@ extension _PlayerControls on _PlayerPageState { return out; } + /// The viewer-facing name of [id], for VoiceOver on the native bars. + String _controlName(String id) => + PlayerControlCatalogue.byId(id)?.labelKey.tr() ?? id; + /// One control as it appears on the TOP bar: a bare icon, no label. Widget? _topBarControl(String id) { + final face = _topBarFace(id); + return face == null ? null : _flutterIcon(face); + } + + /// [face] as the Flutter top-bar button it has always been. + Widget _flutterIcon(PlayerControlFace face) { + if (face.pill) { + // Its own gap: every other control on this bar is an _IconButton, whose + // 44pt tap target already carries 3pt of transparent padding a side. + // The pill has none, so without this it sits flush against its + // neighbour. + return Padding( + padding: const EdgeInsets.only(right: 5), + child: _LangPill(label: face.label!, onTap: face.onTap!), + ); + } + return _IconButton( + icon: face.icon, + enabled: face.enabled, + color: face.active ? AppColors.primary : null, + onTap: face.onTap!, + onLongPress: face.onLongPress, + tooltip: face.tooltip, + ); + } + + /// The speed presets as a native menu, the current one checked. + List get _speedRows => [ + for (final s in _PlayerPanels._speedPresets) + NativeMenuRow( + title: _speedLabel(s), + checked: (s - _playbackSpeed).abs() < 0.01, + key: s, + ), + ]; + + void _pickSpeed(int i) => _setSpeed(_PlayerPanels._speedPresets[i]); + + List get _fitRows => [ + for (final f in _PlayerFit.values) + NativeMenuRow(title: _fitLabel(f), checked: f == _fit, key: f), + ]; + + void _pickFit(int i) => _setFit(_PlayerFit.values[i]); + + /// What the sleep button's hold offers: everything its sheet does. + List<(NativeMenuRow, VoidCallback)> get _sleepChoices => [ + if (_sleepArmed) + (NativeMenuRow(title: 'general.off'.tr(), key: 'off'), _cancelSleepTimer), + for (final d in _PlayerSleepTimer._presets) + ( + NativeMenuRow( + title: d.inMinutes < 60 + ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) + : 'player.sleep_hours'.tr(args: ['${d.inHours}']), + breakBefore: _sleepArmed && d == _PlayerSleepTimer._presets.first, + key: d, + ), + () => _armSleepDuration(d), + ), + if (widget.args.isSerial) + ( + NativeMenuRow( + title: 'player.sleep_episode_end'.tr(), + checked: _sleepAtEpisodeEnd, + breakBefore: true, + key: 'episode', + ), + _armSleepAtEpisodeEnd, + ), + ]; + + PlayerControlFace _sleepFace({String? label}) { + final choices = _sleepChoices; + return PlayerControlFace( + id: 'sleep', + icon: Icons.bedtime_rounded, + semanticLabel: _controlName('sleep'), + label: label, + // Filled while armed, but not tinted: the Flutter button never was. + symbol: _sleepArmed ? 'moon.zzz.fill' : 'moon.zzz', + onTap: _openSleepSheet, + menu: [for (final c in choices) c.$1], + onMenu: (i) => choices[i].$2(), + ); + } + + /// One control as the top bar shows it, or null when it cannot exist now. + PlayerControlFace? _topBarFace(String id) { final a = _affordances; + final name = _controlName(id); switch (id) { case 'language': if (!a.hasLangs) return null; - // Its own gap: every other control on this bar is an _IconButton, whose - // 44pt tap target already carries 3pt of transparent padding a side. - // The pill has none, so without this it sits flush against its - // neighbour. - return Padding( - padding: const EdgeInsets.only(right: 5), - child: _LangPill( - label: (_currentLang ?? _kSubLang).toUpperCase(), - onTap: _openLangSheet, - ), + return PlayerControlFace( + id: id, + icon: Icons.translate_rounded, + semanticLabel: name, + pill: true, + label: (_currentLang ?? _kSubLang).toUpperCase(), + onTap: _openLangSheet, ); case 'subtitles': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.closed_caption_rounded, + semanticLabel: name, onTap: _openSubtitleSheet, ); case 'settings': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.settings_rounded, + semanticLabel: name, onTap: _openSettingsSheet, ); case 'orientation': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.screen_rotation_rounded, + semanticLabel: name, onTap: _toggleOrientation, ); case 'lock': @@ -1919,82 +2223,117 @@ extension _PlayerControls on _PlayerPageState { // tap target the D-pad cannot reach; the button is landscape-only for // that reason, not for room. if (_isPortrait) return null; - return _IconButton(icon: Icons.lock_rounded, onTap: _lock); + return PlayerControlFace( + id: id, + icon: Icons.lock_rounded, + semanticLabel: name, + onTap: _lock, + ); case 'stats': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.info_rounded, - color: _showPlayerInfo ? AppColors.primary : null, + semanticLabel: name, + active: _showPlayerInfo, onTap: () => setState(() => _showPlayerInfo = !_showPlayerInfo), ); case 'speed': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.speed_rounded, + semanticLabel: name, onTap: _cycleSpeed, onLongPress: _openSpeedSheet, + menu: _speedRows, + onMenu: _pickSpeed, ); case 'server': if (!a.hasServers) return null; - return _IconButton(icon: Icons.dns_rounded, onTap: _openServerSheet); + return PlayerControlFace( + id: id, + icon: Icons.dns_rounded, + semanticLabel: name, + onTap: _openServerSheet, + ); case 'quality': if (!a.hasQualities) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.high_quality_rounded, + semanticLabel: name, onTap: () => _openPanel(_SidePanel.quality), ); case 'episodes': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.video_library_rounded, + semanticLabel: name, onTap: () => _openPanel(_SidePanel.episodes), ); case 'previous': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.skip_previous_rounded, + semanticLabel: name, enabled: _hasPrevEpisode, onTap: () => _partyEpisodeNav(_episodeIndex - 1), ); case 'next': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.skip_next_rounded, + semanticLabel: name, enabled: _hasNextEpisode, onTap: () => _partyEpisodeNav(_episodeIndex + 1), ); case 'shader': if (!(_controller?.supportsShaders ?? false)) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.auto_awesome_rounded, - color: _shaderPreset.isOff ? null : AppColors.primary, + semanticLabel: name, + active: !_shaderPreset.isOff, onTap: _openShaderSheet, ); case 'fit': - return _IconButton( + return PlayerControlFace( + id: id, icon: _fitIcon(_fit), + semanticLabel: name, // Forward on a tap, back on a hold. Three modes and a glyph that // names the current one leaves nothing for a dialog to add — and a // hold that steps backwards is worth more than one that opens a list - // of what the taps already walk through. + // of what the taps already walk through. The native button's hold + // opens all three instead, with the current one checked. onTap: _cycleFit, onLongPress: () => _cycleFit(step: -1), + menu: _fitRows, + onMenu: _pickFit, ); case 'sleep': if (_isLive) return null; - return _IconButton(icon: Icons.bedtime_rounded, onTap: _openSleepSheet); + return _sleepFace(); case 'cast': if (!_canCast) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.cast_rounded, - color: _cast.isCasting ? AppColors.primary : null, + semanticLabel: name, + active: _cast.isCasting, onTap: _openCastSheet, ); case 'party': // CloudStream sources need an on-device plugin per peer, so they are // out of Watch2Gether until every peer can resolve them. if (!_inParty && widget.args.provider.startsWith('cs:')) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: _inParty ? Icons.groups_rounded : Icons.groups_2_rounded, - color: _inParty ? AppColors.primary : null, + semanticLabel: name, + active: _inParty, onTap: _openWatchParty, ); case 'pip': @@ -2004,34 +2343,57 @@ extension _PlayerControls on _PlayerPageState { // corner of the app, and a long press pops the window out above the // others. if (isDesktopPlatform) { - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.picture_in_picture_alt_rounded, onTap: _openMiniPlayer, onLongPress: _toggleDesktopMini, + semanticLabel: name, tooltip: 'player.mini_popout_hint'.tr(), ); } if (!isAndroidPlatform && !isIosPlatform) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.picture_in_picture_rounded, + semanticLabel: name, onTap: _enterPip, ); case 'download': if (!a.canDownload) return null; - return _IconButton(icon: Icons.download_rounded, onTap: _startDownload); + return PlayerControlFace( + id: id, + icon: Icons.download_rounded, + semanticLabel: name, + onTap: _startDownload, + ); } return null; } - /// One control as it appears on the BOTTOM row. + /// [face] as the Flutter bottom-row button it has always been. + Widget _flutterBottom(PlayerControlFace face) { + if (!face.labelled) return _flutterIcon(face); + return _BottomTextButton( + icon: face.icon, + label: face.label!, + compactLabel: face.compactLabel, + compact: face.compact, + enabled: face.enabled, + onTap: face.onTap!, + ); + } + + /// One control as the bottom row shows it. /// /// Labelled where the label carries live state the icon cannot — the current /// server, the resolved quality, the speed, the sleep countdown. Everything /// else falls back to the same icon the top bar uses, so a control keeps its /// identity wherever the viewer puts it. - Widget? _bottomControl(String id) { + PlayerControlFace? _bottomFace(String id) { final a = _affordances; final compact = _compactBottomBar; + final name = _controlName(id); switch (id) { case 'lock': // Portrait has exactly one way out of the lock overlay and it is a tap @@ -2041,17 +2403,20 @@ extension _PlayerControls on _PlayerPageState { // no room — and it still has to render, or the viewer would simply // lose the control. if (isTvPlatform || _isPortrait) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.lock_rounded, + semanticLabel: name, label: 'player.lock'.tr(), compact: compact, - enabled: true, onTap: _lock, ); case 'previous': if (!a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.skip_previous_rounded, + semanticLabel: name, label: 'player.previous'.tr(), compact: compact, enabled: _hasPrevEpisode, @@ -2059,86 +2424,95 @@ extension _PlayerControls on _PlayerPageState { ); case 'next': if (!a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.skip_next_rounded, + semanticLabel: name, label: 'general.next'.tr(), compact: compact, enabled: _hasNextEpisode, onTap: () => _partyEpisodeNav(_episodeIndex + 1), ); case 'speed': - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.speed_rounded, + semanticLabel: name, label: '${_playbackSpeed.toStringAsFixed(_playbackSpeed == _playbackSpeed.roundToDouble() ? 0 : 2)}x', compact: compact, - enabled: true, onTap: _openSpeedSheet, + onLongPress: _openSpeedSheet, + menu: _speedRows, + onMenu: _pickSpeed, ); case 'server': if (isTvPlatform || !a.hasServers) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.dns_rounded, + semanticLabel: name, label: _currentServer ?? '—', compact: compact, - enabled: true, onTap: _openServerSheet, ); case 'quality': if (isTvPlatform || !a.hasQualities) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.high_quality_rounded, + semanticLabel: name, label: _qualityChipLabel, // The height is the point of this button, so it stays readable // even where every other label is dropped. compactLabel: _qualityChipShort, compact: compact, - enabled: true, onTap: () => _openPanel(_SidePanel.quality), ); case 'episodes': if (isTvPlatform || !a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( // The same glyph the top bar draws. A control that changes its face // when the viewer moves it stops being the control they moved. + id: id, icon: Icons.video_library_rounded, + semanticLabel: name, label: 'player.episodes'.tr(), compact: compact, - enabled: true, onTap: () => _openPanel(_SidePanel.episodes), ); case 'shader': if (!_roomForExtras) return null; if (!(_controller?.supportsShaders ?? false)) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.auto_awesome_rounded, + semanticLabel: name, label: _shaderPreset.isOff ? 'general.off'.tr() : _shaderPreset.labelKey.tr(), - enabled: true, + active: !_shaderPreset.isOff, onTap: _openShaderSheet, ); case 'fit': if (!_roomForExtras) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: _fitIcon(_fit), + semanticLabel: name, label: _fitLabel(_fit), - enabled: true, // Cycles. There are three fits, the button is already showing which // one is on, and it changes under the finger — so a sheet listing // three radio buttons was a modal over the video to answer a question // the button itself answers. No toast either: the label IS the // feedback, and a snackbar repeating it is one message too many. onTap: () => _cycleFit(toast: false), + menu: _fitRows, + onMenu: _pickFit, ); case 'sleep': if (!_roomForExtras || _isLive) return null; - return _BottomTextButton( - icon: Icons.bedtime_rounded, - label: _sleepValueLabel, - enabled: true, - onTap: _openSleepSheet, - ); + return _sleepFace(label: _sleepValueLabel); case 'party': case 'pip': case 'download': @@ -2146,17 +2520,25 @@ extension _PlayerControls on _PlayerPageState { // top bar, and rendering them here as well is the duplication the // `&& !isTvPlatform` guards used to prevent. if (isTvPlatform || isDesktopPlatform) return null; - return _topBarControl(id); + return _topBarFace(id); default: // No live value to print, so the icon form is the honest one and the // top bar already builds it. - return _topBarControl(id); + return _topBarFace(id); } } /// The controls for [slot], in the viewer's order, minus the ones that cannot /// exist right now. - List _controlsFor(PlayerControlSlot slot, {required bool topBar}) { + List _controlsFor(PlayerControlSlot slot, {required bool topBar}) => [ + for (final face in _facesFor(slot, topBar: topBar)) + topBar ? _flutterIcon(face) : _flutterBottom(face), + ]; + + List _facesFor( + PlayerControlSlot slot, { + required bool topBar, + }) { // A television's top bar is where the D-pad lands first and a desktop // window has its own row; the editor is a phone screen and must not govern // either. They render the shipped arrangement instead — which is also why @@ -2173,12 +2555,9 @@ extension _PlayerControls on _PlayerPageState { if (topBar || !lifted.contains(id)) id, if (topBar && slot == PlayerControlSlot.topBar) ...lifted, ]; - final out = []; - for (final id in ids) { - final w = topBar ? _topBarControl(id) : _bottomControl(id); - if (w != null) out.add(w); - } - return out; + return [ + for (final id in ids) ?(topBar ? _topBarFace(id) : _bottomFace(id)), + ]; } /// A landscape phone has room the portrait one does not, so it shows more. @@ -2259,68 +2638,69 @@ extension _PlayerControls on _PlayerPageState { // every drag event (120 Hz on a Samsung S22), and without a // boundary each of those repainted the whole controls overlay — // scrim, title, every icon and its text shadow — not the bar. - RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: _sliderDragValue, - builder: (_, dragVal, _) { - return ValueListenableBuilder( - valueListenable: c, - builder: (_, value, _) { - if (_isLive) return _buildLiveBar(); - final duration = value.duration.inMilliseconds == 0 - ? Duration.zero - : value.duration; - final maxMs = duration.inMilliseconds.toDouble().clamp( - 1.0, - double.infinity, - ); - final sliderVal = - dragVal ?? - (duration.inMilliseconds == 0 - ? 0.0 - : value.position.inMilliseconds - .clamp(0, duration.inMilliseconds) - .toDouble()); - final displayPos = dragVal != null - ? Duration(milliseconds: dragVal.toInt()) - : value.position; - - return Row( - children: [ - IgnorePointer( - child: Text( - _positionLabel(displayPos, duration), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - shadows: _kControlShadow, + _fadeFlutter( + RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: _sliderDragValue, + builder: (_, dragVal, _) { + return ValueListenableBuilder( + valueListenable: c, + builder: (_, value, _) { + if (_isLive) return _buildLiveBar(); + final duration = value.duration.inMilliseconds == 0 + ? Duration.zero + : value.duration; + final maxMs = duration.inMilliseconds + .toDouble() + .clamp(1.0, double.infinity); + final sliderVal = + dragVal ?? + (duration.inMilliseconds == 0 + ? 0.0 + : value.position.inMilliseconds + .clamp(0, duration.inMilliseconds) + .toDouble()); + final displayPos = dragVal != null + ? Duration(milliseconds: dragVal.toInt()) + : value.position; + + return Row( + children: [ + IgnorePointer( + child: Text( + _positionLabel(displayPos, duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + shadows: _kControlShadow, + ), ), ), - ), - Expanded( - child: _buildSeekBar( - value: value, - sliderVal: sliderVal.clamp(0.0, maxMs), - maxMs: maxMs, - scrubbing: dragVal != null, - previewPosition: displayPos, + Expanded( + child: _buildSeekBar( + value: value, + sliderVal: sliderVal.clamp(0.0, maxMs), + maxMs: maxMs, + scrubbing: dragVal != null, + previewPosition: displayPos, + ), ), - ), - IgnorePointer( - child: Text( - _formatDuration(duration), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - shadows: _kControlShadow, + IgnorePointer( + child: Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + shadows: _kControlShadow, + ), ), ), - ), - ], - ); - }, - ); - }, + ], + ); + }, + ); + }, + ), ), ), const SizedBox(height: 4), @@ -2342,11 +2722,11 @@ extension _PlayerControls on _PlayerPageState { // and the corners are what the viewer is complaining about now. Builder( builder: (context) { - final left = _controlsFor( + final left = _facesFor( PlayerControlSlot.bottomLeft, topBar: false, ); - final rightAll = _controlsFor( + final rightAll = _facesFor( PlayerControlSlot.bottomRight, topBar: false, ); @@ -2367,10 +2747,33 @@ extension _PlayerControls on _PlayerPageState { _fittingControls(box.maxWidth, left.length), ) .toList(); - return MediaGlassLayer( + final flutter = MediaGlassLayer( child: PlayerBottomControlRow( - leading: left, - trailing: right, + leading: [for (final f in left) _flutterBottom(f)], + trailing: [ + for (final f in right) _flutterBottom(f), + ], + ), + ); + if (!_nativeBars || left.length + right.length == 0) { + return _fadeFlutter(flutter); + } + final trailingGroup = left.isEmpty ? 0 : 1; + return Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + shown: _controlsVisible, + accent: AppColors.primary, + onMenuOpen: _nativeMenuOpened, + onMenuClose: _nativeMenuClosed, + items: [ + for (final f in left) f.toNative(), + for (final f in right) + f.toNative(group: trailingGroup), + ], + // Too long even tightened: the Flutter row, + // which scrolls. + fallback: (_) => _fadeFlutter(flutter), ), ); }, diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 3a046445..288d2fe4 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -27,7 +27,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:file_picker/file_picker.dart'; import 'package:dio/dio.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:floating/floating.dart'; import 'package:gal/gal.dart'; import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; @@ -75,6 +75,12 @@ import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/glass/glass_hold.dart'; import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/native/native_bar_layout.dart' show BarSpread; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; import 'package:soplay/features/mal/data/mal_tracker.dart'; import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; @@ -487,6 +493,16 @@ class _PlayerPageState extends State bool get _hasPrevEpisode => _window.hasPrev; bool _controlsVisible = true; bool _locked = false; + + /// Whether the controls are the phone's own glass buttons (iOS 26+), + /// decided once on the first build so Flutter and UIKit never swap under + /// the viewer — again only when Reduce Transparency is thrown. + bool _nativeControls = false; + bool? _nativeDecidedUnder; + + /// The small players draw no native bars: the corner player is moved and + /// clipped as a whole, which a hosted view cannot follow. + bool get _nativeBars => _nativeControls && !_compact; _SidePanel _panel = _SidePanel.none; String? _currentLang; @@ -963,6 +979,16 @@ class _PlayerPageState extends State super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final rt = GlassSense.reduceTransparencyOf(context); + if (_nativeDecidedUnder != rt) { + _nativeDecidedUnder = rt; + _nativeControls = NativeControls.decide(context); + } + } + @override Widget build(BuildContext context) { if (_docked) _reportDockAspect(); @@ -1067,47 +1093,50 @@ class _PlayerPageState extends State } }, onPointerCancel: (_) => _pointerCancelled = true, - child: Stack( - fit: StackFit.expand, - children: [ - _buildVideoLayer(), - _buildSubtitleOverlay(), - if (!_locked) _buildReactionLane(), - if (!_locked) _buildSeekRipple(), - if (_locked) - _buildLockOverlay() - else - _buildControlsOverlay(), - if (!_locked) _buildScrubOverlay(), - if (!_locked) _buildSpeedBoostBadge(), - if (!_locked) _buildSwipeIndicator(), - // Renders nothing unless the URL is a local torrent stream, - // so it is safe to hand it every playback unconditionally. - // It keeps showing while the controls are hidden if the - // pre-buffer is still filling — that is precisely when the - // picture is frozen and the user needs to know why. - TorrentStatsOverlay( - videoUrl: _videoUrl, - visible: _controlsVisible && !_locked, - ), - // Above the controls layer so it is reachable while they are - // hidden — the offer is at its most useful to a viewer who has - // not touched the screen. Renders nothing when no interval is - // active, so it costs nothing on non-anime playback. - if (!_locked) _buildPlayerInfoOverlay(), - if (!_locked) _buildSkipButton(), - if (!_locked) _buildUpNextPrompt(), - if (!_locked) _buildEpisodeSocialRail(), - if (!_locked && _panel != _SidePanel.none) - _buildSidePanel(), - if (!_locked && _inParty) _buildPartyReactionsLayer(), - 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 - // scrub a surface nobody is watching. - _buildCastOverlay(), - ], + child: NativeControlsScope( + native: _nativeBars, + child: Stack( + fit: StackFit.expand, + children: [ + _buildVideoLayer(), + _buildSubtitleOverlay(), + if (!_locked) _buildReactionLane(), + if (!_locked) _buildSeekRipple(), + if (_locked) + _buildLockOverlay() + else + _buildControlsOverlay(), + if (!_locked) _buildScrubOverlay(), + if (!_locked) _buildSpeedBoostBadge(), + if (!_locked) _buildSwipeIndicator(), + // Renders nothing unless the URL is a local torrent stream, + // so it is safe to hand it every playback unconditionally. + // It keeps showing while the controls are hidden if the + // pre-buffer is still filling — that is precisely when the + // picture is frozen and the user needs to know why. + TorrentStatsOverlay( + videoUrl: _videoUrl, + visible: _controlsVisible && !_locked, + ), + // Above the controls layer so it is reachable while they are + // hidden — the offer is at its most useful to a viewer who has + // not touched the screen. Renders nothing when no interval is + // active, so it costs nothing on non-anime playback. + if (!_locked) _buildPlayerInfoOverlay(), + if (!_locked) _buildSkipButton(), + if (!_locked) _buildUpNextPrompt(), + if (!_locked) _buildEpisodeSocialRail(), + if (!_locked && _panel != _SidePanel.none) + _buildSidePanel(), + if (!_locked && _inParty) _buildPartyReactionsLayer(), + 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 + // scrub a surface nobody is watching. + _buildCastOverlay(), + ], + ), ), ), ), diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index c2b6ff63..be8eee97 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -924,8 +924,11 @@ extension _PlayerPanels on _PlayerPageState { } } + /// The speeds the sheet lists, and the native speed button's menu. + static const List _speedPresets = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + void _openSpeedSheet() { - const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + const speeds = _speedPresets; showAdaptiveModal( context: context, backgroundColor: const Color(0xFF111111), diff --git a/lib/features/detail/presentation/pages/player_page.reactions.dart b/lib/features/detail/presentation/pages/player_page.reactions.dart index bf6924ab..424bde42 100644 --- a/lib/features/detail/presentation/pages/player_page.reactions.dart +++ b/lib/features/detail/presentation/pages/player_page.reactions.dart @@ -193,33 +193,62 @@ extension _PlayerEpisodeSocial on _PlayerPageState { /// React and Comments, on the end edge while the controls are up. Widget _buildEpisodeSocialRail() { - if (_compact || !_controlsVisible || _socialContentUrl == null) { + // The native rail stays built while hidden, so UIKit can fade it out. + if (_compact || + (!_controlsVisible && !_nativeBars) || + _socialContentUrl == null) { return const SizedBox.shrink(); } + Widget flutter(BuildContext _) { + if (!_controlsVisible) return const SizedBox.shrink(); + return FadeTransition( + opacity: _controlsAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _IconButton( + icon: Icons.add_reaction_outlined, + tooltip: 'player.react'.tr(), + onTap: _openReactionPicker, + ), + const SizedBox(height: 8), + _IconButton( + icon: Icons.forum_outlined, + tooltip: 'player.comments'.tr(), + onTap: _openEpisodeComments, + ), + ], + ), + ); + } + return PositionedDirectional( end: 8, top: 0, bottom: 0, child: Center( - child: FadeTransition( - opacity: _controlsAnimation, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _IconButton( - icon: Icons.add_reaction_outlined, - tooltip: 'player.react'.tr(), - onTap: _openReactionPicker, - ), - const SizedBox(height: 8), - _IconButton( - icon: Icons.forum_outlined, - tooltip: 'player.comments'.tr(), - onTap: _openEpisodeComments, - ), - ], - ), - ), + child: _nativeBars + ? NativeGlassBar( + look: NativeBarLook.capsule, + axis: Axis.vertical, + shown: _controlsVisible, + items: [ + NativeBarItem( + id: 'react', + symbol: playerSymbol('react'), + semanticLabel: 'player.react'.tr(), + onPressed: _openReactionPicker, + ), + NativeBarItem( + id: 'comments', + symbol: playerSymbol('comments'), + semanticLabel: 'player.comments'.tr(), + onPressed: _openEpisodeComments, + ), + ], + fallback: flutter, + ) + : flutter(context), ), ); } diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 9b78702a..ec9e28ee 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -2191,3 +2191,55 @@ class _SwitchArrowState extends State<_SwitchArrow> ); } } + +/// Rebuilds [builder] only when [select] reads something new off +/// [listenable]: the native play button wants playing and buffering, not +/// every position tick the controller sends. +class _Selected extends StatefulWidget { + const _Selected({ + required this.listenable, + required this.select, + required this.builder, + }); + + final Listenable listenable; + final T Function() select; + final Widget Function(BuildContext context, T value) builder; + + @override + State<_Selected> createState() => _SelectedState(); +} + +class _SelectedState extends State<_Selected> { + late T _value = widget.select(); + + @override + void initState() { + super.initState(); + widget.listenable.addListener(_changed); + } + + @override + void didUpdateWidget(_Selected old) { + super.didUpdateWidget(old); + if (old.listenable != widget.listenable) { + old.listenable.removeListener(_changed); + widget.listenable.addListener(_changed); + } + _value = widget.select(); + } + + @override + void dispose() { + widget.listenable.removeListener(_changed); + super.dispose(); + } + + void _changed() { + final value = widget.select(); + if (value != _value) setState(() => _value = value); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} diff --git a/lib/features/detail/presentation/widgets/player_control_face.dart b/lib/features/detail/presentation/widgets/player_control_face.dart new file mode 100644 index 00000000..cc1b8e30 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_control_face.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; + +/// One player control as it stands on a bar right now: what it shows, what it +/// does, whether it is on. The player builds one per control and draws it +/// either as its Flutter button or as a native glass button +/// ([toNative]), so the two can never disagree about what a control is. +/// +/// Named a face, not a spec: `PlayerControlSpec` is the catalogue's entry +/// (where a control may go), this is what it looks like where it went. +@immutable +class PlayerControlFace { + const PlayerControlFace({ + required this.id, + required this.icon, + required this.semanticLabel, + this.symbol, + this.label, + this.compactLabel, + this.compact = false, + this.pill = false, + this.tooltip, + this.onTap, + this.onLongPress, + this.active = false, + this.enabled = true, + this.menu, + this.onMenu, + }); + + final String id; + final IconData icon; + final String semanticLabel; + + /// The SF Symbol; null takes the control's own ([playerSymbol]). + final String? symbol; + + /// Words beside the glyph: the bottom row's live value, the language pill. + final String? label; + + /// Drawn instead of the glyph on a compact row. + final String? compactLabel; + final bool compact; + + /// The language pill rather than a round button. + final bool pill; + + /// The Flutter button's tooltip. + final String? tooltip; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool active; + final bool enabled; + + /// The rows a hold opens on the native button. + final List? menu; + final ValueChanged? onMenu; + + bool get labelled => label != null; + + NativeBarItem toNative({int group = 0}) { + final wordsOnly = compact && compactLabel != null; + final title = pill + ? label + : wordsOnly + ? compactLabel + : compact + ? null + : label; + return NativeBarItem( + id: id, + symbol: symbol ?? playerSymbol(id, active: active), + title: title, + semanticLabel: semanticLabel, + enabled: enabled, + active: active, + hasGlyph: !wordsOnly, + fontSize: pill ? 11 : 12, + glyph: pill ? 14 : 20, + group: group, + onPressed: onTap, + onLongPress: onLongPress, + menu: menu, + onMenuSelected: onMenu, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3307fcae..1f25fd14 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:liquid_glass_widgets/liquid_glass_widgets.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/extensions/extension_bridge.dart'; import 'package:soplay/core/storage/box_recovery.dart'; @@ -246,6 +247,9 @@ void main(List args) async { // launch per install — the cold one everybody's first impression is made // of — would fall back to a flat logo. await brand; + // The player decides once, on its first build, whether its controls are + // the phone's own glass; asked here so the answer is in by then. + await NativeControls.warm(); runApp(root); diff --git a/test/core/native/bar_harness.dart b/test/core/native/bar_harness.dart new file mode 100644 index 00000000..b9a23822 --- /dev/null +++ b/test/core/native/bar_harness.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/native_link.dart'; + +import 'fake_native_host.dart'; + +const NativeGlassCapability ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +/// An iPhone on iOS 26 with the bar factory, unless told otherwise. +Widget barHarness( + Widget child, { + required FakeNativeHost host, + NativeGlassCapability capability = ios26, + bool reduceTransparency = false, + bool disableAnimations = false, + TextDirection dir = TextDirection.ltr, +}) => MediaQuery( + data: MediaQueryData( + size: const Size(800, 600), + disableAnimations: disableAnimations, + ), + child: Directionality( + textDirection: dir, + child: NativeGlassScope( + capability: capability, + child: GlassSenseScope( + reduceTransparency: reduceTransparency, + child: NativeHostScope(host: host, child: child), + ), + ), + ), +); + +/// [body] on an iPhone (not the desktop the tests run on), the overrides +/// reset before the test ends as flutter_test requires. +void testOnIPhone( + String description, + Future Function(WidgetTester tester) body, { + bool desktop = false, + TargetPlatform platform = TargetPlatform.iOS, +}) { + testWidgets(description, (tester) async { + debugDefaultTargetPlatformOverride = platform; + debugNativeControlsDesktopOverride = desktop; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugNativeControlsDesktopOverride = null; + } + }); +} diff --git a/test/core/native/fake_native_host.dart b/test/core/native/fake_native_host.dart new file mode 100644 index 00000000..55866464 --- /dev/null +++ b/test/core/native/fake_native_host.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:soplay/core/native/native_link.dart'; + +/// One thing said to a fake view. +typedef Said = ({String method, Object? arguments}); + +class FakeLink implements NativeLink { + final List said = []; + bool disposed = false; + + @override + void say(String method, [Object? arguments]) { + if (!disposed) said.add((method: method, arguments: arguments)); + } + + @override + Future ask(String method, [Object? arguments]) async { + say(method, arguments); + return null; + } + + @override + void dispose() => disposed = true; + + Iterable of(String method) => said.where((s) => s.method == method); +} + +class FakeView { + FakeView(this.type, this.params, this.onHeard); + + final String type; + final Map params; + final NativeHeard onHeard; + final FakeLink link = FakeLink(); + + /// UIKit saying [method]. + void hear(String method, [Object? arguments]) => onHeard(method, arguments); +} + +/// Draws a plain box that takes taps, and hands its control a [FakeLink] on +/// the frame after it is built, as a platform view's creation does. +class FakeNativeHost implements NativeHost { + final List views = []; + + /// Taps that landed on a hosted view rather than falling through. + int taps = 0; + + @override + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }) => _FakeBox( + make: () { + final v = FakeView(type, params, onHeard); + views.add(v); + return v; + }, + onLink: onLink, + onTap: () => taps++, + ); +} + +class _FakeBox extends StatefulWidget { + const _FakeBox({ + required this.make, + required this.onLink, + required this.onTap, + }); + + final FakeView Function() make; + final void Function(NativeLink) onLink; + final VoidCallback onTap; + + @override + State<_FakeBox> createState() => _FakeBoxState(); +} + +class _FakeBoxState extends State<_FakeBox> { + late final FakeView view = widget.make(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onLink(view.link); + }); + } + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: const SizedBox.expand(), + ); +} diff --git a/test/core/native/native_bar_layout_test.dart b/test/core/native/native_bar_layout_test.dart new file mode 100644 index 00000000..823799a3 --- /dev/null +++ b/test/core/native/native_bar_layout_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_bar_layout.dart'; + +void main() { + const icon = BarSlotSpec(); + + test('LTR lays slots left to right inside the capsule padding', () { + final l = layoutBar( + slots: const [icon, icon, icon], + dir: TextDirection.ltr, + maxExtent: 400, + )!; + expect(l.slots.map((r) => r.left), [6, 54, 102]); + expect(l.size, const Size(6 + 44 * 3 + 4 * 2 + 6, 44)); + expect(l.capsules.single, Offset.zero & l.size); + }); + + test('RTL mirrors the order', () { + final l = layoutBar( + slots: const [icon, icon, icon], + dir: TextDirection.rtl, + maxExtent: 400, + )!; + expect(l.slots.first.right, l.size.width - 6); + expect(l.slots.first.left, greaterThan(l.slots.last.left)); + }); + + test('an even cluster spaces its slots as spaceEvenly does', () { + final l = layoutBar( + slots: const [ + BarSlotSpec(extent: 52), + BarSlotSpec(extent: 72), + BarSlotSpec(extent: 52), + ], + dir: TextDirection.ltr, + maxExtent: 264, + capsules: false, + spread: BarSpread.even, + )!; + const space = (264 - 176) / 4; + expect(l.slots[0].left, space); + expect(l.slots[1].left, space * 2 + 52); + expect(l.slots[1].center.dy, l.slots[0].center.dy); + expect(l.size, const Size(264, 72)); + expect(l.capsules, isEmpty); + }); + + test('a label is measured, and grows with the text scale', () { + double width(double scale) => layoutBar( + slots: const [BarSlotSpec(label: 'Server 1080p')], + dir: TextDirection.ltr, + maxExtent: 999, + textScaler: TextScaler.linear(scale), + )!.slots.single.width; + expect(width(1), greaterThan(44)); + expect(width(1.5), greaterThan(width(1))); + }); + + test('a bar that does not fit tightens first, then gives up', () { + final slots = List.filled(8, icon); + final loose = 6 + 44 * 8 + 4 * 7 + 6; + final tight = 6 + 40 * 8 + 6; + expect( + layoutBar( + slots: slots, + dir: TextDirection.ltr, + maxExtent: loose + 0.0, + )!.slots.first.width, + 44, + ); + final squeezed = layoutBar( + slots: slots, + dir: TextDirection.ltr, + maxExtent: tight + 0.0, + )!; + expect(squeezed.slots.first.width, 40); + expect(squeezed.size.width, tight); + expect( + layoutBar(slots: slots, dir: TextDirection.ltr, maxExtent: tight - 1.0), + isNull, + ); + }); + + test('a vertical bar stacks its slots', () { + final l = layoutBar( + slots: const [icon, icon], + dir: TextDirection.rtl, + axis: Axis.vertical, + maxExtent: 400, + )!; + expect(l.slots.map((r) => r.top), [6, 54]); + expect(l.slots.every((r) => r.left == 0), isTrue); + expect(l.size, const Size(44, 6 + 44 * 2 + 4 + 6)); + }); + + test('two groups are two capsules with the group gap between', () { + final l = layoutBar( + slots: const [icon, BarSlotSpec(group: 1), BarSlotSpec(group: 1)], + dir: TextDirection.ltr, + maxExtent: 999, + )!; + expect(l.capsules, hasLength(2)); + expect(l.capsules[1].left - l.capsules[0].right, 16); + expect(l.capsules[0].contains(l.slots[0].center), isTrue); + expect(l.capsules[1].contains(l.slots[2].center), isTrue); + + final rtl = layoutBar( + slots: const [icon, BarSlotSpec(group: 1)], + dir: TextDirection.rtl, + maxExtent: 999, + )!; + expect(rtl.capsules[0].left, greaterThan(rtl.capsules[1].left)); + }); +} diff --git a/test/core/native/native_controls_gate_test.dart b/test/core/native/native_controls_gate_test.dart new file mode 100644 index 00000000..ba05b4f4 --- /dev/null +++ b/test/core/native/native_controls_gate_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; + +NativeGlassCapability _cap({ + bool systemGlass = true, + Set styles = const {'regular', 'clear'}, + bool glassControls = true, +}) => NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: systemGlass, + styles: styles, + materials: const {'thin'}, + reduceTransparency: false, + glassControls: glassControls, +); + +bool _draw({ + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, + NativeGlassCapability? cap, + bool rt = false, +}) => nativeControlsDraw( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap ?? _cap(), + reduceTransparency: rt, +); + +void main() { + test('an iOS 26 iPhone with clear glass and the bar factory hosts', () { + expect(_draw(), isTrue); + }); + + test('Reduce Transparency keeps Flutter', () { + expect(_draw(rt: true), isFalse); + }); + + test('no glassControls keeps Flutter', () { + expect(_draw(cap: _cap(glassControls: false)), isFalse); + }); + + test('regular-only glass keeps Flutter', () { + expect(_draw(cap: _cap(styles: const {'regular'})), isFalse); + expect(_draw(cap: _cap(systemGlass: false)), isFalse); + }); + + test('Android, TV and desktop never host', () { + expect(_draw(isIOS: false), isFalse); + expect(_draw(isTv: true), isFalse); + expect(_draw(isDesktop: true), isFalse); + }); + + test('absent capability keeps Flutter', () { + expect(_draw(cap: NativeGlassCapability.absent), isFalse); + }); +} diff --git a/test/core/native/native_glass_bar_test.dart b/test/core/native/native_glass_bar_test.dart new file mode 100644 index 00000000..72606f48 --- /dev/null +++ b/test/core/native/native_glass_bar_test.dart @@ -0,0 +1,280 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_bar_layout.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/system/platform_utils.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +class _Calls { + int play = 0; + int hold = 0; + int pageTaps = 0; +} + +/// A transport cluster centred on a page that toggles on a tap. +Widget _page( + _Calls calls, { + bool shown = true, + bool enabled = true, + bool active = false, + double width = 280, +}) => Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => calls.pageTaps++, + ), + ), + Center( + child: SizedBox( + width: width, + child: NativeGlassBar( + look: NativeBarLook.cluster, + spread: BarSpread.even, + shown: shown, + fallback: (_) => const Text('fallback'), + items: [ + const NativeBarItem( + id: 'rewind', + symbol: 'gobackward.10', + semanticLabel: 'Rewind', + extent: 52, + ), + NativeBarItem( + id: 'play', + symbol: 'play.fill', + semanticLabel: 'Play', + extent: 72, + prominent: true, + enabled: enabled, + active: active, + onPressed: () => calls.play++, + onLongPress: () => calls.hold++, + ), + const NativeBarItem( + id: 'forward', + symbol: 'goforward.10', + semanticLabel: 'Forward', + extent: 52, + ), + ], + ), + ), + ), + ], +); + +void main() { + testOnIPhone('one hosted view for the whole cluster', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, hasLength(1)); + final items = host.views.single.params['items']! as List; + expect( + [for (final i in items) (i as Map)['symbol']], + ['gobackward.10', 'play.fill', 'goforward.10'], + ); + expect(find.text('fallback'), findsNothing); + }); + + testOnIPhone('each slot says its label, enabled and selected state', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final calls = _Calls(); + await tester.pumpWidget( + barHarness(_page(calls, active: true), host: FakeNativeHost()), + ); + final play = tester.getSemantics(find.bySemanticsLabel('Play')); + expect( + play, + matchesSemantics( + label: 'Play', + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasSelectedState: true, + isSelected: true, + hasTapAction: true, + hasLongPressAction: true, + ), + ); + tester.semantics.tap(find.semantics.byLabel('Play')); + tester.semantics.longPress(find.semantics.byLabel('Play')); + expect(calls.play, 1); + expect(calls.hold, 1); + + await tester.pumpWidget( + barHarness(_page(calls, enabled: false), host: FakeNativeHost()), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Play')), + matchesSemantics( + label: 'Play', + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + ), + ); + semantics.dispose(); + }); + + testOnIPhone('a tap in a gap is the page, a tap on a slot is UIKit', ( + tester, + ) async { + final host = FakeNativeHost(); + final calls = _Calls(); + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + final bar = tester.getRect(find.byType(NativeGlassBar)); + + // Between rewind and play. + final rewind = (host.views.single.params['items']! as List)[0] as Map; + final rect = rewind['rect']! as List; + final gap = + bar.topLeft + Offset((rect[0] as double) + 52 + 4, bar.height / 2); + await tester.tapAt(gap); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1); + expect(host.taps, 0); + + await tester.tapAt(bar.center); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1, reason: 'the play slot took it'); + expect(host.taps, 1); + + host.views.single.hear('press', 'play'); + expect(calls.play, 1); + }); + + testOnIPhone('hiding fades in UIKit, then leaves the frame', (tester) async { + final host = FakeNativeHost(); + final calls = _Calls(); + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_page(calls, shown: false), host: host)); + await tester.pump(); + expect(link.of('setShown').single.arguments, {'shown': false, 'ms': 220}); + expect( + tester.widget(find.byType(Offstage)).offstage, + isFalse, + reason: 'still fading', + ); + await tester.pump(NativeGlassBar.fade); + expect(tester.widget(find.byType(Offstage)).offstage, isTrue); + expect(host.views, hasLength(1), reason: 'kept alive, not remade'); + + // Hidden, a tap is the page's. + await tester.tapAt(tester.getCenter(find.byType(NativeGlassBar))); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1); + + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + expect(tester.widget(find.byType(Offstage)).offstage, isFalse); + expect(link.of('setShown').last.arguments, {'shown': true, 'ms': 220}); + }); + + testOnIPhone('no Flutter opacity stands over a native bar', (tester) async { + await tester.pumpWidget( + barHarness(_page(_Calls()), host: FakeNativeHost()), + ); + expect( + find.ancestor( + of: find.byType(NativeGlassBar), + matching: find.byType(FadeTransition), + ), + findsNothing, + ); + }); + + testOnIPhone('Reduce Motion hides at once', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness(_page(_Calls()), host: host, disableAnimations: true), + ); + await tester.pump(); + await tester.pumpWidget( + barHarness( + _page(_Calls(), shown: false), + host: host, + disableAnimations: true, + ), + ); + await tester.pump(); + expect(host.views.single.link.of('setShown').single.arguments, { + 'shown': false, + 'ms': 0, + }); + }); + + group('the Flutter fallback', () { + testOnIPhone('without glassControls', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness( + _page(_Calls()), + host: host, + capability: NativeGlassCapability.absent, + ), + ); + expect(host.views, isEmpty); + expect(find.text('fallback'), findsOneWidget); + }); + + testOnIPhone('under Reduce Transparency', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness(_page(_Calls()), host: host, reduceTransparency: true), + ); + expect(host.views, isEmpty); + expect(find.text('fallback'), findsOneWidget); + }); + + testOnIPhone('on a desktop', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + }, desktop: true); + + testOnIPhone('on Android', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + }, platform: TargetPlatform.android); + + testOnIPhone('on a television', (tester) async { + debugSetTvPlatform(true); + try { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + } finally { + debugSetTvPlatform(false); + } + }); + + testOnIPhone('for the one bar that does not fit', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness( + Column( + children: [ + SizedBox(width: 120, child: _page(_Calls(), width: 120)), + SizedBox(width: 400, height: 100, child: _page(_Calls())), + ], + ), + host: host, + ), + ); + expect(find.text('fallback'), findsOneWidget); + expect(host.views, hasLength(1)); + }); + }); +} diff --git a/test/core/native/native_glass_capability_test.dart b/test/core/native/native_glass_capability_test.dart new file mode 100644 index 00000000..4ad9ae7d --- /dev/null +++ b/test/core/native/native_glass_capability_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +void main() { + test('an older Swift side that sends no glassControls reads as false', () { + final cap = NativeGlassCapability.fromChannel(const { + 'systemVersion': '26.0', + 'majorVersion': 26, + 'systemGlass': true, + 'glassStyles': ['regular', 'clear'], + 'materials': ['thin'], + 'reduceTransparency': false, + }); + expect(cap.glassControls, isFalse); + expect(NativeGlassCapability.absent.glassControls, isFalse); + }); + + test('glassControls is read and counts in equality', () { + Map said(bool controls) => { + 'systemGlass': true, + 'glassStyles': ['clear'], + 'glassControls': controls, + }; + final yes = NativeGlassCapability.fromChannel(said(true)); + expect(yes.glassControls, isTrue); + expect(yes == NativeGlassCapability.fromChannel(said(false)), isFalse); + expect(yes == NativeGlassCapability.fromChannel(said(true)), isTrue); + }); +} diff --git a/test/core/native/native_link_test.dart b/test/core/native/native_link_test.dart new file mode 100644 index 00000000..979648fe --- /dev/null +++ b/test/core/native/native_link_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +Widget _bar({bool active = false, VoidCallback? onPressed, String? title}) => + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const Text('fallback'), + items: [ + NativeBarItem( + id: 'stats', + symbol: 'info.circle', + semanticLabel: 'Stats', + active: active, + title: title, + onPressed: onPressed, + ), + const NativeBarItem( + id: 'settings', + symbol: 'gearshape', + semanticLabel: 'Settings', + ), + ], + ), + ); + +void main() { + testOnIPhone('the view is made with everything and told nothing twice', ( + tester, + ) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final view = host.views.single; + expect(view.type, NativeGlassBar.viewType); + expect((view.params['items']! as List), hasLength(2)); + expect(view.params['look'], 'capsule'); + // `made:` counts the creation params as told: linking sends nothing. + expect(view.link.said, isEmpty); + + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + expect(view.link.said, isEmpty, reason: 'same items, nothing to say'); + }); + + testOnIPhone('a changed field is sent alone, as setItem', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_bar(active: true), host: host)); + await tester.pump(); + expect(link.said, hasLength(1)); + expect(link.said.single.method, 'setItem'); + expect(link.said.single.arguments, {'id': 'stats', 'active': true}); + }); + + testOnIPhone('a slot that moves is sent as a whole setItems', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_bar(title: 'Long words'), host: host)); + await tester.pump(); + expect(link.of('setItems'), hasLength(1)); + expect(link.of('setItem'), isEmpty); + }); + + testOnIPhone('UIKit is not heard once the bar has gone', (tester) async { + final host = FakeNativeHost(); + var pressed = 0; + await tester.pumpWidget( + barHarness(_bar(onPressed: () => pressed++), host: host), + ); + await tester.pump(); + final view = host.views.single; + view.hear('press', 'stats'); + expect(pressed, 1); + + await tester.pumpWidget(barHarness(const SizedBox(), host: host)); + view.hear('press', 'stats'); + expect(pressed, 1); + expect(view.link.disposed, isTrue); + }); +} diff --git a/test/core/native/native_menu_select_test.dart b/test/core/native/native_menu_select_test.dart new file mode 100644 index 00000000..ba9f73f7 --- /dev/null +++ b/test/core/native/native_menu_select_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +Widget _speed(List speeds, ValueChanged onMenu) => Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + items: [ + NativeBarItem( + id: 'speed', + symbol: 'gauge.with.dots.needle.67percent', + semanticLabel: 'Speed', + menu: [for (final s in speeds) NativeMenuRow(title: '${s}x', key: s)], + onMenuSelected: onMenu, + ), + ], + ), +); + +void main() { + testOnIPhone('a row is chosen by what the open menu showed', (tester) async { + final host = FakeNativeHost(); + final picked = []; + await tester.pumpWidget( + barHarness(_speed([1, 1.5, 2], picked.add), host: host), + ); + await tester.pump(); + final view = host.views.single; + expect((view.params['items']! as List).single['menu'], hasLength(3)); + + view.hear('menuOpen', 'speed'); + // The rows change under the open menu: UIKit still shows the old ones. + await tester.pumpWidget( + barHarness(_speed([2, 1, 1.5], picked.add), host: host), + ); + await tester.pump(); + view.hear('select', {'id': 'speed', 'row': 0}); + view.hear('menuClose', 'speed'); + // Row 0 was 1x when the menu opened; 1x is now index 1. + expect(picked, [1]); + + // A menu opened after the change reads the new rows. + view.hear('menuOpen', 'speed'); + view.hear('select', {'id': 'speed', 'row': 0}); + expect(picked, [1, 0]); + }); + + testOnIPhone('the menu holding open and closing is said', (tester) async { + final host = FakeNativeHost(); + final said = []; + await tester.pumpWidget( + barHarness( + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + onMenuOpen: () => said.add('open'), + onMenuClose: () => said.add('close'), + items: const [ + NativeBarItem( + id: 'fit', + symbol: 'aspectratio', + semanticLabel: 'Fit', + ), + ], + ), + ), + host: host, + ), + ); + await tester.pump(); + host.views.single + ..hear('menuOpen', 'fit') + ..hear('menuClose', 'fit'); + expect(said, ['open', 'close']); + }); +} diff --git a/test/core/native/sf_symbols_test.dart b/test/core/native/sf_symbols_test.dart new file mode 100644 index 00000000..54619d86 --- /dev/null +++ b/test/core/native/sf_symbols_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/domain/player_controls_layout.dart'; + +void main() { + test('every control in the catalogue has a symbol', () { + final missing = [ + for (final spec in PlayerControlCatalogue.all) + if (!playerSymbols.containsKey(spec.id)) spec.id, + ]; + expect(missing, isEmpty); + }); + + test('the player pieces outside the catalogue have one too', () { + for (final id in [ + 'back', 'play', 'pause', 'replay', 'rewind', 'forward', 'react', // + 'comments', 'unlock', 'skip', + ]) { + expect(playerSymbols[id], isNotNull, reason: id); + } + }); + + test( + 'a step SF Symbols numbers gets its number, another the plain arrow', + () { + expect(rewindSymbol(10), 'gobackward.10'); + expect(rewindSymbol(7), 'gobackward'); + expect(forwardSymbol(30), 'goforward.30'); + expect(forwardSymbol(20), 'goforward'); + }, + ); + + test('an active control wears its filled face where it has one', () { + expect(playerSymbol('stats', active: true), 'info.circle.fill'); + expect(playerSymbol('stats'), 'info.circle'); + expect(playerSymbol('settings', active: true), 'gearshape'); + }); +} diff --git a/test/features/detail/player_native_controls_test.dart b/test/features/detail/player_native_controls_test.dart new file mode 100644 index 00000000..88c55068 --- /dev/null +++ b/test/features/detail/player_native_controls_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/features/detail/domain/player_controls_layout.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; + +/// The player page cannot be stood up in a test (a resolved stream, getIt, a +/// controller), so its wiring of the native bars is read from the source, as +/// player_controls_visibility_test.dart reads `_controlsVisible`, and the +/// face each control hands a bar is tested on its own. +void main() { + const dir = 'lib/features/detail/presentation/pages'; + String read(String name) => File('$dir/$name').readAsStringSync(); + + group('a control face as a native item', () { + PlayerControlFace face({ + String id = 'speed', + String? label, + String? compactLabel, + bool compact = false, + bool pill = false, + bool active = false, + }) => PlayerControlFace( + id: id, + icon: Icons.speed_rounded, + semanticLabel: 'Speed', + label: label, + compactLabel: compactLabel, + compact: compact, + pill: pill, + active: active, + onTap: () {}, + menu: const [NativeMenuRow(title: '1x', checked: true)], + onMenu: (_) {}, + ); + + test('keeps its symbol, label, menu and state', () { + final item = face(label: '1.25x').toNative(group: 1); + expect(item.symbol, 'gauge.with.dots.needle.67percent'); + expect(item.title, '1.25x'); + expect(item.group, 1); + expect(item.menu, hasLength(1)); + expect(item.onMenuSelected, isNotNull); + }); + + test('a compact row drops the words, or shows only them', () { + expect(face(label: '1.25x', compact: true).toNative().title, isNull); + final quality = face( + id: 'quality', + label: 'Auto · 1080p', + compactLabel: '1080', + compact: true, + ).toNative(); + expect(quality.title, '1080'); + expect(quality.hasGlyph, isFalse); + }); + + test('the language pill keeps its code and its smaller type', () { + final item = face(id: 'language', label: 'UZ', pill: true).toNative(); + expect(item.symbol, 'character.bubble'); + expect(item.title, 'UZ'); + expect(item.fontSize, 11); + }); + + test('an active control wears its filled face', () { + expect( + face(id: 'stats', active: true).toNative().symbol, + 'info.circle.fill', + ); + }); + }); + + test('every catalogue control has a face on the top bar', () { + final source = read('player_page.controls.dart'); + final body = source.substring( + source.indexOf('PlayerControlFace? _topBarFace('), + source.indexOf('/// [face] as the Flutter bottom-row button'), + ); + final missing = [ + for (final spec in PlayerControlCatalogue.all) + if (!body.contains("case '${spec.id}':")) spec.id, + ]; + expect(missing, isEmpty); + }); + + test('the native overlay is held at full and only Flutter pieces fade', () { + final source = read('player_page.controls.dart'); + expect( + source, + contains('FadeTransition(opacity: kAlwaysCompleteAnimation'), + ); + // The glass hold belongs to the shader path only. + final hold = source.indexOf('GlassHoldWhile('); + final native = source.indexOf('final overlay = native'); + expect(native, greaterThan(-1)); + expect(hold, greaterThan(native)); + expect(source, contains('_fadeFlutter(const _ControlsScrim())')); + }); + + test('every bar that hides with the controls is told so', () { + final sources = [ + read('player_page.controls.dart'), + read('player_page.reactions.dart'), + ].join('\n'); + final bars = RegExp(r'NativeGlassBar\(').allMatches(sources).length; + final told = RegExp( + r'NativeGlassBar\([^;]*?shown: _controlsVisible', + dotAll: true, + ).allMatches(sources).length; + // The unlock pill is the one bar that does not follow the controls. + expect(bars, 6); + expect(told, bars - 1); + }); +} From bb4e44d5c73592a13b5ae32a8ce8dbf1adf113c7 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:55:15 +0500 Subject: [PATCH 04/21] Player native bars: review fixes - Busy ring keeps spinning after the app or the bar leaves the window - Unlock pill keeps its deeper 0.6 dimming on the native glass - Fit button shows the current mode's glyph - VoiceOver no longer reads a title that repeats the label - Seek buttons say the real step (player.rewind_by / forward_by) --- assets/translations/ar.json | 2 ++ assets/translations/ckb.json | 2 ++ assets/translations/de.json | 2 ++ assets/translations/en.json | 2 ++ assets/translations/es.json | 2 ++ assets/translations/fr.json | 2 ++ assets/translations/hi.json | 2 ++ assets/translations/id.json | 2 ++ assets/translations/ku.json | 2 ++ assets/translations/nl.json | 2 ++ assets/translations/pt.json | 2 ++ assets/translations/ru.json | 2 ++ assets/translations/tr.json | 2 ++ assets/translations/uz.json | 2 ++ assets/translations/vi.json | 2 ++ assets/translations/yue.json | 2 ++ ios/Runner/NativePlayerBar.swift | 21 +++++++++++++++---- lib/core/native/native_glass_bar.dart | 7 ++++++- .../pages/player_page.controls.dart | 13 ++++++++++-- 19 files changed, 66 insertions(+), 7 deletions(-) diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 2469a828..b73473fb 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -570,6 +570,8 @@ "subtitle_n": "ترجمة {}", "subtitle_in_video": "ضمن الفيديو", "rewind": "ترجيع", + "rewind_by": "ترجيع {} ثانية", + "forward_by": "تقديم {} ثانية", "mini_restore": "العودة إلى النافذة الكاملة", "pip_unavailable": "تعذّر تشغيل صورة داخل صورة لهذا الفيديو.", "scrub_release_cancel": "اترك للإلغاء", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 0bd7bf68..44fc115c 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -570,6 +570,8 @@ "subtitle_n": "ژێرنووس {}", "subtitle_in_video": "لەناو ڤیدیۆکەدا", "rewind": "گەڕاندنەوە بۆ دواوە", + "rewind_by": "{} چرکە بۆ دواوە", + "forward_by": "{} چرکە بۆ پێشەوە", "mini_restore": "گەڕانەوە بۆ پەنجەرەی تەواو", "pip_unavailable": "وێنە لەناو وێنە نەیتوانی بۆ ئەم ڤیدیۆیە دەستپێبکات.", "scrub_release_cancel": "بەری بدە بۆ هەڵوەشاندنەوە", diff --git a/assets/translations/de.json b/assets/translations/de.json index 266f00e4..696e7d83 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -570,6 +570,8 @@ "subtitle_n": "Untertitel {}", "subtitle_in_video": "Im Video", "rewind": "Zurückspulen", + "rewind_by": "{} s zurück", + "forward_by": "{} s vor", "mini_restore": "Zurück zum großen Fenster", "pip_unavailable": "Bild-in-Bild konnte für dieses Video nicht gestartet werden.", "scrub_release_cancel": "Loslassen zum Abbrechen", diff --git a/assets/translations/en.json b/assets/translations/en.json index 32dda706..1939f38e 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -570,6 +570,8 @@ "subtitle_n": "Subtitle {}", "subtitle_in_video": "In the video", "rewind": "Rewind", + "rewind_by": "Rewind {}s", + "forward_by": "Forward {}s", "mini_restore": "Back to the full window", "pip_unavailable": "Picture-in-picture could not start for this video.", "scrub_release_cancel": "Release to cancel", diff --git a/assets/translations/es.json b/assets/translations/es.json index c464b7de..7f438e00 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -570,6 +570,8 @@ "subtitle_n": "Subtítulo {}", "subtitle_in_video": "En el vídeo", "rewind": "Retroceder", + "rewind_by": "Retroceder {} s", + "forward_by": "Adelantar {} s", "mini_restore": "Volver a la ventana completa", "pip_unavailable": "No se pudo iniciar imagen en imagen para este vídeo.", "scrub_release_cancel": "Suelta para cancelar", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index e1a47c74..857ba956 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -570,6 +570,8 @@ "subtitle_n": "Sous-titres {}", "subtitle_in_video": "Dans la vidéo", "rewind": "Reculer", + "rewind_by": "Reculer de {} s", + "forward_by": "Avancer de {} s", "mini_restore": "Revenir à la fenêtre complète", "pip_unavailable": "Impossible de lancer l’image dans l’image pour cette vidéo.", "scrub_release_cancel": "Relâchez pour annuler", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 83f534df..2df72fdd 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -570,6 +570,8 @@ "subtitle_n": "सबटाइटल {}", "subtitle_in_video": "वीडियो में", "rewind": "रिवाइंड", + "rewind_by": "{} सेकंड पीछे", + "forward_by": "{} सेकंड आगे", "mini_restore": "वापस पूरी विंडो में", "pip_unavailable": "इस वीडियो के लिए पिक्चर-इन-पिक्चर शुरू नहीं हो पाया।", "scrub_release_cancel": "रद्द करने के लिए छोड़ें", diff --git a/assets/translations/id.json b/assets/translations/id.json index 89ecd4ce..48b3d25b 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -570,6 +570,8 @@ "subtitle_n": "Subtitel {}", "subtitle_in_video": "Di dalam video", "rewind": "Mundur", + "rewind_by": "Mundur {} dtk", + "forward_by": "Maju {} dtk", "mini_restore": "Kembali ke jendela penuh", "pip_unavailable": "Gambar-dalam-gambar tidak bisa dimulai untuk video ini.", "scrub_release_cancel": "Lepas untuk membatalkan", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 56a7ff83..20807afa 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -570,6 +570,8 @@ "subtitle_n": "Binnivîs {}", "subtitle_in_video": "Di vîdyoyê de", "rewind": "Paşve bibe", + "rewind_by": "{}s paş", + "forward_by": "{}s pêş", "mini_restore": "Vegere pencereya tevahî", "pip_unavailable": "Wêne di nav wêneyê de ji bo vê vîdyoyê dest pê nekir.", "scrub_release_cancel": "Ji bo betalkirinê berde", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b86ec879..e8438d88 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -570,6 +570,8 @@ "subtitle_n": "Ondertiteling {}", "subtitle_in_video": "In de video", "rewind": "Terugspoelen", + "rewind_by": "{} s terug", + "forward_by": "{} s vooruit", "mini_restore": "Terug naar het volledige venster", "pip_unavailable": "Beeld-in-beeld kon voor deze video niet starten.", "scrub_release_cancel": "Loslaten om te annuleren", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 01e96d87..95dc36aa 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -570,6 +570,8 @@ "subtitle_n": "Legenda {}", "subtitle_in_video": "No vídeo", "rewind": "Voltar", + "rewind_by": "Voltar {} s", + "forward_by": "Avançar {} s", "mini_restore": "Voltar à janela inteira", "pip_unavailable": "Não foi possível iniciar o picture-in-picture para este vídeo.", "scrub_release_cancel": "Solte para cancelar", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 9bc900dd..4fbe80d3 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -570,6 +570,8 @@ "subtitle_n": "Субтитры {}", "subtitle_in_video": "В самом видео", "rewind": "Назад", + "rewind_by": "{} сек назад", + "forward_by": "{} сек вперёд", "mini_restore": "Вернуть в большое окно", "pip_unavailable": "Не удалось включить «картинку в картинке» для этого видео.", "scrub_release_cancel": "Отпустите для отмены", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 701b1e45..ebfa6859 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -570,6 +570,8 @@ "subtitle_n": "Altyazı {}", "subtitle_in_video": "Videonun içinde", "rewind": "Geri sar", + "rewind_by": "{} sn geri", + "forward_by": "{} sn ileri", "mini_restore": "Tam pencereye dön", "pip_unavailable": "Bu video için resim içinde resim başlatılamadı.", "scrub_release_cancel": "İptal için bırak", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index dddc90c9..eae3fa25 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -570,6 +570,8 @@ "subtitle_n": "Subtitr {}", "subtitle_in_video": "Videoning ichida", "rewind": "Orqaga", + "rewind_by": "{}s orqaga", + "forward_by": "{}s oldinga", "mini_restore": "Katta oynaga qaytish", "pip_unavailable": "Bu video uchun “rasm ichida rasm” ishga tushmadi.", "scrub_release_cancel": "Qo‘yib yuborsangiz bekor bo‘ladi", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 54733568..3c95eec7 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -570,6 +570,8 @@ "subtitle_n": "Phụ đề {}", "subtitle_in_video": "Trong video", "rewind": "Tua lại", + "rewind_by": "Tua lại {} giây", + "forward_by": "Tua tới {} giây", "mini_restore": "Trở về cửa sổ đầy đủ", "pip_unavailable": "Không thể bật hình trong hình cho video này.", "scrub_release_cancel": "Thả ra để hủy", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 531b9e88..cf8e8db2 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -570,6 +570,8 @@ "subtitle_n": "字幕 {}", "subtitle_in_video": "影片內置", "rewind": "倒帶", + "rewind_by": "倒帶 {} 秒", + "forward_by": "快進 {} 秒", "mini_restore": "返去大視窗", "pip_unavailable": "呢條片開唔到子母畫面。", "scrub_release_cancel": "鬆手就取消", diff --git a/ios/Runner/NativePlayerBar.swift b/ios/Runner/NativePlayerBar.swift index ba0fa072..74d468a1 100644 --- a/ios/Runner/NativePlayerBar.swift +++ b/ios/Runner/NativePlayerBar.swift @@ -130,6 +130,11 @@ private final class BarButton: UIButton { ring.path = UIBezierPath(ovalIn: bounds.insetBy(dx: inset, dy: inset)).cgPath } + override func didMoveToWindow() { + super.didMoveToWindow() + if busy, window != nil, ring.animation(forKey: "spin") == nil { spin() } + } + private func spin() { ring.isHidden = false let turn = CABasicAnimation(keyPath: "transform.rotation.z") @@ -137,6 +142,8 @@ private final class BarButton: UIButton { turn.toValue = CGFloat.pi * 2 turn.duration = 0.9 turn.repeatCount = .infinity + // Kept through a trip to the background or out of the window. + turn.isRemovedOnCompletion = false ring.add(turn, forKey: "spin") } @@ -197,6 +204,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { private let channel: FlutterMethodChannel private var look = "capsule" + private var dim = UIColor.black.withAlphaComponent(0.22) private var ids: [String] = [] private var items: [String: [String: Any]] = [:] private var buttons: [String: BarButton] = [:] @@ -276,6 +284,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { look = newLook ids = newIds if rebuild { build() } + if let raw = args["dim"] as? NSNumber { dim = color(raw.uint32Value) } placeCapsules(args["capsules"] as? [[NSNumber]] ?? []) for id in ids { if let button = buttons[id], let item = items[id] { @@ -334,10 +343,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { return } while capsules.count < rects.count { - let glass = UIGlassEffect(style: .clear) - glass.isInteractive = false - glass.tintColor = UIColor.black.withAlphaComponent(0.22) - let capsule = UIVisualEffectView(effect: glass) + let capsule = UIVisualEffectView(effect: nil) capsule.cornerConfiguration = .capsule() container.contentView.addSubview(capsule) capsules.append(capsule) @@ -345,6 +351,13 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { while capsules.count > rects.count { capsules.removeLast().removeFromSuperview() } + for capsule in capsules + where (capsule.effect as? UIGlassEffect)?.tintColor != dim { + let glass = UIGlassEffect(style: .clear) + glass.isInteractive = false + glass.tintColor = dim + capsule.effect = glass + } for (i, rect) in rects.enumerated() { let r = cgRect(rect) capsules[i].bounds = CGRect(origin: .zero, size: r.size) diff --git a/lib/core/native/native_glass_bar.dart b/lib/core/native/native_glass_bar.dart index 97c883e8..cf90ff9a 100644 --- a/lib/core/native/native_glass_bar.dart +++ b/lib/core/native/native_glass_bar.dart @@ -147,6 +147,7 @@ class NativeGlassBar extends StatefulWidget { this.spread = BarSpread.packed, this.maxExtent = double.infinity, this.accent = const Color(0xFFFFFFFF), + this.dim = const Color(0x38000000), this.onMenuOpen, this.onMenuClose, }); @@ -169,6 +170,9 @@ class NativeGlassBar extends StatefulWidget { /// A cap under the room the parent gives. final double maxExtent; final Color accent; + + /// The capsule glass's tint; words on it want a deeper one. + final Color dim; final VoidCallback? onMenuOpen; final VoidCallback? onMenuClose; @@ -273,6 +277,7 @@ class _NativeGlassBarState extends State _itemWire(widget.items[i], layout.slots[i]), ], 'look': widget.look.name, + 'dim': widget.dim.toARGB32(), 'dir': _dir == TextDirection.rtl ? 'rtl' : 'ltr', 'axis': widget.axis.name, 'capsules': [ @@ -442,7 +447,7 @@ class _NativeGlassBarState extends State container: true, button: true, label: item.semanticLabel, - value: item.title, + value: item.title == item.semanticLabel ? null : item.title, enabled: item.enabled, selected: item.active, onTap: item.enabled ? item.onPressed : null, diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index dfaf94f9..2789b3d4 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -348,6 +348,12 @@ extension _PlayerControls on _PlayerPageState { _PlayerFit.fill => Icons.fit_screen_rounded, }; + static String _fitSymbol(_PlayerFit fit) => switch (fit) { + _PlayerFit.contain => 'aspectratio', + _PlayerFit.cover => 'viewfinder', + _PlayerFit.fill => 'arrow.up.left.and.arrow.down.right', + }; + String _fitLabel(_PlayerFit fit) { switch (fit) { case _PlayerFit.contain: @@ -1465,7 +1471,7 @@ extension _PlayerControls on _PlayerPageState { NativeBarItem( id: 'rewind', symbol: rewindSymbol(step), - semanticLabel: 'player.rewind'.tr(), + semanticLabel: 'player.rewind_by'.tr(args: ['$step']), extent: 52, glyph: 24, onPressed: rewind, @@ -1484,7 +1490,7 @@ extension _PlayerControls on _PlayerPageState { NativeBarItem( id: 'forward', symbol: forwardSymbol(step), - semanticLabel: 'player.forward'.tr(), + semanticLabel: 'player.forward_by'.tr(args: ['$step']), extent: 52, glyph: 24, onPressed: forward, @@ -1608,6 +1614,7 @@ extension _PlayerControls on _PlayerPageState { /// whatever the controls' fade is doing. Widget _unlockPill(Widget flutter) => NativeGlassBar( look: NativeBarLook.capsule, + dim: Colors.black.withValues(alpha: 0.6), items: [ NativeBarItem( id: 'unlock', @@ -2302,6 +2309,7 @@ extension _PlayerControls on _PlayerPageState { return PlayerControlFace( id: id, icon: _fitIcon(_fit), + symbol: _fitSymbol(_fit), semanticLabel: name, // Forward on a tap, back on a hold. Three modes and a glyph that // names the current one leaves nothing for a dialog to add — and a @@ -2499,6 +2507,7 @@ extension _PlayerControls on _PlayerPageState { return PlayerControlFace( id: id, icon: _fitIcon(_fit), + symbol: _fitSymbol(_fit), semanticLabel: name, label: _fitLabel(_fit), // Cycles. There are three fits, the button is already showing which From 8cc963c88f150a1f36034e568191141dc4205065 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:13:54 +0500 Subject: [PATCH 05/21] Native player bars: system-sized glyphs, untinted glass, no alpha on glass Glyphs are 17 pt medium on bar buttons (22 for skip, 28 for play, 13 in the unlock pill), as the system's glass toolbar buttons are, instead of 20-32 semibold. The clear glass carries no dark wash unless a caller needs the contrast, which only the unlock pill does. Separate circles stand 12 apart. Show and hide no longer animate alpha on the hosted root: a visual effect view, or anything above it, under alpha 1 draws its effect wrongly. The capsules dematerialise by losing their effect inside the animation, and a hidden bar is isHidden. --- ios/Runner/NativePlayerBar.swift | 64 ++++++++++++++----- lib/core/native/native_glass_bar.dart | 4 +- .../pages/player_page.controls.dart | 10 +-- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/ios/Runner/NativePlayerBar.swift b/ios/Runner/NativePlayerBar.swift index 74d468a1..f4876080 100644 --- a/ios/Runner/NativePlayerBar.swift +++ b/ios/Runner/NativePlayerBar.swift @@ -81,7 +81,7 @@ private final class BarRoot: UIView { var buttons: [BarButton] = [] override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - guard alpha > 0.01 else { return false } + guard !isHidden else { return false } return buttons.contains { !$0.isHidden && $0.point(inside: convert(point, to: $0), with: event) } @@ -204,7 +204,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { private let channel: FlutterMethodChannel private var look = "capsule" - private var dim = UIColor.black.withAlphaComponent(0.22) + private var dim = UIColor.clear private var ids: [String] = [] private var items: [String: [String: Any]] = [:] private var buttons: [String: BarButton] = [:] @@ -213,7 +213,9 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { init(frame: CGRect, id: Int64, args: [String: Any], messenger: FlutterBinaryMessenger) { root = BarRoot(frame: frame) let effect = UIGlassContainerEffect() - effect.spacing = 8 + // Separate circles at 12 apart, as the system's own glass buttons + // stand; closer, they melt into one shape too soon. + effect.spacing = 12 container = UIVisualEffectView(effect: effect) channel = FlutterMethodChannel( name: "\(NativePlayerBar.viewType)/\(id)", binaryMessenger: messenger) @@ -226,8 +228,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { container.autoresizingMask = [.flexibleWidth, .flexibleHeight] root.addSubview(container) setItems(args) - let shown = args["shown"] as? Bool ?? true - root.alpha = shown ? 1 : 0 + root.isHidden = !(args["shown"] as? Bool ?? true) channel.setMethodCallHandler { [weak self] call, result in self?.handle(call, result: result) } @@ -256,21 +257,48 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { case "setShown": let shown = args["shown"] as? Bool ?? true let ms = (args["ms"] as? NSNumber)?.doubleValue ?? 0 - let target: CGFloat = shown ? 1 : 0 - if ms <= 0 { - root.alpha = target - } else { - UIView.animate( - withDuration: ms / 1000, delay: 0, - options: [.beginFromCurrentState, .curveEaseInOut, .allowUserInteraction] - ) { self.root.alpha = target } - } + setShown(shown, seconds: ms / 1000) result(nil) default: result(FlutterMethodNotImplemented) } } + /// Shows or hides the bar without an alpha on the glass: a + /// UIVisualEffectView, or anything above it, at an alpha below 1 draws its + /// effect wrongly or not at all. The capsules dematerialise by losing their + /// effect inside the animation, the buttons fade, and the hidden bar leaves + /// the window's rendering altogether. + private func setShown(_ shown: Bool, seconds: Double) { + let glass = { () -> UIGlassEffect in + let g = UIGlassEffect(style: .clear) + g.isInteractive = false + g.tintColor = self.dim.cgColor.alpha > 0 ? self.dim : nil + return g + } + if shown { + guard root.isHidden else { return } + capsules.forEach { $0.effect = nil } + buttons.values.forEach { $0.alpha = 0 } + root.isHidden = false + UIView.animate(withDuration: seconds, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) { + self.capsules.forEach { $0.effect = glass() } + self.buttons.values.forEach { $0.alpha = 1 } + } + } else { + guard !root.isHidden else { return } + UIView.animate(withDuration: seconds, delay: 0, options: [.curveEaseIn, .beginFromCurrentState]) { + self.capsules.forEach { $0.effect = nil } + self.buttons.values.forEach { $0.alpha = 0 } + } completion: { _ in + guard self.buttons.values.allSatisfy({ $0.alpha == 0 }) else { return } + self.root.isHidden = true + self.capsules.forEach { $0.effect = glass() } + self.buttons.values.forEach { $0.alpha = 1 } + } + } + } + private func setItems(_ args: [String: Any]) { let list = args["items"] as? [[String: Any]] ?? [] let newLook = args["look"] as? String ?? "capsule" @@ -352,10 +380,12 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { capsules.removeLast().removeFromSuperview() } for capsule in capsules - where (capsule.effect as? UIGlassEffect)?.tintColor != dim { + where capsule.effect == nil || (capsule.effect as? UIGlassEffect)?.tintColor != (dim.cgColor.alpha > 0 ? dim : nil) { let glass = UIGlassEffect(style: .clear) glass.isInteractive = false - glass.tintColor = dim + // Apple's material as it is: a dark wash turns the glass into a pill. + // Only a caller that needs the contrast (the unlock pill) sends one. + glass.tintColor = dim.cgColor.alpha > 0 ? dim : nil capsule.effect = glass } for (i, rect) in rects.enumerated() { @@ -395,7 +425,7 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { // The colour is baked into the glyph: a glass configuration recolours a // template image with its own foreground, which lost the active accent. if let name = item["symbol"] as? String, !name.isEmpty { - let symbol = UIImage.SymbolConfiguration(pointSize: glyph, weight: .semibold) + let symbol = UIImage.SymbolConfiguration(pointSize: glyph, weight: .medium) if let image = UIImage(systemName: name, withConfiguration: symbol) { config.image = image.withTintColor(tint, renderingMode: .alwaysOriginal) } else { diff --git a/lib/core/native/native_glass_bar.dart b/lib/core/native/native_glass_bar.dart index cf90ff9a..3c8bb373 100644 --- a/lib/core/native/native_glass_bar.dart +++ b/lib/core/native/native_glass_bar.dart @@ -67,7 +67,7 @@ class NativeBarItem { this.hidden = false, this.extent, this.size, - this.glyph = 20, + this.glyph = 17, this.fontSize = 12, this.hasGlyph = true, this.group = 0, @@ -147,7 +147,7 @@ class NativeGlassBar extends StatefulWidget { this.spread = BarSpread.packed, this.maxExtent = double.infinity, this.accent = const Color(0xFFFFFFFF), - this.dim = const Color(0x38000000), + this.dim = const Color(0x00000000), this.onMenuOpen, this.onMenuClose, }); diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 2789b3d4..19807749 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -1442,7 +1442,7 @@ extension _PlayerControls on _PlayerPageState { hidden: shown == null, enabled: enabled, extent: 52, - glyph: 22, + glyph: 17, onPressed: () => _partyEpisodeNav(to), ); return _Selected<(bool, bool)>( @@ -1473,7 +1473,7 @@ extension _PlayerControls on _PlayerPageState { symbol: rewindSymbol(step), semanticLabel: 'player.rewind_by'.tr(args: ['$step']), extent: 52, - glyph: 24, + glyph: 22, onPressed: rewind, ), NativeBarItem( @@ -1482,7 +1482,7 @@ extension _PlayerControls on _PlayerPageState { semanticLabel: (state.$1 ? 'player.pause' : 'player.play') .tr(), extent: 72, - glyph: 32, + glyph: 28, prominent: true, busy: state.$2, onPressed: _togglePlay, @@ -1492,7 +1492,7 @@ extension _PlayerControls on _PlayerPageState { symbol: forwardSymbol(step), semanticLabel: 'player.forward_by'.tr(args: ['$step']), extent: 52, - glyph: 24, + glyph: 22, onPressed: forward, ), if (episodes) @@ -1621,7 +1621,7 @@ extension _PlayerControls on _PlayerPageState { symbol: playerSymbol('unlock'), title: 'player.tap_to_unlock'.tr(), semanticLabel: 'player.tap_to_unlock'.tr(), - glyph: 16, + glyph: 13, onPressed: _unlock, ), ], From 8d6e6abd850b85a2ad429e6e247c69612bb313b9 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:43:19 +0500 Subject: [PATCH 06/21] Player on iOS 26+: Apple's symbols, pull-down menus, iOS sheets - Symbols checked against the phone's own list: Watch Party wears person.2 (not SharePlay), unlock lock.open.fill, rotate the rectangle.*.rotate pair, fit AVKit's zoom arrows and the stretch arrows; filled faces for party and cast. A name UIKit lacks falls back to its stem instead of drawing nothing. - Speed, fit, sleep, quality, server, sub/dub and subtitles open the phone's own menu on a tap: the current choice checked, a line under a row where the sheet had one, a red row for a file that will not play. The subtitle menu ends on "More options", the full sheet. VoiceOver's tap still opens the Flutter sheet. - The player's sheets on that path: a grabber, 38pt continuous corners, a frosted material, a centred title with a close button, inset grouped rows with checks and chevrons, Cupertino glyphs. Everywhere else they are the sheets they were. --- ios/Runner/NativeMenuRows.swift | 53 +- ios/Runner/NativePlayerBar.swift | 9 +- lib/core/native/native_glass_bar.dart | 24 +- lib/core/native/sf_symbols.dart | 19 +- .../presentation/pages/player_page.cast.dart | 33 +- .../pages/player_page.controls.dart | 91 +- .../presentation/pages/player_page.dart | 4 + .../presentation/pages/player_page.menus.dart | 166 ++++ .../pages/player_page.panels.dart | 921 ++++++++---------- .../pages/player_page.sheets.dart | 49 + .../presentation/pages/player_page.sleep.dart | 94 +- .../pages/player_page.subtitles.dart | 145 +-- .../pages/player_page.widgets.dart | 26 + .../widgets/player_control_face.dart | 29 + .../presentation/widgets/player_menus.dart | 203 ++++ .../widgets/player_sheet_look.dart | 502 ++++++++++ test/core/native/native_menu_select_test.dart | 70 ++ test/core/native/sf_symbols_test.dart | 64 ++ test/features/detail/player_menus_test.dart | 132 +++ .../detail/player_native_controls_test.dart | 52 + .../detail/player_sheet_look_test.dart | 179 ++++ 21 files changed, 2100 insertions(+), 765 deletions(-) create mode 100644 lib/features/detail/presentation/pages/player_page.menus.dart create mode 100644 lib/features/detail/presentation/pages/player_page.sheets.dart create mode 100644 lib/features/detail/presentation/widgets/player_menus.dart create mode 100644 lib/features/detail/presentation/widgets/player_sheet_look.dart create mode 100644 test/features/detail/player_menus_test.dart create mode 100644 test/features/detail/player_sheet_look_test.dart diff --git a/ios/Runner/NativeMenuRows.swift b/ios/Runner/NativeMenuRows.swift index e44fe2b2..c10b0db4 100644 --- a/ios/Runner/NativeMenuRows.swift +++ b/ios/Runner/NativeMenuRows.swift @@ -3,33 +3,66 @@ import UIKit /// The rows Dart sends for a hosted button's menu, as UIKit's menu elements /// (lib/core/native/native_glass_bar.dart, `NativeMenuRow`). /// -/// A row is `{title, symbol?, checked?, breakBefore, destructive}`. Each run -/// of rows between two breaks is an inline group of its own, so UIKit draws -/// the separator between them. +/// A row is `{title, subtitle?, symbol?, checked?, breakBefore, section?, +/// destructive, disabled}`. Each run of rows between two breaks is an inline +/// group of its own, so UIKit draws the separator between them; a run's +/// first row may name it (`section`). enum NativeMenuRows { /// [choose] is told the row's index, in the list as it was sent. static func elements( _ rows: [[String: Any]], choose: @escaping (Int) -> Void ) -> [UIMenuElement] { - var groups: [[UIMenuElement]] = [[]] + var groups: [(title: String, rows: [UIMenuElement])] = [("", [])] for (i, row) in rows.enumerated() { - if row["breakBefore"] as? Bool == true, !(groups.last?.isEmpty ?? true) { - groups.append([]) + if row["breakBefore"] as? Bool == true, !(groups.last?.rows.isEmpty ?? true) { + groups.append(("", [])) } - groups[groups.count - 1].append(element(row, index: i, choose: choose)) + if groups[groups.count - 1].rows.isEmpty, let section = row["section"] as? String { + groups[groups.count - 1].title = section + } + groups[groups.count - 1].rows.append(element(row, index: i, choose: choose)) } - if groups.count == 1 { return groups[0] } - return groups.map { UIMenu(options: .displayInline, children: $0) } + if groups.count == 1, groups[0].title.isEmpty { return groups[0].rows } + return groups.map { UIMenu(title: $0.title, options: .displayInline, children: $0.rows) } } private static func element( _ row: [String: Any], index: Int, choose: @escaping (Int) -> Void ) -> UIMenuElement { let title = row["title"] as? String ?? "" - let image = (row["symbol"] as? String).flatMap { UIImage(systemName: $0) } + let image = (row["symbol"] as? String).flatMap { NativeSymbol.image($0) } let action = UIAction(title: title, image: image) { _ in choose(index) } + if #available(iOS 16.0, *), let subtitle = row["subtitle"] as? String, !subtitle.isEmpty { + action.subtitle = subtitle + } if row["destructive"] as? Bool == true { action.attributes.insert(.destructive) } + if row["disabled"] as? Bool == true { action.attributes.insert(.disabled) } if let checked = row["checked"] as? Bool { action.state = checked ? .on : .off } return action } } + +/// An SF Symbol by name, never nothing: a name this system lacks falls back +/// to its stem (`gobackward.7` → `gobackward`, `x.y.rotate` → `x.y`) and at +/// last to a plain circle, so a wrong name costs a glyph, not the button. +enum NativeSymbol { + static func image( + _ name: String, configuration: UIImage.SymbolConfiguration? = nil + ) -> UIImage? { + if name.isEmpty { return nil } + if let image = UIImage(systemName: name, withConfiguration: configuration) { + return image + } + assertionFailure("No SF Symbol named \(name)") + var parts = name.split(separator: ".") + while parts.count > 1 { + parts.removeLast() + if let image = UIImage( + systemName: parts.joined(separator: "."), withConfiguration: configuration) + { + return image + } + } + return UIImage(systemName: "circle", withConfiguration: configuration) + } +} diff --git a/ios/Runner/NativePlayerBar.swift b/ios/Runner/NativePlayerBar.swift index f4876080..b6f227a6 100644 --- a/ios/Runner/NativePlayerBar.swift +++ b/ios/Runner/NativePlayerBar.swift @@ -22,7 +22,8 @@ import UIKit /// rebuilt only when the ids change), `setItem {id, …changed}`, /// `setShown {shown, ms}`. /// - here → Flutter: `press id`, `longPress id`, `select {id, row}` once the -/// menu has gone, `menuOpen id`, `menuClose id`. +/// menu has gone, `menuOpen id`, `menuClose id`. An item sent `menuPrimary` +/// opens its menu on a tap, as a pull-down button does. /// /// Flutter owns semantics: none of these buttons is an accessibility element. enum NativePlayerBar { @@ -426,10 +427,8 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { // template image with its own foreground, which lost the active accent. if let name = item["symbol"] as? String, !name.isEmpty { let symbol = UIImage.SymbolConfiguration(pointSize: glyph, weight: .medium) - if let image = UIImage(systemName: name, withConfiguration: symbol) { + if let image = NativeSymbol.image(name, configuration: symbol) { config.image = image.withTintColor(tint, renderingMode: .alwaysOriginal) - } else { - assertionFailure("No SF Symbol named \(name)") } config.imagePlacement = .leading config.imagePadding = 4 @@ -462,6 +461,8 @@ final class NativePlayerBarView: NSObject, FlutterPlatformView { } else { button.menu = nil } + // A pull-down button opens its menu on a tap too, and then says no press. + button.showsMenuAsPrimaryAction = button.menu != nil && item["menuPrimary"] as? Bool == true // A menu opens on its own hold; only a button without one hears this. button.hold.isEnabled = button.menu == nil && item["longPress"] as? Bool == true diff --git a/lib/core/native/native_glass_bar.dart b/lib/core/native/native_glass_bar.dart index 3c8bb373..efe728f1 100644 --- a/lib/core/native/native_glass_bar.dart +++ b/lib/core/native/native_glass_bar.dart @@ -21,20 +21,32 @@ enum NativeBarLook { class NativeMenuRow { const NativeMenuRow({ required this.title, + this.subtitle, this.symbol, this.checked, this.breakBefore = false, + this.section, this.destructive = false, + this.disabled = false, this.key, }); final String title; + + /// A second line under the title, smaller. + final String? subtitle; final String? symbol; - /// Null draws no check column; false an empty one. + /// Null draws no check column; false an empty one. VoiceOver reads a + /// checked row as selected. final bool? checked; final bool breakBefore; + + /// The heading over the run of rows this one starts (with [breakBefore], + /// or as the first row). + final String? section; final bool destructive; + final bool disabled; /// What the row stands for, so a choice made while the rows changed under /// the open menu still lands on the row the viewer saw. The title without. @@ -45,10 +57,13 @@ class NativeMenuRow { Map toWire() => { 'title': title, + 'subtitle': subtitle, 'symbol': symbol, 'checked': checked, 'breakBefore': breakBefore, + 'section': section, 'destructive': destructive, + 'disabled': disabled, }; } @@ -74,6 +89,7 @@ class NativeBarItem { this.onPressed, this.onLongPress, this.menu, + this.menuPrimary = false, this.onMenuSelected, }); @@ -115,6 +131,11 @@ class NativeBarItem { /// Opened by a hold, as UIKit's own buttons open theirs. final List? menu; + + /// The [menu] opens on a tap as well, as a pull-down button's does; the + /// button then says no `press`. VoiceOver's tap still runs [onPressed], + /// the Flutter sheet holding the same choices. + final bool menuPrimary; final ValueChanged? onMenuSelected; BarSlotSpec get _slot => BarSlotSpec( @@ -268,6 +289,7 @@ class _NativeGlassBarState extends State 'rect': [rect.left, rect.top, rect.width, rect.height], 'group': item.group, 'menu': item.menu?.map((r) => r.toWire()).toList(), + 'menuPrimary': item.menuPrimary && item.menu != null, 'longPress': item.onLongPress != null, }; diff --git a/lib/core/native/sf_symbols.dart b/lib/core/native/sf_symbols.dart index 91eb1ad0..2cf97b44 100644 --- a/lib/core/native/sf_symbols.dart +++ b/lib/core/native/sf_symbols.dart @@ -1,13 +1,18 @@ /// The SF Symbol each player control wears on the native glass bars, keyed by /// the control's id (`PlayerControlCatalogue`) and the player's own pieces. +/// +/// Apple's own where Apple's players have one (AVKit: `gobackward.10`, +/// `gauge.with.dots.needle.67percent`, `pip.enter`, `captions.bubble`), the +/// nearest standard symbol elsewhere. Every name is checked against the +/// phone's own list in test/core/native/sf_symbols_test.dart. const Map playerSymbols = { 'back': 'chevron.backward', 'language': 'character.bubble', 'subtitles': 'captions.bubble', 'settings': 'gearshape', - 'orientation': 'rotate.right', + 'orientation': 'rectangle.portrait.rotate', 'lock': 'lock.fill', - 'unlock': 'lock.fill', + 'unlock': 'lock.open.fill', 'stats': 'info.circle', 'speed': 'gauge.with.dots.needle.67percent', 'server': 'server.rack', @@ -19,7 +24,7 @@ const Map playerSymbols = { 'fit': 'aspectratio', 'sleep': 'moon.zzz', 'cast': 'tv.badge.wifi', - 'party': 'shareplay', + 'party': 'person.2', 'pip': 'pip.enter', 'download': 'arrow.down.circle', 'rewind': 'gobackward', @@ -30,6 +35,7 @@ const Map playerSymbols = { 'react': 'face.smiling', 'comments': 'bubble.left.and.bubble.right', 'skip': 'forward.fill', + 'more': 'ellipsis.circle', }; /// The filled face of a control while it is on. @@ -38,12 +44,19 @@ const Map activeSymbols = { 'stats': 'info.circle.fill', 'sleep': 'moon.zzz.fill', 'download': 'arrow.down.circle.fill', + 'party': 'person.2.fill', + 'cast': 'tv.badge.wifi.fill', }; /// [id]'s symbol, its filled face when [active] and it has one. String playerSymbol(String id, {bool active = false}) => (active ? activeSymbols[id] : null) ?? playerSymbols[id] ?? 'circle'; +/// The rotate button names the turn it makes: the phone upright turns to +/// landscape, and back. +String orientationSymbol({required bool portrait}) => + portrait ? 'rectangle.portrait.rotate' : 'rectangle.landscape.rotate'; + /// The step lengths SF Symbols draws a number into. const Set _numberedSteps = {5, 10, 15, 30, 45, 60, 75, 90}; diff --git a/lib/features/detail/presentation/pages/player_page.cast.dart b/lib/features/detail/presentation/pages/player_page.cast.dart index 060f5e5b..446c1dc0 100644 --- a/lib/features/detail/presentation/pages/player_page.cast.dart +++ b/lib/features/detail/presentation/pages/player_page.cast.dart @@ -78,23 +78,26 @@ extension _PlayerCast on _PlayerPageState { final cast = _cast; cast.startDiscovery(); - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.background, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(18)), - ), - builder: (sheetContext) => AnimatedBuilder( - animation: cast, - builder: (context, _) => _CastSheet( - cast: cast, - onPick: (device) async { - Navigator.of(sheetContext).pop(); - await _castTo(device, media); - }, - ), + Widget sheet(BuildContext sheetContext) => AnimatedBuilder( + animation: cast, + builder: (context, _) => _CastSheet( + cast: cast, + onPick: (device) async { + Navigator.of(sheetContext).pop(); + await _castTo(device, media); + }, ), ); + await (_iosSheets + ? _showSheet(builder: sheet) + : showModalBottomSheet( + context: context, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: sheet, + )); cast.stopDiscovery(); } diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 19807749..9a457927 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -348,10 +348,11 @@ extension _PlayerControls on _PlayerPageState { _PlayerFit.fill => Icons.fit_screen_rounded, }; + /// AVKit's own pair for fit and zoom, and the stretch arrows for fill. static String _fitSymbol(_PlayerFit fit) => switch (fit) { _PlayerFit.contain => 'aspectratio', - _PlayerFit.cover => 'viewfinder', - _PlayerFit.fill => 'arrow.up.left.and.arrow.down.right', + _PlayerFit.cover => 'arrow.up.left.and.arrow.down.right', + _PlayerFit.fill => 'rectangle.arrowtriangle.2.outward', }; String _fitLabel(_PlayerFit fit) { @@ -2128,54 +2129,8 @@ extension _PlayerControls on _PlayerPageState { ); } - /// The speed presets as a native menu, the current one checked. - List get _speedRows => [ - for (final s in _PlayerPanels._speedPresets) - NativeMenuRow( - title: _speedLabel(s), - checked: (s - _playbackSpeed).abs() < 0.01, - key: s, - ), - ]; - - void _pickSpeed(int i) => _setSpeed(_PlayerPanels._speedPresets[i]); - - List get _fitRows => [ - for (final f in _PlayerFit.values) - NativeMenuRow(title: _fitLabel(f), checked: f == _fit, key: f), - ]; - - void _pickFit(int i) => _setFit(_PlayerFit.values[i]); - - /// What the sleep button's hold offers: everything its sheet does. - List<(NativeMenuRow, VoidCallback)> get _sleepChoices => [ - if (_sleepArmed) - (NativeMenuRow(title: 'general.off'.tr(), key: 'off'), _cancelSleepTimer), - for (final d in _PlayerSleepTimer._presets) - ( - NativeMenuRow( - title: d.inMinutes < 60 - ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) - : 'player.sleep_hours'.tr(args: ['${d.inHours}']), - breakBefore: _sleepArmed && d == _PlayerSleepTimer._presets.first, - key: d, - ), - () => _armSleepDuration(d), - ), - if (widget.args.isSerial) - ( - NativeMenuRow( - title: 'player.sleep_episode_end'.tr(), - checked: _sleepAtEpisodeEnd, - breakBefore: true, - key: 'episode', - ), - _armSleepAtEpisodeEnd, - ), - ]; - PlayerControlFace _sleepFace({String? label}) { - final choices = _sleepChoices; + final menu = _nativeBars ? _sleepMenu : null; return PlayerControlFace( id: 'sleep', icon: Icons.bedtime_rounded, @@ -2184,8 +2139,9 @@ extension _PlayerControls on _PlayerPageState { // Filled while armed, but not tinted: the Flutter button never was. symbol: _sleepArmed ? 'moon.zzz.fill' : 'moon.zzz', onTap: _openSleepSheet, - menu: [for (final c in choices) c.$1], - onMenu: (i) => choices[i].$2(), + menu: menu?.rows, + menuPrimary: menu != null, + onMenu: menu?.pick, ); } @@ -2203,14 +2159,14 @@ extension _PlayerControls on _PlayerPageState { pill: true, label: (_currentLang ?? _kSubLang).toUpperCase(), onTap: _openLangSheet, - ); + ).withMenu(_nativeBars ? _langMenu : null); case 'subtitles': return PlayerControlFace( id: id, icon: Icons.closed_caption_rounded, semanticLabel: name, onTap: _openSubtitleSheet, - ); + ).withMenu(_nativeBars ? _subtitleMenu : null); case 'settings': return PlayerControlFace( id: id, @@ -2222,6 +2178,7 @@ extension _PlayerControls on _PlayerPageState { return PlayerControlFace( id: id, icon: Icons.screen_rotation_rounded, + symbol: orientationSymbol(portrait: _isPortrait), semanticLabel: name, onTap: _toggleOrientation, ); @@ -2251,9 +2208,7 @@ extension _PlayerControls on _PlayerPageState { semanticLabel: name, onTap: _cycleSpeed, onLongPress: _openSpeedSheet, - menu: _speedRows, - onMenu: _pickSpeed, - ); + ).withMenu(_nativeBars ? _speedMenu : null); case 'server': if (!a.hasServers) return null; return PlayerControlFace( @@ -2261,7 +2216,7 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.dns_rounded, semanticLabel: name, onTap: _openServerSheet, - ); + ).withMenu(_nativeBars ? _serverMenu : null); case 'quality': if (!a.hasQualities) return null; return PlayerControlFace( @@ -2269,7 +2224,7 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.high_quality_rounded, semanticLabel: name, onTap: () => _openPanel(_SidePanel.quality), - ); + ).withMenu(_nativeBars ? _qualityMenu : null); case 'episodes': if (!a.hasEpisodes) return null; return PlayerControlFace( @@ -2314,13 +2269,11 @@ extension _PlayerControls on _PlayerPageState { // Forward on a tap, back on a hold. Three modes and a glyph that // names the current one leaves nothing for a dialog to add — and a // hold that steps backwards is worth more than one that opens a list - // of what the taps already walk through. The native button's hold - // opens all three instead, with the current one checked. + // of what the taps already walk through. The native button opens + // all three as its menu instead, the current one checked. onTap: _cycleFit, onLongPress: () => _cycleFit(step: -1), - menu: _fitRows, - onMenu: _pickFit, - ); + ).withMenu(_nativeBars ? _fitMenu : null); case 'sleep': if (_isLive) return null; return _sleepFace(); @@ -2451,9 +2404,7 @@ extension _PlayerControls on _PlayerPageState { compact: compact, onTap: _openSpeedSheet, onLongPress: _openSpeedSheet, - menu: _speedRows, - onMenu: _pickSpeed, - ); + ).withMenu(_nativeBars ? _speedMenu : null); case 'server': if (isTvPlatform || !a.hasServers) return null; return PlayerControlFace( @@ -2463,7 +2414,7 @@ extension _PlayerControls on _PlayerPageState { label: _currentServer ?? '—', compact: compact, onTap: _openServerSheet, - ); + ).withMenu(_nativeBars ? _serverMenu : null); case 'quality': if (isTvPlatform || !a.hasQualities) return null; return PlayerControlFace( @@ -2476,7 +2427,7 @@ extension _PlayerControls on _PlayerPageState { compactLabel: _qualityChipShort, compact: compact, onTap: () => _openPanel(_SidePanel.quality), - ); + ).withMenu(_nativeBars ? _qualityMenu : null); case 'episodes': if (isTvPlatform || !a.hasEpisodes) return null; return PlayerControlFace( @@ -2516,9 +2467,7 @@ extension _PlayerControls on _PlayerPageState { // the button itself answers. No toast either: the label IS the // feedback, and a snackbar repeating it is one message too many. onTap: () => _cycleFit(toast: false), - menu: _fitRows, - onMenu: _pickFit, - ); + ).withMenu(_nativeBars ? _fitMenu : null); case 'sleep': if (!_roomForExtras || _isLive) return null; return _sleepFace(label: _sleepValueLabel); diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 288d2fe4..6c51350f 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -81,6 +81,8 @@ import 'package:soplay/core/native/native_controls_gate.dart'; import 'package:soplay/core/native/native_glass_bar.dart'; import 'package:soplay/core/native/sf_symbols.dart'; import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_sheet_look.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; import 'package:soplay/features/mal/data/mal_tracker.dart'; import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; @@ -167,6 +169,8 @@ part 'player_page.tv.dart'; part 'player_page.jellyfin.dart'; part 'player_page.upnext.dart'; part 'player_page.reactions.dart'; +part 'player_page.menus.dart'; +part 'player_page.sheets.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; diff --git a/lib/features/detail/presentation/pages/player_page.menus.dart b/lib/features/detail/presentation/pages/player_page.menus.dart new file mode 100644 index 00000000..19516c33 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.menus.dart @@ -0,0 +1,166 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The native pull-down menus of the iOS 26 controls: a flat choice opens +/// as the phone's own menu on the button, where the Flutter sheet held +/// nothing more. Built only on that path ([_nativeBars]); the Flutter +/// buttons keep their sheets. +extension _PlayerMenus on _PlayerPageState { + PlayerMenu get _speedMenu => choiceMenu( + values: _PlayerPanels._speedPresets, + isCurrent: (s) => (s - _playbackSpeed).abs() < 0.01, + label: _speedLabel, + onPick: _setSpeed, + ); + + PlayerMenu get _fitMenu => choiceMenu<_PlayerFit>( + values: _PlayerFit.values, + isCurrent: (f) => f == _fit, + label: _fitLabel, + symbol: _PlayerControls._fitSymbol, + onPick: _setFit, + ); + + /// Everything the sleep sheet offers. + PlayerMenu get _sleepMenu { + String label(Duration d) => d.inMinutes < 60 + ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) + : 'player.sleep_hours'.tr(args: ['${d.inHours}']); + return PlayerMenu([ + if (_sleepArmed) + [ + PlayerMenuOption( + key: 'off', + title: 'general.off'.tr(), + onPick: _cancelSleepTimer, + ), + ], + [ + for (final d in _PlayerSleepTimer._presets) + PlayerMenuOption( + key: d, + title: label(d), + onPick: () => _armSleepDuration(d), + ), + ], + if (widget.args.isSerial) + [ + PlayerMenuOption( + key: 'episode', + title: 'player.sleep_episode_end'.tr(), + checked: _sleepAtEpisodeEnd, + onPick: _armSleepAtEpisodeEnd, + ), + ], + ]); + } + + /// Sub or dub; null with one to choose from. + PlayerMenu? get _langMenu { + final langs = _availableLangsForCurrentEpisode(); + if (langs.length < 2) return null; + return choiceMenu( + values: langs, + isCurrent: (l) => l == _currentLang, + label: _langLabel, + onPick: _switchLang, + ); + } + + /// The hosts, each with the heights it offers; null with one host. + PlayerMenu? get _serverMenu { + final servers = _sourceServers; + if (servers.length < 2) return null; + final labels = _sourceLabels; + return choiceMenu( + values: servers, + isCurrent: (s) => s == _currentServer, + label: (s) => s, + subtitle: (s) { + final q = VideoOptionGroups.qualitiesFor(labels, s).join(' · '); + return q.isEmpty ? null : q; + }, + onPick: _switchServer, + ); + } + + /// The rows the quality panel lists, the panel's own choice of them. + PlayerMenu? get _qualityMenu { + final tracks = _engineVideoTracks; + final active = _controller?.activeVideoTrackId; + QualityMenuEntry file(int i, {required bool selected}) { + final src = _videoSources[i]; + final worst = StreamWarning.worst(src); + return QualityMenuEntry( + key: ('file', i), + label: _qualityLabel(src.quality), + detail: + worst?.message ?? + (src.isDefault ? 'player.recommended'.tr() : null), + blocking: worst?.risk == StreamRisk.blocking, + selected: selected, + onPick: () => _switchQuality(src), + ); + } + + if (tracks.isNotEmpty) { + return qualityMenu( + renditions: [ + for (final t in tracks) + QualityMenuEntry( + key: ('track', t.id), + label: t.isAuto ? 'player.auto'.tr() : t.label, + detail: t.isAuto ? 'player.auto_quality_desc_mpv'.tr() : t.detail, + selected: t.id == active, + onPick: () => _switchVideoTrack(t), + ), + ], + files: [for (final i in _otherServerSources) file(i, selected: false)], + ); + } + return qualityMenu( + renditions: [ + for (final i in _currentServerSources) + file(i, selected: i == _currentSourceIndex), + ], + files: const [], + ); + } + + /// The tracks, Off, and the full sheet last. + PlayerMenu? get _subtitleMenu { + final c = _controller; + final embedded = c?.subtitleTracks ?? const []; + return subtitleMenu( + offLabel: 'player.off'.tr(), + offSelected: + _activeSubtitleIndex == -1 && c?.activeSubtitleTrackId == null, + onOff: _disableSubtitle, + tracks: [ + for (final t in embedded) + SubtitleMenuTrack( + key: ('embedded', t.id), + label: t.hasMetadata + ? t.label + : 'player.subtitle_n'.tr(args: ['${t.ordinal}']), + subtitle: 'player.subtitle_in_video'.tr(), + selected: + _activeSubtitleIndex == -1 && c?.activeSubtitleTrackId == t.id, + onPick: () => _selectEmbeddedSubtitle(t), + ), + for (var i = 0; i < _subtitles.length; i++) + SubtitleMenuTrack( + key: ('file', i), + label: _subtitles[i].label, + subtitle: i == _secondarySubtitleIndex + ? 'player.second_track'.tr() + : null, + selected: i == _activeSubtitleIndex, + onPick: () => _loadSubtitle(i), + ), + ], + moreLabel: 'detail.more_options'.tr(), + onMore: _openSubtitleSheet, + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index be8eee97..a9bc09ab 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -79,49 +79,27 @@ extension _PlayerPanels on _PlayerPageState { void _openLangSheet() { final langs = _availableLangsForCurrentEpisode(); if (langs.isEmpty) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.translate_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.audio_language'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), + ..._sheetHead( + Icons.translate_rounded, + 'player.audio_language'.tr(), ), - const Divider(color: Colors.white12, height: 1), - for (final l in langs) - _OptionTile( - label: _langLabel(l), - selected: l == _currentLang, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchLang(l); - }, - ), + ..._sheetGroup([ + for (final l in langs) + _OptionTile( + label: _langLabel(l), + selected: l == _currentLang, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchLang(l); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -281,7 +259,9 @@ extension _PlayerPanels on _PlayerPageState { final pinned = source.height ?? VideoOptionGroups.resolutionOf(source.quality); if (pinned != null) return '${playing ?? pinned}p'; - if (!_hasParsedSiblings(source.quality)) return _qualityLabel(source.quality); + if (!_hasParsedSiblings(source.quality)) { + return _qualityLabel(source.quality); + } // On the platform player Auto is ExoPlayer's own ABR, and video_player // reports only the size it started at. "Auto · 1080p" stayed on screen // while the picture adapted down to the lowest rung, which is how @@ -335,53 +315,28 @@ extension _PlayerPanels on _PlayerPageState { final labels = _sourceLabels; final servers = _sourceServers; if (servers.length < 2) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.dns_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.server'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final server in servers) - _ServerTile( - label: server, - qualities: VideoOptionGroups.qualitiesFor( - labels, - server, - ).join(' · '), - selected: server == _currentServer, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchServer(server); - }, - ), + ..._sheetHead(Icons.dns_outlined, 'player.server'.tr()), + ..._sheetGroup([ + for (final server in servers) + _ServerTile( + label: server, + qualities: VideoOptionGroups.qualitiesFor( + labels, + server, + ).join(' · '), + selected: server == _currentServer, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchServer(server); + }, + ), + ], inset: 56), const SizedBox(height: 8), ], ), @@ -415,13 +370,7 @@ extension _PlayerPanels on _PlayerPageState { void _openSettingsSheet() { final a = _affordances; final hasLangs = a.hasLangs; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( // StatefulBuilder so a row can change its own value without the sheet // closing and reopening — see the aspect-ratio tile below. builder: (sheetContext) => StatefulBuilder( @@ -430,334 +379,317 @@ extension _PlayerPanels on _PlayerPageState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.settings_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'general.settings'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - // Switching provider without losing your place. - // - // The app carries the same title on several ecosystems at once, - // and until now the only way to leave a struggling source was to - // back out and start the episode again somewhere else. Offered - // here rather than only on the error screen, because a source - // that is buffering badly has not failed yet — that is exactly - // when someone wants out, and exactly when they have a position - // worth keeping. - // - // Not for live: a channel is one stream, and there is no other - // provider carrying the same minute of it. - if (!_isLive) - _SettingsTile( - icon: Icons.swap_horiz_rounded, - label: 'player.change_source'.tr(), - value: _providerLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openAlternateSources(keepPosition: true); - }, - ), - // Speed, server, quality and subtitles used to sit here as well - // as on the bar, calling the very same functions. A settings list - // is where things go when they have nowhere else; those all have - // a button one tap away, so the copies were padding a list that - // was already nineteen rows long. Removed, not moved. - // - // Download is the exception and stays: the bar button for it is - // in the PHONE branch only, so on desktop this sheet is the only - // way to start one. Removing it took the feature off Windows, - // macOS and Linux entirely. - if (isDesktopPlatform && a.canDownload) + ..._sheetHead(Icons.settings_outlined, 'general.settings'.tr()), + ..._sheetGroup([ + // Switching provider without losing your place. + // + // The app carries the same title on several ecosystems at once, + // and until now the only way to leave a struggling source was to + // back out and start the episode again somewhere else. Offered + // here rather than only on the error screen, because a source + // that is buffering badly has not failed yet — that is exactly + // when someone wants out, and exactly when they have a position + // worth keeping. + // + // Not for live: a channel is one stream, and there is no other + // provider carrying the same minute of it. + if (!_isLive) + _SettingsTile( + icon: Icons.swap_horiz_rounded, + label: 'player.change_source'.tr(), + value: _providerLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openAlternateSources(keepPosition: true); + }, + ), + // Speed, server, quality and subtitles used to sit here as well + // as on the bar, calling the very same functions. A settings list + // is where things go when they have nowhere else; those all have + // a button one tap away, so the copies were padding a list that + // was already nineteen rows long. Removed, not moved. + // + // Download is the exception and stays: the bar button for it is + // in the PHONE branch only, so on desktop this sheet is the only + // way to start one. Removing it took the feature off Windows, + // macOS and Linux entirely. + if (isDesktopPlatform && a.canDownload) + _SettingsTile( + icon: Icons.download_rounded, + label: 'movie.download'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _startDownload(); + }, + ), + // Three values, stepped in place. + // + // This opened a SECOND sheet over the first to choose one of + // three — a whole modal, covering the video, for a setting whose + // entire interaction is "the next one". The row cycles now and + // shows where it landed, and the sheet it is in stays open so + // two steps cost two taps. _SettingsTile( - icon: Icons.download_rounded, - label: 'movie.download'.tr(), - value: '', + icon: Icons.aspect_ratio_rounded, + label: 'player.aspect'.tr(), + value: _fitLabel(_fit), onTap: () { - Navigator.of(sheetContext).pop(); - _startDownload(); + _cycleFit(toast: false); + setSheetState(() {}); }, ), - // Three values, stepped in place. - // - // This opened a SECOND sheet over the first to choose one of - // three — a whole modal, covering the video, for a setting whose - // entire interaction is "the next one". The row cycles now and - // shows where it landed, and the sheet it is in stays open so - // two steps cost two taps. - _SettingsTile( - icon: Icons.aspect_ratio_rounded, - label: 'player.aspect'.tr(), - value: _fitLabel(_fit), - onTap: () { - _cycleFit(toast: false); - setSheetState(() {}); - }, - ), - if (!isDesktopPlatform && - !_preferPlatformPlayer && - (_controller?.supportsVideoTracks ?? false)) + if (!isDesktopPlatform && + !_preferPlatformPlayer && + (_controller?.supportsVideoTracks ?? false)) + _SettingsTile( + icon: Icons.play_circle_outline, + label: 'profile.player_engine_native'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _playWithSystemPlayer(); + }, + ), + // Absent on the platform player, which has no runtime picture + // control at all. Offering a menu that silently does nothing + // teaches people the app is broken rather than that the feature + // is unavailable here. + if (_controller?.supportsColorProfile ?? false) + _SettingsTile( + icon: Icons.tune_rounded, + label: 'player.color_profile'.tr(), + value: _colorProfile.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openColorSheet(); + }, + ), + // Only on libmpv, which is the one backend that can read a frame + // back. The platform player and the DRM one keep their pixels in + // a texture the Dart side never sees, so the row is absent there + // rather than present and producing nothing. + // + // Live has no frame worth sharing and no title to attach it to. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.photo_camera_outlined, + label: 'player.share_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _shareCurrentFrame(); + }, + ), + // Sharing and keeping are different intentions, and routing + // "keep" through the share sheet made it a three-tap detour via + // whichever app the system decided could save a file. The frame + // people wanted was also the one the system screenshot key could + // not give them: that one carries the controls, the seek bar and + // the status bar burnt into it, at the screen's resolution + // rather than the video's. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.save_alt_rounded, + label: 'player.save_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _saveCurrentFrame(); + }, + ), + // Always listed, disabled where it cannot run. + // + // It used to disappear entirely on the platform player, which is + // the default on most devices — so Anime4K, sharpen, deblur and + // denoise were invisible to the people most likely to want them, + // with nothing on screen to say the feature existed or why it was + // absent. A row that explains itself beats a row that is not + // there. + if (_controller?.supportsShaders ?? false) + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _shaderPreset.isOff + ? 'general.off'.tr() + : _shaderPreset.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openShaderSheet(); + }, + ) + else + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _mediaKitMissing(), + onTap: null, + ), + // Shown here, and not only in Settings, so a mode that suppresses + // history cannot sit on unnoticed for weeks: the sheet is the one + // surface a viewer opens mid-episode, and this is where they will + // wonder why nothing is in Continue Watching. + if (_hive.isIncognito) + _SettingsTile( + icon: Icons.visibility_off_outlined, + label: 'profile.incognito'.tr(), + value: 'general.on'.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _hive.setIncognito(false); + if (!mounted) return; + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('profile.incognito_off'.tr()), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + // Hidden rather than disabled when the stream cannot be cast — a + // torrent handle or a downloaded file has no address a device on + // the network could fetch, and a greyed-out row invites a tap + // that can only ever explain itself. + if (_canCast) + _SettingsTile( + icon: _cast.isCasting + ? Icons.cast_connected_rounded + : Icons.cast_rounded, + label: 'player.cast'.tr(), + value: _cast.device?.name ?? '—', + onTap: () { + Navigator.of(sheetContext).pop(); + if (_cast.isCasting) { + _stopCasting(); + } else { + _openCastSheet(); + } + }, + ), + // Not offered for live TV: a broadcast has no end to stop at, and + // a countdown that pauses a channel is just a mute button with + // extra steps. + if (!_isLive) + _SettingsTile( + icon: Icons.bedtime_outlined, + label: 'player.sleep_timer'.tr(), + value: _sleepValueLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openSleepSheet(); + }, + ), + if (hasLangs) + _SettingsTile( + icon: Icons.translate_rounded, + label: 'player.audio_language'.tr(), + value: _currentLang == null + ? '—' + : _langLabel(_currentLang!), + onTap: () { + Navigator.of(sheetContext).pop(); + _openLangSheet(); + }, + ), + if (_subtitles.isNotEmpty || + (_controller?.subtitleTracks.isNotEmpty ?? false)) + _SettingsTile( + icon: Icons.text_fields_rounded, + label: 'player.subtitle_style'.tr(), + value: '${_subtitleStyle.fontSize.round()}px', + onTap: () { + Navigator.of(sheetContext).pop(); + _openSubtitleAppearanceSheet(); + }, + ), + // Audio tracks are a media_kit capability: video_player exposes no + // track-selection API at all. Under the default engine the tile + // stays disabled and says why, rather than opening an empty sheet. + if (!hasLangs) + Builder( + builder: (_) { + final c = _controller; + final supported = c?.supportsAudioTracks ?? false; + final tracks = + c?.audioTracks ?? const []; + if (!supported) { + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: _mediaKitMissing(onlyLabel: true), + onTap: null, + ); + } + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: tracks.length < 2 + ? 'player.audio_track_single'.tr() + : _activeAudioTrackLabel(), + onTap: tracks.length < 2 + ? null + : () { + Navigator.of(sheetContext).pop(); + _openAudioTrackSheet(); + }, + ); + }, + ), + if (ExternalPlayer.isSupported && _videoUrl != null) + _SettingsTile( + icon: Icons.open_in_new_rounded, + label: 'player.external_player'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _handOffToExternalPlayer(); + }, + ), + // Was `showDownloadAction && provider != 'uzmovi'` — the url + // Beside the diagnostics log, which answers the same question + // for whoever wrote the player. This one answers it for whoever + // is watching — and is small enough to screenshot into a report. _SettingsTile( - icon: Icons.play_circle_outline, - label: 'profile.player_engine_native'.tr(), - value: '', + icon: Icons.info_outline_rounded, + label: 'player.info_title'.tr(), + value: _showPlayerInfo + ? 'player.on'.tr() + : 'player.off'.tr(), onTap: () { Navigator.of(sheetContext).pop(); - _playWithSystemPlayer(); + setState(() => _showPlayerInfo = !_showPlayerInfo); }, ), - // Absent on the platform player, which has no runtime picture - // control at all. Offering a menu that silently does nothing - // teaches people the app is broken rather than that the feature - // is unavailable here. - if (_controller?.supportsColorProfile ?? false) _SettingsTile( icon: Icons.tune_rounded, - label: 'player.color_profile'.tr(), - value: _colorProfile.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openColorSheet(); - }, - ), - // Only on libmpv, which is the one backend that can read a frame - // back. The platform player and the DRM one keep their pixels in - // a texture the Dart side never sees, so the row is absent there - // rather than present and producing nothing. - // - // Live has no frame worth sharing and no title to attach it to. - if (!_isLive && (_controller?.supportsShaders ?? false)) - _SettingsTile( - icon: Icons.photo_camera_outlined, - label: 'player.share_frame'.tr(), + label: 'player.info_fields_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _shareCurrentFrame(); + _openInfoFieldsPicker(); }, ), - // Sharing and keeping are different intentions, and routing - // "keep" through the share sheet made it a three-tap detour via - // whichever app the system decided could save a file. The frame - // people wanted was also the one the system screenshot key could - // not give them: that one carries the controls, the seek bar and - // the status bar burnt into it, at the screen's resolution - // rather than the video's. - if (!_isLive && (_controller?.supportsShaders ?? false)) _SettingsTile( - icon: Icons.save_alt_rounded, - label: 'player.save_frame'.tr(), + icon: Icons.dashboard_customize_outlined, + label: 'player.layout_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _saveCurrentFrame(); - }, - ), - // Always listed, disabled where it cannot run. - // - // It used to disappear entirely on the platform player, which is - // the default on most devices — so Anime4K, sharpen, deblur and - // denoise were invisible to the people most likely to want them, - // with nothing on screen to say the feature existed or why it was - // absent. A row that explains itself beats a row that is not - // there. - if (_controller?.supportsShaders ?? false) - _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _shaderPreset.isOff - ? 'general.off'.tr() - : _shaderPreset.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openShaderSheet(); - }, - ) - else - _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _mediaKitMissing(), - onTap: null, - ), - // Shown here, and not only in Settings, so a mode that suppresses - // history cannot sit on unnoticed for weeks: the sheet is the one - // surface a viewer opens mid-episode, and this is where they will - // wonder why nothing is in Continue Watching. - if (_hive.isIncognito) - _SettingsTile( - icon: Icons.visibility_off_outlined, - label: 'profile.incognito'.tr(), - value: 'general.on'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _hive.setIncognito(false); - if (!mounted) return; - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.incognito_off'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - }, - ), - // Hidden rather than disabled when the stream cannot be cast — a - // torrent handle or a downloaded file has no address a device on - // the network could fetch, and a greyed-out row invites a tap - // that can only ever explain itself. - if (_canCast) - _SettingsTile( - icon: _cast.isCasting - ? Icons.cast_connected_rounded - : Icons.cast_rounded, - label: 'player.cast'.tr(), - value: _cast.device?.name ?? '—', - onTap: () { - Navigator.of(sheetContext).pop(); - if (_cast.isCasting) { - _stopCasting(); - } else { - _openCastSheet(); - } - }, - ), - // Not offered for live TV: a broadcast has no end to stop at, and - // a countdown that pauses a channel is just a mute button with - // extra steps. - if (!_isLive) - _SettingsTile( - icon: Icons.bedtime_outlined, - label: 'player.sleep_timer'.tr(), - value: _sleepValueLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openSleepSheet(); - }, - ), - if (hasLangs) - _SettingsTile( - icon: Icons.translate_rounded, - label: 'player.audio_language'.tr(), - value: _currentLang == null - ? '—' - : _langLabel(_currentLang!), - onTap: () { - Navigator.of(sheetContext).pop(); - _openLangSheet(); - }, - ), - if (_subtitles.isNotEmpty || - (_controller?.subtitleTracks.isNotEmpty ?? false)) - _SettingsTile( - icon: Icons.text_fields_rounded, - label: 'player.subtitle_style'.tr(), - value: '${_subtitleStyle.fontSize.round()}px', - onTap: () { - Navigator.of(sheetContext).pop(); - _openSubtitleAppearanceSheet(); + _openControlsLayoutPage(); }, ), - // Audio tracks are a media_kit capability: video_player exposes no - // track-selection API at all. Under the default engine the tile - // stays disabled and says why, rather than opening an empty sheet. - if (!hasLangs) - Builder( - builder: (_) { - final c = _controller; - final supported = c?.supportsAudioTracks ?? false; - final tracks = - c?.audioTracks ?? const []; - if (!supported) { - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: _mediaKitMissing(onlyLabel: true), - onTap: null, - ); - } - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: tracks.length < 2 - ? 'player.audio_track_single'.tr() - : _activeAudioTrackLabel(), - onTap: tracks.length < 2 - ? null - : () { - Navigator.of(sheetContext).pop(); - _openAudioTrackSheet(); - }, - ); - }, - ), - if (ExternalPlayer.isSupported && _videoUrl != null) _SettingsTile( - icon: Icons.open_in_new_rounded, - label: 'player.external_player'.tr(), + icon: Icons.bug_report_outlined, + label: 'player.diagnostics_logs'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _handOffToExternalPlayer(); + LogViewerSheet.show(context); }, ), - // Was `showDownloadAction && provider != 'uzmovi'` — the url - // Beside the diagnostics log, which answers the same question - // for whoever wrote the player. This one answers it for whoever - // is watching — and is small enough to screenshot into a report. - _SettingsTile( - icon: Icons.info_outline_rounded, - label: 'player.info_title'.tr(), - value: _showPlayerInfo ? 'player.on'.tr() : 'player.off'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - setState(() => _showPlayerInfo = !_showPlayerInfo); - }, - ), - _SettingsTile( - icon: Icons.tune_rounded, - label: 'player.info_fields_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openInfoFieldsPicker(); - }, - ), - _SettingsTile( - icon: Icons.dashboard_customize_outlined, - label: 'player.layout_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openControlsLayoutPage(); - }, - ), - _SettingsTile( - icon: Icons.bug_report_outlined, - label: 'player.diagnostics_logs'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - LogViewerSheet.show(context); - }, - ), + ], inset: 56), const SizedBox(height: 8), ], ), @@ -794,49 +726,27 @@ extension _PlayerPanels on _PlayerPageState { if (c == null || !c.supportsAudioTracks) return; final tracks = c.audioTracks; if (tracks.length < 2) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.audiotrack_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.audio_track'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), + ..._sheetHead( + Icons.audiotrack_outlined, + 'player.audio_track'.tr(), ), - const Divider(color: Colors.white12, height: 1), - for (final t in tracks) - _OptionTile( - label: _audioTrackLabel(t), - selected: t.id == c.activeAudioTrackId, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchAudioTrack(t.id); - }, - ), + ..._sheetGroup([ + for (final t in tracks) + _OptionTile( + label: _audioTrackLabel(t), + selected: t.id == c.activeAudioTrackId, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchAudioTrack(t.id); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -929,50 +839,25 @@ extension _PlayerPanels on _PlayerPageState { void _openSpeedSheet() { const speeds = _speedPresets; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.speed_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.speed'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final s in speeds) - _OptionTile( - label: - '${s.toStringAsFixed(s == s.roundToDouble() ? 0 : 2)}x', - selected: s == _playbackSpeed, - onTap: () { - Navigator.of(sheetContext).pop(); - _setSpeed(s); - }, - ), + ..._sheetHead(Icons.speed_rounded, 'player.speed'.tr()), + ..._sheetGroup([ + for (final s in speeds) + _OptionTile( + label: + '${s.toStringAsFixed(s == s.roundToDouble() ? 0 : 2)}x', + selected: s == _playbackSpeed, + onTap: () { + Navigator.of(sheetContext).pop(); + _setSpeed(s); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -989,53 +874,28 @@ extension _PlayerPanels on _PlayerPageState { /// an approximation of it, so there is no way to get stuck somewhere worse /// than where you started. void _openColorSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: StatefulBuilder( builder: (context, setSheetState) => Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.tune_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.color_profile'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final profile in ColorProfile.all) - _OptionTile( - label: profile.labelKey.tr(), - selected: profile.id == _colorProfile.id, - // The sheet stays open. Comparing two looks means switching - // back and forth, and a menu that closes on every tap makes - // that four gestures instead of one. - onTap: () { - _setColorProfile(profile); - setSheetState(() {}); - }, - ), + ..._sheetHead(Icons.tune_rounded, 'player.color_profile'.tr()), + ..._sheetGroup([ + for (final profile in ColorProfile.all) + _OptionTile( + label: profile.labelKey.tr(), + selected: profile.id == _colorProfile.id, + // The sheet stays open. Comparing two looks means switching + // back and forth, and a menu that closes on every tap makes + // that four gestures instead of one. + onTap: () { + _setColorProfile(profile); + setSheetState(() {}); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -1057,13 +917,7 @@ extension _PlayerPanels on _PlayerPageState { /// part of the same judgement — "is this better, and does it still run /// smoothly" — and that can only be made while something is playing. void _openShaderSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: StatefulBuilder( @@ -1363,6 +1217,38 @@ extension _PlayerPanels on _PlayerPageState { ), ); + final title = isQuality ? 'player.quality'.tr() : 'player.episodes'.tr(); + if (asSheet && _iosSheets) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.55, + ), + child: PlayerSheetLook( + ios: true, + child: IosSheetFrame( + child: SafeArea( + top: false, + child: FocusScope( + node: _tvPanelFocus, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IosSheetHeader(title: title, onClose: _closePanel), + Flexible(child: list), + ], + ), + ), + ), + ), + ), + ), + ); + } + final body = Column( mainAxisSize: asSheet ? MainAxisSize.min : MainAxisSize.max, children: [ @@ -1381,7 +1267,7 @@ extension _PlayerPanels on _PlayerPageState { child: Row( children: [ Text( - isQuality ? 'player.quality'.tr() : 'player.episodes'.tr(), + title, style: const TextStyle( color: Colors.white, fontSize: 16, @@ -1460,6 +1346,17 @@ class _ServerTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + leading: ServerBadge(name: label, selected: selected), + title: label, + subtitle: qualities, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, diff --git a/lib/features/detail/presentation/pages/player_page.sheets.dart b/lib/features/detail/presentation/pages/player_page.sheets.dart new file mode 100644 index 00000000..6f799320 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.sheets.dart @@ -0,0 +1,49 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The player's sheets, dressed as iOS 26's on the native controls' path +/// ([showPlayerSheet], [PlayerSheetLook]); the same sheets as ever +/// everywhere else, widget for widget. +extension _PlayerSheets on _PlayerPageState { + bool get _iosSheets => _nativeControls; + + Future _showSheet({ + required WidgetBuilder builder, + double desktopMaxWidth = 460, + }) => showPlayerSheet( + context: context, + ios: _iosSheets, + desktopMaxWidth: desktopMaxWidth, + builder: builder, + ); + + /// A sheet's title: the icon, the words and the rule under them, or iOS's + /// centred title with its close button. + List _sheetHead(IconData icon, String title) => _iosSheets + ? [IosSheetHeader(title: title)] + : [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + Icon(icon, color: Colors.white, size: 18), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + ]; + + /// [rows] as one inset group on the iOS path, as they were elsewhere. + List _sheetGroup(List rows, {double inset = 16}) => _iosSheets + ? [IosSheetGroup(separatorInset: inset, children: rows)] + : rows; +} diff --git a/lib/features/detail/presentation/pages/player_page.sleep.dart b/lib/features/detail/presentation/pages/player_page.sleep.dart index bacd99f7..04bea2a2 100644 --- a/lib/features/detail/presentation/pages/player_page.sleep.dart +++ b/lib/features/detail/presentation/pages/player_page.sleep.dart @@ -130,71 +130,46 @@ extension _PlayerSleepTimer on _PlayerPageState { } void _openSleepSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.bedtime_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.sleep_timer'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - _SleepOptionTile( - label: 'general.off'.tr(), - selected: !_sleepArmed, - onTap: () { - Navigator.of(sheetContext).pop(); - _cancelSleepTimer(); - }, - ), - for (final d in _PlayerSleepTimer._presets) - _SleepOptionTile( - label: d.inMinutes < 60 - ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) - : 'player.sleep_hours'.tr(args: ['${d.inHours}']), - selected: false, - onTap: () { - Navigator.of(sheetContext).pop(); - _armSleepDuration(d); - }, - ), - // Only meaningful for a series: on a film "end of episode" and - // "end of file" are the same thing, and the player already stops - // there on its own. - if (widget.args.isSerial) + ..._sheetHead(Icons.bedtime_outlined, 'player.sleep_timer'.tr()), + ..._sheetGroup([ _SleepOptionTile( - label: 'player.sleep_episode_end'.tr(), - selected: _sleepAtEpisodeEnd, + label: 'general.off'.tr(), + selected: !_sleepArmed, onTap: () { Navigator.of(sheetContext).pop(); - _armSleepAtEpisodeEnd(); + _cancelSleepTimer(); }, ), + for (final d in _PlayerSleepTimer._presets) + _SleepOptionTile( + label: d.inMinutes < 60 + ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) + : 'player.sleep_hours'.tr(args: ['${d.inHours}']), + selected: false, + onTap: () { + Navigator.of(sheetContext).pop(); + _armSleepDuration(d); + }, + ), + // Only meaningful for a series: on a film "end of episode" and + // "end of file" are the same thing, and the player already stops + // there on its own. + if (widget.args.isSerial) + _SleepOptionTile( + label: 'player.sleep_episode_end'.tr(), + selected: _sleepAtEpisodeEnd, + onTap: () { + Navigator.of(sheetContext).pop(); + _armSleepAtEpisodeEnd(); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -217,6 +192,15 @@ class _SleepOptionTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + title: label, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + ); + } return ListTile( dense: true, onTap: onTap, diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 8d9e2b50..d714bb69 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -239,91 +239,66 @@ extension _PlayerSubtitles on _PlayerPageState { } void _openSubtitleSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.subtitles_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.subtitles'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - _OptionTile( - label: 'player.off'.tr(), - selected: - _activeSubtitleIndex == -1 && - _controller?.activeSubtitleTrackId == null, - onTap: () { - Navigator.of(sheetContext).pop(); - _disableSubtitle(); - }, - ), - // The stream's own tracks — an MKV's, an HLS master's. They - // were drawn by the engine in a fixed style, could not be turned - // off, and never appeared here. - for (final track - in _controller?.subtitleTracks ?? - const []) + ..._sheetHead(Icons.subtitles_rounded, 'player.subtitles'.tr()), + ..._sheetGroup([ _OptionTile( - label: track.hasMetadata - ? track.label - : 'player.subtitle_n'.tr(args: ['${track.ordinal}']), - subtitle: 'player.subtitle_in_video'.tr(), + label: 'player.off'.tr(), selected: _activeSubtitleIndex == -1 && - _controller?.activeSubtitleTrackId == track.id, + _controller?.activeSubtitleTrackId == null, onTap: () { Navigator.of(sheetContext).pop(); - _selectEmbeddedSubtitle(track); - }, - ), - for (var i = 0; i < _subtitles.length; i++) - _OptionTile( - label: _subtitles[i].label, - // Named only when it IS the second track, so the row reads - // normally for everyone who never uses this. - subtitle: i == _secondarySubtitleIndex - ? 'player.second_track'.tr() - : null, - selected: i == _activeSubtitleIndex, - onTap: () { - Navigator.of(sheetContext).pop(); - _loadSubtitle(i); - }, - // Long-press adds it as a SECOND track rather than replacing - // the first — the two languages side by side, which is what - // somebody learning one of them is here for. A separate - // picker would have been a second list of the same tracks. - onLongPress: () { - Navigator.of(sheetContext).pop(); - _toggleSecondarySubtitle(i); + _disableSubtitle(); }, ), + // The stream's own tracks — an MKV's, an HLS master's. They + // were drawn by the engine in a fixed style, could not be turned + // off, and never appeared here. + for (final track + in _controller?.subtitleTracks ?? + const []) + _OptionTile( + label: track.hasMetadata + ? track.label + : 'player.subtitle_n'.tr(args: ['${track.ordinal}']), + subtitle: 'player.subtitle_in_video'.tr(), + selected: + _activeSubtitleIndex == -1 && + _controller?.activeSubtitleTrackId == track.id, + onTap: () { + Navigator.of(sheetContext).pop(); + _selectEmbeddedSubtitle(track); + }, + ), + for (var i = 0; i < _subtitles.length; i++) + _OptionTile( + label: _subtitles[i].label, + // Named only when it IS the second track, so the row reads + // normally for everyone who never uses this. + subtitle: i == _secondarySubtitleIndex + ? 'player.second_track'.tr() + : null, + selected: i == _activeSubtitleIndex, + onTap: () { + Navigator.of(sheetContext).pop(); + _loadSubtitle(i); + }, + // Long-press adds it as a SECOND track rather than replacing + // the first — the two languages side by side, which is what + // somebody learning one of them is here for. A separate + // picker would have been a second list of the same tracks. + onLongPress: () { + Navigator.of(sheetContext).pop(); + _toggleSecondarySubtitle(i); + }, + ), + ]), // Otherwise the gesture is undiscoverable: nothing on a list of // track names suggests that holding one does something else. if (_subtitles.length > 1) @@ -654,13 +629,7 @@ extension _PlayerSubtitles on _PlayerPageState { text: widget.args.title.replaceAll(RegExp(r'\(.*?\)'), '').trim(), ); - await showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + await _showSheet( desktopMaxWidth: 520, builder: (sheetCtx) { var started = false; @@ -1457,13 +1426,7 @@ extension _PlayerSubtitles on _PlayerPageState { ]; void _openSubtitleSyncSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( desktopMaxWidth: 460, builder: (sheetContext) { return StatefulBuilder( @@ -1699,13 +1662,7 @@ extension _PlayerSubtitles on _PlayerPageState { } void _openSubtitleAppearanceSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( desktopMaxWidth: 520, builder: (sheetContext) { return StatefulBuilder( diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index ec9e28ee..612d6099 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -1177,6 +1177,15 @@ class _SettingsTile extends StatelessWidget { @override Widget build(BuildContext context) { final disabled = onTap == null; + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + leading: Icon(cupertinoIconFor(icon)), + title: label, + value: value, + accessory: IosRowAccessory.chevron, + onTap: onTap, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, @@ -1260,6 +1269,17 @@ class _OptionTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + title: label, + subtitle: subtitle, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + onLongPress: onLongPress, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, @@ -1381,6 +1401,12 @@ class _SheetSectionLabel extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(32, 14, 32, 2), + child: Text(label.toUpperCase(), style: IosSheet.footnote), + ); + } return Padding( padding: const EdgeInsetsDirectional.fromSTEB(20, 14, 16, 6), child: Text( diff --git a/lib/features/detail/presentation/widgets/player_control_face.dart b/lib/features/detail/presentation/widgets/player_control_face.dart index cc1b8e30..c095047a 100644 --- a/lib/features/detail/presentation/widgets/player_control_face.dart +++ b/lib/features/detail/presentation/widgets/player_control_face.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:soplay/core/native/native_glass_bar.dart'; import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; /// One player control as it stands on a bar right now: what it shows, what it /// does, whether it is on. The player builds one per control and draws it @@ -26,6 +27,7 @@ class PlayerControlFace { this.active = false, this.enabled = true, this.menu, + this.menuPrimary = false, this.onMenu, }); @@ -55,10 +57,36 @@ class PlayerControlFace { /// The rows a hold opens on the native button. final List? menu; + + /// The native button opens [menu] on a tap as well ([NativeBarItem.menuPrimary]). + final bool menuPrimary; final ValueChanged? onMenu; bool get labelled => label != null; + /// This face with [menu] as its native pull-down: a tap on the native + /// button opens it. Null leaves the face as it is. + PlayerControlFace withMenu(PlayerMenu? menu) => menu == null + ? this + : PlayerControlFace( + id: id, + icon: icon, + semanticLabel: semanticLabel, + symbol: symbol, + label: label, + compactLabel: compactLabel, + compact: compact, + pill: pill, + tooltip: tooltip, + onTap: onTap, + onLongPress: onLongPress, + active: active, + enabled: enabled, + menu: menu.rows, + menuPrimary: true, + onMenu: menu.pick, + ); + NativeBarItem toNative({int group = 0}) { final wordsOnly = compact && compactLabel != null; final title = pill @@ -82,6 +110,7 @@ class PlayerControlFace { onPressed: onTap, onLongPress: onLongPress, menu: menu, + menuPrimary: menuPrimary, onMenuSelected: onMenu, ); } diff --git a/lib/features/detail/presentation/widgets/player_menus.dart b/lib/features/detail/presentation/widgets/player_menus.dart new file mode 100644 index 00000000..58c338f1 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_menus.dart @@ -0,0 +1,203 @@ +import 'package:flutter/foundation.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +/// One choice of a player control's native menu: the row the menu draws and +/// what choosing it does. +@immutable +class PlayerMenuOption { + const PlayerMenuOption({ + required this.key, + required this.title, + required this.onPick, + this.subtitle, + this.checked, + this.symbol, + this.destructive = false, + }); + + /// What the row stands for ([NativeMenuRow.key]). + final Object key; + final String title; + final String? subtitle; + final bool? checked; + final String? symbol; + final bool destructive; + final VoidCallback onPick; +} + +/// A native pull-down menu for a player control (iOS 26 path): runs of +/// options with a separator between runs, each choice's action beside its +/// row so the two never drift apart. +@immutable +class PlayerMenu { + PlayerMenu(List> sections) + : options = [ + for (final run in sections.where((r) => r.isNotEmpty)) + for (final o in run) o, + ], + _breaks = _startsOf(sections); + + final List options; + final Set _breaks; + + static Set _startsOf(List> sections) { + final starts = {}; + var at = 0; + for (final run in sections.where((r) => r.isNotEmpty)) { + if (at > 0) starts.add(at); + at += run.length; + } + return starts; + } + + List get rows => [ + for (var i = 0; i < options.length; i++) + NativeMenuRow( + title: options[i].title, + subtitle: options[i].subtitle, + symbol: options[i].symbol, + checked: options[i].checked, + destructive: options[i].destructive, + breakBefore: _breaks.contains(i), + key: options[i].key, + ), + ]; + + /// Runs the choice at [index] of [rows]; an index out of range is a menu + /// that changed under the finger, and does nothing. + void pick(int index) { + if (index < 0 || index >= options.length) return; + options[index].onPick(); + } +} + +/// One value out of [values], the [current] one checked: speed, fit, +/// server, audio language. +PlayerMenu choiceMenu({ + required List values, + required bool Function(T value) isCurrent, + required String Function(T value) label, + required void Function(T value) onPick, + String? Function(T value)? subtitle, + String? Function(T value)? symbol, +}) => PlayerMenu([ + [ + for (final v in values) + PlayerMenuOption( + key: v, + title: label(v), + subtitle: subtitle?.call(v), + symbol: symbol?.call(v), + checked: isCurrent(v), + onPick: () => onPick(v), + ), + ], +]); + +/// A subtitle track as the subtitle menu lists it. +@immutable +class SubtitleMenuTrack { + const SubtitleMenuTrack({ + required this.key, + required this.label, + required this.selected, + required this.onPick, + this.subtitle, + }); + + final Object key; + final String label; + final String? subtitle; + final bool selected; + final VoidCallback onPick; +} + +/// The subtitle button's menu: Off, the stream's own tracks, the loaded +/// ones, and last the full sheet for everything a menu cannot hold — +/// search, a file, AI translation, style, sync, a second track. +/// +/// Null with no track to choose: the button opens the sheet directly, since +/// a menu of Off and "More" is one tap more for nothing. +PlayerMenu? subtitleMenu({ + required String offLabel, + required bool offSelected, + required VoidCallback onOff, + required List tracks, + required String moreLabel, + required VoidCallback onMore, +}) { + if (tracks.isEmpty) return null; + return PlayerMenu([ + [ + PlayerMenuOption( + key: 'off', + title: offLabel, + checked: offSelected, + onPick: onOff, + ), + for (final t in tracks) + PlayerMenuOption( + key: t.key, + title: t.label, + subtitle: t.subtitle, + checked: t.selected, + onPick: t.onPick, + ), + ], + [ + PlayerMenuOption( + key: 'more', + title: moreLabel, + symbol: 'ellipsis.circle', + onPick: onMore, + ), + ], + ]); +} + +/// A rendition or a file as the quality menu lists it. +@immutable +class QualityMenuEntry { + const QualityMenuEntry({ + required this.key, + required this.label, + required this.selected, + required this.onPick, + this.detail, + this.blocking = false, + }); + + final Object key; + final String label; + + /// The line under it: what Auto does, the bitrate, or the worst thing + /// about the file. + final String? detail; + final bool selected; + + /// The file will not play here (Dolby Vision, unreachable): drawn red, as + /// the sheet draws its warning. + final bool blocking; + final VoidCallback onPick; +} + +/// The quality button's menu: the stream's renditions, then the server's +/// other files under a separator. Null when there is nothing to choose. +PlayerMenu? qualityMenu({ + required List renditions, + required List files, +}) { + if (renditions.length + files.length < 2) return null; + PlayerMenuOption option(QualityMenuEntry e) => PlayerMenuOption( + key: e.key, + title: e.label, + subtitle: e.detail, + checked: e.selected, + destructive: e.blocking, + onPick: e.onPick, + ); + return PlayerMenu([ + [for (final e in renditions) option(e)], + [for (final e in files) option(e)], + ]); +} diff --git a/lib/features/detail/presentation/widgets/player_sheet_look.dart b/lib/features/detail/presentation/widgets/player_sheet_look.dart new file mode 100644 index 00000000..3065f3c7 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_sheet_look.dart @@ -0,0 +1,502 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// Whether the player's sheets under it wear iOS 26's look: the native +/// controls' path on an iPhone. Everywhere else they stay as they were. +class PlayerSheetLook extends InheritedWidget { + const PlayerSheetLook({super.key, required this.ios, required super.child}); + + final bool ios; + + static bool of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.ios ?? + false; + + @override + bool updateShouldNotify(PlayerSheetLook old) => old.ios != ios; +} + +/// iOS 26's numbers for a sheet and its grouped list, dark. +abstract final class IosSheet { + static const double radius = 38; + static const double groupRadius = 26; + static const double gutter = 16; + static const double rowHeight = 52; + + /// The sheet's material over the blurred video, and with nothing through + /// it for Reduce Transparency. + static const Color tint = Color(0xC71C1C1E); + static const Color opaque = Color(0xFF1C1C1E); + static const double blur = 30; + + /// A group's cell, a step up from the sheet. + static const Color cell = Color(0x1FFFFFFF); + static const Color separator = Color(0x2EFFFFFF); + static const Color grabber = Color(0x5CFFFFFF); + static const Color secondary = Color(0x99EBEBF5); + static const Color tertiary = Color(0x4DEBEBF5); + static const Color pressed = Color(0x1AFFFFFF); + + static const TextStyle title = TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.43, + ); + static const TextStyle body = TextStyle( + color: Colors.white, + fontSize: 17, + letterSpacing: -0.43, + ); + static const TextStyle footnote = TextStyle( + color: secondary, + fontSize: 13, + letterSpacing: -0.08, + ); +} + +/// Opens one of the player's sheets: on the iOS path ([ios]) the phone's own +/// shape — a grabber, deep continuous corners, a frosted material over the +/// video — and otherwise exactly as [showAdaptiveModal] always drew it. +Future showPlayerSheet({ + required BuildContext context, + required bool ios, + required WidgetBuilder builder, + double desktopMaxWidth = 460, +}) { + if (!ios) { + return showAdaptiveModal( + context: context, + backgroundColor: const Color(0xFF111111), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + desktopMaxWidth: desktopMaxWidth, + builder: builder, + ); + } + // An iPad keeps its centred dialog, with the rows dressed the same way. + if (MediaQuery.sizeOf(context).shortestSide >= 600) { + return showAdaptiveModal( + context: context, + backgroundColor: IosSheet.opaque, + isScrollControlled: true, + desktopMaxWidth: desktopMaxWidth, + builder: (ctx) => PlayerSheetLook(ios: true, child: builder(ctx)), + ); + } + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + elevation: 0, + barrierColor: Colors.black.withValues(alpha: 0.3), + sheetAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + reverseDuration: const Duration(milliseconds: 220), + reverseCurve: Curves.easeInCubic, + ), + builder: (ctx) => + PlayerSheetLook(ios: true, child: IosSheetFrame(child: builder(ctx))), + ); +} + +/// The sheet itself: the grabber over [child], on the frosted material, its +/// top corners the phone's own. +class IosSheetFrame extends StatelessWidget { + const IosSheetFrame({super.key, required this.child}); + + final Widget child; + + static const ShapeBorder shape = RoundedSuperellipseBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(IosSheet.radius)), + ); + + @override + Widget build(BuildContext context) { + final opaque = GlassSense.reduceTransparencyOf(context); + final body = Material( + type: MaterialType.transparency, + child: ListTileTheme( + data: const ListTileThemeData( + iconColor: Colors.white, + textColor: Colors.white, + minVerticalPadding: 10, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const IosSheetGrabber(), + Flexible(child: child), + ], + ), + ), + ); + return ClipPath( + clipper: const ShapeBorderClipper(shape: shape), + child: opaque + ? ColoredBox(color: IosSheet.opaque, child: body) + : BackdropFilter( + filter: ImageFilter.blur( + sigmaX: IosSheet.blur, + sigmaY: IosSheet.blur, + ), + child: ColoredBox(color: IosSheet.tint, child: body), + ), + ); + } +} + +class IosSheetGrabber extends StatelessWidget { + const IosSheetGrabber({super.key}); + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: Container( + width: 36, + height: 5, + margin: const EdgeInsets.only(top: 5, bottom: 3), + decoration: const ShapeDecoration( + color: IosSheet.grabber, + shape: StadiumBorder(), + ), + ), + ); +} + +/// The sheet's title, centred, with the close button at the trailing edge. +class IosSheetHeader extends StatelessWidget { + const IosSheetHeader({super.key, required this.title, this.onClose}); + + final String title; + + /// Null pops the sheet's route. + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + final close = MaterialLocalizations.of(context).closeButtonLabel; + void dismiss() => + onClose != null ? onClose!() : Navigator.of(context).maybePop(); + return SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox(width: 60), + Expanded( + child: Semantics( + header: true, + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: IosSheet.title, + ), + ), + ), + SizedBox( + width: 60, + child: Center( + child: Semantics( + button: true, + label: close, + excludeSemantics: true, + onTap: dismiss, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: const SizedBox.square( + dimension: 44, + child: Center( + child: DecoratedBox( + decoration: ShapeDecoration( + color: IosSheet.cell, + shape: CircleBorder(), + ), + child: SizedBox.square( + dimension: 30, + child: Icon( + CupertinoIcons.xmark, + size: 14, + color: IosSheet.secondary, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// An inset group: [children] on one rounded cell, a hairline between each +/// two, starting [separatorInset] in from the leading edge. +class IosSheetGroup extends StatelessWidget { + const IosSheetGroup({ + super.key, + required this.children, + this.separatorInset = IosSheet.gutter, + this.footer, + }); + + final List children; + final double separatorInset; + + /// A note under the group, in the footnote style. + final String? footer; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) return const SizedBox.shrink(); + final hair = 1 / MediaQuery.devicePixelRatioOf(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + IosSheet.gutter, + 6, + IosSheet.gutter, + 10, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: IosSheet.cell, + clipBehavior: Clip.antiAlias, + shape: const RoundedSuperellipseBorder( + borderRadius: BorderRadius.all( + Radius.circular(IosSheet.groupRadius), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Padding( + padding: EdgeInsetsDirectional.only( + start: separatorInset, + ), + child: SizedBox( + height: hair, + child: const ColoredBox(color: IosSheet.separator), + ), + ), + children[i], + ], + ], + ), + ), + if (footer != null) + Padding( + padding: const EdgeInsets.fromLTRB( + IosSheet.gutter, + 6, + IosSheet.gutter, + 0, + ), + child: Text(footer!, style: IosSheet.footnote), + ), + ], + ), + ); + } +} + +/// What a row shows at its trailing edge. +enum IosRowAccessory { none, check, chevron } + +/// One row of an inset group: a leading glyph, the title and a line under +/// it, a value, and a check or a chevron — the press a grey wash, not a +/// ripple. +class IosSheetRow extends StatelessWidget { + const IosSheetRow({ + super.key, + required this.title, + this.leading, + this.subtitle, + this.subtitleColor, + this.value, + this.accessory = IosRowAccessory.none, + this.selected = false, + this.choice = false, + this.titleColor, + this.onTap, + this.onLongPress, + this.autofocus = false, + }); + + final String title; + final Widget? leading; + final String? subtitle; + final Color? subtitleColor; + final String? value; + final IosRowAccessory accessory; + + /// Drawn with the check, and read out as selected. + final bool selected; + + /// One of a set of which one is on: VoiceOver says so. + final bool choice; + final Color? titleColor; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool autofocus; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final dim = enabled ? 1.0 : 0.38; + final sub = subtitle; + final shown = value; + final row = ConstrainedBox( + constraints: const BoxConstraints(minHeight: IosSheet.rowHeight), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + IosSheet.gutter, + 10, + IosSheet.gutter - 2, + 10, + ), + child: Row( + children: [ + if (leading != null) ...[ + IconTheme.merge( + data: IconThemeData( + color: Colors.white.withValues(alpha: dim), + size: 22, + ), + child: SizedBox(width: 28, child: Center(child: leading)), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: IosSheet.body.copyWith( + color: (titleColor ?? Colors.white).withValues( + alpha: dim, + ), + ), + ), + if (sub != null && sub.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + sub, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: IosSheet.footnote.copyWith( + color: subtitleColor ?? IosSheet.secondary, + ), + ), + ], + ], + ), + ), + if (shown != null && shown.isNotEmpty) ...[ + const SizedBox(width: 12), + Flexible( + child: Text( + shown, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: IosSheet.body.copyWith( + color: enabled ? IosSheet.secondary : IosSheet.tertiary, + ), + ), + ), + ], + if (accessory == IosRowAccessory.check) + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: SizedBox( + width: 22, + child: selected + ? Icon( + CupertinoIcons.checkmark_alt, + size: 22, + color: AppColors.primary, + ) + : null, + ), + ), + if (accessory == IosRowAccessory.chevron && enabled) + const Padding( + padding: EdgeInsetsDirectional.only(start: 8), + child: Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: IosSheet.tertiary, + ), + ), + ], + ), + ), + ); + return Semantics( + button: true, + enabled: enabled, + selected: choice ? selected : null, + inMutuallyExclusiveGroup: choice ? true : null, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + autofocus: autofocus, + splashFactory: NoSplash.splashFactory, + highlightColor: IosSheet.pressed, + child: row, + ), + ); + } +} + +/// The Cupertino glyph for a Material one the player's sheets use, so a row +/// on the iOS path draws the phone's own icon; one not listed keeps its own. +IconData cupertinoIconFor(IconData icon) => _cupertino[icon] ?? icon; + +final Map _cupertino = { + Icons.swap_horiz_rounded: CupertinoIcons.arrow_right_arrow_left, + Icons.download_rounded: CupertinoIcons.arrow_down_circle, + Icons.aspect_ratio_rounded: CupertinoIcons.crop, + Icons.play_circle_outline: CupertinoIcons.play_circle, + Icons.tune_rounded: CupertinoIcons.slider_horizontal_3, + Icons.photo_camera_outlined: CupertinoIcons.camera, + Icons.save_alt_rounded: CupertinoIcons.square_arrow_down, + Icons.auto_awesome_rounded: CupertinoIcons.sparkles, + Icons.visibility_off_outlined: CupertinoIcons.eye_slash, + Icons.cast_rounded: CupertinoIcons.tv, + Icons.cast_connected_rounded: CupertinoIcons.tv_fill, + Icons.bedtime_outlined: CupertinoIcons.moon_zzz, + Icons.translate_rounded: CupertinoIcons.globe, + Icons.text_fields_rounded: CupertinoIcons.textformat_size, + Icons.audiotrack_outlined: CupertinoIcons.speaker_2, + Icons.open_in_new_rounded: CupertinoIcons.arrow_up_right_square, + Icons.info_outline_rounded: CupertinoIcons.info_circle, + Icons.dashboard_customize_outlined: CupertinoIcons.square_grid_2x2, + Icons.bug_report_outlined: CupertinoIcons.ant, + Icons.travel_explore_rounded: CupertinoIcons.search, + Icons.folder_open_rounded: CupertinoIcons.folder, + Icons.av_timer_rounded: CupertinoIcons.timer, +}; diff --git a/test/core/native/native_menu_select_test.dart b/test/core/native/native_menu_select_test.dart index ba9f73f7..ed39b42a 100644 --- a/test/core/native/native_menu_select_test.dart +++ b/test/core/native/native_menu_select_test.dart @@ -78,4 +78,74 @@ void main() { ..hear('menuClose', 'fit'); expect(said, ['open', 'close']); }); + + testOnIPhone('a pull-down menu says so, with its rows in full', ( + tester, + ) async { + final host = FakeNativeHost(); + final pressed = []; + await tester.pumpWidget( + barHarness( + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + items: [ + NativeBarItem( + id: 'subtitles', + symbol: 'captions.bubble', + semanticLabel: 'Subtitles', + onPressed: () => pressed.add('subtitles'), + menuPrimary: true, + menu: const [ + NativeMenuRow(title: 'Off', checked: true, key: 'off'), + NativeMenuRow( + title: 'English', + subtitle: 'In the video', + checked: false, + key: 'en', + ), + NativeMenuRow( + title: 'More options', + symbol: 'ellipsis.circle', + breakBefore: true, + section: 'More', + disabled: true, + key: 'more', + ), + ], + onMenuSelected: (_) {}, + ), + // Asks for a pull-down with nothing to pull down: a plain button. + NativeBarItem( + id: 'settings', + symbol: 'gearshape', + semanticLabel: 'Settings', + menuPrimary: true, + onPressed: () => pressed.add('settings'), + ), + ], + ), + ), + host: host, + ), + ); + await tester.pump(); + final items = host.views.single.params['items']! as List; + final subtitles = items[0] as Map; + expect(subtitles['menuPrimary'], isTrue); + expect(items[1]['menuPrimary'], isFalse); + final rows = subtitles['menu'] as List; + expect(rows[0], containsPair('checked', true)); + expect(rows[1], containsPair('subtitle', 'In the video')); + expect(rows[2], containsPair('section', 'More')); + expect(rows[2], containsPair('disabled', true)); + expect(rows[2], containsPair('symbol', 'ellipsis.circle')); + + // VoiceOver cannot reach UIKit's menu; its tap opens the sheet instead. + final semantics = tester.ensureSemantics(); + tester.semantics.tap(find.semantics.byLabel('Subtitles')); + expect(pressed, ['subtitles']); + semantics.dispose(); + }); } diff --git a/test/core/native/sf_symbols_test.dart b/test/core/native/sf_symbols_test.dart index 54619d86..1973ba9a 100644 --- a/test/core/native/sf_symbols_test.dart +++ b/test/core/native/sf_symbols_test.dart @@ -1,8 +1,72 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/core/native/sf_symbols.dart'; import 'package:soplay/features/detail/domain/player_controls_layout.dart'; +/// Every SF Symbol the player may send, as the phone's own list names them: +/// checked against CoreGlyphs' name_availability.plist (macOS 27, the +/// iOS 26 symbol set); none is newer than iOS 17. A name missing here is a +/// name to check there before it ships, since UIKit draws nothing for one +/// it does not know. +final Set _knownSymbols = { + 'arrow.counterclockwise', 'arrow.down.circle', 'arrow.down.circle.fill', // + 'arrow.up.left.and.arrow.down.right', 'aspectratio', 'backward.end.fill', + 'bubble.left.and.bubble.right', 'captions.bubble', 'captions.bubble.fill', + 'character.bubble', 'chevron.backward', 'ellipsis.circle', 'face.smiling', + 'forward.end.fill', 'forward.fill', 'gauge.with.dots.needle.67percent', + 'gearshape', 'gobackward', 'goforward', 'info.circle', 'info.circle.fill', + 'lock.fill', 'lock.open.fill', 'moon.zzz', 'moon.zzz.fill', 'pause.fill', + 'person.2', 'person.2.fill', 'pip.enter', 'play.fill', + 'rectangle.arrowtriangle.2.outward', 'rectangle.landscape.rotate', + 'rectangle.portrait.rotate', 'rectangle.stack', 'server.rack', + 'slider.horizontal.3', 'sparkles', 'tv.badge.wifi', 'tv.badge.wifi.fill', + for (final s in [5, 10, 15, 30, 45, 60, 75, 90]) ...[ + 'gobackward.$s', + 'goforward.$s', + ], +}; + void main() { + test('every symbol the player sends is one the phone has', () { + final sent = { + ...playerSymbols.values, + ...activeSymbols.values, + orientationSymbol(portrait: true), + orientationSymbol(portrait: false), + for (var s = 1; s <= 120; s++) ...[rewindSymbol(s), forwardSymbol(s)], + }; + expect(sent.difference(_knownSymbols), isEmpty); + }); + + test('the fit menu and button wear known symbols', () { + final source = File( + 'lib/features/detail/presentation/pages/player_page.controls.dart', + ).readAsStringSync(); + final body = source.substring( + source.indexOf('static String _fitSymbol('), + source.indexOf('String _fitLabel('), + ); + final names = RegExp(r"=> '([^']+)'").allMatches(body).map((m) => m[1]!); + expect(names, hasLength(3)); + expect(names.toSet().difference(_knownSymbols), isEmpty); + }); + + test('controls Apple draws a certain way wear Apple\'s symbol', () { + expect(playerSymbol('play'), 'play.fill'); + expect(playerSymbol('pause'), 'pause.fill'); + expect(playerSymbol('previous'), 'backward.end.fill'); + expect(playerSymbol('next'), 'forward.end.fill'); + expect(playerSymbol('subtitles'), 'captions.bubble'); + expect(playerSymbol('speed'), 'gauge.with.dots.needle.67percent'); + expect(playerSymbol('pip'), 'pip.enter'); + expect(playerSymbol('back'), 'chevron.backward'); + expect(playerSymbol('unlock'), 'lock.open.fill'); + // Watch Party is not SharePlay: its symbol would promise Apple's. + expect(playerSymbol('party'), 'person.2'); + expect(playerSymbol('party', active: true), 'person.2.fill'); + }); + test('every control in the catalogue has a symbol', () { final missing = [ for (final spec in PlayerControlCatalogue.all) diff --git a/test/features/detail/player_menus_test.dart b/test/features/detail/player_menus_test.dart new file mode 100644 index 00000000..0a6b8428 --- /dev/null +++ b/test/features/detail/player_menus_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; + +void main() { + group('a player menu', () { + PlayerMenuOption option(String key, List picked) => + PlayerMenuOption(key: key, title: key, onPick: () => picked.add(key)); + + test('puts a separator before every run but the first', () { + final menu = PlayerMenu([ + [option('a', []), option('b', [])], + const [], + [option('c', [])], + ]); + expect(menu.rows.map((r) => r.title), ['a', 'b', 'c']); + expect(menu.rows.map((r) => r.breakBefore), [false, false, true]); + }); + + test('runs the choice at the index, and ignores one out of range', () { + final picked = []; + final menu = PlayerMenu([ + [option('a', picked), option('b', picked)], + ]); + menu + ..pick(1) + ..pick(2) + ..pick(-1); + expect(picked, ['b']); + }); + }); + + test('a choice menu checks the current value and keys rows by it', () { + double? chose; + final menu = choiceMenu( + values: const [0.5, 1, 2], + isCurrent: (s) => s == 1, + label: (s) => '${s}x', + onPick: (s) => chose = s, + ); + expect(menu.rows.map((r) => r.checked), [false, true, false]); + expect(menu.rows.map((r) => r.key), [0.5, 1, 2]); + menu.pick(2); + expect(chose, 2); + }); + + group('the subtitle menu', () { + PlayerMenu? build({ + List tracks = const [], + bool offSelected = false, + void Function()? onMore, + }) => subtitleMenu( + offLabel: 'Off', + offSelected: offSelected, + onOff: () {}, + tracks: tracks, + moreLabel: 'More options', + onMore: onMore ?? () {}, + ); + + SubtitleMenuTrack track(String label, {bool selected = false}) => + SubtitleMenuTrack( + key: label, + label: label, + selected: selected, + subtitle: 'In the video', + onPick: () {}, + ); + + test('is none without a track: the sheet opens straight away', () { + expect(build(), isNull); + }); + + test('opens on Off, lists the tracks, and ends on the full sheet', () { + final menu = build(tracks: [track('English', selected: true)])!; + final rows = menu.rows; + expect(rows.map((r) => r.title), ['Off', 'English', 'More options']); + expect(rows.map((r) => r.checked), [false, true, null]); + expect(rows[1].subtitle, 'In the video'); + expect(rows.last.breakBefore, isTrue); + expect(rows.last.symbol, 'ellipsis.circle'); + }); + + test('checks Off when nothing is shown', () { + final menu = build(tracks: [track('English')], offSelected: true)!; + expect(menu.rows.first.checked, isTrue); + }); + + test('its last row opens the sheet', () { + var opened = false; + final menu = build( + tracks: [track('English')], + onMore: () => opened = true, + )!; + menu.pick(menu.rows.length - 1); + expect(opened, isTrue); + }); + }); + + group('the quality menu', () { + QualityMenuEntry entry( + String label, { + bool selected = false, + bool blocking = false, + String? detail, + }) => QualityMenuEntry( + key: label, + label: label, + selected: selected, + blocking: blocking, + detail: detail, + onPick: () {}, + ); + + test('is none with nothing to choose between', () { + expect(qualityMenu(renditions: [entry('Auto')], files: const []), isNull); + }); + + test('lists the renditions, then the other files apart', () { + final menu = qualityMenu( + renditions: [entry('Auto', selected: true), entry('1080p')], + files: [entry('720p', detail: 'Dolby Vision', blocking: true)], + )!; + final rows = menu.rows; + expect(rows.map((r) => r.title), ['Auto', '1080p', '720p']); + expect(rows.map((r) => r.checked), [true, false, false]); + expect(rows.map((r) => r.breakBefore), [false, false, true]); + // A file that will not play here reads red, as its warning did. + expect(rows.last.destructive, isTrue); + expect(rows.last.subtitle, 'Dolby Vision'); + }); + }); +} diff --git a/test/features/detail/player_native_controls_test.dart b/test/features/detail/player_native_controls_test.dart index 88c55068..5d54274f 100644 --- a/test/features/detail/player_native_controls_test.dart +++ b/test/features/detail/player_native_controls_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/core/native/native_glass_bar.dart'; import 'package:soplay/features/detail/domain/player_controls_layout.dart'; import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; /// The player page cannot be stood up in a test (a resolved stream, getIt, a /// controller), so its wiring of the native bars is read from the source, as @@ -64,6 +65,28 @@ void main() { expect(item.fontSize, 11); }); + test('a menu makes the native button a pull-down, the rest kept', () { + final plain = face(label: '1x'); + expect(identical(plain.withMenu(null), plain), isTrue); + expect(plain.toNative().menuPrimary, isFalse); + + var picked = -1; + final menu = choiceMenu( + values: const [1, 2], + isCurrent: (v) => v == 1, + label: (v) => '${v}x', + onPick: (v) => picked = v, + ); + final item = plain.withMenu(menu).toNative(group: 1); + expect(item.menuPrimary, isTrue); + expect(item.menu!.map((r) => r.title), ['1x', '2x']); + expect(item.title, '1x'); + expect(item.group, 1); + expect(item.onPressed, isNotNull); + item.onMenuSelected!(1); + expect(picked, 2); + }); + test('an active control wears its filled face', () { expect( face(id: 'stats', active: true).toNative().symbol, @@ -113,4 +136,33 @@ void main() { expect(bars, 6); expect(told, bars - 1); }); + + test('the flat choices open as native menus, on the native path only', () { + final source = read('player_page.controls.dart'); + for (final menu in [ + '_langMenu', + '_subtitleMenu', + '_speedMenu', + '_serverMenu', + '_qualityMenu', + '_fitMenu', + ]) { + expect( + source, + contains('.withMenu(_nativeBars ? $menu : null)'), + reason: menu, + ); + } + expect(source, contains('final menu = _nativeBars ? _sleepMenu : null;')); + }); + + test('every player sheet goes through the one that knows the iOS look', () { + for (final name in [ + 'player_page.panels.dart', + 'player_page.sleep.dart', + 'player_page.subtitles.dart', + ]) { + expect(read(name), isNot(contains('showAdaptiveModal')), reason: name); + } + }); } diff --git a/test/features/detail/player_sheet_look_test.dart b/test/features/detail/player_sheet_look_test.dart new file mode 100644 index 00000000..da48c9b9 --- /dev/null +++ b/test/features/detail/player_sheet_look_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_sheet_look.dart'; + +Widget _app(Widget child, {TextDirection dir = TextDirection.ltr}) => + MaterialApp( + home: Directionality( + textDirection: dir, + child: Scaffold(body: child), + ), + ); + +class _Opener extends StatelessWidget { + const _Opener({required this.ios}); + + final bool ios; + + @override + Widget build(BuildContext context) => TextButton( + onPressed: () => showPlayerSheet( + context: context, + ios: ios, + builder: (_) => Builder( + builder: (inner) => + Text(PlayerSheetLook.of(inner) ? 'ios rows' : 'flutter rows'), + ), + ), + child: const Text('open'), + ); +} + +/// An iPhone's screen: a wider one gets the iPad's centred dialog. +void _phone(WidgetTester tester) { + tester.view + ..physicalSize = const Size(1170, 2532) + ..devicePixelRatio = 3; + addTearDown(tester.view.reset); +} + +void main() { + group('a player sheet', () { + testWidgets('off the iOS path is the sheet it always was', (tester) async { + await tester.pumpWidget(_app(const _Opener(ios: false))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('flutter rows'), findsOneWidget); + expect(find.byType(IosSheetFrame), findsNothing); + expect(find.byType(IosSheetGrabber), findsNothing); + }); + + testWidgets('on the iOS path has the grabber, the corners and the look', ( + tester, + ) async { + _phone(tester); + await tester.pumpWidget(_app(const _Opener(ios: true))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('ios rows'), findsOneWidget); + expect(find.byType(IosSheetFrame), findsOneWidget); + expect(find.byType(IosSheetGrabber), findsOneWidget); + expect(find.byType(BackdropFilter), findsOneWidget); + }); + }); + + group('an iOS row', () { + testWidgets('a choice reads out as selected, one of a set', (tester) async { + final semantics = tester.ensureSemantics(); + var taps = 0; + await tester.pumpWidget( + _app( + IosSheetGroup( + children: [ + IosSheetRow( + title: '1080p', + accessory: IosRowAccessory.check, + selected: true, + choice: true, + onTap: () => taps++, + ), + IosSheetRow( + title: '720p', + accessory: IosRowAccessory.check, + choice: true, + onTap: () {}, + ), + ], + ), + ), + ); + expect( + tester.getSemantics(find.text('1080p')), + matchesSemantics( + label: '1080p', + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasSelectedState: true, + isSelected: true, + isInMutuallyExclusiveGroup: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('720p')), + matchesSemantics( + label: '720p', + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasSelectedState: true, + isInMutuallyExclusiveGroup: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + await tester.tap(find.text('1080p')); + expect(taps, 1); + semantics.dispose(); + }); + + testWidgets('a group draws one hairline between each two rows', ( + tester, + ) async { + await tester.pumpWidget( + _app( + const IosSheetGroup( + children: [ + IosSheetRow(title: 'a'), + IosSheetRow(title: 'b'), + IosSheetRow(title: 'c'), + ], + ), + ), + ); + final hairlines = find.byWidgetPredicate( + (w) => w is ColoredBox && w.color == IosSheet.separator, + ); + expect(hairlines, findsNWidgets(2)); + }); + + testWidgets('the check stands at the trailing edge, in RTL too', ( + tester, + ) async { + Future checkX(TextDirection dir) async { + await tester.pumpWidget( + _app( + const IosSheetRow( + title: 'Auto', + accessory: IosRowAccessory.check, + selected: true, + choice: true, + ), + dir: dir, + ), + ); + return tester.getCenter(find.byType(Icon)).dx; + } + + final width = + tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(await checkX(TextDirection.ltr), greaterThan(width / 2)); + expect(await checkX(TextDirection.rtl), lessThan(width / 2)); + }); + + testWidgets('a header closes through its own callback', (tester) async { + var closed = 0; + await tester.pumpWidget( + _app(IosSheetHeader(title: 'Quality', onClose: () => closed++)), + ); + expect(find.text('Quality'), findsOneWidget); + await tester.tap(find.byIcon(CupertinoIcons.xmark)); + expect(closed, 1); + }); + }); +} From f65c9121d4ffe14d4aed90f8f9394072f289d1d3 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:14:51 +0500 Subject: [PATCH 07/21] Player on iPhone: choose the controls style Settings > Player > Controls style, iPhone and iPad only: - Native (iOS 26), the default: the phone's own Liquid Glass buttons, pull-down menus and iOS sheets. - System glass: Flutter's buttons on the phone's own clear glass, the Flutter taps and sheets. - Classic glass: Flutter's buttons on the shader glass, the Flutter sheets, as in 3.2.1. Below iOS 26 the first two are disabled and the player keeps the classic glass; Reduce Transparency still overrides all three. Stored in Hive as player_controls_style and read when the player opens. --- assets/translations/ar.json | 9 + assets/translations/ckb.json | 9 + assets/translations/de.json | 9 + assets/translations/en.json | 9 + assets/translations/es.json | 9 + assets/translations/fr.json | 9 + assets/translations/hi.json | 9 + assets/translations/id.json | 9 + assets/translations/ku.json | 9 + assets/translations/nl.json | 9 + assets/translations/pt.json | 9 + assets/translations/ru.json | 9 + assets/translations/tr.json | 9 + assets/translations/uz.json | 9 + assets/translations/vi.json | 9 + assets/translations/yue.json | 9 + lib/core/constants/app_constants.dart | 1 + lib/core/glass/media_glass.dart | 5 + lib/core/native/native_controls_gate.dart | 28 +++- lib/core/native/player_controls_style.dart | 89 ++++++++++ lib/core/storage/hive_service.dart | 9 + .../presentation/pages/player_page.dart | 94 ++++++----- .../pages/player_settings_page.dart | 23 +++ .../player_controls_style_section.dart | 95 +++++++++++ .../native/player_controls_style_test.dart | 158 ++++++++++++++++++ .../detail/player_native_controls_test.dart | 11 ++ .../player_controls_style_section_test.dart | 92 ++++++++++ 27 files changed, 699 insertions(+), 50 deletions(-) create mode 100644 lib/core/native/player_controls_style.dart create mode 100644 lib/features/profile/presentation/widgets/player_controls_style_section.dart create mode 100644 test/core/native/player_controls_style_test.dart create mode 100644 test/features/profile/player_controls_style_section_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index b73473fb..1220344c 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -679,6 +679,15 @@ "player_engine_section": "محرّك التشغيل", "ask_engine_on_play": "اسأل في كل مرة", "ask_engine_on_play_desc": "اختر المحرّك عند بدء كل فيديو", + "controls_style": "نمط أزرار المشغّل", + "controls_style_desc": "يُطبَّق في المرة القادمة التي تفتح فيها المشغّل.", + "controls_style_native": "أصلي (iOS 26)", + "controls_style_native_desc": "أزرار وقوائم ونوافذ Liquid Glass الخاصة بالهاتف", + "controls_style_system": "زجاج النظام", + "controls_style_system_desc": "أزرار التطبيق على زجاج Liquid Glass الخاص بالهاتف", + "controls_style_classic": "زجاج كلاسيكي", + "controls_style_classic_desc": "زجاج التطبيق الخاص، كما في الإصدارات السابقة", + "controls_style_needs_ios26": "يتطلب iOS 26 أو أحدث", "playback_section": "التشغيل", "default_speed": "السرعة الافتراضية", "default_speed_desc": "السرعة التي يبدأ بها كل فيديو", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 44fc115c..42e0ed56 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -681,6 +681,15 @@ "player_engine_section": "بزوێنەری لێدان", "ask_engine_on_play": "هەموو جارێک بپرسە", "ask_engine_on_play_desc": "هەر جارێک ڤیدیۆیەک دەستپێدەکات بزوێنەر هەڵبژێرە", + "controls_style": "شێوازی دوگمەکانی لێدەر", + "controls_style_desc": "کاتێک جاری داهاتوو لێدەر دەکەیتەوە جێبەجێ دەبێت.", + "controls_style_native": "ڕەسەن (iOS 26)", + "controls_style_native_desc": "دوگمە و لیست و پەڕەکانی Liquid Glassی خودی مۆبایلەکە", + "controls_style_system": "شووشەی سیستەم", + "controls_style_system_desc": "دوگمەکانی ئەپ لەسەر Liquid Glassی مۆبایلەکە", + "controls_style_classic": "شووشەی کلاسیک", + "controls_style_classic_desc": "شووشەی تایبەتی ئەپ، وەک وەشانەکانی پێشوو", + "controls_style_needs_ios26": "پێویستی بە iOS 26 یان نوێترە", "playback_section": "لێدان", "default_speed": "خێرایی بنەڕەت", "default_speed_desc": "ئەو خێراییەی هەموو ڤیدیۆیەک پێی دەستپێدەکات", diff --git a/assets/translations/de.json b/assets/translations/de.json index 696e7d83..10bf6145 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -679,6 +679,15 @@ "player_engine_section": "WIEDERGABE-ENGINE", "ask_engine_on_play": "Jedes Mal fragen", "ask_engine_on_play_desc": "Die Engine bei jedem Videostart wählen", + "controls_style": "STIL DER PLAYER-STEUERUNG", + "controls_style_desc": "Gilt ab dem nächsten Öffnen des Players.", + "controls_style_native": "Nativ (iOS 26)", + "controls_style_native_desc": "Die Liquid-Glass-Tasten, Menüs und Sheets des Geräts", + "controls_style_system": "Systemglas", + "controls_style_system_desc": "Die Tasten der App auf dem Liquid Glass des Geräts", + "controls_style_classic": "Klassisches Glas", + "controls_style_classic_desc": "Das eigene Glas der App, wie in früheren Versionen", + "controls_style_needs_ios26": "Erfordert iOS 26 oder neuer", "playback_section": "WIEDERGABE", "default_speed": "Standardtempo", "default_speed_desc": "Tempo, mit dem jedes Video startet", diff --git a/assets/translations/en.json b/assets/translations/en.json index 1939f38e..c56b127a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -681,6 +681,15 @@ "player_engine_section": "PLAYBACK ENGINE", "ask_engine_on_play": "Ask every time", "ask_engine_on_play_desc": "Choose the engine each time a video starts", + "controls_style": "PLAYER CONTROLS STYLE", + "controls_style_desc": "Takes effect the next time you open the player.", + "controls_style_native": "Native (iOS 26)", + "controls_style_native_desc": "The phone's own Liquid Glass buttons, menus and sheets", + "controls_style_system": "System glass", + "controls_style_system_desc": "The app's buttons on the phone's own Liquid Glass", + "controls_style_classic": "Classic glass", + "controls_style_classic_desc": "The app's own glass, as in earlier versions", + "controls_style_needs_ios26": "Needs iOS 26 or later", "playback_section": "PLAYBACK", "default_speed": "Default speed", "default_speed_desc": "Speed every video starts at", diff --git a/assets/translations/es.json b/assets/translations/es.json index 7f438e00..6aa84f4a 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -679,6 +679,15 @@ "player_engine_section": "MOTOR DE REPRODUCCIÓN", "ask_engine_on_play": "Preguntar siempre", "ask_engine_on_play_desc": "Elige el motor cada vez que empieza un vídeo", + "controls_style": "ESTILO DE LOS CONTROLES DEL REPRODUCTOR", + "controls_style_desc": "Se aplica la próxima vez que abras el reproductor.", + "controls_style_native": "Nativo (iOS 26)", + "controls_style_native_desc": "Los botones, menús y hojas Liquid Glass del propio teléfono", + "controls_style_system": "Cristal del sistema", + "controls_style_system_desc": "Los botones de la app sobre el Liquid Glass del teléfono", + "controls_style_classic": "Cristal clásico", + "controls_style_classic_desc": "El cristal propio de la app, como en versiones anteriores", + "controls_style_needs_ios26": "Requiere iOS 26 o posterior", "playback_section": "REPRODUCCIÓN", "default_speed": "Velocidad por defecto", "default_speed_desc": "Velocidad con la que empieza cada vídeo", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 857ba956..233f50d5 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -679,6 +679,15 @@ "player_engine_section": "MOTEUR DE LECTURE", "ask_engine_on_play": "Demander à chaque fois", "ask_engine_on_play_desc": "Choisir le moteur à chaque démarrage de vidéo", + "controls_style": "STYLE DES COMMANDES DU LECTEUR", + "controls_style_desc": "S'applique à la prochaine ouverture du lecteur.", + "controls_style_native": "Natif (iOS 26)", + "controls_style_native_desc": "Les boutons, menus et feuilles Liquid Glass du téléphone", + "controls_style_system": "Verre système", + "controls_style_system_desc": "Les boutons de l'app sur le Liquid Glass du téléphone", + "controls_style_classic": "Verre classique", + "controls_style_classic_desc": "Le verre propre à l'app, comme dans les versions précédentes", + "controls_style_needs_ios26": "Nécessite iOS 26 ou version ultérieure", "playback_section": "LECTURE", "default_speed": "Vitesse par défaut", "default_speed_desc": "Vitesse à laquelle chaque vidéo démarre", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 2df72fdd..4bdbe5c0 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -681,6 +681,15 @@ "player_engine_section": "प्लेबैक इंजन", "ask_engine_on_play": "हर बार पूछें", "ask_engine_on_play_desc": "हर बार वीडियो शुरू होने पर इंजन चुनें", + "controls_style": "प्लेयर कंट्रोल की शैली", + "controls_style_desc": "अगली बार प्लेयर खोलने पर लागू होगा।", + "controls_style_native": "नेटिव (iOS 26)", + "controls_style_native_desc": "फ़ोन के अपने Liquid Glass बटन, मेन्यू और शीट", + "controls_style_system": "सिस्टम ग्लास", + "controls_style_system_desc": "फ़ोन के अपने Liquid Glass पर ऐप के बटन", + "controls_style_classic": "क्लासिक ग्लास", + "controls_style_classic_desc": "ऐप का अपना ग्लास, पिछले वर्ज़न की तरह", + "controls_style_needs_ios26": "iOS 26 या नया चाहिए", "playback_section": "प्लेबैक", "default_speed": "डिफ़ॉल्ट स्पीड", "default_speed_desc": "हर वीडियो इसी स्पीड से शुरू होगा", diff --git a/assets/translations/id.json b/assets/translations/id.json index 48b3d25b..e701eb4b 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -679,6 +679,15 @@ "player_engine_section": "MESIN PEMUTARAN", "ask_engine_on_play": "Tanya setiap kali", "ask_engine_on_play_desc": "Pilih mesinnya setiap kali video dimulai", + "controls_style": "GAYA KONTROL PEMUTAR", + "controls_style_desc": "Berlaku saat pemutar dibuka berikutnya.", + "controls_style_native": "Bawaan (iOS 26)", + "controls_style_native_desc": "Tombol, menu, dan lembar Liquid Glass milik ponsel sendiri", + "controls_style_system": "Kaca sistem", + "controls_style_system_desc": "Tombol aplikasi di atas Liquid Glass milik ponsel", + "controls_style_classic": "Kaca klasik", + "controls_style_classic_desc": "Kaca milik aplikasi, seperti di versi sebelumnya", + "controls_style_needs_ios26": "Perlu iOS 26 atau lebih baru", "playback_section": "PEMUTARAN", "default_speed": "Kecepatan bawaan", "default_speed_desc": "Kecepatan awal setiap video", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 20807afa..85b48c78 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -681,6 +681,15 @@ "player_engine_section": "MOTORA LÊDANÊ", "ask_engine_on_play": "Her car bipirse", "ask_engine_on_play_desc": "Her car ku vîdyoyek dest pê dike motorê hilbijêre", + "controls_style": "ŞÊWAZA BIŞKOJÊN LÊXISTOKÊ", + "controls_style_desc": "Gava cara din lêxistokê vekî dê derbasdar bibe.", + "controls_style_native": "Xwecihî (iOS 26)", + "controls_style_native_desc": "Bişkok, menû û rûpelên Liquid Glass ên telefonê bi xwe", + "controls_style_system": "Cama pergalê", + "controls_style_system_desc": "Bişkojên sepanê li ser Liquid Glass a telefonê", + "controls_style_classic": "Cama klasîk", + "controls_style_classic_desc": "Cama sepanê bi xwe, wek di guhertoyên berê de", + "controls_style_needs_ios26": "iOS 26 an nûtir pêwîst e", "playback_section": "LÊDAN", "default_speed": "Leza standard", "default_speed_desc": "Leza ku her vîdyo pê dest pê dike", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index e8438d88..37b9a7c2 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -679,6 +679,15 @@ "player_engine_section": "AFSPEELENGINE", "ask_engine_on_play": "Elke keer vragen", "ask_engine_on_play_desc": "Kies de engine telkens als een video start", + "controls_style": "STIJL VAN DE SPELERKNOPPEN", + "controls_style_desc": "Geldt vanaf de volgende keer dat je de speler opent.", + "controls_style_native": "Native (iOS 26)", + "controls_style_native_desc": "De eigen Liquid Glass-knoppen, -menu's en -panelen van de telefoon", + "controls_style_system": "Systeemglas", + "controls_style_system_desc": "De knoppen van de app op het Liquid Glass van de telefoon", + "controls_style_classic": "Klassiek glas", + "controls_style_classic_desc": "Het eigen glas van de app, zoals in eerdere versies", + "controls_style_needs_ios26": "Vereist iOS 26 of nieuwer", "playback_section": "AFSPELEN", "default_speed": "Standaardsnelheid", "default_speed_desc": "Snelheid waarmee elke video begint", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 95dc36aa..f971ef5d 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -679,6 +679,15 @@ "player_engine_section": "MOTOR DE REPRODUÇÃO", "ask_engine_on_play": "Perguntar sempre", "ask_engine_on_play_desc": "Escolha o motor toda vez que um vídeo começar", + "controls_style": "ESTILO DOS CONTROLES DO PLAYER", + "controls_style_desc": "Vale a partir da próxima vez que você abrir o player.", + "controls_style_native": "Nativo (iOS 26)", + "controls_style_native_desc": "Os botões, menus e painéis Liquid Glass do próprio telefone", + "controls_style_system": "Vidro do sistema", + "controls_style_system_desc": "Os botões do app sobre o Liquid Glass do telefone", + "controls_style_classic": "Vidro clássico", + "controls_style_classic_desc": "O vidro próprio do app, como nas versões anteriores", + "controls_style_needs_ios26": "Requer iOS 26 ou posterior", "playback_section": "REPRODUÇÃO", "default_speed": "Velocidade padrão", "default_speed_desc": "Velocidade com que todo vídeo começa", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 4fbe80d3..56d55aa8 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -681,6 +681,15 @@ "player_engine_section": "ДВИЖОК ВОСПРОИЗВЕДЕНИЯ", "ask_engine_on_play": "Спрашивать каждый раз", "ask_engine_on_play_desc": "Выбирать плеер при запуске видео", + "controls_style": "СТИЛЬ КНОПОК ПЛЕЕРА", + "controls_style_desc": "Применится при следующем открытии плеера.", + "controls_style_native": "Системный (iOS 26)", + "controls_style_native_desc": "Кнопки, меню и окна Liquid Glass самого телефона", + "controls_style_system": "Системное стекло", + "controls_style_system_desc": "Кнопки приложения на Liquid Glass телефона", + "controls_style_classic": "Классическое стекло", + "controls_style_classic_desc": "Собственное стекло приложения, как в прежних версиях", + "controls_style_needs_ios26": "Нужна iOS 26 или новее", "playback_section": "ВОСПРОИЗВЕДЕНИЕ", "default_speed": "Скорость по умолчанию", "default_speed_desc": "С какой скоростью начинается видео", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index ebfa6859..8fe4c291 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -679,6 +679,15 @@ "player_engine_section": "OYNATMA MOTORU", "ask_engine_on_play": "Her seferinde sor", "ask_engine_on_play_desc": "Her video başlarken motoru seç", + "controls_style": "OYNATICI KONTROL STİLİ", + "controls_style_desc": "Oynatıcıyı bir sonraki açışınızda geçerli olur.", + "controls_style_native": "Yerel (iOS 26)", + "controls_style_native_desc": "Telefonun kendi Liquid Glass düğmeleri, menüleri ve sayfaları", + "controls_style_system": "Sistem camı", + "controls_style_system_desc": "Uygulamanın düğmeleri telefonun kendi Liquid Glass'ı üzerinde", + "controls_style_classic": "Klasik cam", + "controls_style_classic_desc": "Uygulamanın kendi camı, önceki sürümlerdeki gibi", + "controls_style_needs_ios26": "iOS 26 veya üstü gerekir", "playback_section": "OYNATMA", "default_speed": "Varsayılan hız", "default_speed_desc": "Her videonun başlayacağı hız", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index eae3fa25..104a8956 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -681,6 +681,15 @@ "player_engine_section": "IJRO DVIGATELI", "ask_engine_on_play": "Har safar so‘ralsin", "ask_engine_on_play_desc": "Video boshlanganda pleyerni tanlash", + "controls_style": "PLEYER TUGMALARI USLUBI", + "controls_style_desc": "Pleyer keyingi safar ochilganda kuchga kiradi.", + "controls_style_native": "Tizimniki (iOS 26)", + "controls_style_native_desc": "Telefonning o'z Liquid Glass tugmalari, menyulari va oynalari", + "controls_style_system": "Tizim shishasi", + "controls_style_system_desc": "Ilova tugmalari telefonning o'z Liquid Glass shishasida", + "controls_style_classic": "Klassik shisha", + "controls_style_classic_desc": "Ilovaning o'z shishasi, avvalgi versiyalardagidek", + "controls_style_needs_ios26": "iOS 26 yoki yangisi kerak", "playback_section": "IJRO", "default_speed": "Standart tezlik", "default_speed_desc": "Har bir video shu tezlikda boshlanadi", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 3c95eec7..2e1f7b68 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -681,6 +681,15 @@ "player_engine_section": "CÔNG CỤ PHÁT", "ask_engine_on_play": "Luôn hỏi", "ask_engine_on_play_desc": "Chọn công cụ phát mỗi khi bắt đầu video", + "controls_style": "KIỂU NÚT ĐIỀU KHIỂN TRÌNH PHÁT", + "controls_style_desc": "Áp dụng từ lần mở trình phát tiếp theo.", + "controls_style_native": "Gốc (iOS 26)", + "controls_style_native_desc": "Nút, menu và bảng Liquid Glass của chính điện thoại", + "controls_style_system": "Kính hệ thống", + "controls_style_system_desc": "Nút của ứng dụng trên Liquid Glass của điện thoại", + "controls_style_classic": "Kính cổ điển", + "controls_style_classic_desc": "Lớp kính riêng của ứng dụng, như các phiên bản trước", + "controls_style_needs_ios26": "Cần iOS 26 trở lên", "playback_section": "PHÁT LẠI", "default_speed": "Tốc độ mặc định", "default_speed_desc": "Tốc độ khi bắt đầu mỗi video", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index cf8e8db2..3ea1477d 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -679,6 +679,15 @@ "player_engine_section": "播放引擎", "ask_engine_on_play": "每次都問", "ask_engine_on_play_desc": "每次開片都揀一次引擎", + "controls_style": "播放器控制樣式", + "controls_style_desc": "下次打開播放器時生效。", + "controls_style_native": "原生(iOS 26)", + "controls_style_native_desc": "手機自己嘅 Liquid Glass 按鈕、選單同面板", + "controls_style_system": "系統玻璃", + "controls_style_system_desc": "App 嘅按鈕放喺手機自己嘅 Liquid Glass 上面", + "controls_style_classic": "經典玻璃", + "controls_style_classic_desc": "App 自己嘅玻璃,同舊版本一樣", + "controls_style_needs_ios26": "需要 iOS 26 或更新版本", "playback_section": "播放", "default_speed": "預設速度", "default_speed_desc": "每條片開始時嘅速度", diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 10285d4f..e54eadfb 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -287,6 +287,7 @@ class AppConstants { /// Open the player without starting it. static const String startPausedKey = 'start_paused'; + static const String playerControlsStyleKey = 'player_controls_style'; static const String doubleTapSeekSecondsKey = 'double_tap_seek_seconds'; static const String longPressBoostKey = 'long_press_boost'; static const String brightnessGestureKey = 'brightness_gesture'; diff --git a/lib/core/glass/media_glass.dart b/lib/core/glass/media_glass.dart index d3baa7f7..4ab1526e 100644 --- a/lib/core/glass/media_glass.dart +++ b/lib/core/glass/media_glass.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import '../native/player_controls_style.dart'; import 'app_glass.dart'; import 'glass_sense.dart'; import 'glass_surface.dart'; @@ -116,11 +117,14 @@ class MediaGlass extends StatelessWidget { /// the shader: an iPhone whose OS has `UIGlassEffect` with its clear style, /// and Reduce Transparency off. An older iPhone keeps the shader, not the /// system blur material, which frosts the picture the controls stand on. +/// The classic glass [style] keeps the shader everywhere. bool mediaGlassGoesNative({ required TargetPlatform platform, required NativeGlassCapability capability, required bool reduceTransparency, + PlayerControlsStyle? style, }) => + style != PlayerControlsStyle.classicGlass && nativeGlassDraw( platform: platform, capability: capability, @@ -167,6 +171,7 @@ class _MediaGlassLayerState extends State { _said ?? NativeGlassCapability.absent, reduceTransparency: GlassSense.reduceTransparencyOf(context), + style: PlayerControlsStyleScope.maybeOf(context), ); // Hosted glass never stands inside a refracting group: the shader would // read a backdrop with the hosted view cut out of it (NativeGlass). diff --git a/lib/core/native/native_controls_gate.dart b/lib/core/native/native_controls_gate.dart index 9a930d88..0641994b 100644 --- a/lib/core/native/native_controls_gate.dart +++ b/lib/core/native/native_controls_gate.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import '../glass/glass_sense.dart'; import '../glass/native_glass.dart'; import '../system/platform_utils.dart'; +import 'player_controls_style.dart'; /// Whether the player's controls are the phone's own glass buttons /// (ios/Runner/NativePlayerBar.swift) rather than Flutter's. @@ -62,14 +63,25 @@ abstract final class NativeControls { return decide(context); } - /// The decision from the capability and the switches, ignoring any scope. - static bool decide(BuildContext context) => nativeControlsDraw( - isIOS: defaultTargetPlatform == TargetPlatform.iOS, - isTv: isTvPlatform, - isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, - cap: NativeGlassScope.of(context) ?? _known ?? NativeGlassCapability.absent, - reduceTransparency: GlassSense.reduceTransparencyOf(context), - ); + /// The decision from the capability and the switches, ignoring any scope; + /// only the native [chosen] style draws the native bars. + static bool decide( + BuildContext context, { + PlayerControlsStyle chosen = PlayerControlsStyle.native, + }) => + effectivePlayerControlsStyle( + chosen: chosen, + isIOS: defaultTargetPlatform == TargetPlatform.iOS, + isTv: isTvPlatform, + isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, + cap: capabilityOf(context), + reduceTransparency: GlassSense.reduceTransparencyOf(context), + ) == + PlayerControlsStyle.native; + + /// What the phone said, or [NativeGlassCapability.absent] before it has. + static NativeGlassCapability capabilityOf(BuildContext context) => + NativeGlassScope.of(context) ?? _known ?? NativeGlassCapability.absent; } /// One decision for everything under it. diff --git a/lib/core/native/player_controls_style.dart b/lib/core/native/player_controls_style.dart new file mode 100644 index 00000000..3038e747 --- /dev/null +++ b/lib/core/native/player_controls_style.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +import '../glass/native_glass.dart'; +import 'native_controls_gate.dart'; + +/// How the player's controls are drawn on an iPhone, as chosen in +/// Settings → Player. +enum PlayerControlsStyle { + /// iOS 26's own Liquid Glass buttons, pull-down menus and iOS sheets. + native, + + /// Flutter's buttons, each on the phone's own clear glass. + systemGlass, + + /// Flutter's buttons on the Flutter shader glass, with the Flutter sheets. + classicGlass; + + static const PlayerControlsStyle fallback = PlayerControlsStyle.native; + + /// Stored by name; anything unknown reads as [fallback]. + static PlayerControlsStyle fromName(Object? name) => values.firstWhere( + (s) => s.name == name, + orElse: () => fallback, + ); +} + +/// Whether the phone has the clear system glass (iOS 26+) that the system +/// glass style stands on. +bool systemGlassAvailable(NativeGlassCapability cap) => + cap.systemGlass && cap.styles.contains(NativeGlassStyle.clear.name); + +/// Whether [style] can be drawn on a phone answering [cap]. +bool playerControlsStyleAvailable( + PlayerControlsStyle style, + NativeGlassCapability cap, +) => switch (style) { + PlayerControlsStyle.native => systemGlassAvailable(cap) && cap.glassControls, + PlayerControlsStyle.systemGlass => systemGlassAvailable(cap), + PlayerControlsStyle.classicGlass => true, +}; + +/// The style the player actually draws for [chosen]: classic glass off an +/// iPhone, under Reduce Transparency, and below iOS 26. The native choice on +/// a phone with the glass but not the bar factory keeps the system glass +/// under Flutter's buttons, as it always did. +PlayerControlsStyle effectivePlayerControlsStyle({ + required PlayerControlsStyle chosen, + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, +}) { + if (!isIOS || isTv || isDesktop || reduceTransparency) { + return PlayerControlsStyle.classicGlass; + } + if (!systemGlassAvailable(cap)) return PlayerControlsStyle.classicGlass; + if (chosen == PlayerControlsStyle.native) { + return nativeControlsDraw( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: reduceTransparency, + ) + ? PlayerControlsStyle.native + : PlayerControlsStyle.systemGlass; + } + return chosen; +} + +/// The chosen style for everything under it; the player's media glass layers +/// read it to keep to the shader on [PlayerControlsStyle.classicGlass]. +class PlayerControlsStyleScope extends InheritedWidget { + const PlayerControlsStyleScope({ + super.key, + required this.style, + required super.child, + }); + + final PlayerControlsStyle style; + + static PlayerControlsStyle? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.style; + + @override + bool updateShouldNotify(PlayerControlsStyleScope old) => old.style != style; +} diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 5c6793fc..11a0e02f 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../constants/app_constants.dart'; +import '../native/player_controls_style.dart'; import '../player/quality_preference.dart'; import 'profile_scope.dart'; import '../subtitles/subtitle_languages.dart'; @@ -616,6 +617,14 @@ class HiveService { await _settingsBox.put(AppConstants.defaultPlayerFitKey, fit); } + /// How the player's controls are drawn on an iPhone; stored by name. + PlayerControlsStyle get playerControlsStyle => PlayerControlsStyle.fromName( + _settingsBox.get(AppConstants.playerControlsStyleKey), + ); + + Future setPlayerControlsStyle(PlayerControlsStyle style) => + _settingsBox.put(AppConstants.playerControlsStyleKey, style.name); + /// A [QualityPreference] value; anything unknown reads as Auto. int get preferredQuality => QualityPreference.normalize( _settingsBox.get(AppConstants.preferredQualityKey), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 6c51350f..02604770 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -78,6 +78,7 @@ import 'package:soplay/core/glass/media_glass.dart'; import 'package:soplay/core/glass/glass_sense.dart'; import 'package:soplay/core/native/native_bar_layout.dart' show BarSpread; import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; import 'package:soplay/core/native/native_glass_bar.dart'; import 'package:soplay/core/native/sf_symbols.dart'; import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; @@ -504,6 +505,9 @@ class _PlayerPageState extends State bool _nativeControls = false; bool? _nativeDecidedUnder; + /// Settings → Player → Controls style, read once as the page opens. + late final PlayerControlsStyle _controlsStyle = _hive.playerControlsStyle; + /// The small players draw no native bars: the corner player is moved and /// clipped as a whole, which a hosted view cannot follow. bool get _nativeBars => _nativeControls && !_compact; @@ -989,7 +993,10 @@ class _PlayerPageState extends State final rt = GlassSense.reduceTransparencyOf(context); if (_nativeDecidedUnder != rt) { _nativeDecidedUnder = rt; - _nativeControls = NativeControls.decide(context); + _nativeControls = NativeControls.decide( + context, + chosen: _controlsStyle, + ); } } @@ -1099,47 +1106,50 @@ class _PlayerPageState extends State onPointerCancel: (_) => _pointerCancelled = true, child: NativeControlsScope( native: _nativeBars, - child: Stack( - fit: StackFit.expand, - children: [ - _buildVideoLayer(), - _buildSubtitleOverlay(), - if (!_locked) _buildReactionLane(), - if (!_locked) _buildSeekRipple(), - if (_locked) - _buildLockOverlay() - else - _buildControlsOverlay(), - if (!_locked) _buildScrubOverlay(), - if (!_locked) _buildSpeedBoostBadge(), - if (!_locked) _buildSwipeIndicator(), - // Renders nothing unless the URL is a local torrent stream, - // so it is safe to hand it every playback unconditionally. - // It keeps showing while the controls are hidden if the - // pre-buffer is still filling — that is precisely when the - // picture is frozen and the user needs to know why. - TorrentStatsOverlay( - videoUrl: _videoUrl, - visible: _controlsVisible && !_locked, - ), - // Above the controls layer so it is reachable while they are - // hidden — the offer is at its most useful to a viewer who has - // not touched the screen. Renders nothing when no interval is - // active, so it costs nothing on non-anime playback. - if (!_locked) _buildPlayerInfoOverlay(), - if (!_locked) _buildSkipButton(), - if (!_locked) _buildUpNextPrompt(), - if (!_locked) _buildEpisodeSocialRail(), - if (!_locked && _panel != _SidePanel.none) - _buildSidePanel(), - if (!_locked && _inParty) _buildPartyReactionsLayer(), - 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 - // scrub a surface nobody is watching. - _buildCastOverlay(), - ], + child: PlayerControlsStyleScope( + style: _controlsStyle, + child: Stack( + fit: StackFit.expand, + children: [ + _buildVideoLayer(), + _buildSubtitleOverlay(), + if (!_locked) _buildReactionLane(), + if (!_locked) _buildSeekRipple(), + if (_locked) + _buildLockOverlay() + else + _buildControlsOverlay(), + if (!_locked) _buildScrubOverlay(), + if (!_locked) _buildSpeedBoostBadge(), + if (!_locked) _buildSwipeIndicator(), + // Renders nothing unless the URL is a local torrent stream, + // so it is safe to hand it every playback unconditionally. + // It keeps showing while the controls are hidden if the + // pre-buffer is still filling — that is precisely when the + // picture is frozen and the user needs to know why. + TorrentStatsOverlay( + videoUrl: _videoUrl, + visible: _controlsVisible && !_locked, + ), + // Above the controls layer so it is reachable while they are + // hidden — the offer is at its most useful to a viewer who has + // not touched the screen. Renders nothing when no interval is + // active, so it costs nothing on non-anime playback. + if (!_locked) _buildPlayerInfoOverlay(), + if (!_locked) _buildSkipButton(), + if (!_locked) _buildUpNextPrompt(), + if (!_locked) _buildEpisodeSocialRail(), + if (!_locked && _panel != _SidePanel.none) + _buildSidePanel(), + if (!_locked && _inParty) _buildPartyReactionsLayer(), + 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 + // scrub a surface nobody is watching. + _buildCastOverlay(), + ], + ), ), ), ), diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 30076261..651a103a 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:soplay/features/detail/presentation/pages/player_controls_page.dart'; import 'package:soplay/features/detail/presentation/widgets/player_info_fields_sheet.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; import 'package:soplay/core/player/media_controller.dart' show warmUpPlayerEngine; import 'package:soplay/core/player/player_engine.dart'; @@ -16,6 +18,7 @@ import 'package:soplay/core/subtitles/subtitle_languages.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/subtitle_size_control.dart'; +import 'package:soplay/features/profile/presentation/widgets/player_controls_style_section.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; import 'package:soplay/features/comments/data/social_playback_prefs.dart'; @@ -68,6 +71,8 @@ class _PlayerSettingsPageState extends State { late SubtitleStyle _subtitle; late bool _autoTranslate; late String _translateLang; + late PlayerControlsStyle _controlsStyle; + NativeGlassCapability _glass = NativeGlassCapability.absent; static const List _speedChoices = [ 0.5, @@ -111,6 +116,12 @@ class _PlayerSettingsPageState extends State { _subtitle = _hive.getSubtitleStyle(); _autoTranslate = _hive.getSubtitleAutoTranslate(); _translateLang = _hive.getSubtitleTranslateLang(); + _controlsStyle = _hive.playerControlsStyle; + if (PlayerControlsStyleSection.shown) { + NativeGlass.capability().then((c) { + if (mounted) setState(() => _glass = c); + }); + } if (kDebugMode) { final readMs = _openWatch.elapsedMilliseconds; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -212,6 +223,18 @@ class _PlayerSettingsPageState extends State { const SizedBox(height: 22), ], + if (PlayerControlsStyleSection.shown) ...[ + PlayerControlsStyleSection( + chosen: _controlsStyle, + capability: _glass, + onChanged: (v) { + setState(() => _controlsStyle = v); + _hive.setPlayerControlsStyle(v); + }, + ), + const SizedBox(height: 22), + ], + SettingsLabel('profile.playback_section'.tr()), SettingsCard( children: [ diff --git a/lib/features/profile/presentation/widgets/player_controls_style_section.dart b/lib/features/profile/presentation/widgets/player_controls_style_section.dart new file mode 100644 index 00000000..269b81f0 --- /dev/null +++ b/lib/features/profile/presentation/widgets/player_controls_style_section.dart @@ -0,0 +1,95 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; + +/// Settings → Player → Controls style: iPhone and iPad only. +class PlayerControlsStyleSection extends StatelessWidget { + const PlayerControlsStyleSection({ + super.key, + required this.chosen, + required this.capability, + required this.onChanged, + }); + + final PlayerControlsStyle chosen; + + /// What the phone said it can draw; the styles it cannot are disabled. + final NativeGlassCapability capability; + + final ValueChanged onChanged; + + static bool get shown => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + static IconData _icon(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => Icons.phone_iphone_rounded, + PlayerControlsStyle.systemGlass => Icons.blur_on_rounded, + PlayerControlsStyle.classicGlass => Icons.blur_circular_rounded, + }; + + static String _title(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => 'profile.controls_style_native'.tr(), + PlayerControlsStyle.systemGlass => 'profile.controls_style_system'.tr(), + PlayerControlsStyle.classicGlass => 'profile.controls_style_classic'.tr(), + }; + + static String _desc(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => 'profile.controls_style_native_desc'.tr(), + PlayerControlsStyle.systemGlass => + 'profile.controls_style_system_desc'.tr(), + PlayerControlsStyle.classicGlass => + 'profile.controls_style_classic_desc'.tr(), + }; + + @override + Widget build(BuildContext context) { + if (!shown) return const SizedBox.shrink(); + // What the player will draw: a choice this phone cannot have shows as + // the classic glass it falls back to. + final current = playerControlsStyleAvailable(chosen, capability) + ? chosen + : PlayerControlsStyle.classicGlass; + final styles = PlayerControlsStyle.values; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingsLabel('profile.controls_style'.tr()), + SettingsCard( + children: [ + for (var i = 0; i < styles.length; i++) ...[ + if (i > 0) const SettingsDivider(), + _row(styles[i], current), + ], + ], + ), + SettingsFootnote('profile.controls_style_desc'.tr()), + ], + ); + } + + Widget _row(PlayerControlsStyle style, PlayerControlsStyle current) { + final available = playerControlsStyleAvailable(style, capability); + final selected = style == current; + return SettingsNavTile( + key: ValueKey('controls-style-${style.name}'), + icon: _icon(style), + title: _title(style), + subtitle: available + ? _desc(style) + : 'profile.controls_style_needs_ios26'.tr(), + enabled: available, + trailing: Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + size: 20, + color: selected ? AppColors.primary : AppColors.textHint, + ), + onTap: available && !selected ? () => onChanged(style) : null, + ); + } +} diff --git a/test/core/native/player_controls_style_test.dart b/test/core/native/player_controls_style_test.dart new file mode 100644 index 00000000..75e5eacd --- /dev/null +++ b/test/core/native/player_controls_style_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; +import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; + +const _ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +const _ios18 = NativeGlassCapability( + systemVersion: '18.5', + majorVersion: 18, + systemGlass: false, + styles: {}, + materials: {'thin'}, + reduceTransparency: false, +); + +PlayerControlsStyle _effective( + PlayerControlsStyle chosen, { + NativeGlassCapability cap = _ios26, + bool rt = false, + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, +}) => effectivePlayerControlsStyle( + chosen: chosen, + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: rt, +); + +bool _mediaNative(PlayerControlsStyle style, NativeGlassCapability cap, bool rt) => + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: cap, + reduceTransparency: rt, + style: style, + ); + +void main() { + const native = PlayerControlsStyle.native; + const system = PlayerControlsStyle.systemGlass; + const classic = PlayerControlsStyle.classicGlass; + + group('style × iOS version × Reduce Transparency', () { + final expected = <(PlayerControlsStyle, bool, bool), PlayerControlsStyle>{ + (native, true, false): native, + (native, true, true): classic, + (native, false, false): classic, + (native, false, true): classic, + (system, true, false): system, + (system, true, true): classic, + (system, false, false): classic, + (system, false, true): classic, + (classic, true, false): classic, + (classic, true, true): classic, + (classic, false, false): classic, + (classic, false, true): classic, + }; + expected.forEach((key, want) { + final (chosen, ios26, rt) = key; + test('${chosen.name}, iOS ${ios26 ? 26 : 18}, RT $rt -> ${want.name}', () { + final cap = ios26 ? _ios26 : _ios18; + expect(_effective(chosen, cap: cap, rt: rt), want); + // The media glass is the phone's own only on the two iOS 26 styles. + expect( + _mediaNative(chosen, cap, rt), + want == native || want == system, + ); + }); + }); + }); + + test('native without the bar factory keeps the system glass', () { + const noBars = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + ); + expect(_effective(native, cap: noBars), system); + expect(playerControlsStyleAvailable(native, noBars), isFalse); + expect(playerControlsStyleAvailable(system, noBars), isTrue); + }); + + test('off an iPhone it is always the classic glass', () { + for (final s in PlayerControlsStyle.values) { + expect(_effective(s, isIOS: false), classic); + expect(_effective(s, isTv: true), classic); + expect(_effective(s, isDesktop: true), classic); + } + }); + + test('availability below iOS 26: only the classic glass', () { + expect(playerControlsStyleAvailable(native, _ios18), isFalse); + expect(playerControlsStyleAvailable(system, _ios18), isFalse); + expect(playerControlsStyleAvailable(classic, _ios18), isTrue); + }); + + test('the stored name reads back, and anything else is native', () { + for (final s in PlayerControlsStyle.values) { + expect(PlayerControlsStyle.fromName(s.name), s); + } + expect(PlayerControlsStyle.fromName(null), native); + expect(PlayerControlsStyle.fromName('glass'), native); + expect(PlayerControlsStyle.fromName(3), native); + }); + + test('no style keeps the old media glass decision', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _ios26, + reduceTransparency: false, + ), + isTrue, + ); + }); + + for (final (style, hosted) in [(system, true), (classic, false)]) { + testWidgets('a layer under ${style.name} draws ' + '${hosted ? 'the system glass' : 'the shader'}', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _ios26, + child: PlayerControlsStyleScope( + style: style, + child: const Center( + child: MediaGlassLayer( + child: MediaGlass(child: SizedBox(width: 38, height: 38)), + ), + ), + ), + ), + ), + ); + expect(find.byType(NativeGlass), hosted ? findsOneWidget : findsNothing); + expect(find.byType(GlassGroup), hosted ? findsNothing : findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + } +} diff --git a/test/features/detail/player_native_controls_test.dart b/test/features/detail/player_native_controls_test.dart index 5d54274f..61c6b9dd 100644 --- a/test/features/detail/player_native_controls_test.dart +++ b/test/features/detail/player_native_controls_test.dart @@ -156,6 +156,17 @@ void main() { expect(source, contains('final menu = _nativeBars ? _sleepMenu : null;')); }); + test('the player latches the chosen controls style', () { + final source = read('player_page.dart'); + expect(source, contains('_hive.playerControlsStyle')); + expect(source, contains('chosen: _controlsStyle')); + expect(source, contains('PlayerControlsStyleScope(')); + expect( + read('player_page.sheets.dart'), + contains('bool get _iosSheets => _nativeControls;'), + ); + }); + test('every player sheet goes through the one that knows the iOS look', () { for (final name in [ 'player_page.panels.dart', diff --git a/test/features/profile/player_controls_style_section_test.dart b/test/features/profile/player_controls_style_section_test.dart new file mode 100644 index 00000000..69323970 --- /dev/null +++ b/test/features/profile/player_controls_style_section_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/features/profile/presentation/widgets/player_controls_style_section.dart'; + +const _ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +/// No translations are loaded, so every `.tr()` renders as its own key. +void main() { + Future> pump( + WidgetTester tester, { + NativeGlassCapability cap = _ios26, + PlayerControlsStyle chosen = PlayerControlsStyle.native, + }) async { + final picked = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: PlayerControlsStyleSection( + chosen: chosen, + capability: cap, + onChanged: picked.add, + ), + ), + ), + ), + ); + return picked; + } + + Finder row(PlayerControlsStyle s) => + find.byKey(ValueKey('controls-style-${s.name}')); + + for (final platform in TargetPlatform.values) { + final ios = platform == TargetPlatform.iOS; + testWidgets('${ios ? 'shown' : 'hidden'} on ${platform.name}', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; + await pump(tester); + expect(PlayerControlsStyleSection.shown, ios); + expect(find.text('profile.controls_style'), ios ? findsOneWidget : findsNothing); + for (final s in PlayerControlsStyle.values) { + expect(row(s), ios ? findsOneWidget : findsNothing); + } + debugDefaultTargetPlatformOverride = null; + }); + } + + testWidgets('on iOS 26 every style can be picked', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final picked = await pump(tester); + expect(find.text('profile.controls_style_desc'), findsOneWidget); + expect(find.text('profile.controls_style_needs_ios26'), findsNothing); + await tester.tap(row(PlayerControlsStyle.classicGlass)); + await tester.tap(row(PlayerControlsStyle.systemGlass)); + expect(picked, [ + PlayerControlsStyle.classicGlass, + PlayerControlsStyle.systemGlass, + ]); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('below iOS 26 the two glass styles are disabled', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final picked = await pump(tester, cap: NativeGlassCapability.absent); + expect(find.text('profile.controls_style_needs_ios26'), findsNWidgets(2)); + await tester.tap(row(PlayerControlsStyle.native)); + await tester.tap(row(PlayerControlsStyle.systemGlass)); + expect(picked, isEmpty); + // The classic glass shows as the current one. + expect( + find.descendant( + of: row(PlayerControlsStyle.classicGlass), + matching: find.byIcon(Icons.radio_button_checked_rounded), + ), + findsOneWidget, + ); + debugDefaultTargetPlatformOverride = null; + }); +} From c1577d1b8c0562f11b483bb976f4cf40f2964e33 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:39:59 +0500 Subject: [PATCH 08/21] Player on iPhone: classic controls by default, offer the native ones once A viewer who never chose a style gets the 3.2.1 classic glass. On iOS 26 with Liquid Glass available, the player asks once, after playback starts and the controls show, whether to try the native controls. Try it stores native and swaps the controls in place; Not now or any dismissal stores classic. The offer is marked shown before it appears. --- assets/translations/ar.json | 6 +- assets/translations/ckb.json | 6 +- assets/translations/de.json | 6 +- assets/translations/en.json | 6 +- assets/translations/es.json | 6 +- assets/translations/fr.json | 6 +- assets/translations/hi.json | 6 +- assets/translations/id.json | 6 +- assets/translations/ku.json | 6 +- assets/translations/nl.json | 6 +- assets/translations/pt.json | 6 +- assets/translations/ru.json | 6 +- assets/translations/tr.json | 6 +- assets/translations/uz.json | 6 +- assets/translations/vi.json | 6 +- assets/translations/yue.json | 6 +- lib/core/constants/app_constants.dart | 1 + lib/core/native/native_controls_gate.dart | 16 ++ lib/core/native/player_controls_style.dart | 32 +++- lib/core/storage/hive_service.dart | 18 ++- .../pages/player_page.controls.dart | 1 + .../presentation/pages/player_page.dart | 9 +- .../presentation/pages/player_page.media.dart | 1 + .../pages/player_page.native_offer.dart | 57 +++++++ .../widgets/native_controls_offer.dart | 52 +++++++ .../native/player_controls_style_test.dart | 120 ++++++++++++--- .../player_controls_style_store_test.dart | 44 ++++++ .../detail/native_controls_offer_test.dart | 142 ++++++++++++++++++ .../detail/player_native_controls_test.dart | 14 ++ 29 files changed, 554 insertions(+), 49 deletions(-) create mode 100644 lib/features/detail/presentation/pages/player_page.native_offer.dart create mode 100644 lib/features/detail/presentation/widgets/native_controls_offer.dart create mode 100644 test/core/storage/player_controls_style_store_test.dart create mode 100644 test/features/detail/native_controls_offer_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 1220344c..e149a6d1 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -587,7 +587,11 @@ "reaction_text_hint": "اكتب شيئاً قصيراً…", "reaction_wait": "انتظر بضع ثوانٍ قبل التفاعل مجدداً", "reaction_limit": "وصلت إلى حد التفاعلات لهذه الحلقة", - "reaction_failed": "تعذّر إرسال التفاعل" + "reaction_failed": "تعذّر إرسال التفاعل", + "native_offer_title": "هل تريد تجربة المشغّل الجديد؟", + "native_offer_body": "أزرار وقوائم Liquid Glass من Apple. يمكنك تغيير ذلك في أي وقت من الإعدادات ← المشغّل.", + "native_offer_try": "جرّبه", + "native_offer_not_now": "ليس الآن" }, "profile": { "title": "حسابي", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 42e0ed56..9f984bfb 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -587,7 +587,11 @@ "reaction_text_hint": "شتێکی کورت بنووسە…", "reaction_wait": "چەند چرکەیەک چاوەڕێ بکە پێش کاردانەوەی دووبارە", "reaction_limit": "گەیشتیتە سنووری کاردانەوەکان بۆ ئەم ئەڵقەیە", - "reaction_failed": "نەتوانرا کاردانەوەکە بنێردرێت" + "reaction_failed": "نەتوانرا کاردانەوەکە بنێردرێت", + "native_offer_title": "لێدەری نوێ تاقی دەکەیتەوە؟", + "native_offer_body": "دوگمە و لیستەکانی Liquid Glassی Apple. هەر کاتێک دەتوانیت لە ڕێکخستنەکان ← لێدەر بیگۆڕیت.", + "native_offer_try": "تاقیکردنەوە", + "native_offer_not_now": "ئێستا نا" }, "profile": { "title": "پرۆفایل", diff --git a/assets/translations/de.json b/assets/translations/de.json index 10bf6145..24ea0d3f 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Kurz etwas sagen…", "reaction_wait": "Warte ein paar Sekunden, bevor du erneut reagierst", "reaction_limit": "Du hast das Reaktionslimit für diese Folge erreicht", - "reaction_failed": "Reaktion konnte nicht gesendet werden" + "reaction_failed": "Reaktion konnte nicht gesendet werden", + "native_offer_title": "Den neuen Player ausprobieren?", + "native_offer_body": "Apples Liquid-Glass-Bedienelemente und -Menüs. Du kannst das jederzeit unter Einstellungen → Player ändern.", + "native_offer_try": "Ausprobieren", + "native_offer_not_now": "Nicht jetzt" }, "profile": { "title": "Profil", diff --git a/assets/translations/en.json b/assets/translations/en.json index c56b127a..de087504 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Say something short…", "reaction_wait": "Wait a few seconds before reacting again", "reaction_limit": "You've reached the reaction limit for this episode", - "reaction_failed": "Couldn't send the reaction" + "reaction_failed": "Couldn't send the reaction", + "native_offer_title": "Try the new player?", + "native_offer_body": "Apple's Liquid Glass controls and menus. You can change this anytime in Settings → Player.", + "native_offer_try": "Try it", + "native_offer_not_now": "Not now" }, "profile": { "title": "Profile", diff --git a/assets/translations/es.json b/assets/translations/es.json index 6aa84f4a..339931f5 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Di algo breve…", "reaction_wait": "Espera unos segundos antes de volver a reaccionar", "reaction_limit": "Has alcanzado el límite de reacciones de este episodio", - "reaction_failed": "No se pudo enviar la reacción" + "reaction_failed": "No se pudo enviar la reacción", + "native_offer_title": "¿Probar el nuevo reproductor?", + "native_offer_body": "Los controles y menús Liquid Glass de Apple. Puedes cambiarlo cuando quieras en Ajustes → Reproductor.", + "native_offer_try": "Probar", + "native_offer_not_now": "Ahora no" }, "profile": { "title": "Perfil", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 233f50d5..20dd2805 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Un mot rapide…", "reaction_wait": "Attendez quelques secondes avant de réagir à nouveau", "reaction_limit": "Vous avez atteint la limite de réactions pour cet épisode", - "reaction_failed": "Impossible d'envoyer la réaction" + "reaction_failed": "Impossible d'envoyer la réaction", + "native_offer_title": "Essayer le nouveau lecteur ?", + "native_offer_body": "Les commandes et menus Liquid Glass d’Apple. Vous pouvez changer cela à tout moment dans Réglages → Lecteur.", + "native_offer_try": "Essayer", + "native_offer_not_now": "Plus tard" }, "profile": { "title": "Profil", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 4bdbe5c0..4c402bff 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -587,7 +587,11 @@ "reaction_text_hint": "कुछ छोटा लिखें…", "reaction_wait": "फिर से प्रतिक्रिया देने से पहले कुछ सेकंड रुकें", "reaction_limit": "इस एपिसोड के लिए प्रतिक्रियाओं की सीमा पूरी हो गई", - "reaction_failed": "प्रतिक्रिया नहीं भेजी जा सकी" + "reaction_failed": "प्रतिक्रिया नहीं भेजी जा सकी", + "native_offer_title": "नया प्लेयर आज़माएँ?", + "native_offer_body": "Apple के Liquid Glass कंट्रोल और मेन्यू। आप इसे कभी भी सेटिंग्स → प्लेयर में बदल सकते हैं।", + "native_offer_try": "आज़माएँ", + "native_offer_not_now": "अभी नहीं" }, "profile": { "title": "प्रोफ़ाइल", diff --git a/assets/translations/id.json b/assets/translations/id.json index e701eb4b..73960bf6 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Tulis sesuatu yang singkat…", "reaction_wait": "Tunggu beberapa detik sebelum bereaksi lagi", "reaction_limit": "Kamu sudah mencapai batas reaksi untuk episode ini", - "reaction_failed": "Gagal mengirim reaksi" + "reaction_failed": "Gagal mengirim reaksi", + "native_offer_title": "Coba pemutar baru?", + "native_offer_body": "Kontrol dan menu Liquid Glass dari Apple. Anda bisa mengubahnya kapan saja di Pengaturan → Pemutar.", + "native_offer_try": "Coba", + "native_offer_not_now": "Nanti saja" }, "profile": { "title": "Profil", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 85b48c78..c74a718c 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Tiştekî kurt binivîse…", "reaction_wait": "Berî ku dîsa bertek bidî çend saniyan bisekine", "reaction_limit": "Te sînorê berteka vê beşê tije kir", - "reaction_failed": "Bertek nehat şandin" + "reaction_failed": "Bertek nehat şandin", + "native_offer_title": "Lêdara nû biceribînî?", + "native_offer_body": "Bişkok û menuyên Liquid Glass ên Apple. Tu dikarî vê her dem di Mîheng → Lêdar de biguherînî.", + "native_offer_try": "Biceribîne", + "native_offer_not_now": "Ne niha" }, "profile": { "title": "Profîl", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 37b9a7c2..b48f0473 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Iets korts…", "reaction_wait": "Wacht een paar seconden voordat je opnieuw reageert", "reaction_limit": "Je hebt het reactielimiet voor deze aflevering bereikt", - "reaction_failed": "Kon de reactie niet versturen" + "reaction_failed": "Kon de reactie niet versturen", + "native_offer_title": "De nieuwe speler proberen?", + "native_offer_body": "De Liquid Glass-bediening en -menu’s van Apple. Je kunt dit altijd wijzigen via Instellingen → Speler.", + "native_offer_try": "Proberen", + "native_offer_not_now": "Niet nu" }, "profile": { "title": "Profiel", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f971ef5d..ea4bf092 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Diga algo curto…", "reaction_wait": "Espere alguns segundos antes de reagir de novo", "reaction_limit": "Você atingiu o limite de reações deste episódio", - "reaction_failed": "Não foi possível enviar a reação" + "reaction_failed": "Não foi possível enviar a reação", + "native_offer_title": "Experimentar o novo player?", + "native_offer_body": "Os controles e menus Liquid Glass da Apple. Você pode mudar isso a qualquer momento em Configurações → Player.", + "native_offer_try": "Experimentar", + "native_offer_not_now": "Agora não" }, "profile": { "title": "Perfil", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 56d55aa8..454de6c9 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Коротко…", "reaction_wait": "Подождите несколько секунд перед следующей реакцией", "reaction_limit": "Лимит реакций для этой серии исчерпан", - "reaction_failed": "Не удалось отправить реакцию" + "reaction_failed": "Не удалось отправить реакцию", + "native_offer_title": "Попробовать новый плеер?", + "native_offer_body": "Кнопки и меню Liquid Glass от Apple. Это можно изменить в любой момент в Настройки → Плеер.", + "native_offer_try": "Попробовать", + "native_offer_not_now": "Не сейчас" }, "profile": { "title": "Профиль", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 8fe4c291..8c3cec9b 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Kısa bir şey yaz…", "reaction_wait": "Tekrar tepki vermeden önce birkaç saniye bekle", "reaction_limit": "Bu bölüm için tepki sınırına ulaştın", - "reaction_failed": "Tepki gönderilemedi" + "reaction_failed": "Tepki gönderilemedi", + "native_offer_title": "Yeni oynatıcıyı denemek ister misiniz?", + "native_offer_body": "Apple’ın Liquid Glass kontrolleri ve menüleri. Bunu istediğiniz zaman Ayarlar → Oynatıcı bölümünden değiştirebilirsiniz.", + "native_offer_try": "Dene", + "native_offer_not_now": "Şimdi değil" }, "profile": { "title": "Profil", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 104a8956..79885c27 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Qisqa yozing…", "reaction_wait": "Qayta reaksiya bildirishdan oldin bir necha soniya kuting", "reaction_limit": "Bu qism uchun reaksiyalar limiti tugadi", - "reaction_failed": "Reaksiyani yuborib bo'lmadi" + "reaction_failed": "Reaksiyani yuborib bo'lmadi", + "native_offer_title": "Yangi pleyerni sinab ko‘rasizmi?", + "native_offer_body": "Apple’ning Liquid Glass tugmalari va menyulari. Buni istalgan vaqt Sozlamalar → Pleyer bo‘limida o‘zgartirishingiz mumkin.", + "native_offer_try": "Sinab ko‘rish", + "native_offer_not_now": "Hozir emas" }, "profile": { "title": "Profil", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 2e1f7b68..0df9c365 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -587,7 +587,11 @@ "reaction_text_hint": "Viết ngắn thôi…", "reaction_wait": "Chờ vài giây trước khi bày tỏ tiếp", "reaction_limit": "Bạn đã đạt giới hạn cảm xúc cho tập này", - "reaction_failed": "Không gửi được cảm xúc" + "reaction_failed": "Không gửi được cảm xúc", + "native_offer_title": "Dùng thử trình phát mới?", + "native_offer_body": "Nút điều khiển và menu Liquid Glass của Apple. Bạn có thể thay đổi bất cứ lúc nào trong Cài đặt → Trình phát.", + "native_offer_try": "Dùng thử", + "native_offer_not_now": "Để sau" }, "profile": { "title": "Hồ sơ", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 3ea1477d..1dbc9d5a 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -587,7 +587,11 @@ "reaction_text_hint": "講句簡短嘅…", "reaction_wait": "等幾秒先再反應", "reaction_limit": "呢集嘅反應已經到上限", - "reaction_failed": "送唔到個反應" + "reaction_failed": "送唔到個反應", + "native_offer_title": "試吓新播放器?", + "native_offer_body": "Apple 嘅 Liquid Glass 控制同選單。你隨時可以喺 設定 → 播放器 度改返。", + "native_offer_try": "試吓", + "native_offer_not_now": "遲啲先" }, "profile": { "title": "個人檔案", diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index e54eadfb..b43c6dbd 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -288,6 +288,7 @@ class AppConstants { /// Open the player without starting it. static const String startPausedKey = 'start_paused'; static const String playerControlsStyleKey = 'player_controls_style'; + static const String nativeControlsOfferedKey = 'native_controls_offered'; static const String doubleTapSeekSecondsKey = 'double_tap_seek_seconds'; static const String longPressBoostKey = 'long_press_boost'; static const String brightnessGestureKey = 'brightness_gesture'; diff --git a/lib/core/native/native_controls_gate.dart b/lib/core/native/native_controls_gate.dart index 0641994b..a18330ed 100644 --- a/lib/core/native/native_controls_gate.dart +++ b/lib/core/native/native_controls_gate.dart @@ -79,6 +79,22 @@ abstract final class NativeControls { ) == PlayerControlsStyle.native; + /// Whether to offer the native controls once, on the same switches as + /// [decide]. + static bool shouldOffer( + BuildContext context, { + required PlayerControlsStyle? stored, + required bool alreadyOffered, + }) => shouldOfferNativeControls( + isIOS: defaultTargetPlatform == TargetPlatform.iOS, + isTv: isTvPlatform, + isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, + cap: capabilityOf(context), + reduceTransparency: GlassSense.reduceTransparencyOf(context), + stored: stored, + alreadyOffered: alreadyOffered, + ); + /// What the phone said, or [NativeGlassCapability.absent] before it has. static NativeGlassCapability capabilityOf(BuildContext context) => NativeGlassScope.of(context) ?? _known ?? NativeGlassCapability.absent; diff --git a/lib/core/native/player_controls_style.dart b/lib/core/native/player_controls_style.dart index 3038e747..5f0f8173 100644 --- a/lib/core/native/player_controls_style.dart +++ b/lib/core/native/player_controls_style.dart @@ -15,13 +15,12 @@ enum PlayerControlsStyle { /// Flutter's buttons on the Flutter shader glass, with the Flutter sheets. classicGlass; - static const PlayerControlsStyle fallback = PlayerControlsStyle.native; + /// The 3.2.1 look, until the viewer picks another. + static const PlayerControlsStyle fallback = PlayerControlsStyle.classicGlass; /// Stored by name; anything unknown reads as [fallback]. - static PlayerControlsStyle fromName(Object? name) => values.firstWhere( - (s) => s.name == name, - orElse: () => fallback, - ); + static PlayerControlsStyle fromName(Object? name) => + values.firstWhere((s) => s.name == name, orElse: () => fallback); } /// Whether the phone has the clear system glass (iOS 26+) that the system @@ -39,6 +38,29 @@ bool playerControlsStyleAvailable( PlayerControlsStyle.classicGlass => true, }; +/// Whether the player should offer the native controls once: an iPhone or +/// iPad that can draw them, a viewer who never chose a style, and an offer +/// not shown before. +bool shouldOfferNativeControls({ + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, + required PlayerControlsStyle? stored, + required bool alreadyOffered, +}) { + if (!isIOS || isTv || isDesktop || reduceTransparency) return false; + if (stored != null || alreadyOffered) return false; + return playerControlsStyleAvailable(PlayerControlsStyle.native, cap); +} + +/// What the offer's answer stores: "Try it" is [PlayerControlsStyle.native], +/// and "Not now" or any other dismissal keeps the classic glass for good. +PlayerControlsStyle nativeOfferOutcome(bool? accepted) => accepted == true + ? PlayerControlsStyle.native + : PlayerControlsStyle.classicGlass; + /// The style the player actually draws for [chosen]: classic glass off an /// iPhone, under Reduce Transparency, and below iOS 26. The native choice on /// a phone with the glass but not the bar factory keeps the system glass diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 11a0e02f..f77b7103 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -618,9 +618,21 @@ class HiveService { } /// How the player's controls are drawn on an iPhone; stored by name. - PlayerControlsStyle get playerControlsStyle => PlayerControlsStyle.fromName( - _settingsBox.get(AppConstants.playerControlsStyleKey), - ); + PlayerControlsStyle get playerControlsStyle => + storedPlayerControlsStyle ?? PlayerControlsStyle.fallback; + + /// The style the viewer chose, or null when they never have. + PlayerControlsStyle? get storedPlayerControlsStyle { + final raw = _settingsBox.get(AppConstants.playerControlsStyleKey); + return raw == null ? null : PlayerControlsStyle.fromName(raw); + } + + /// Whether the player has offered the native controls already. + bool get nativeControlsOffered => + _settingsBox.get(AppConstants.nativeControlsOfferedKey) == true; + + Future markNativeControlsOffered() => + _settingsBox.put(AppConstants.nativeControlsOfferedKey, true); Future setPlayerControlsStyle(PlayerControlsStyle style) => _settingsBox.put(AppConstants.playerControlsStyleKey, style.name); diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 9a457927..f46637c4 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -76,6 +76,7 @@ extension _PlayerControls on _PlayerPageState { if (_controlsVisible) { _controlsAnimation.forward(); _scheduleHide(); + _maybeOfferNativeControls(); } else { _controlsAnimation.reverse(); _hideTimer?.cancel(); diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 02604770..d1104cfe 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -82,6 +82,7 @@ import 'package:soplay/core/native/player_controls_style.dart'; import 'package:soplay/core/native/native_glass_bar.dart'; import 'package:soplay/core/native/sf_symbols.dart'; import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; +import 'package:soplay/features/detail/presentation/widgets/native_controls_offer.dart'; import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; import 'package:soplay/features/detail/presentation/widgets/player_sheet_look.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; @@ -172,6 +173,7 @@ part 'player_page.upnext.dart'; part 'player_page.reactions.dart'; part 'player_page.menus.dart'; part 'player_page.sheets.dart'; +part 'player_page.native_offer.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; @@ -505,8 +507,11 @@ class _PlayerPageState extends State bool _nativeControls = false; bool? _nativeDecidedUnder; - /// Settings → Player → Controls style, read once as the page opens. - late final PlayerControlsStyle _controlsStyle = _hive.playerControlsStyle; + /// Settings → Player → Controls style, read once as the page opens; only + /// a yes to the one-time offer changes it while the page is up. + late PlayerControlsStyle _controlsStyle = _hive.playerControlsStyle; + bool _nativeOfferPending = true; + bool _nativeOfferOpen = false; /// The small players draw no native bars: the corner player is moved and /// clipped as a whole, which a hosted view cannot follow. diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 3894e20e..90271312 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -2106,6 +2106,7 @@ extension _PlayerMedia on _PlayerPageState { _wasBuffering = v.isBuffering; changed = true; } + _maybeOfferNativeControls(); if (v.isInitialized && v.duration.inMilliseconds > 0) { if (WatchProgress.isWatched(v.position, v.duration)) { diff --git a/lib/features/detail/presentation/pages/player_page.native_offer.dart b/lib/features/detail/presentation/pages/player_page.native_offer.dart new file mode 100644 index 00000000..acfbf696 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.native_offer.dart @@ -0,0 +1,57 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The one-time "Try the new player?" offer, and applying a yes in place. +extension _PlayerNativeOffer on _PlayerPageState { + /// Asked when the controls show and when playback changes state; shows the + /// offer once the picture is up, the controls are, and no finger is busy. + void _maybeOfferNativeControls() { + if (!_nativeOfferPending || _nativeOfferOpen || !mounted) return; + final c = _controller; + if (c == null) return; + final v = c.value; + if (!v.isInitialized || v.hasError || v.isBuffering) return; + if (_initializing || _errorMessage != null || _isCodecError) return; + if (_serverSwitch != null || _autoRetrying) return; + // Paused, or within the first moments of playing. + if (v.isPlaying && _playbackWatch.elapsed > const Duration(seconds: 20)) { + return; + } + if (!_controlsVisible || _locked || _compact || _isPip) return; + if (_panel != _SidePanel.none || _cast.isCasting) return; + if (_speedBoost.value || + _seekBarDragging.value || + _sliderDragValue.value != null || + _swipeIndicator.value != null || + _dragSwipeType != null) { + return; + } + final offer = NativeControls.shouldOffer( + context, + stored: _hive.storedPlayerControlsStyle, + alreadyOffered: _hive.nativeControlsOffered, + ); + if (!offer) { + _nativeOfferPending = false; + return; + } + _nativeOfferOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + _nativeOfferPending = false; + final style = await presentNativeControlsOffer(context, _hive); + _nativeOfferOpen = false; + if (mounted) _applyControlsStyle(style); + }); + WidgetsBinding.instance.scheduleFrame(); + } + + /// Swaps the controls in this player without reopening it. + void _applyControlsStyle(PlayerControlsStyle style) { + if (style == _controlsStyle) return; + setState(() { + _controlsStyle = style; + _nativeControls = NativeControls.decide(context, chosen: style); + }); + } +} diff --git a/lib/features/detail/presentation/widgets/native_controls_offer.dart b/lib/features/detail/presentation/widgets/native_controls_offer.dart new file mode 100644 index 00000000..71556850 --- /dev/null +++ b/lib/features/detail/presentation/widgets/native_controls_offer.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; + +/// "Try the new player?", asked once over the player. +class NativeControlsOfferDialog extends StatelessWidget { + const NativeControlsOfferDialog({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoTheme( + data: const CupertinoThemeData(brightness: Brightness.dark), + child: CupertinoAlertDialog( + title: Text('player.native_offer_title'.tr()), + content: Text('player.native_offer_body'.tr()), + actions: [ + CupertinoDialogAction( + key: const ValueKey('native-offer-not-now'), + onPressed: () => Navigator.of(context).pop(false), + child: Text('player.native_offer_not_now'.tr()), + ), + CupertinoDialogAction( + key: const ValueKey('native-offer-try'), + isDefaultAction: true, + onPressed: () => Navigator.of(context).pop(true), + child: Text('player.native_offer_try'.tr()), + ), + ], + ), + ); + } +} + +/// Marks the offer shown before it appears, so a crash never asks twice, +/// then stores the answer and returns it. Anything but "Try it" is "Not now". +Future presentNativeControlsOffer( + BuildContext context, + HiveService hive, +) async { + unawaited(hive.markNativeControlsOffered()); + final accepted = await showCupertinoDialog( + context: context, + barrierDismissible: true, + builder: (_) => const NativeControlsOfferDialog(), + ); + final style = nativeOfferOutcome(accepted); + unawaited(hive.setPlayerControlsStyle(style)); + return style; +} diff --git a/test/core/native/player_controls_style_test.dart b/test/core/native/player_controls_style_test.dart index 75e5eacd..a067cb70 100644 --- a/test/core/native/player_controls_style_test.dart +++ b/test/core/native/player_controls_style_test.dart @@ -41,13 +41,16 @@ PlayerControlsStyle _effective( reduceTransparency: rt, ); -bool _mediaNative(PlayerControlsStyle style, NativeGlassCapability cap, bool rt) => - mediaGlassGoesNative( - platform: TargetPlatform.iOS, - capability: cap, - reduceTransparency: rt, - style: style, - ); +bool _mediaNative( + PlayerControlsStyle style, + NativeGlassCapability cap, + bool rt, +) => mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: cap, + reduceTransparency: rt, + style: style, +); void main() { const native = PlayerControlsStyle.native; @@ -71,15 +74,18 @@ void main() { }; expected.forEach((key, want) { final (chosen, ios26, rt) = key; - test('${chosen.name}, iOS ${ios26 ? 26 : 18}, RT $rt -> ${want.name}', () { - final cap = ios26 ? _ios26 : _ios18; - expect(_effective(chosen, cap: cap, rt: rt), want); - // The media glass is the phone's own only on the two iOS 26 styles. - expect( - _mediaNative(chosen, cap, rt), - want == native || want == system, - ); - }); + test( + '${chosen.name}, iOS ${ios26 ? 26 : 18}, RT $rt -> ${want.name}', + () { + final cap = ios26 ? _ios26 : _ios18; + expect(_effective(chosen, cap: cap, rt: rt), want); + // The media glass is the phone's own only on the two iOS 26 styles. + expect( + _mediaNative(chosen, cap, rt), + want == native || want == system, + ); + }, + ); }); }); @@ -111,13 +117,81 @@ void main() { expect(playerControlsStyleAvailable(classic, _ios18), isTrue); }); - test('the stored name reads back, and anything else is native', () { - for (final s in PlayerControlsStyle.values) { - expect(PlayerControlsStyle.fromName(s.name), s); - } - expect(PlayerControlsStyle.fromName(null), native); - expect(PlayerControlsStyle.fromName('glass'), native); - expect(PlayerControlsStyle.fromName(3), native); + test( + 'the stored name reads back, and anything else is the classic glass', + () { + expect(PlayerControlsStyle.fallback, classic); + for (final s in PlayerControlsStyle.values) { + expect(PlayerControlsStyle.fromName(s.name), s); + } + expect(PlayerControlsStyle.fromName(null), classic); + expect(PlayerControlsStyle.fromName('glass'), classic); + expect(PlayerControlsStyle.fromName(3), classic); + }, + ); + + group('the one-time native offer', () { + bool offer({ + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, + NativeGlassCapability cap = _ios26, + bool rt = false, + PlayerControlsStyle? stored, + bool shown = false, + }) => shouldOfferNativeControls( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: rt, + stored: stored, + alreadyOffered: shown, + ); + + test('offered on iOS 26 to a viewer who never chose', () { + expect(offer(), isTrue); + }); + + test('never off an iPhone or iPad', () { + expect(offer(isIOS: false), isFalse); + expect(offer(isTv: true), isFalse); + expect(offer(isDesktop: true), isFalse); + }); + + test('never where native cannot be drawn', () { + expect(offer(cap: _ios18), isFalse); + expect(offer(cap: NativeGlassCapability.absent), isFalse); + const noBars = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + ); + expect(offer(cap: noBars), isFalse); + }); + + test('never under Reduce Transparency', () { + expect(offer(rt: true), isFalse); + }); + + test('never once a style is stored', () { + for (final s in PlayerControlsStyle.values) { + expect(offer(stored: s), isFalse, reason: s.name); + } + }); + + test('never twice', () { + expect(offer(shown: true), isFalse); + }); + + test('Try it is native; Not now and any dismissal are classic', () { + expect(nativeOfferOutcome(true), native); + expect(nativeOfferOutcome(false), classic); + expect(nativeOfferOutcome(null), classic); + }); }); test('no style keeps the old media glass decision', () { diff --git a/test/core/storage/player_controls_style_store_test.dart b/test/core/storage/player_controls_style_store_test.dart new file mode 100644 index 00000000..0336ca72 --- /dev/null +++ b/test/core/storage/player_controls_style_store_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; + +void main() { + late Directory dir; + late HiveService hive; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo_controls_style_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.authBox); + await Hive.openBox(AppConstants.settingsBox); + hive = HiveService(); + }); + + tearDown(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + test('nothing stored reads as the classic glass', () { + expect(hive.storedPlayerControlsStyle, isNull); + expect(hive.playerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + test('a stored choice is respected', () async { + for (final s in PlayerControlsStyle.values) { + await hive.setPlayerControlsStyle(s); + expect(hive.storedPlayerControlsStyle, s); + expect(hive.playerControlsStyle, s); + } + }); + + test('the offer is remembered as shown', () async { + expect(hive.nativeControlsOffered, isFalse); + await hive.markNativeControlsOffered(); + expect(hive.nativeControlsOffered, isTrue); + }); +} diff --git a/test/features/detail/native_controls_offer_test.dart b/test/features/detail/native_controls_offer_test.dart new file mode 100644 index 00000000..08e552f8 --- /dev/null +++ b/test/features/detail/native_controls_offer_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/detail/presentation/widgets/native_controls_offer.dart'; + +const _ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +/// No translations are loaded, so every `.tr()` renders as its own key. +void main() { + late HiveService hive; + + setUp(() async { + // In memory: real file writes never finish inside a widget test. + await Hive.openBox(AppConstants.authBox, bytes: Uint8List(0)); + await Hive.openBox(AppConstants.settingsBox, bytes: Uint8List(0)); + hive = HiveService(); + }); + + tearDown(() async { + await Hive.close(); + }); + + final answers = []; + + /// On an iPhone, reset before the test ends as the binding insists. + void iosTest(String name, Future Function(WidgetTester) body) => + testWidgets(name, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + debugNativeControlsDesktopOverride = false; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugNativeControlsDesktopOverride = null; + } + }); + + /// Stands in for the player: each tap asks the gate the player asks, and + /// presents the offer when it says so. + Future pumpHost(WidgetTester tester) async { + answers.clear(); + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _ios26, + child: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () async { + if (!NativeControls.shouldOffer( + context, + stored: hive.storedPlayerControlsStyle, + alreadyOffered: hive.nativeControlsOffered, + )) { + return; + } + answers.add(await presentNativeControlsOffer(context, hive)); + }, + child: const Text('controls'), + ), + ), + ), + ), + ), + ); + } + + Future reveal(WidgetTester tester) async { + await tester.tap(find.text('controls')); + await tester.pumpAndSettle(); + } + + final card = find.text('player.native_offer_title'); + + iosTest('Try it stores native', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + expect(hive.nativeControlsOffered, isTrue); + await tester.tap(find.byKey(const ValueKey('native-offer-try'))); + await tester.pumpAndSettle(); + expect(card, findsNothing); + expect(answers, [PlayerControlsStyle.native]); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.native); + }); + + iosTest('Not now stores classic', (tester) async { + await pumpHost(tester); + await reveal(tester); + await tester.tap(find.byKey(const ValueKey('native-offer-not-now'))); + await tester.pumpAndSettle(); + expect(answers, [PlayerControlsStyle.classicGlass]); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + iosTest('a barrier tap counts as Not now', (tester) async { + await pumpHost(tester); + await reveal(tester); + await tester.tapAt(const Offset(4, 4)); + await tester.pumpAndSettle(); + expect(card, findsNothing); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + iosTest('the card appears once', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + await tester.tap(find.byKey(const ValueKey('native-offer-not-now'))); + await tester.pumpAndSettle(); + await reveal(tester); + expect(card, findsNothing); + await reveal(tester); + expect(card, findsNothing); + expect(answers, hasLength(1)); + }); + + iosTest('shown once even if it was never answered', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + // The app dies with the card up: the settings survive, the answer not. + await tester.pumpWidget(const SizedBox()); + await pumpHost(tester); + await reveal(tester); + expect(card, findsNothing); + }); +} diff --git a/test/features/detail/player_native_controls_test.dart b/test/features/detail/player_native_controls_test.dart index 61c6b9dd..710057a5 100644 --- a/test/features/detail/player_native_controls_test.dart +++ b/test/features/detail/player_native_controls_test.dart @@ -167,6 +167,20 @@ void main() { ); }); + test('a yes to the offer swaps the controls in place', () { + final source = read('player_page.native_offer.dart'); + expect(source, contains('NativeControls.decide(context, chosen: style)')); + expect(source, contains('_controlsStyle = style;')); + expect( + read('player_page.controls.dart'), + contains('_maybeOfferNativeControls();'), + ); + expect( + read('player_page.media.dart'), + contains('_maybeOfferNativeControls();'), + ); + }); + test('every player sheet goes through the one that knows the iOS look', () { for (final name in [ 'player_page.panels.dart', From 1d654753289d14e0c6b9f858fde9a7e109425e1b Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:48:02 +0500 Subject: [PATCH 09/21] Detail tabs: keep panes alive and stop rebuilding the page on a switch Every tab change ran setState on the whole detail page (header, action row, both bars) and the AnimatedSwitcher threw the old pane away, so Relations and Comments refetched and Similar rebuilt its grid on every swipe. DetailTabPanes now listens to the TabController itself, builds a pane on first visit and keeps it offstage afterwards, with the same 160 ms cross-fade. --- .../presentation/pages/detail_page.dart | 71 ++++---- .../widgets/detail_tab_panes.dart | 130 +++++++++++++++ .../detail/detail_tab_panes_test.dart | 152 ++++++++++++++++++ 3 files changed, 310 insertions(+), 43 deletions(-) create mode 100644 lib/features/detail/presentation/widgets/detail_tab_panes.dart create mode 100644 test/features/detail/detail_tab_panes_test.dart diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index c6c5bc50..7a20ffa8 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -53,6 +53,7 @@ import 'package:soplay/features/detail/presentation/widgets/alternate_source_she import 'package:soplay/features/detail/presentation/widgets/source_notice_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_cast_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_comments_tab.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_tab_panes.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_hero.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_info.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_more_menu.dart'; @@ -437,9 +438,10 @@ class _DetailViewState extends State<_DetailView> unawaited(getIt().markPrivateShowcaseSeen()); } + /// No setState: [DetailTabPanes] listens to the controller itself, and + /// rebuilding this page rebuilt the header and both bars on every switch. void _onTabChanged() { if (!_tabController.indexIsChanging) return; - if (mounted) setState(() {}); _settleOnTabs(); } @@ -538,47 +540,26 @@ class _DetailViewState extends State<_DetailView> _ => tab, }; - Widget _buildTabContent(DetailEntity detail) { - final tab = _tabs[_tabController.index]; - // Tabs used to cut over on the frame the index changed, which on a page - // this dense reads as a flicker rather than a change of view. Aligned to - // the top so the outgoing and incoming panes do not slide against each - // other while they cross-fade. - return AnimatedSwitcher( - duration: const Duration(milliseconds: 160), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - layoutBuilder: (current, previous) => Stack( - alignment: Alignment.topCenter, - children: [...previous, ?current], - ), - child: KeyedSubtree( - key: ValueKey('detail-tab-$tab'), - child: switch (tab) { - 'About' => DetailAboutTab(record: detail.record!), - 'Similar' => DetailRelatedSection(related: detail.related), - 'Relations' => DetailRelationsTab( - provider: detail.provider, - contentUrl: detail.contentUrl, - title: detail.title, - year: detail.year, - isSerial: detail.isSerial, - tmdbId: detail.record?.tmdbId, - anilistId: detail.record?.anilistId, - ), - 'Cast' => DetailCastTab(cast: detail.cast, director: detail.director), - 'Comments' => DetailCommentsTab( - provider: detail.provider, - contentUrl: detail.contentUrl, - ), - 'Screenshots' => DetailScreenshotsSection( - screenshots: detail.screenshots, - ), - _ => const SizedBox.shrink(), - }, - ), - ); - } + Widget _buildTabPane(DetailEntity detail, String tab) => switch (tab) { + 'About' => DetailAboutTab(record: detail.record!), + 'Similar' => DetailRelatedSection(related: detail.related), + 'Relations' => DetailRelationsTab( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + year: detail.year, + isSerial: detail.isSerial, + tmdbId: detail.record?.tmdbId, + anilistId: detail.record?.anilistId, + ), + 'Cast' => DetailCastTab(cast: detail.cast, director: detail.director), + 'Comments' => DetailCommentsTab( + provider: detail.provider, + contentUrl: detail.contentUrl, + ), + 'Screenshots' => DetailScreenshotsSection(screenshots: detail.screenshots), + _ => const SizedBox.shrink(), + }; void _goBack() { if (context.canPop()) { @@ -1612,7 +1593,11 @@ class _DetailViewState extends State<_DetailView> 36) .clamp(0.0, double.infinity), ), - child: _buildTabContent(detail), + child: DetailTabPanes( + controller: _tabController, + tabs: _tabs, + paneBuilder: (_, tab) => _buildTabPane(detail, tab), + ), ), ), ), diff --git a/lib/features/detail/presentation/widgets/detail_tab_panes.dart b/lib/features/detail/presentation/widgets/detail_tab_panes.dart new file mode 100644 index 00000000..5c097a46 --- /dev/null +++ b/lib/features/detail/presentation/widgets/detail_tab_panes.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +/// The detail page's tab contents, switched by [controller]. +/// +/// A pane is built the first time its tab opens and then kept: switching +/// away puts it offstage instead of disposing it, so Relations and Comments +/// do not refetch, and Similar does not decode thirty posters again, on every +/// swipe. Only this widget rebuilds on a tab change, not the page around it. +class DetailTabPanes extends StatefulWidget { + const DetailTabPanes({ + super.key, + required this.controller, + required this.tabs, + required this.paneBuilder, + }); + + final TabController controller; + final List tabs; + final Widget Function(BuildContext context, String tab) paneBuilder; + + static const Duration fade = Duration(milliseconds: 160); + + @override + State createState() => _DetailTabPanesState(); +} + +class _DetailTabPanesState extends State + with SingleTickerProviderStateMixin { + late int _current = widget.controller.index; + int? _previous; + + /// Each pane's widget, kept between tab switches so a switch does not + /// rebuild the panes it does not touch. Dropped when the page rebuilds. + /// Its keys are the panes built so far, a null value one to build afresh. + late final Map _panes = {_current: null}; + + late final AnimationController _fade = AnimationController( + vsync: this, + duration: DetailTabPanes.fade, + value: 1, + )..addStatusListener(_onFadeStatus); + late final Animation _in = _fade.drive( + CurveTween(curve: Curves.easeOut), + ); + late final Animation _out = ReverseAnimation( + _fade, + ).drive(CurveTween(curve: Curves.easeIn)); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTab); + } + + @override + void didUpdateWidget(DetailTabPanes old) { + super.didUpdateWidget(old); + _panes.updateAll((_, _) => null); + if (!identical(old.controller, widget.controller)) { + old.controller.removeListener(_onTab); + widget.controller.addListener(_onTab); + _onTab(); + } + } + + void _onTab() { + final index = widget.controller.index; + if (index == _current) return; + setState(() { + _previous = _current; + _current = index; + _panes.putIfAbsent(index, () => null); + }); + _fade.forward(from: 0); + } + + void _onFadeStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && _previous != null && mounted) { + setState(() => _previous = null); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onTab); + _fade.dispose(); + super.dispose(); + } + + Widget _pane(int index) { + final tab = widget.tabs[index]; + final current = index == _current; + final live = current || index == _previous; + return KeyedSubtree( + key: ValueKey('detail-tab-$tab'), + child: Offstage( + offstage: !live, + child: TickerMode( + enabled: live, + child: IgnorePointer( + ignoring: !current, + child: FadeTransition( + opacity: current + ? _in + : live + ? _out + : kAlwaysCompleteAnimation, + child: _panes[index] ??= widget.paneBuilder(context, tab), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Offstage panes report no size, so the stack is as tall as the panes on + // screen, outgoing below incoming while they cross-fade. + return Stack( + alignment: Alignment.topCenter, + children: [ + for (final i in _panes.keys.toList()) + if (i != _current && i != _previous) _pane(i), + if (_previous != null) _pane(_previous!), + _pane(_current), + ], + ); + } +} diff --git a/test/features/detail/detail_tab_panes_test.dart b/test/features/detail/detail_tab_panes_test.dart new file mode 100644 index 00000000..fc9cffad --- /dev/null +++ b/test/features/detail/detail_tab_panes_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_tab_panes.dart'; + +class _Counting extends StatefulWidget { + const _Counting(this.tab, this.counts); + final String tab; + final Map counts; + + @override + State<_Counting> createState() => _CountingState(); +} + +class _CountingState extends State<_Counting> { + int taps = 0; + + @override + void initState() { + super.initState(); + final c = widget.counts[widget.tab] ?? (inits: 0, builds: 0); + widget.counts[widget.tab] = (inits: c.inits + 1, builds: c.builds); + } + + @override + Widget build(BuildContext context) { + final c = widget.counts[widget.tab]!; + widget.counts[widget.tab] = (inits: c.inits, builds: c.builds + 1); + return GestureDetector( + onTap: () => setState(() => taps++), + child: SizedBox(height: 200, child: Text('${widget.tab}:$taps')), + ); + } +} + +class _Host extends StatefulWidget { + const _Host(this.counts, this.hostBuilds); + final Map counts; + final List hostBuilds; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with SingleTickerProviderStateMixin { + static const tabs = ['A', 'B', 'C']; + late final TabController controller = TabController( + length: tabs.length, + vsync: this, + ); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + widget.hostBuilds[0]++; + return MaterialApp( + home: Scaffold( + body: DetailTabPanes( + controller: controller, + tabs: tabs, + paneBuilder: (_, tab) => _Counting(tab, widget.counts), + ), + ), + ); + } +} + +void main() { + late Map counts; + late List hostBuilds; + + Future pump(WidgetTester tester) async { + counts = {}; + hostBuilds = [0]; + await tester.pumpWidget(_Host(counts, hostBuilds)); + return tester.state<_HostState>(find.byType(_Host)).controller; + } + + testWidgets('only the open tab is built at first', (tester) async { + await pump(tester); + expect(counts.keys, ['A']); + expect(find.text('A:0'), findsOneWidget); + }); + + testWidgets('a pane keeps its state when the tab is left and reopened', ( + tester, + ) async { + final controller = await pump(tester); + await tester.tap(find.text('A:0')); + await tester.pump(); + expect(find.text('A:1'), findsOneWidget); + + controller.animateTo(1); + await tester.pumpAndSettle(); + expect(find.text('B:0'), findsOneWidget); + expect(find.text('A:1'), findsNothing); + + controller.animateTo(0); + await tester.pumpAndSettle(); + expect(find.text('A:1'), findsOneWidget); + expect(counts['A']!.inits, 1); + expect(counts['B']!.inits, 1); + }); + + testWidgets('a tab switch rebuilds neither the page nor idle panes', ( + tester, + ) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pumpAndSettle(); + final aBuilds = counts['A']!.builds; + + controller.animateTo(2); + await tester.pumpAndSettle(); + controller.animateTo(1); + await tester.pumpAndSettle(); + + expect(hostBuilds[0], 1); + expect(counts['A']!.builds, aBuilds); + expect(counts['B']!.inits, 1); + expect(counts['C']!.inits, 1); + }); + + testWidgets('the outgoing pane cross-fades, then leaves the screen', ( + tester, + ) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pump(); + await tester.pump(DetailTabPanes.fade ~/ 2); + expect(find.text('A:0'), findsOneWidget); + expect(find.text('B:0'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('A:0'), findsNothing); + expect(find.text('A:0', skipOffstage: false), findsOneWidget); + }); + + testWidgets('taps reach only the open pane', (tester) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pumpAndSettle(); + await tester.tap(find.text('B:0')); + await tester.pump(); + expect(find.text('B:1'), findsOneWidget); + expect(find.text('A:0', skipOffstage: false), findsOneWidget); + }); +} From 5a17663342cc0269880778a5779d6b44c70a0c5e Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:48:02 +0500 Subject: [PATCH 10/21] Detail tabs: decode Similar posters and cast avatars at drawn size --- .../presentation/widgets/detail_cast_tab.dart | 2 ++ .../presentation/widgets/detail_related.dart | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/features/detail/presentation/widgets/detail_cast_tab.dart b/lib/features/detail/presentation/widgets/detail_cast_tab.dart index 08f847ff..c62e7850 100644 --- a/lib/features/detail/presentation/widgets/detail_cast_tab.dart +++ b/lib/features/detail/presentation/widgets/detail_cast_tab.dart @@ -194,6 +194,8 @@ class _CastAvatar extends StatelessWidget { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + memCacheWidth: (64 * MediaQuery.devicePixelRatioOf(context)) + .round(), fadeInDuration: const Duration(milliseconds: 160), errorWidget: (_, _, _) => _Initials(initials: _initials), placeholder: (_, _) => _Initials(initials: _initials), diff --git a/lib/features/detail/presentation/widgets/detail_related.dart b/lib/features/detail/presentation/widgets/detail_related.dart index 180da584..b4191ed9 100644 --- a/lib/features/detail/presentation/widgets/detail_related.dart +++ b/lib/features/detail/presentation/widgets/detail_related.dart @@ -160,18 +160,26 @@ class _RelatedThumbnail extends StatelessWidget { // Cached: these posters are the same ones the home rows already fetched, // and re-downloading them made every "Similar" strip repaint grey on the // way back to a title. - return CachedNetworkImage( - imageUrl: url!, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 180), - placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), - errorWidget: (_, _, _) => Container( - color: AppColors.surfaceVariant, - child: const Center( - child: Icon( - Icons.movie_outlined, - color: AppColors.textHint, - size: 28, + // Decoded at the width it is drawn at: thirty full-size posters decoded + // at once was a hitch every time the tab opened. + return LayoutBuilder( + builder: (context, constraints) => CachedNetworkImage( + imageUrl: url!, + fit: BoxFit.cover, + memCacheWidth: constraints.maxWidth.isFinite + ? (constraints.maxWidth * MediaQuery.devicePixelRatioOf(context)) + .round() + : null, + fadeInDuration: const Duration(milliseconds: 180), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), + errorWidget: (_, _, _) => Container( + color: AppColors.surfaceVariant, + child: const Center( + child: Icon( + Icons.movie_outlined, + color: AppColors.textHint, + size: 28, + ), ), ), ), From d169de1b8ab12f33d066528af4f405ba26f76a33 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:53:01 +0500 Subject: [PATCH 11/21] Decrypt KissKH .txt1 subtitles on the device KissKH's .srt.txt1 tracks are plain SRT with every cue text line AES-128-CBC encrypted and base64 encoded. The extractor (v7) and /contents/media tag them with a cipher object; the app ignored it and showed the ciphertext. The subtitle loader now decrypts such a track before parsing it, per docs/SUBTITLE_CIPHER.md: cue numbers, timing and blank lines pass through, a line that will not open is kept, and an unknown cipher or a key that opens fewer than half the lines fails the track, as on TV. Downloads keep the decrypted file, since the sidecar manifest has no key. Ciphered tracks are not handed to Cast or an external player, which would only show the ciphertext. AES comes from package:cryptography (pure Dart), already in the lock file as a transitive dependency. --- lib/core/subtitles/subtitle_cipher.dart | 118 ++++++++++++++++++ .../detail/data/models/subtitle_model.dart | 3 + .../domain/entities/subtitle_entity.dart | 6 + .../presentation/pages/player_page.cast.dart | 2 +- .../pages/player_page.panels.dart | 1 + .../pages/player_page.subtitles.dart | 6 +- .../download/data/subtitle_sidecar.dart | 6 +- pubspec.lock | 2 +- pubspec.yaml | 1 + test/core/subtitles/subtitle_cipher_test.dart | 115 +++++++++++++++++ 10 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 lib/core/subtitles/subtitle_cipher.dart create mode 100644 test/core/subtitles/subtitle_cipher_test.dart diff --git a/lib/core/subtitles/subtitle_cipher.dart b/lib/core/subtitles/subtitle_cipher.dart new file mode 100644 index 00000000..e3339e85 --- /dev/null +++ b/lib/core/subtitles/subtitle_cipher.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:cryptography/dart.dart'; + +import 'subtitle_parser.dart'; + +class SubtitleCipherException implements Exception { + const SubtitleCipherException(this.message); + final String message; + @override + String toString() => 'SubtitleCipherException: $message'; +} + +/// The key a source sends with a subtitle whose cue text lines are encrypted. +/// +/// KissKH's `.srt.txt1` files are ordinary SRT with every text line AES-128-CBC +/// encrypted and base64 encoded. See the backend's `docs/SUBTITLE_CIPHER.md`. +class SubtitleCipher { + const SubtitleCipher({ + required this.alg, + required this.key, + required this.iv, + this.scope = _scope, + this.encoding = _encoding, + }); + + static const _alg = 'aes-128-cbc'; + static const _scope = 'cue-text'; + static const _encoding = 'base64'; + + final String alg; + final String key; + final String iv; + final String scope; + final String encoding; + + /// Null unless [raw] is a map with a non-empty `alg`, `key` and `iv`. + static SubtitleCipher? fromJson(Object? raw) { + if (raw is! Map) return null; + String? text(String field) { + final v = raw[field]; + return v is String && v.trim().isNotEmpty ? v.trim() : null; + } + + final alg = text('alg'); + final key = text('key'); + final iv = text('iv'); + if (alg == null || key == null || iv == null) return null; + return SubtitleCipher( + alg: alg, + key: key, + iv: iv, + scope: text('scope') ?? _scope, + encoding: text('encoding') ?? _encoding, + ); + } + + bool get isSupported => + alg.toLowerCase() == _alg && + scope.toLowerCase() == _scope && + encoding.toLowerCase() == _encoding; + + /// Decrypts a downloaded file and returns it as UTF-8 bytes. + Future> decryptBytes(List bytes) async => + utf8.encode(await decrypt(decodeSubtitleBytes(bytes))); + + /// Decrypts every cue text line of [body]; cue numbers, timing lines and + /// blank lines pass through, and a line that does not open is kept as is. + /// + /// Throws when the cipher is unsupported or fewer than half the text lines + /// open: a wrong key still yields valid padding now and then. + Future decrypt(String body) async { + if (!isSupported) { + throw SubtitleCipherException('unsupported $alg/$scope/$encoding'); + } + final keyBytes = utf8.encode(key); + final ivBytes = utf8.encode(iv); + if (keyBytes.length != 16 || ivBytes.length != 16) { + throw const SubtitleCipherException('key and iv must be 16 bytes'); + } + final aes = DartAesCbc.with128bits(macAlgorithm: MacAlgorithm.empty); + final secret = SecretKeyData(keyBytes); + + var encrypted = 0; + var opened = 0; + final out = []; + for (final line in body.split('\n')) { + final cr = line.endsWith('\r'); + final text = (cr ? line.substring(0, line.length - 1) : line).trim(); + if (!_isCueText(text)) { + out.add(line); + continue; + } + encrypted++; + try { + final clear = await aes.decrypt( + SecretBox(base64.decode(text), nonce: ivBytes, mac: Mac.empty), + secretKey: secret, + ); + out.add(cr ? '${utf8.decode(clear)}\r' : utf8.decode(clear)); + opened++; + } catch (_) { + out.add(line); + } + } + if (encrypted > 0 && opened * 2 < encrypted) { + throw SubtitleCipherException('opened $opened of $encrypted lines'); + } + return out.join('\n'); + } + + static bool _isCueText(String text) => + text.isNotEmpty && + !text.contains('-->') && + text != 'WEBVTT' && + !RegExp(r'^\d+$').hasMatch(text); +} diff --git a/lib/features/detail/data/models/subtitle_model.dart b/lib/features/detail/data/models/subtitle_model.dart index a725b575..041290f1 100644 --- a/lib/features/detail/data/models/subtitle_model.dart +++ b/lib/features/detail/data/models/subtitle_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; class SubtitleModel extends SubtitleEntity { @@ -6,6 +7,7 @@ class SubtitleModel extends SubtitleEntity { required super.file, required super.isDefault, super.headers, + super.cipher, }); factory SubtitleModel.fromJson(Map json) => SubtitleModel( @@ -21,6 +23,7 @@ class SubtitleModel extends SubtitleEntity { '', isDefault: json['default'] as bool? ?? json['isDefault'] as bool? ?? false, headers: _parseHeaders(json['headers']), + cipher: SubtitleCipher.fromJson(json['cipher']), ); static Map _parseHeaders(dynamic raw) { diff --git a/lib/features/detail/domain/entities/subtitle_entity.dart b/lib/features/detail/domain/entities/subtitle_entity.dart index f5434553..92cedeee 100644 --- a/lib/features/detail/domain/entities/subtitle_entity.dart +++ b/lib/features/detail/domain/entities/subtitle_entity.dart @@ -1,13 +1,19 @@ +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; + class SubtitleEntity { final String label; final String file; final bool isDefault; final Map headers; + /// Set when the file's cue text is encrypted; the loader decrypts it. + final SubtitleCipher? cipher; + const SubtitleEntity({ required this.label, required this.file, this.isDefault = false, this.headers = const {}, + this.cipher, }); } diff --git a/lib/features/detail/presentation/pages/player_page.cast.dart b/lib/features/detail/presentation/pages/player_page.cast.dart index 446c1dc0..67114d05 100644 --- a/lib/features/detail/presentation/pages/player_page.cast.dart +++ b/lib/features/detail/presentation/pages/player_page.cast.dart @@ -42,7 +42,7 @@ extension _PlayerCast on _PlayerPageState { : const []; final subtitles = [ for (final s in active) - if (CastHandoff.isSendable(s.file)) + if (CastHandoff.isSendable(s.file) && s.cipher == null) CastSubtitle( url: s.file, label: s.label, diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index a9bc09ab..25b8f16a 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -818,6 +818,7 @@ extension _PlayerPanels on _PlayerPageState { if (j != _activeSubtitleIndex) j, ]) if (_subtitles[i].file.startsWith('http') && + _subtitles[i].cipher == null && ExternalPlayer.gatingHeaders(_subtitles[i].headers).isEmpty) (url: _subtitles[i].file, label: _subtitles[i].label), ]; diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index d714bb69..f9fc8c7a 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -158,8 +158,12 @@ extension _PlayerSubtitles on _PlayerPageState { _toast('player.subtitle_failed_download'.tr()); return null; } + var bytes = response.data ?? const []; + final cipher = sub.cipher; + if (cipher != null) bytes = await cipher.decryptBytes(bytes); + if (!mounted) return null; result = parseSubtitleBytes( - response.data ?? const [], + bytes, url: sub.file, declaredFormat: declaredFormat, ); diff --git a/lib/features/download/data/subtitle_sidecar.dart b/lib/features/download/data/subtitle_sidecar.dart index 9a6dff40..1de03b5c 100644 --- a/lib/features/download/data/subtitle_sidecar.dart +++ b/lib/features/download/data/subtitle_sidecar.dart @@ -79,8 +79,12 @@ class SubtitleSidecar { if (bytes == null || bytes.isEmpty || bytes.length > maxBytes) { continue; } + // Kept decrypted: the manifest does not carry the key. + final cipher = t.cipher; + if (cipher != null) bytes = await cipher.decryptBytes(bytes); if (!await dir.exists()) await dir.create(recursive: true); - final name = '${kept.length}.${_extensionOf(t.file)}'; + final ext = t.cipher != null ? 'srt' : _extensionOf(t.file); + final name = '${kept.length}.$ext'; await File('${dir.path}/$name').writeAsBytes(bytes, flush: true); kept.add({'label': t.label, 'file': name}); } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 33502ce9..773cbc1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,7 +282,7 @@ packages: source: hosted version: "3.0.7" cryptography: - dependency: transitive + dependency: "direct main" description: name: cryptography sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" diff --git a/pubspec.yaml b/pubspec.yaml index 86b1e90f..9f16ad32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: # Security crypto: ^3.0.6 + cryptography: ^2.9.0 local_auth: ^2.3.0 # Localization diff --git a/test/core/subtitles/subtitle_cipher_test.dart b/test/core/subtitles/subtitle_cipher_test.dart new file mode 100644 index 00000000..14726961 --- /dev/null +++ b/test/core/subtitles/subtitle_cipher_test.dart @@ -0,0 +1,115 @@ +// KissKH `.srt.txt1` tracks: AES-128-CBC cue text, per docs/SUBTITLE_CIPHER.md. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; +import 'package:soplay/core/subtitles/subtitle_parser.dart'; +import 'package:soplay/features/detail/data/models/subtitle_model.dart'; +import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; +import 'package:soplay/features/download/data/subtitle_sidecar.dart'; + +const kisskh = SubtitleCipher( + alg: 'aes-128-cbc', + key: 'AmSmZVcH93UQUezi', + iv: 'ReBKWW8cqdjPEnF6', +); + +// The head of Revenged Love Ep1 (English) as the CDN serves it. +const ciphered = + '1\r\n00:00:17,152 --> 00:00:19,208\r\n' + 'Sq/yslS8glo0CoTCWZBT0GepZqSNY4KqATqtSAO1798=\r\n\r\n' + '2\r\n00:00:19,968 --> 00:00:23,584\r\n' + '8eGx+aPSbxKlXGJ6GNMb7lwpbp4jM4y1/LLyz3X0TgP/ogC0dl/OB+meqM2DBows\r\n\r\n' + '3\r\n00:00:23,584 --> 00:00:28,907\r\n' + 'pNx/wGy5Ujioalb4vL7Avv1y7G3gcz9kBHuyjHRWCdGsT56/mF1Oz2kbnUoys2H7SzHzM/xvHGymaa10KzUKUw==\r\n\r\n'; + +void main() { + test('the documented test vector', () async { + expect( + await kisskh.decrypt('Sq/yslS8glo0CoTCWZBT0GepZqSNY4KqATqtSAO1798='), + '♫ Set off ♫', + ); + }); + + test('a real file decrypts and parses, structure untouched', () async { + final plain = await kisskh.decrypt(ciphered); + expect( + plain, + contains('1\r\n00:00:17,152 --> 00:00:19,208\r\n♫ Set off ♫\r\n'), + ); + final cues = parseSubtitleBytes( + await kisskh.decryptBytes(utf8.encode(ciphered)), + url: 'https://sub.cdnvideo11.shop/x.en.srt.txt1?v=1', + ).captions; + expect(cues.map((c) => c.text), [ + '♫ Set off ♫', + '♫ With the latte I just brewed ♫', + '♫ And the long-lost sunshine in early summer ♫', + ]); + expect(cues.first.start, const Duration(seconds: 17, milliseconds: 152)); + }); + + test('a line that will not open is kept, the rest still decrypt', () async { + final out = await kisskh.decrypt( + '${ciphered}4\n00:00:30,000 --> 00:00:31,000\nplain words\n', + ); + expect(out, contains('plain words')); + expect(out, contains('♫ Set off ♫')); + }); + + test('a wrong key or an unknown cipher fails the track', () async { + const wrong = SubtitleCipher( + alg: 'aes-128-cbc', + key: 'AAAAAAAAAAAAAAAA', + iv: 'ReBKWW8cqdjPEnF6', + ); + await expectLater( + wrong.decrypt(ciphered), + throwsA(isA()), + ); + const other = SubtitleCipher(alg: 'aes-256-gcm', key: 'k', iv: 'i'); + expect(other.isSupported, isFalse); + await expectLater( + other.decrypt(ciphered), + throwsA(isA()), + ); + }); + + test('the model reads the cipher and only a complete one', () { + final sub = SubtitleModel.fromJson({ + 'label': 'English', + 'src': 'https://sub.cdnvideo11.shop/a.en.srt.txt1', + 'cipher': { + 'alg': 'aes-128-cbc', + 'key': 'AmSmZVcH93UQUezi', + 'iv': 'ReBKWW8cqdjPEnF6', + 'scope': 'cue-text', + 'encoding': 'base64', + }, + }); + expect(sub.file, endsWith('.txt1')); + expect(sub.cipher!.isSupported, isTrue); + expect(SubtitleModel.fromJson({'file': 'a.srt'}).cipher, isNull); + expect(SubtitleCipher.fromJson({'alg': 'aes-128-cbc', 'key': ''}), isNull); + expect(SubtitleCipher.fromJson('aes'), isNull); + }); + + test('a downloaded ciphered track is kept decrypted', () async { + final root = await Directory.systemTemp.createTemp('sozo_cipher_root_'); + final src = await Directory.systemTemp.createTemp('sozo_cipher_src_'); + addTearDown(() async { + await root.delete(recursive: true); + await src.delete(recursive: true); + }); + final f = File('${src.path}/a.en.srt.txt1')..writeAsStringSync(ciphered); + final sidecar = SubtitleSidecar(root: () async => root); + await sidecar.save('ep', [ + SubtitleEntity(label: 'English', file: f.path, cipher: kisskh), + ]); + final back = await sidecar.load('ep'); + expect(back.single.file, endsWith('.srt')); + expect(back.single.cipher, isNull); + expect(await File(back.single.file).readAsString(), contains('Set off')); + }); +} From 9f8d25cbec7293ea29f2fd22368b620a07f4739e Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:53:01 +0500 Subject: [PATCH 12/21] Read extractor answers as plain JSON on iOS and macOS On Android, callAsyncJavaScript results arrive as JSON text and decode to Map all the way down. On iOS and macOS WebKit's objects cross the platform channel, so every nested object is a Map, and _coerceMap only cast the top level. For KissKH, a client-mode provider, that meant on iPhone: - home and view-all failed with "type '_Map' is not a subtype of type 'Map'"; - a search result opened to the same error on the detail page, and the episode list too; - resolveMedia's whereType> silently dropped every video source and subtitle. The answer is now converted to plain JSON types throughout. The test replays live KissKH v7 payloads through StandardMessageCodec, the same path the iOS channel takes. --- lib/core/js/js_runtime_service.dart | 16 +- lib/core/js/js_value.dart | 33 ++++ test/core/js/js_value_test.dart | 94 ++++++++++ test/fixtures/kisskh_extractor_v7.json | 234 +++++++++++++++++++++++++ 4 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 lib/core/js/js_value.dart create mode 100644 test/core/js/js_value_test.dart create mode 100644 test/fixtures/kisskh_extractor_v7.json diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index e6de5904..37c41fbe 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -11,6 +11,7 @@ import 'dart_fetch.dart'; import 'extractor_cache.dart'; import 'extractor_remote.dart'; import 'js_log.dart'; +import 'js_value.dart'; import 'js_timeouts.dart'; import 'provider_registry.dart'; @@ -264,7 +265,7 @@ class JsRuntimeService { ); throw Exception(blocked ?? error); } - final map = _coerceMap(result.value); + final map = jsObjectFrom(result.value); JsLog.res( tag, fn, @@ -278,19 +279,6 @@ class JsRuntimeService { } } - Map? _coerceMap(dynamic value) { - if (value == null) return null; - if (value is Map) return value.cast(); - if (value is String) { - if (value.isEmpty || value == 'null') return null; - try { - final decoded = jsonDecode(value); - if (decoded is Map) return decoded.cast(); - } catch (_) {} - } - return null; - } - String _summarizeArgs(List args) { if (args.isEmpty) return ''; return args diff --git a/lib/core/js/js_value.dart b/lib/core/js/js_value.dart new file mode 100644 index 00000000..7a16cbd6 --- /dev/null +++ b/lib/core/js/js_value.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +/// An extractor's answer as plain JSON types, whichever platform delivered it. +/// +/// Android hands `callAsyncJavaScript` results over as JSON text, which decodes +/// to `Map` all the way down. iOS and macOS pass WebKit's +/// objects through the platform channel instead, so every nested object arrives +/// as `Map`, and the models' `as Map` casts +/// throw on the first nested card. A shallow `cast` only fixes the top level. +Map? jsObjectFrom(Object? value) { + if (value == null) return null; + if (value is String) { + if (value.isEmpty || value == 'null') return null; + try { + value = jsonDecode(value); + } catch (_) { + return null; + } + } + if (value is! Map) return null; + return _object(value); +} + +Map _object(Map raw) => { + for (final e in raw.entries) + if (e.key != null) '${e.key}': _plain(e.value), +}; + +Object? _plain(Object? value) { + if (value is Map) return _object(value); + if (value is List) return value.map(_plain).toList(); + return value; +} diff --git a/test/core/js/js_value_test.dart b/test/core/js/js_value_test.dart new file mode 100644 index 00000000..d643569f --- /dev/null +++ b/test/core/js/js_value_test.dart @@ -0,0 +1,94 @@ +// KissKH's device extractor answers as they reach the app on iPhone. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/js/js_value.dart'; +import 'package:soplay/features/detail/data/models/detail_model.dart'; +import 'package:soplay/features/detail/data/models/media_resolve_model.dart'; +import 'package:soplay/features/detail/data/models/playback_model.dart'; +import 'package:soplay/features/home/data/models/home_data_model.dart'; +import 'package:soplay/features/home/data/models/view_all_paging_model.dart'; +import 'package:soplay/features/search/data/model/search_model.dart'; + +void main() { + final payloads = + jsonDecode( + File('test/fixtures/kisskh_extractor_v7.json').readAsStringSync(), + ) + as Map; + + // WebKit's result crosses the platform channel through this codec, which + // turns every JS object into a Map. + Object? overChannel(String key) => const StandardMessageCodec().decodeMessage( + const StandardMessageCodec().encodeMessage(payloads[key]), + ); + + test('the channel really does hand over Map', () { + final home = overChannel('home') as Map; + expect(home['sections'], isA()); + expect( + (home['sections'] as List).first, + isNot(isA>()), + ); + // What the app used to do with it: a shallow cast, then the model. + expect( + () => HomeDataModel.fromJson(home.cast()), + throwsA(isA()), + ); + }); + + test('home, view-all and search parse', () { + final home = HomeDataModel.fromJson(jsObjectFrom(overChannel('home'))!); + expect(home.sections.single.label, 'Latest'); + expect(home.sections.single.items.first.title, isNotEmpty); + expect(home.banner, hasLength(1)); + + final page = ViewAllPagingModel.fromJson( + jsObjectFrom(overChannel('category'))!, + ); + expect(page.items, hasLength(2)); + expect(page.totalPages, 1384); + + final search = SearchModel.fromJson(jsObjectFrom(overChannel('search'))!); + expect(search.items.first.title, 'Revenged Love'); + }); + + test('detail and episodes parse', () { + final detail = DetailModel.fromJson(jsObjectFrom(overChannel('detail'))!); + expect(detail.title, 'Revenged Love'); + expect(detail.genres, ['TVSeries', 'Completed', 'China']); + expect(detail.related.single.title, 'The First Frost'); + + final eps = PlaybackModel.fromJson(jsObjectFrom(overChannel('episodes'))!); + expect(eps.episodes.map((e) => e.mediaRef), [ + 'kisskh:10826:185459', + 'kisskh:10826:185460', + ]); + }); + + test('media keeps its sources and its ciphered subtitles', () { + final media = MediaResolveModel.fromJson( + jsObjectFrom(overChannel('media'))!, + ); + expect(media.videoSources.single.type, 'hls'); + expect(media.headers['Referer'], 'https://kisskh.co/'); + expect(media.subtitles.map((s) => s.label), ['English', 'Indonesia']); + final cipher = media.subtitles.first.cipher!; + expect(cipher.key, 'AmSmZVcH93UQUezi'); + expect(cipher.iv, 'ReBKWW8cqdjPEnF6'); + expect(cipher.isSupported, isTrue); + }); + + test('Android JSON text and odd values', () { + expect( + jsObjectFrom(jsonEncode(payloads['detail']))!['title'], + 'Revenged Love', + ); + expect(jsObjectFrom(null), isNull); + expect(jsObjectFrom('null'), isNull); + expect(jsObjectFrom('not json'), isNull); + expect(jsObjectFrom(const [1, 2]), isNull); + }); +} diff --git a/test/fixtures/kisskh_extractor_v7.json b/test/fixtures/kisskh_extractor_v7.json new file mode 100644 index 00000000..ea13f2b6 --- /dev/null +++ b/test/fixtures/kisskh_extractor_v7.json @@ -0,0 +1,234 @@ +{ + "home": { + "provider": "kisskh", + "banner": [ + { + "provider": "kisskh", + "externalId": "9987", + "title": "Ang Mutya ng Section E - The Muse of Section E", + "slug": "9987", + "contentUrl": "https://kisskh.co/Drama/Ang%20Mutya%20ng%20Section%20E%20-%20The%20Muse%20of%20Section%20E?id=9987", + "thumbnail": "https://serveproxy.com/?url=https://fragrancecdn1.site/c0d73a81-d05b-4873-83a7-a838d8aff09a.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + } + ], + "bannerViewAll": { + "type": "category", + "slug": "latest" + }, + "sections": [ + { + "key": "latest", + "label": "Latest", + "viewAll": { + "type": "category", + "slug": "latest" + }, + "items": [ + { + "provider": "kisskh", + "externalId": "14107", + "title": "Sunshine through the Rain (2026)", + "slug": "14107", + "contentUrl": "https://kisskh.co/Drama/Sunshine%20through%20the%20Rain%20(2026)?id=14107", + "thumbnail": "https://media.themoviedb.org/t/p/w1000_and_h563_face/hQwmwOFLg0V8X2oVt7rScalP3rZ.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + }, + { + "provider": "kisskh", + "externalId": "12262", + "title": "When I Meet the Moon", + "slug": "12262", + "contentUrl": "https://kisskh.co/Drama/When%20I%20Meet%20the%20Moon?id=12262", + "thumbnail": "http://wsrv.nl/?url=pic1.iqiyipic.com/image/20261102/4b/6c/a_100671428_m_601_en_m2_720_405.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + } + ] + } + ] + }, + "search": { + "provider": "kisskh", + "items": [ + { + "provider": "kisskh", + "externalId": "10826", + "title": "Revenged Love", + "slug": "10826", + "contentUrl": "https://kisskh.co/Drama/Revenged%20Love?id=10826", + "thumbnail": "https://media.themoviedb.org/t/p/w1000_and_h563_face/ganlWcWXWaJTzR3PSl7Er6yOhxM.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + }, + { + "provider": "kisskh", + "externalId": "10387", + "title": "Game of True Love - Crazy Love Game", + "slug": "10387", + "contentUrl": "https://kisskh.co/Drama/Game%20of%20True%20Love%20-%20Crazy%20Love%20Game?id=10387", + "thumbnail": "https://m.ykimg.com/0535000067DA376E20039613B8385D8E", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + } + ], + "page": 1, + "totalPages": 1, + "query": "revenged love" + }, + "detail": { + "provider": "kisskh", + "contentId": "10826", + "contentUrl": "https://kisskh.co/Drama/Revenged%20Love?id=10826", + "title": "Revenged Love", + "description": "After being dumped for a wealthy man, Wu Suo Wei sets out for revenge—by seducing his ex-girlfriend's new boyfriend, Chi Cheng. But when Chi Cheng unexpectedly falls for him, their game of manipulation turns into something neither of them saw coming: real feelings.\r\n", + "thumbnail": "https://media.themoviedb.org/t/p/w1000_and_h563_face/ganlWcWXWaJTzR3PSl7Er6yOhxM.jpg", + "banner": null, + "year": 2025, + "duration": null, + "country": "China", + "director": null, + "genres": [ + "TVSeries", + "Completed", + "China" + ], + "cast": [], + "likes": 0, + "dislikes": 0, + "isSerial": true, + "screenshots": [], + "related": [ + { + "provider": "kisskh", + "externalId": "8316", + "title": "The First Frost", + "slug": "8316", + "contentUrl": "https://kisskh.co/Drama/The%20First%20Frost?id=8316", + "thumbnail": "https://media.themoviedb.org/t/p/w1000_and_h563_face/aL3J5pi3bbqHPicpELZ0f5r6X34.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + } + ], + "extra": { + "status": "Completed", + "type": "TVSeries", + "episodesCount": 24, + "videoAvailable": true + } + }, + "episodes": { + "provider": "kisskh", + "contentUrl": "https://kisskh.co/Drama/Revenged%20Love?id=10826", + "isSerial": true, + "episodes": [ + { + "episode": 1, + "label": "Episode 1", + "mediaRef": "kisskh:10826:185459" + }, + { + "episode": 2, + "label": "Episode 2", + "mediaRef": "kisskh:10826:185460" + } + ] + }, + "media": { + "videoUrl": "https://hls.cdnvideo11.shop/hls07/10826/Ep1_index.m3u8", + "type": "hls", + "headers": { + "Referer": "https://kisskh.co/", + "Origin": "https://kisskh.co" + }, + "videoSources": [ + { + "quality": "Auto", + "videoUrl": "https://hls.cdnvideo11.shop/hls07/10826/Ep1_index.m3u8", + "isDefault": true, + "accessible": true, + "type": "hls" + } + ], + "subtitles": [ + { + "label": "English", + "src": "https://sub.cdnvideo11.shop/Revenged-Love.2025.Ep1.en.srt.txt1?v=imat3tjzsl5", + "lang": "English", + "cipher": { + "alg": "aes-128-cbc", + "key": "AmSmZVcH93UQUezi", + "iv": "ReBKWW8cqdjPEnF6", + "scope": "cue-text", + "encoding": "base64" + } + }, + { + "label": "Indonesia", + "src": "https://sub.cdnvideo11.shop/Revenged-Love.2025.Ep1.id.srt.txt1?v=wec2f5dyzkt", + "lang": "Indonesia", + "cipher": { + "alg": "aes-128-cbc", + "key": "AmSmZVcH93UQUezi", + "iv": "ReBKWW8cqdjPEnF6", + "scope": "cue-text", + "encoding": "base64" + } + } + ] + }, + "category": { + "provider": "kisskh", + "items": [ + { + "provider": "kisskh", + "externalId": "14107", + "title": "Sunshine through the Rain (2026)", + "slug": "14107", + "contentUrl": "https://kisskh.co/Drama/Sunshine%20through%20the%20Rain%20(2026)?id=14107", + "thumbnail": "https://media.themoviedb.org/t/p/w1000_and_h563_face/hQwmwOFLg0V8X2oVt7rScalP3rZ.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + }, + { + "provider": "kisskh", + "externalId": "12262", + "title": "When I Meet the Moon", + "slug": "12262", + "contentUrl": "https://kisskh.co/Drama/When%20I%20Meet%20the%20Moon?id=12262", + "thumbnail": "http://wsrv.nl/?url=pic1.iqiyipic.com/image/20261102/4b/6c/a_100671428_m_601_en_m2_720_405.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": null + } + ], + "page": 1, + "totalPages": 1384, + "category": "latest" + } +} From 53cd83329667a593038366bb288a0f60b945d88e Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:16:44 +0500 Subject: [PATCH 13/21] Return device extractor answers as JSON text The extractor call now returns JSON.stringify of the provider's answer, so iOS and macOS decode the same JSON text Android already gets. Before this, WebKit's conversion sent every object as a Map and every number as a double. jsObjectFrom converted the maps, but a view-all page still failed on iPhone with "type 'double' is not a subtype of type 'int'" because its page number came back as 1.0. jsObjectFrom still converts channel maps. It now also turns whole doubles back into ints. New helpers jsonMap and jsonMaps do the same deep conversion for maps read from a channel or a Hive box. jsonInt and jsonText read numbers and text that a source sends in either form. ExtractorRunner no longer hard-casts its decoded answer. --- lib/core/extractor/extractor_runner.dart | 4 ++- lib/core/js/js_runtime_service.dart | 7 ++-- lib/core/js/js_value.dart | 41 ++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/core/extractor/extractor_runner.dart b/lib/core/extractor/extractor_runner.dart index 103c1bdd..dfc6921e 100644 --- a/lib/core/extractor/extractor_runner.dart +++ b/lib/core/extractor/extractor_runner.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/core/system/webview_env.dart'; import 'extractor_entity.dart'; @@ -92,7 +93,8 @@ try { throw Exception('Extractor returned null'); } - final decoded = jsonDecode(result) as Map; + final decoded = jsObjectFrom(result); + if (decoded == null) throw Exception('Extractor returned no object'); if (decoded['__error'] == true) { throw Exception(decoded['message'] as String? ?? 'JS execution error'); } diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index 37c41fbe..e6496635 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -185,6 +185,9 @@ class JsRuntimeService { // cross-search legs can be in flight at once and their network waits // overlap instead of queueing — which is what made searching several // sources feel frozen. + // + // The answer comes back as JSON text so iOS reads it as Android does: + // WebKit's own conversion hands over Map and doubles. var result = await _controller! .callAsyncJavaScript( functionBody: r''' @@ -198,7 +201,7 @@ class JsRuntimeService { throw new Error('Provider.' + fnName + ' is not implemented'); } const __r = await __fn.apply(__p, fnArgs); - return __r === undefined ? null : __r; + return __r === undefined ? null : JSON.stringify(__r); ''', arguments: { 'providerName': extractor.name, @@ -236,7 +239,7 @@ class JsRuntimeService { throw new Error('Provider.' + fnName + ' is not implemented'); } const __r = await __fn.apply(__p, fnArgs); - return __r === undefined ? null : __r; + return __r === undefined ? null : JSON.stringify(__r); ''', arguments: { 'providerName': extractor.name, diff --git a/lib/core/js/js_value.dart b/lib/core/js/js_value.dart index 7a16cbd6..f6cb56f1 100644 --- a/lib/core/js/js_value.dart +++ b/lib/core/js/js_value.dart @@ -7,6 +7,10 @@ import 'dart:convert'; /// objects through the platform channel instead, so every nested object arrives /// as `Map`, and the models' `as Map` casts /// throw on the first nested card. A shallow `cast` only fixes the top level. +/// +/// WebKit also hands every JavaScript number over as a double, where JSON text +/// gives `1` as an int. Whole numbers are turned back into ints so a page +/// number reads the same on both platforms. Map? jsObjectFrom(Object? value) { if (value == null) return null; if (value is String) { @@ -17,10 +21,23 @@ Map? jsObjectFrom(Object? value) { return null; } } - if (value is! Map) return null; - return _object(value); + return jsonMap(value); } +/// [value] as a `Map` with plain JSON types all the way down, +/// or null when it is not a map. For maps from a platform channel or a Hive +/// box, which arrive as `Map` or `Map`. +Map? jsonMap(Object? value) => + value is Map ? _object(value) : null; + +/// The maps in [value], each as [jsonMap] gives it. Anything else in the list +/// is skipped, and a value that is not a list gives an empty one. +List> jsonMaps(Object? value) => [ + if (value is List) + for (final e in value) + if (e is Map) _object(e), +]; + Map _object(Map raw) => { for (final e in raw.entries) if (e.key != null) '${e.key}': _plain(e.value), @@ -29,5 +46,25 @@ Map _object(Map raw) => { Object? _plain(Object? value) { if (value is Map) return _object(value); if (value is List) return value.map(_plain).toList(); + if (value is double && + value.isFinite && + value == value.truncateToDouble() && + value.abs() <= 9007199254740991) { + return value.toInt(); + } return value; } + +/// A whole number from a number or from numeric text, such as a count a source +/// sends as `"12"`. +int? jsonInt(Object? value) { + final n = value is String ? num.tryParse(value.trim()) : value; + return n is num && n.isFinite ? n.toInt() : null; +} + +/// Trimmed text from a string or a number, or null when there is none. +String? jsonText(Object? value) { + if (value is! String && value is! num) return null; + final s = '$value'.trim(); + return s.isEmpty ? null : s; +} From 151519db6b053eb12244237b6e2e3873466491e8 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:16:53 +0500 Subject: [PATCH 14/21] Read Zangetsu metadata whatever shape it arrives in The server's Zangetsu wrapper passes a script's cast, screenshots and extra through unchanged. One 18+ source sends its screenshots as bare image URLs. DetailModel cast each entry as Map, so its detail page failed with "type 'String' is not a subtype of type 'Map' in type cast" on iOS and Android. What changed: - A screenshot may now be a URL or a {full, thumb} map. - A cast entry may be a map, with its image under image, avatar or photo, or a bare name. - A genre may be a {name} map. - Year, likes and counts are read from numbers or numeric text. Home, view-all, search, episodes and media now convert every nested map through jsonMaps instead of casting it. A map from a channel or a Hive box no longer throws, and media no longer drops sources that whereType> skipped. ViewAllPagingModel no longer assigns page, totalPages and provider unchecked. HistoryItem reads its numbers as num, so a double from a channel cannot fail the continue-watching row. The fixture holds an 18+ device source's live answers, with titles, images and URLs replaced by placeholders, plus the server passthrough shape. Each payload goes through StandardMessageCodec with doubles, as WebKit delivers it, and also as JSON text. --- .../detail/data/models/cast_model.dart | 15 +- .../detail/data/models/detail_model.dart | 66 +-- .../data/models/media_resolve_model.dart | 17 +- .../detail/data/models/playback_model.dart | 13 +- .../detail/data/models/related_model.dart | 12 +- .../detail/data/models/screenshot_model.dart | 19 +- .../history/domain/entities/history_item.dart | 10 +- .../home/data/models/home_data_model.dart | 21 +- .../home/data/models/home_section_model.dart | 12 +- .../home/data/models/movie_model.dart | 12 +- .../data/models/view_all_paging_model.dart | 11 +- .../search/data/model/search_model.dart | 22 +- test/core/js/js_value_test.dart | 7 +- test/core/js/zangetsu_payloads_test.dart | 194 +++++++++ test/fixtures/zangetsu_payloads.json | 389 ++++++++++++++++++ 15 files changed, 716 insertions(+), 104 deletions(-) create mode 100644 test/core/js/zangetsu_payloads_test.dart create mode 100644 test/fixtures/zangetsu_payloads.json diff --git a/lib/features/detail/data/models/cast_model.dart b/lib/features/detail/data/models/cast_model.dart index 909498b6..4f62b73a 100644 --- a/lib/features/detail/data/models/cast_model.dart +++ b/lib/features/detail/data/models/cast_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/cast_entity.dart'; class CastModel extends CastEntity { @@ -8,8 +9,16 @@ class CastModel extends CastEntity { }); factory CastModel.fromJson(Map json) => CastModel( - id: json['id'] as String? ?? '', - name: json['name'] as String? ?? '', - image: json['image'] as String? ?? '', + id: jsonText(json['id']) ?? '', + name: jsonText(json['name']) ?? '', + image: jsonText(json['image'] ?? json['avatar'] ?? json['photo']) ?? '', ); + + /// A cast entry as a map or as a bare name; null for anything else. + static CastModel? tryParse(Object? raw) { + final map = jsonMap(raw); + if (map != null) return CastModel.fromJson(map); + final name = jsonText(raw); + return name == null ? null : CastModel(id: '', name: name, image: ''); + } } diff --git a/lib/features/detail/data/models/detail_model.dart b/lib/features/detail/data/models/detail_model.dart index dd4b5c09..db777232 100644 --- a/lib/features/detail/data/models/detail_model.dart +++ b/lib/features/detail/data/models/detail_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'cast_model.dart'; import 'related_model.dart'; @@ -27,39 +28,45 @@ class DetailModel extends DetailEntity { }); factory DetailModel.fromJson(Map json) { - final castList = (json['cast'] as List? ?? []) - .map((e) => CastModel.fromJson(e as Map)) - .toList(); + // Zangetsu sources pass cast and screenshots through as they give them: + // a screenshot is often a bare url, and a cast member may be a bare name. + final castList = [ + for (final e in _list(json['cast'])) ?CastModel.tryParse(e), + ]; - final screenshotList = (json['screenshots'] as List? ?? []) - .map((e) => ScreenshotModel.fromJson(e as Map)) - .toList(); + final screenshotList = [ + for (final e in _list(json['screenshots'])) ?ScreenshotModel.tryParse(e), + ]; - final relatedList = (json['related'] as List? ?? []) - .map((e) => RelatedModel.fromJson(e as Map)) - .toList(); + final relatedList = jsonMaps( + json['related'], + ).map(RelatedModel.fromJson).toList(); - final genreList = (json['genres'] as List? ?? []) - .map((e) => e?.toString() ?? '') - .where((g) => g.isNotEmpty) - .toList(); + final genreList = [ + for (final e in _list(json['genres'])) + ?jsonText(e is Map ? e['name'] ?? e['label'] : e), + ]; return DetailModel( - provider: json['provider'] as String? ?? '', - contentId: json['contentId'] as String? ?? '', - contentUrl: json['contentUrl'] as String? ?? '', - title: json['title'] as String? ?? '', - description: json['description'] as String? ?? '', - thumbnail: json['thumbnail'] as String?, - year: (json['year'] as num?)?.toInt(), - duration: _nonEmpty(json['duration'] as String?), - country: _nonEmpty(json['country'] as String?), - director: _nonEmpty(json['director'] as String?), + provider: jsonText(json['provider']) ?? '', + contentId: jsonText(json['contentId']) ?? '', + contentUrl: jsonText(json['contentUrl']) ?? '', + title: jsonText(json['title']) ?? '', + description: json['description'] is String + ? json['description'] as String + : '', + thumbnail: json['thumbnail'] is String + ? json['thumbnail'] as String + : null, + year: jsonInt(json['year']), + duration: _nonEmpty(json['duration']), + country: _nonEmpty(json['country']), + director: _nonEmpty(json['director']), genres: genreList, cast: castList, - likes: (json['likes'] as num?)?.toInt() ?? 0, - dislikes: (json['dislikes'] as num?)?.toInt() ?? 0, - isSerial: json['isSerial'] as bool? ?? false, + likes: jsonInt(json['likes']) ?? 0, + dislikes: jsonInt(json['dislikes']) ?? 0, + isSerial: _nullableBool(json['isSerial']) ?? false, isFavorited: _nullableBool(json['isFavorited']), screenshots: screenshotList, related: relatedList, @@ -94,8 +101,11 @@ class DetailModel extends DetailEntity { return null; } - static String? _nonEmpty(String? value) { - if (value == null || value.trim().isEmpty) return null; + static List _list(Object? value) => value is List ? value : const []; + + /// Text only: a number here would be a unit nobody can name. + static String? _nonEmpty(Object? value) { + if (value is! String || value.trim().isEmpty) return null; return value.trim(); } } diff --git a/lib/features/detail/data/models/media_resolve_model.dart b/lib/features/detail/data/models/media_resolve_model.dart index 2866f1a8..7e6c55c1 100644 --- a/lib/features/detail/data/models/media_resolve_model.dart +++ b/lib/features/detail/data/models/media_resolve_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/data/models/subtitle_model.dart'; import 'package:soplay/features/detail/data/models/thumbnails_model.dart'; import 'package:soplay/features/detail/data/models/video_source_model.dart'; @@ -20,14 +21,14 @@ class MediaResolveModel extends MediaResolveEntity { factory MediaResolveModel.fromJson(Map json) { final typeRaw = json['type'] as String?; - final sources = (json['videoSources'] as List? ?? const []) - .whereType>() - .map(VideoSourceModel.fromJson) - .toList(growable: false); - final subs = (json['subtitles'] as List? ?? const []) - .whereType>() - .map(SubtitleModel.fromJson) - .toList(growable: false); + // Every map, whatever its type arguments: whereType> + // silently dropped each source a platform channel delivered. + final sources = jsonMaps( + json['videoSources'], + ).map(VideoSourceModel.fromJson).toList(growable: false); + final subs = jsonMaps( + json['subtitles'], + ).map(SubtitleModel.fromJson).toList(growable: false); return MediaResolveModel( videoUrl: json['videoUrl'] as String? ?? '', type: typeRaw == null || typeRaw.isEmpty ? null : typeRaw.toLowerCase(), diff --git a/lib/features/detail/data/models/playback_model.dart b/lib/features/detail/data/models/playback_model.dart index 8813b288..79b9da1b 100644 --- a/lib/features/detail/data/models/playback_model.dart +++ b/lib/features/detail/data/models/playback_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; import 'package:soplay/features/detail/domain/entities/playback_entity.dart'; import 'package:soplay/features/detail/domain/entities/source_notice.dart'; @@ -26,13 +27,13 @@ class PlaybackModel extends PlaybackEntity { }); factory PlaybackModel.fromJson(Map json) { - final episodes = (json['episodes'] as List? ?? []) - .map((e) => EpisodeModel.fromJson(e as Map)) - .toList(); + final episodes = jsonMaps( + json['episodes'], + ).map(EpisodeModel.fromJson).toList(); - final videoSources = (json['videoSources'] as List? ?? []) - .map((e) => VideoSourceModel.fromJson(e as Map)) - .toList(); + final videoSources = jsonMaps( + json['videoSources'], + ).map(VideoSourceModel.fromJson).toList(); final src = json['playerSrc'] as String?; final typeRaw = json['type'] as String?; diff --git a/lib/features/detail/data/models/related_model.dart b/lib/features/detail/data/models/related_model.dart index 6b203aa4..6136827d 100644 --- a/lib/features/detail/data/models/related_model.dart +++ b/lib/features/detail/data/models/related_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/related_entity.dart'; class RelatedModel extends RelatedEntity { @@ -23,11 +24,12 @@ class RelatedModel extends RelatedEntity { slug: json['slug'] as String? ?? '', contentUrl: json['contentUrl'] as String? ?? json['url'] as String? ?? '', thumbnail: json['thumbnail'] as String?, - year: (json['year'] as num?)?.toInt(), - rating: (json['rating'] as num?)?.toInt() ?? 0, - qualities: json['qualities'] != null - ? List.from(json['qualities'] as List) - : const [], + year: jsonInt(json['year']), + rating: jsonInt(json['rating']) ?? 0, + qualities: [ + if (json['qualities'] case final List q) + for (final e in q) ?jsonText(e), + ], category: json['category'] as String? ?? '', ); } diff --git a/lib/features/detail/data/models/screenshot_model.dart b/lib/features/detail/data/models/screenshot_model.dart index b69549ae..83b198cd 100644 --- a/lib/features/detail/data/models/screenshot_model.dart +++ b/lib/features/detail/data/models/screenshot_model.dart @@ -1,11 +1,20 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/screenshot_entity.dart'; class ScreenshotModel extends ScreenshotEntity { const ScreenshotModel({required super.full, required super.thumb}); - factory ScreenshotModel.fromJson(Map json) => - ScreenshotModel( - full: json['full'] as String? ?? '', - thumb: json['thumb'] as String? ?? '', - ); + factory ScreenshotModel.fromJson(Map json) { + final full = jsonText(json['full'] ?? json['url']) ?? ''; + return ScreenshotModel(full: full, thumb: jsonText(json['thumb']) ?? full); + } + + /// A screenshot as a `{full, thumb}` map or as a bare image url, which is + /// how Zangetsu sources send them; null for anything else. + static ScreenshotModel? tryParse(Object? raw) { + final map = jsonMap(raw); + if (map != null) return ScreenshotModel.fromJson(map); + final url = raw is String ? raw.trim() : ''; + return url.isEmpty ? null : ScreenshotModel(full: url, thumb: url); + } } diff --git a/lib/features/history/domain/entities/history_item.dart b/lib/features/history/domain/entities/history_item.dart index 70d918f5..6c9a04f9 100644 --- a/lib/features/history/domain/entities/history_item.dart +++ b/lib/features/history/domain/entities/history_item.dart @@ -102,12 +102,12 @@ class HistoryItem { title: json['title'] as String? ?? '', thumbnail: json['thumbnail'] as String?, isSerial: json['isSerial'] as bool? ?? false, - episodeIndex: json['episodeIndex'] as int?, - episodeNumber: json['episodeNumber'] as int?, + episodeIndex: (json['episodeIndex'] as num?)?.toInt(), + episodeNumber: (json['episodeNumber'] as num?)?.toInt(), episodeLabel: json['episodeLabel'] as String?, - positionMs: json['positionMs'] as int? ?? 0, - durationMs: json['durationMs'] as int? ?? 0, - watchedAt: json['watchedAt'] as int? ?? 0, + positionMs: (json['positionMs'] as num?)?.toInt() ?? 0, + durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, + watchedAt: (json['watchedAt'] as num?)?.toInt() ?? 0, mediaType: json['mediaType'] as String?, isFinale: json['finale'] == true, ); diff --git a/lib/features/home/data/models/home_data_model.dart b/lib/features/home/data/models/home_data_model.dart index bc5676e1..23955edf 100644 --- a/lib/features/home/data/models/home_data_model.dart +++ b/lib/features/home/data/models/home_data_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/domain/entities/home_data_entity.dart'; import 'content_filter_model.dart'; @@ -20,18 +21,14 @@ class HomeDataModel extends HomeDataEntity { }) { return HomeDataModel( provider: json['provider'] as String? ?? '', - banner: (json['banner'] as List? ?? []) - .map((e) => MovieModel.fromJson(e as Map)) - .toList(), - sections: (json['sections'] as List? ?? []) - .map((e) => HomeSectionModel.fromJson(e as Map)) - .toList(), - categories: categories - .map((e) => ContentFilterModel.fromJson(e as Map)) - .toList(), - genres: genres - .map((e) => ContentFilterModel.fromJson(e as Map)) - .toList(), + banner: jsonMaps(json['banner']).map(MovieModel.fromJson).toList(), + sections: jsonMaps( + json['sections'], + ).map(HomeSectionModel.fromJson).toList(), + categories: jsonMaps( + categories, + ).map(ContentFilterModel.fromJson).toList(), + genres: jsonMaps(genres).map(ContentFilterModel.fromJson).toList(), ); } } diff --git a/lib/features/home/data/models/home_section_model.dart b/lib/features/home/data/models/home_section_model.dart index 8af51d56..33b9fc3b 100644 --- a/lib/features/home/data/models/home_section_model.dart +++ b/lib/features/home/data/models/home_section_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/view_all_model.dart'; import 'package:soplay/features/home/domain/entities/home_section_entity.dart'; @@ -15,12 +16,11 @@ class HomeSectionModel extends HomeSectionEntity { return HomeSectionModel( key: json['key'] as String? ?? '', label: json['label'] as String? ?? '', - viewAll: json['viewAll'] is Map - ? ViewAllModel.fromJson(json['viewAll'] as Map) - : ViewAllModel(slug: '', type: ''), - items: (json['items'] as List? ?? []) - .map((e) => MovieModel.fromJson(e as Map)) - .toList(), + viewAll: switch (jsonMap(json['viewAll'])) { + final Map v => ViewAllModel.fromJson(v), + null => ViewAllModel(slug: '', type: ''), + }, + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/lib/features/home/data/models/movie_model.dart b/lib/features/home/data/models/movie_model.dart index 10983427..311748f2 100644 --- a/lib/features/home/data/models/movie_model.dart +++ b/lib/features/home/data/models/movie_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; class MovieModel extends MovieEntity { @@ -27,11 +28,12 @@ class MovieModel extends MovieEntity { provider: json['provider'] as String? ?? '', thumbnail: json['thumbnail'] as String?, banner: json['banner'] as String?, - year: (json['year'] as num?)?.toInt(), - rating: (json['rating'] as num?)?.toInt(), - qualities: json['qualities'] != null - ? List.from(json['qualities'] as List) - : null, + year: jsonInt(json['year']), + rating: jsonInt(json['rating']), + qualities: switch (json['qualities']) { + final List q => [for (final e in q) ?jsonText(e)], + _ => null, + }, category: json['category'] as String? ?? '', altTitles: [ for (final n in (json['altTitles'] as List?) ?? const []) diff --git a/lib/features/home/data/models/view_all_paging_model.dart b/lib/features/home/data/models/view_all_paging_model.dart index 9d30f369..4e2bdd40 100644 --- a/lib/features/home/data/models/view_all_paging_model.dart +++ b/lib/features/home/data/models/view_all_paging_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/movie_model.dart'; import '../../domain/entities/view_all_paging_entity.dart'; @@ -12,12 +13,10 @@ class ViewAllPagingModel extends ViewAllPagingEntity { factory ViewAllPagingModel.fromJson(Map json) { return ViewAllPagingModel( - page: json['page'], - totalPages: json['totalPages'], - provider: json['provider'], - items: (json['items'] as List) - .map((e) => MovieModel.fromJson(e)) - .toList(), + page: jsonInt(json['page']) ?? 1, + totalPages: jsonInt(json['totalPages']) ?? 1, + provider: jsonText(json['provider']) ?? '', + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/lib/features/search/data/model/search_model.dart b/lib/features/search/data/model/search_model.dart index c576d62b..fc991319 100644 --- a/lib/features/search/data/model/search_model.dart +++ b/lib/features/search/data/model/search_model.dart @@ -1,24 +1,22 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/movie_model.dart'; import '../../domain/entities/search_entity.dart'; class SearchModel extends SearchEntity { - SearchModel({required super.provider, required super.items, - required super.page, required super.totalPages + SearchModel({ + required super.provider, + required super.items, + required super.page, + required super.totalPages, }); factory SearchModel.fromJson(Map json) { - final rawItems = json['items']; return SearchModel( - page: (json['page'] as num?)?.toInt() ?? 1, - totalPages: (json['totalPages'] as num?)?.toInt() ?? 1, - provider: json['provider'] as String? ?? '', - items: rawItems is List - ? rawItems - .whereType() - .map((e) => MovieModel.fromJson(Map.from(e))) - .toList() - : const [], + page: jsonInt(json['page']) ?? 1, + totalPages: jsonInt(json['totalPages']) ?? 1, + provider: jsonText(json['provider']) ?? '', + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/test/core/js/js_value_test.dart b/test/core/js/js_value_test.dart index d643569f..b8ab287e 100644 --- a/test/core/js/js_value_test.dart +++ b/test/core/js/js_value_test.dart @@ -32,10 +32,11 @@ void main() { (home['sections'] as List).first, isNot(isA>()), ); - // What the app used to do with it: a shallow cast, then the model. + // A shallow cast used to throw on the first card; the model now converts + // nested maps itself. expect( - () => HomeDataModel.fromJson(home.cast()), - throwsA(isA()), + HomeDataModel.fromJson(home.cast()).sections, + hasLength(1), ); }); diff --git a/test/core/js/zangetsu_payloads_test.dart b/test/core/js/zangetsu_payloads_test.dart new file mode 100644 index 00000000..261d70cb --- /dev/null +++ b/test/core/js/zangetsu_payloads_test.dart @@ -0,0 +1,194 @@ +// Zangetsu payloads: an 18+ device source's live answers with the titles +// replaced, and the server wrapper's metadata passthrough. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/js/js_value.dart'; +import 'package:soplay/features/detail/data/models/detail_model.dart'; +import 'package:soplay/features/detail/data/models/media_resolve_model.dart'; +import 'package:soplay/features/detail/data/models/playback_model.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; +import 'package:soplay/features/home/data/models/home_data_model.dart'; +import 'package:soplay/features/home/data/models/view_all_paging_model.dart'; +import 'package:soplay/features/my_list/data/models/favorite_model.dart'; +import 'package:soplay/features/search/data/model/search_model.dart'; + +void main() { + final payloads = + jsonDecode( + File('test/fixtures/zangetsu_payloads.json').readAsStringSync(), + ) + as Map; + + // What WebKit hands the channel: every object a Map and + // every number a double. + Object? doubles(Object? v) => switch (v) { + final int n => n.toDouble(), + final Map m => {for (final e in m.entries) e.key: doubles(e.value)}, + final List l => l.map(doubles).toList(), + _ => v, + }; + Object? overChannel(String key) => const StandardMessageCodec().decodeMessage( + const StandardMessageCodec().encodeMessage(doubles(payloads[key])), + ); + + final arrivals = Function(String)>{ + 'JSON text': (key) => jsObjectFrom(jsonEncode(payloads[key]))!, + 'WebKit objects': (key) => jsObjectFrom(overChannel(key))!, + }; + + for (final MapEntry(key: how, value: read) in arrivals.entries) { + group('device source, $how', () { + test('home, view-all, search and an empty genre page', () { + final home = HomeDataModel.fromJson(read('home')); + expect(home.banner, hasLength(2)); + expect(home.sections.first.viewAll.slug, 'row-0'); + expect(home.sections.first.items.first.title, 'Title 1'); + + final page = ViewAllPagingModel.fromJson(read('category')); + expect(page.page, 1); + expect(page.totalPages, 1); + expect(page.items, hasLength(2)); + + expect(SearchModel.fromJson(read('search')).items, hasLength(2)); + expect(ViewAllPagingModel.fromJson(read('genre')).items, isEmpty); + }); + + test('detail and episodes', () { + final detail = DetailModel.fromJson(read('detail')); + expect(detail.genres, ['Genre A', 'Genre B']); + expect(detail.related, hasLength(2)); + expect(detail.cast, isEmpty); + + final eps = PlaybackModel.fromJson(read('episodes')); + expect(eps.episodes.single.episode, 1); + expect(eps.episodes.single.mediaRef, startsWith('sample:ep:')); + }); + + test('media keeps every source and its headers', () { + final media = MediaResolveModel.fromJson(read('media')); + expect(media.videoSources, hasLength(2)); + expect( + media.videoSources.first.headers['Origin'], + 'https://site.invalid', + ); + expect(media.headers['User-Agent'], 'UA'); + }); + }); + } + + test('WebKit numbers come back as ints', () { + final page = jsObjectFrom(overChannel('category'))!; + expect(page['page'], isA()); + expect(jsObjectFrom({'r': 7.5})!['r'], 7.5); + }); + + group('server wrapper passthrough', () { + Map overDio(String key) => + jsonDecode(jsonEncode(payloads[key])) as Map; + + test('screenshots as bare urls and cast with images', () { + final detail = DetailModel.fromJson(overDio('server_detail')); + expect(detail.screenshots.map((s) => s.full), [ + 'https://img.invalid/9/1.jpg', + 'https://img.invalid/9/2.jpg', + ]); + expect(detail.screenshots.first.thumb, detail.screenshots.first.full); + expect(detail.cast.map((c) => c.name), [ + 'Performer One', + 'Performer Two', + ]); + expect(detail.cast.first.image, 'https://img.invalid/p1.jpg'); + expect(detail.cast.last.image, ''); + expect(detail.duration, '12 min'); + expect(detail.year, 2024); + }); + + test('odd cast, screenshot and count values are tolerated', () { + final detail = DetailModel.fromJson({ + 'cast': [ + 'A Name', + 42, + null, + {'id': 7, 'name': 'B', 'avatar': 'x'}, + ], + 'screenshots': [ + {'full': 'f', 'thumb': 't'}, + '', + 3, + ], + 'genres': [ + {'name': 'G'}, + 'H', + ], + 'duration': 840, + 'likes': '12', + 'year': '2020', + 'isSerial': 1, + }); + expect(detail.cast.map((c) => c.name), ['A Name', '42', 'B']); + expect(detail.cast.last.id, '7'); + expect(detail.cast.last.image, 'x'); + expect(detail.screenshots.single.thumb, 't'); + expect(detail.genres, ['G', 'H']); + expect(detail.duration, isNull); + expect(detail.likes, 12); + expect(detail.year, 2020); + expect(detail.isSerial, isTrue); + }); + + test('actor page', () { + final page = ViewAllPagingModel.fromJson(overDio('server_actor')); + expect(page.totalPages, 2); + expect(page.items.single.provider, 'sample-server'); + }); + }); + + group('stored and read back', () { + test('a Hive map reads as Map', () { + final stored = Map.from( + overChannel('server_detail')! as Map, + ); + final detail = DetailModel.fromJson(jsonMap(stored)!); + expect(detail.screenshots, hasLength(2)); + expect(jsonMap('not a map'), isNull); + expect(jsonMaps([stored, 'x', null]), hasLength(1)); + }); + + test('favourite and history of a device title', () { + final detail = DetailModel.fromJson(jsObjectFrom(overChannel('detail'))!); + final fav = FavoriteModel( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + thumbnail: detail.thumbnail ?? '', + description: detail.description, + ); + final favBack = FavoriteModel.fromJson( + jsonDecode(jsonEncode(fav.toJson())) as Map, + ); + expect(favBack.contentUrl, detail.contentUrl); + + final item = HistoryItem( + contentUrl: detail.contentUrl, + provider: detail.provider, + title: detail.title, + isSerial: detail.isSerial, + episodeIndex: 0, + episodeNumber: 1, + positionMs: 1000, + durationMs: 60000, + watchedAt: 1, + ); + final back = HistoryItem.fromJson(jsonMap(doubles(item.toJson()))!); + expect(back.episodeNumber, 1); + expect(back.positionMs, 1000); + final asDoubles = HistoryItem.fromJson( + (doubles(item.toJson())! as Map).cast(), + ); + expect(asDoubles.durationMs, 60000); + }); + }); +} diff --git a/test/fixtures/zangetsu_payloads.json b/test/fixtures/zangetsu_payloads.json new file mode 100644 index 00000000..97b13544 --- /dev/null +++ b/test/fixtures/zangetsu_payloads.json @@ -0,0 +1,389 @@ +{ + "home": { + "provider": "sample", + "banner": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-1", + "title": "Title 1", + "description": null, + "slug": "title-1", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "thumbnail": "https://img.invalid/1.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-2", + "title": "Title 2", + "description": null, + "slug": "title-2", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-2", + "thumbnail": "https://img.invalid/2.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "sections": [ + { + "key": "row-0", + "label": "Row 1", + "viewAll": { + "type": "category", + "slug": "row-0" + }, + "items": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-1", + "title": "Title 1", + "description": null, + "slug": "title-1", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "thumbnail": "https://img.invalid/1.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-2", + "title": "Title 2", + "description": null, + "slug": "title-2", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-2", + "thumbnail": "https://img.invalid/2.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ] + }, + { + "key": "row-1", + "label": "Row 2", + "viewAll": { + "type": "category", + "slug": "row-1" + }, + "items": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-1", + "title": "Title 1", + "description": null, + "slug": "title-1", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "thumbnail": "https://img.invalid/1.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-2", + "title": "Title 2", + "description": null, + "slug": "title-2", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-2", + "thumbnail": "https://img.invalid/2.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ] + } + ] + }, + "category": { + "provider": "sample", + "items": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-1", + "title": "Title 1", + "description": null, + "slug": "title-1", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "thumbnail": "https://img.invalid/1.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-2", + "title": "Title 2", + "description": null, + "slug": "title-2", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-2", + "thumbnail": "https://img.invalid/2.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "page": 1, + "totalPages": 1, + "category": "row-0" + }, + "search": { + "provider": "sample", + "items": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-3", + "title": "Title 3", + "description": null, + "slug": "title-3", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-3", + "thumbnail": "https://img.invalid/3.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-4", + "title": "Title 4", + "description": null, + "slug": "title-4", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-4", + "thumbnail": "https://img.invalid/4.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "page": 1, + "totalPages": 1, + "query": "title" + }, + "detail": { + "provider": "sample", + "contentId": "https://site.invalid/v/title-1", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "title": "Title 1", + "description": "A placeholder description.", + "thumbnail": "https://img.invalid/1.jpg", + "banner": "https://img.invalid/1.jpg", + "year": null, + "duration": null, + "country": null, + "director": null, + "genres": [ + "Genre A", + "Genre B" + ], + "cast": [], + "likes": 0, + "dislikes": 0, + "isSerial": true, + "screenshots": [], + "related": [ + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-5", + "title": "Title 5", + "description": null, + "slug": "title-5", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-5", + "thumbnail": "https://img.invalid/5.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + }, + { + "provider": "sample", + "externalId": "https://site.invalid/v/title-6", + "title": "Title 6", + "description": null, + "slug": "title-6", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-6", + "thumbnail": "https://img.invalid/6.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "extra": { + "status": null + } + }, + "episodes": { + "provider": "sample", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1", + "isSerial": true, + "episodes": [ + { + "episode": 1, + "label": "Title 1", + "mediaRef": "sample:ep:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-1" + } + ] + }, + "media": { + "provider": "sample", + "videoUrl": "https://cdn.example.invalid/a/1080.m3u8", + "type": "hls", + "headers": { + "User-Agent": "UA", + "Referer": "https://site.invalid/v/title-1", + "Origin": "https://site.invalid" + }, + "videoSources": [ + { + "quality": "1080p", + "videoUrl": "https://cdn.example.invalid/a/1080.m3u8", + "isDefault": true, + "accessible": true, + "type": "hls", + "headers": { + "User-Agent": "UA", + "Referer": "https://site.invalid/v/title-1", + "Origin": "https://site.invalid" + } + }, + { + "quality": "720p", + "videoUrl": "https://cdn.example.invalid/a/720.m3u8", + "isDefault": false, + "accessible": true, + "type": "hls", + "headers": { + "User-Agent": "UA", + "Referer": "https://site.invalid/v/title-1", + "Origin": "https://site.invalid" + } + } + ], + "subtitles": [] + }, + "genre": { + "provider": "sample", + "items": [], + "page": 1, + "totalPages": 1 + }, + "server_detail": { + "provider": "sample-server", + "contentId": "sample-server:k1", + "contentUrl": "sample-server:https%3A%2F%2Fsite.invalid%2Fwatch%3Fk%3Dk1", + "title": "Title 9", + "description": "A placeholder description.", + "thumbnail": "https://img.invalid/9.jpg", + "banner": "https://img.invalid/9.jpg", + "year": 2024, + "duration": "12 min", + "country": null, + "director": null, + "genres": [ + "Genre A", + "Genre B" + ], + "cast": [ + { + "id": "performer-1", + "name": "Performer One", + "image": "https://img.invalid/p1.jpg", + "url": "https://site.invalid/model/performer-1" + }, + { + "id": "performer-2", + "name": "Performer Two", + "image": null, + "url": "https://site.invalid/model/performer-2" + } + ], + "likes": 0, + "dislikes": 0, + "isSerial": false, + "screenshots": [ + "https://img.invalid/9/1.jpg", + "https://img.invalid/9/2.jpg" + ], + "related": [ + { + "provider": "sample-server", + "externalId": "https://site.invalid/v/title-3", + "title": "Title 3", + "description": null, + "slug": "title-3", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-3", + "thumbnail": "https://img.invalid/3.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "extra": { + "noTmdb": true, + "views": 1200, + "likeCount": 34, + "uploader": { + "name": "Uploader", + "url": "https://site.invalid/u/1", + "avatar": "https://img.invalid/u1.jpg" + }, + "uploadDate": "2024-05-01", + "tags": [ + { + "name": "Tag A", + "url": "https://site.invalid/t/a" + } + ], + "status": null + } + }, + "server_actor": { + "provider": "sample-server", + "items": [ + { + "provider": "sample-server", + "externalId": "https://site.invalid/v/title-3", + "title": "Title 3", + "description": null, + "slug": "title-3", + "contentUrl": "sample:https%3A%2F%2Fsite.invalid%2Fv%2Ftitle-3", + "thumbnail": "https://img.invalid/3.jpg", + "year": null, + "rating": 0, + "qualities": [], + "genres": [], + "category": "anime" + } + ], + "page": 1, + "totalPages": 2, + "actor": "Performer One" + } +} From 5c5cca01f975f42a0837251a1dcf70167aed2f82 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:28:19 +0500 Subject: [PATCH 15/21] Player: Android PiP, audio focus, media session and brightness fixes - Closing the PiP window pauses and clears PiP state (onPictureInPictureModeChanged forwarded over soplay/pip); PiP is marked before entry and rolled back on failure. - Pause only on paused/hidden on mobile, not inactive. - Audio focus and becoming-noisy for libmpv via AudioFocusHelper; the DRM ExoPlayer handles focus and noisy itself. - Brightness handed back on exit and background, re-applied on resume; iOS saves and restores the system brightness. - Swipes start from freshly read levels; window brightness no longer guesses from SCREEN_BRIGHTNESS/255. - Auto-enter PiP on Home while playing (setting, default on). - PiP actions: localized labels, the real seek step, vector icons. - MediaSessionCompat while the player is open; Space and arrows on Android with a hardware keyboard. --- android/app/build.gradle.kts | 2 + .../com/soplay/sozo/AudioFocusHelper.kt | 96 ++++++ .../kotlin/com/soplay/sozo/MainActivity.kt | 256 ++++++++++++---- .../com/soplay/sozo/PlayerMediaSession.kt | 140 +++++++++ .../com/soplay/sozo/drm/DrmPlayerHost.kt | 9 + .../src/main/res/drawable/ic_pip_forward.xml | 21 ++ .../main/res/drawable/ic_pip_forward_10.xml | 33 +++ .../main/res/drawable/ic_pip_forward_30.xml | 35 +++ .../main/res/drawable/ic_pip_forward_5.xml | 27 ++ .../app/src/main/res/drawable/ic_pip_next.xml | 21 ++ .../src/main/res/drawable/ic_pip_pause.xml | 18 ++ .../app/src/main/res/drawable/ic_pip_play.xml | 18 ++ .../app/src/main/res/drawable/ic_pip_prev.xml | 21 ++ .../src/main/res/drawable/ic_pip_replay.xml | 21 ++ .../main/res/drawable/ic_pip_replay_10.xml | 33 +++ .../main/res/drawable/ic_pip_replay_30.xml | 35 +++ .../src/main/res/drawable/ic_pip_replay_5.xml | 27 ++ assets/translations/ar.json | 2 + assets/translations/ckb.json | 2 + assets/translations/de.json | 2 + assets/translations/en.json | 2 + assets/translations/es.json | 2 + assets/translations/fr.json | 2 + assets/translations/hi.json | 2 + assets/translations/id.json | 2 + assets/translations/ku.json | 2 + assets/translations/nl.json | 2 + assets/translations/pt.json | 2 + assets/translations/ru.json | 2 + assets/translations/tr.json | 2 + assets/translations/uz.json | 2 + assets/translations/vi.json | 2 + assets/translations/yue.json | 2 + ios/Runner/SystemControls.swift | 24 +- lib/core/constants/app_constants.dart | 1 + lib/core/player/drm_controller.dart | 4 + lib/core/player/media_controller.dart | 11 + lib/core/storage/hive_service.dart | 7 + .../domain/playback/player_system_policy.dart | 210 ++++++++++++++ .../presentation/pages/player_page.dart | 58 +++- .../pages/player_page.gestures.dart | 38 ++- .../presentation/pages/player_page.media.dart | 1 + .../presentation/pages/player_page.pip.dart | 81 +++--- .../pages/player_page.system.dart | 233 +++++++++++++++ .../pages/player_settings_page.dart | 16 + .../detail/player_system_policy_test.dart | 273 ++++++++++++++++++ 46 files changed, 1705 insertions(+), 97 deletions(-) create mode 100644 android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt create mode 100644 android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt create mode 100644 android/app/src/main/res/drawable/ic_pip_forward.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_forward_10.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_forward_30.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_forward_5.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_next.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_pause.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_play.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_prev.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_replay.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_replay_10.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_replay_30.xml create mode 100644 android/app/src/main/res/drawable/ic_pip_replay_5.xml create mode 100644 lib/features/detail/domain/playback/player_system_policy.dart create mode 100644 lib/features/detail/presentation/pages/player_page.system.dart create mode 100644 test/features/detail/player_system_policy_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 64283fe6..ae3e3fd8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -194,6 +194,8 @@ dependencies { // FrameExtractor: seek-bar frames for HLS (preview/HlsFramePreview.kt). implementation("androidx.media3:media3-inspector:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3") + // MediaSessionCompat for the player: headset and Bluetooth buttons. + implementation("androidx.media:media:1.7.0") // CloudStream provider runtime (Android-only feature). The `library` module // carries MainAPI/APIHolder/`app` HTTP client/extractors/BasePlugin so .cs3 diff --git a/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt new file mode 100644 index 00000000..46fba4cc --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt @@ -0,0 +1,96 @@ +package com.soplay.sozo + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build + +/** + * Audio focus and ACTION_AUDIO_BECOMING_NOISY for the players that do not + * handle them themselves (libmpv). Events go to [onEvent] as the wire names + * the Dart AudioFocusPolicy reads: gain, loss, loss_transient, duck, noisy. + */ +class AudioFocusHelper( + private val context: Context, + private val onEvent: (String) -> Unit, +) { + private val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var hasFocus = false + private var request: AudioFocusRequest? = null + private var noisyReceiver: BroadcastReceiver? = null + + private val listener = AudioManager.OnAudioFocusChangeListener { change -> + when (change) { + AudioManager.AUDIOFOCUS_GAIN -> { + hasFocus = true + onEvent("gain") + } + AudioManager.AUDIOFOCUS_LOSS -> { + hasFocus = false + onEvent("loss") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> onEvent("loss_transient") + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> onEvent("duck") + } + } + + fun request(): Boolean { + if (hasFocus) return true + val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val req = request ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + ) + .setOnAudioFocusChangeListener(listener) + .build() + .also { request = it } + audio.requestAudioFocus(req) + } else { + @Suppress("DEPRECATION") + audio.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + } + hasFocus = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasFocus + } + + fun abandon() { + if (!hasFocus) return + hasFocus = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + request?.let { audio.abandonAudioFocusRequest(it) } + } else { + @Suppress("DEPRECATION") + audio.abandonAudioFocus(listener) + } + } + + fun watchNoisy() { + if (noisyReceiver != null) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) onEvent("noisy") + } + } + val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + context.registerReceiver(receiver, filter) + } + noisyReceiver = receiver + } + + fun release() { + abandon() + noisyReceiver?.let { runCatching { context.unregisterReceiver(it) } } + noisyReceiver = null + } +} diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt index a3161af5..e65b338f 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt @@ -11,12 +11,15 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Rect import android.net.Uri import android.media.AudioManager import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.provider.Settings +import android.util.Rational +import androidx.lifecycle.Lifecycle import androidx.annotation.RequiresApi import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -66,6 +69,20 @@ class MainActivity : FlutterFragmentActivity() { private var glassPower: GlassPower? = null private var deeplinkSettingsChannel: MethodChannel? = null private var pipReceiver: BroadcastReceiver? = null + + private val playerSystemChannelName = "soplay/player_system" + private var playerSystemChannel: MethodChannel? = null + private var audioFocus: AudioFocusHelper? = null + private var mediaSession: PlayerMediaSession? = null + + // Auto-enter PiP on Home while playing. On 12+ the system does it from + // the params; before that, onUserLeaveHint does. + private var autoPip = false + private var pipAspect = Rational(16, 9) + private var pipSourceRect: Rect? = null + private var pipActions: List = emptyList() + private var pipLabels: Map = emptyMap() + private var pipExitPending = false private var notificationPermissionResult: MethodChannel.Result? = null // CloudStream plugin host (Android-only feature). Lazy so the runtime only @@ -224,11 +241,24 @@ class MainActivity : FlutterFragmentActivity() { val isPlaying = call.argument("isPlaying") ?: true val hasPrev = call.argument("hasPrev") ?: false val hasNext = call.argument("hasNext") ?: false + val seekSeconds = call.argument("seekSeconds")?.toInt() ?: 10 + call.argument>("labels")?.let { pipLabels = it } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - applyPipActions(isPlaying, hasPrev, hasNext) + applyPipActions(isPlaying, hasPrev, hasNext, seekSeconds) } result.success(true) } + "setAutoPip" -> { + autoPip = call.argument("enabled") ?: false + val num = call.argument("numerator")?.toInt() ?: 16 + val den = call.argument("denominator")?.toInt() ?: 9 + if (num > 0 && den > 0) pipAspect = Rational(num, den) + pipSourceRect = call.argument>("sourceRect") + ?.takeIf { it.size == 4 } + ?.let { Rect(it[0].toInt(), it[1].toInt(), it[2].toInt(), it[3].toInt()) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applyPipParams() + result.success(true) + } else -> result.notImplemented() } } @@ -456,6 +486,62 @@ class MainActivity : FlutterFragmentActivity() { } } + playerSystemChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + playerSystemChannelName + ) + playerSystemChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "attach" -> { + val focus = audioFocus ?: AudioFocusHelper(applicationContext) { event -> + runOnUiThread { playerSystemChannel?.invokeMethod("audioFocus", event) } + }.also { audioFocus = it } + focus.watchNoisy() + val session = mediaSession ?: PlayerMediaSession(this) { action, pos -> + runOnUiThread { + playerSystemChannel?.invokeMethod( + "mediaCommand", + mapOf("action" to action, "positionMs" to pos) + ) + } + }.also { mediaSession = it } + session.start() + result.success(true) + } + "detach" -> { + audioFocus?.release() + mediaSession?.stop() + result.success(true) + } + "requestAudioFocus" -> result.success(audioFocus?.request() ?: true) + "abandonAudioFocus" -> { + audioFocus?.abandon() + result.success(true) + } + "setMetadata" -> { + mediaSession?.setMetadata( + call.argument("title") ?: "", + call.argument("subtitle"), + call.argument("durationMs")?.toLong() ?: 0L, + call.argument("artworkUrl"), + ) + result.success(true) + } + "setPlaybackState" -> { + mediaSession?.setState( + call.argument("playing") ?: false, + call.argument("positionMs")?.toLong() ?: 0L, + call.argument("speed")?.toFloat() ?: 1f, + call.argument("hasPrev") ?: false, + call.argument("hasNext") ?: false, + call.argument("seekable") ?: true, + ) + result.success(true) + } + else -> result.notImplemented() + } + } + deeplinkSettingsChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, deeplinkSettingsChannelName @@ -1226,14 +1312,19 @@ class MainActivity : FlutterFragmentActivity() { audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0) } + // The window override is the truth once the player has set one. Before + // that, the system level is only a guess: its range is 0..255 on most + // phones but not all, and under auto-brightness it is stale. private fun getWindowBrightness(): Double { val windowValue = window.attributes.screenBrightness if (windowValue >= 0f) return windowValue.toDouble().coerceIn(0.0, 1.0) return try { - Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS) - .toDouble() - .div(255.0) - .coerceIn(0.0, 1.0) + val auto = Settings.System.getInt( + contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE + ) == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC + val raw = Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS) + if (auto || raw !in 0..255) 0.5 else (raw / 255.0).coerceIn(0.05, 1.0) } catch (_: Exception) { 0.5 } @@ -1348,74 +1439,133 @@ class MainActivity : FlutterFragmentActivity() { private fun applyPipActions( isPlaying: Boolean, hasPrev: Boolean, - hasNext: Boolean + hasNext: Boolean, + seekSeconds: Int ) { + fun label(key: String, fallback: String) = pipLabels[key]?.takeIf { it.isNotBlank() } ?: fallback val actions = mutableListOf() - - if (hasPrev) { - actions.add( - makeAction( - android.R.drawable.ic_media_previous, - "Previous", - "Previous episode", - ACTION_PREV, - REQ_PREV - ) - ) + // Most windows take three; the episode buttons only when there is room. + val room = maxNumPictureInPictureActions + val showPrev = hasPrev && room >= 5 + val showNext = hasNext && room >= 4 + if (showPrev) { + val t = label("prev", "Previous") + actions.add(makeAction(R.drawable.ic_pip_prev, t, t, ACTION_PREV, REQ_PREV)) } + val rewind = label("rewind", "Rewind ${seekSeconds}s") actions.add( - makeAction( - android.R.drawable.ic_media_rew, - "Rewind 10", - "Rewind 10 seconds", - ACTION_REWIND, - REQ_REWIND - ) + makeAction(replayIcon(seekSeconds), rewind, rewind, ACTION_REWIND, REQ_REWIND) ) + val playPause = if (isPlaying) label("pause", "Pause") else label("play", "Play") actions.add( makeAction( - if (isPlaying) - android.R.drawable.ic_media_pause - else - android.R.drawable.ic_media_play, - if (isPlaying) "Pause" else "Play", - if (isPlaying) "Pause" else "Play", + if (isPlaying) R.drawable.ic_pip_pause else R.drawable.ic_pip_play, + playPause, + playPause, ACTION_PLAY_PAUSE, REQ_PLAY_PAUSE ) ) + val forward = label("forward", "Forward ${seekSeconds}s") actions.add( - makeAction( - android.R.drawable.ic_media_ff, - "Forward 10", - "Forward 10 seconds", - ACTION_FORWARD, - REQ_FORWARD - ) + makeAction(forwardIcon(seekSeconds), forward, forward, ACTION_FORWARD, REQ_FORWARD) ) - if (hasNext) { - actions.add( - makeAction( - android.R.drawable.ic_media_next, - "Next", - "Next episode", - ACTION_NEXT, - REQ_NEXT - ) - ) + if (showNext) { + val t = label("next", "Next") + actions.add(makeAction(R.drawable.ic_pip_next, t, t, ACTION_NEXT, REQ_NEXT)) } + pipActions = actions + applyPipParams() + } + + private fun replayIcon(seconds: Int) = when (seconds) { + 5 -> R.drawable.ic_pip_replay_5 + 10 -> R.drawable.ic_pip_replay_10 + 30 -> R.drawable.ic_pip_replay_30 + else -> R.drawable.ic_pip_replay + } - val params = PictureInPictureParams.Builder() - .setActions(actions) - .build() + private fun forwardIcon(seconds: Int) = when (seconds) { + 5 -> R.drawable.ic_pip_forward_5 + 10 -> R.drawable.ic_pip_forward_10 + 30 -> R.drawable.ic_pip_forward_30 + else -> R.drawable.ic_pip_forward + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildPipParams(): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + .setActions(pipActions) + .setAspectRatio(pipAspect) + pipSourceRect?.let { builder.setSourceRectHint(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoPip) + builder.setSeamlessResizeEnabled(true) + } + return builder.build() + } + @RequiresApi(Build.VERSION_CODES.O) + private fun applyPipParams() { try { - setPictureInPictureParams(params) + setPictureInPictureParams(buildPipParams()) } catch (_: Exception) { // Activity may not be in a state to receive PiP params yet } } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (!autoPip || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return + if (isInPictureInPictureMode) return + try { + enterPictureInPictureMode(buildPipParams()) + } catch (_: Exception) { + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + pipExitPending = false + sendPipMode(inPip = true, dismissed = false) + return + } + // Closed with its X, the activity is already stopped (12+) or about + // to be (older: onStop follows). Expanded, it resumes instead. + val stopped = !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (stopped || isFinishing) { + sendPipMode(inPip = false, dismissed = true) + } else { + pipExitPending = true + sendPipMode(inPip = false, dismissed = false) + } + } + + override fun onResume() { + super.onResume() + pipExitPending = false + } + + override fun onStop() { + super.onStop() + if (pipExitPending) { + pipExitPending = false + sendPipMode(inPip = false, dismissed = true) + } + } + + private fun sendPipMode(inPip: Boolean, dismissed: Boolean) { + methodChannel?.invokeMethod( + "onPipModeChanged", + mapOf("inPip" to inPip, "dismissed" to dismissed) + ) + } + @RequiresApi(Build.VERSION_CODES.O) private fun makeAction( iconRes: Int, @@ -1494,6 +1644,10 @@ class MainActivity : FlutterFragmentActivity() { // with one secure decoder that is the next playback failing to start. drmPlayerHost?.dispose() drmPlayerHost = null + audioFocus?.release() + audioFocus = null + mediaSession?.dispose() + mediaSession = null try { bridgeServer?.stop() } catch (_: Throwable) {} bridgeServer = null pipReceiver?.let { diff --git a/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt b/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt new file mode 100644 index 00000000..7cd91de2 --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt @@ -0,0 +1,140 @@ +package com.soplay.sozo + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.SystemClock +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors + +/** + * The player's media session, live while the player page is open: headset + * and Bluetooth buttons reach the player, and the system knows what is + * playing. Commands go to [onCommand] as (action, positionMs). + */ +class PlayerMediaSession( + private val context: Context, + private val onCommand: (String, Long?) -> Unit, +) { + private var session: MediaSessionCompat? = null + private val artLoader = Executors.newSingleThreadExecutor() + private var artUrl: String? = null + private var art: Bitmap? = null + private var metadata = MediaMetadataCompat.Builder() + + private val callback = object : MediaSessionCompat.Callback() { + override fun onPlay() = onCommand("play", null) + override fun onPause() = onCommand("pause", null) + override fun onStop() = onCommand("pause", null) + override fun onSeekTo(pos: Long) = onCommand("seek", pos) + override fun onFastForward() = onCommand("forward", null) + override fun onRewind() = onCommand("rewind", null) + override fun onSkipToNext() = onCommand("next", null) + override fun onSkipToPrevious() = onCommand("prev", null) + } + + fun start() { + if (session != null) return + session = MediaSessionCompat(context, "SozoPlayer").apply { + setCallback(callback) + isActive = true + } + } + + fun setMetadata(title: String, subtitle: String?, durationMs: Long, artworkUrl: String?) { + val s = session ?: return + metadata = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .apply { + if (!subtitle.isNullOrBlank()) { + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + } + if (durationMs > 0) putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) + } + if (artworkUrl != artUrl) { + artUrl = artworkUrl + art = null + if (!artworkUrl.isNullOrBlank()) loadArt(artworkUrl) + } + art?.let { metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it) } + s.setMetadata(metadata.build()) + } + + fun setState(playing: Boolean, positionMs: Long, speed: Float, hasPrev: Boolean, hasNext: Boolean, seekable: Boolean) { + val s = session ?: return + var actions = PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_STOP + if (seekable) { + actions = actions or PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND + } + if (hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + if (hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + s.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(actions) + .setState( + if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, + positionMs, + if (playing) speed else 0f, + SystemClock.elapsedRealtime(), + ) + .build() + ) + } + + fun stop() { + artUrl = null + art = null + session?.let { + it.isActive = false + it.setCallback(null) + it.release() + } + session = null + } + + fun dispose() { + stop() + artLoader.shutdownNow() + } + + private fun loadArt(url: String) { + artLoader.execute { + val bitmap = runCatching { fetch(url) }.getOrNull() ?: return@execute + android.os.Handler(android.os.Looper.getMainLooper()).post { + if (url != artUrl) return@post + art = bitmap + session?.setMetadata( + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build() + ) + } + } + } + + private fun fetch(url: String): Bitmap? { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + return try { + if (conn.responseCode !in 200..299) return null + val bytes = conn.inputStream.use { it.readBytes() } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + var sample = 1 + while (bounds.outWidth / (sample * 2) >= 512 && bounds.outHeight / (sample * 2) >= 512) sample *= 2 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } finally { + conn.disconnect() + } + } +} diff --git a/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt b/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt index f6cfcf10..a2f4f1c5 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface +import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -221,6 +222,14 @@ class DrmPlayerHost( .setDrmSessionManagerProvider { drmManager }, ) .build() + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + /* handleAudioFocus = */ true, + ) + player.setHandleAudioBecomingNoisy(true) val entry = textures.createSurfaceTexture() val surface = Surface(entry.surfaceTexture()) diff --git a/android/app/src/main/res/drawable/ic_pip_forward.xml b/android/app/src/main/res/drawable/ic_pip_forward.xml new file mode 100644 index 00000000..66eea3b3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_10.xml b/android/app/src/main/res/drawable/ic_pip_forward_10.xml new file mode 100644 index 00000000..099e79df --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_10.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_30.xml b/android/app/src/main/res/drawable/ic_pip_forward_30.xml new file mode 100644 index 00000000..942ab9f4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_30.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_5.xml b/android/app/src/main/res/drawable/ic_pip_forward_5.xml new file mode 100644 index 00000000..f7ca4113 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_5.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_next.xml b/android/app/src/main/res/drawable/ic_pip_next.xml new file mode 100644 index 00000000..939365a1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_next.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_pause.xml b/android/app/src/main/res/drawable/ic_pip_pause.xml new file mode 100644 index 00000000..c984ad96 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_pause.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_play.xml b/android/app/src/main/res/drawable/ic_pip_play.xml new file mode 100644 index 00000000..d8efce31 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_play.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_prev.xml b/android/app/src/main/res/drawable/ic_pip_prev.xml new file mode 100644 index 00000000..1087fb3d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_prev.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay.xml b/android/app/src/main/res/drawable/ic_pip_replay.xml new file mode 100644 index 00000000..43dce0c0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_10.xml b/android/app/src/main/res/drawable/ic_pip_replay_10.xml new file mode 100644 index 00000000..d642263c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_10.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_30.xml b/android/app/src/main/res/drawable/ic_pip_replay_30.xml new file mode 100644 index 00000000..09d66c5f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_30.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_5.xml b/android/app/src/main/res/drawable/ic_pip_replay_5.xml new file mode 100644 index 00000000..ea524253 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_5.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e149a6d1..1075baf1 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "اسحب لأعلى وأسفل على الجانب الأيسر", "volume_gesture": "سحب الصوت", "volume_gesture_desc": "اسحب لأعلى وأسفل على الجانب الأيمن", + "auto_pip": "صورة داخل صورة تلقائيًا", + "auto_pip_desc": "عند الضغط على زر الشاشة الرئيسية أثناء تشغيل فيديو، يستمر في نافذة صغيرة", "seconds_short": "{}ث", "subtitle_style_note": "تنطبق هذه على كل فيديو. أمّا توقيت الترجمة فيبقى لكل حلقة ويُضبط داخل المشغّل.", "searching_all_sources": "البحث في كل المصادر", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 9f984bfb..4d51ccfd 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "لە لای چەپ بۆ سەرەوە و خوارەوە ڕایبکێشە", "volume_gesture": "ڕاکێشانی دەنگ", "volume_gesture_desc": "لە لای ڕاست بۆ سەرەوە و خوارەوە ڕایبکێشە", + "auto_pip": "وێنە لەناو وێنەی خۆکار", + "auto_pip_desc": "ئەگەر لە کاتی لێدانی ڤیدیۆ دوگمەی سەرەکی دابگریت، لە پەنجەرەیەکی بچووکدا بەردەوام دەبێت", "seconds_short": "{} چرکە", "subtitle_style_note": "ئەمانە بۆ هەموو ڤیدیۆیەک جێبەجێ دەبن. کاتبەندیی ژێرنووس بۆ هەر ئەڵقەیەک جیایە و لەناو لێدەر دادەنرێت.", "searching_all_sources": "گەڕان لە هەموو سەرچاوەکان", diff --git a/assets/translations/de.json b/assets/translations/de.json index 24ea0d3f..3052e5a2 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Auf der linken Seite hoch und runter wischen", "volume_gesture": "Lautstärke wischen", "volume_gesture_desc": "Auf der rechten Seite hoch und runter wischen", + "auto_pip": "Automatisches Bild-im-Bild", + "auto_pip_desc": "Wenn du während eines Videos zum Startbildschirm wechselst, läuft es in einem kleinen Fenster weiter", "seconds_short": "{} s", "subtitle_style_note": "Das gilt für jedes Video. Das Untertitel-Timing bleibt pro Folge und wird im Player eingestellt.", "searching_all_sources": "alle Quellen werden durchsucht", diff --git a/assets/translations/en.json b/assets/translations/en.json index de087504..b39bcff5 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "Swipe up and down on the left side", "volume_gesture": "Volume swipe", "volume_gesture_desc": "Swipe up and down on the right side", + "auto_pip": "Auto picture-in-picture", + "auto_pip_desc": "Pressing Home while a video plays keeps it playing in a small window", "seconds_short": "{}s", "subtitle_style_note": "These apply to every video. Subtitle timing stays per episode and is set inside the player.", "searching_all_sources": "searching all sources", diff --git a/assets/translations/es.json b/assets/translations/es.json index 339931f5..e87f6906 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Desliza arriba y abajo en el lado izquierdo", "volume_gesture": "Deslizar para el volumen", "volume_gesture_desc": "Desliza arriba y abajo en el lado derecho", + "auto_pip": "Imagen en imagen automática", + "auto_pip_desc": "Al pulsar Inicio mientras se reproduce un vídeo, sigue en una ventana pequeña", "seconds_short": "{} s", "subtitle_style_note": "Esto vale para todos los vídeos. La sincronía de subtítulos se guarda por episodio y se ajusta dentro del reproductor.", "searching_all_sources": "buscando en todas las fuentes", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 20dd2805..ede075c1 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Glisse de haut en bas sur le côté gauche", "volume_gesture": "Glisser pour le volume", "volume_gesture_desc": "Glisse de haut en bas sur le côté droit", + "auto_pip": "Image dans l’image automatique", + "auto_pip_desc": "Appuyer sur Accueil pendant une vidéo la poursuit dans une petite fenêtre", "seconds_short": "{} s", "subtitle_style_note": "Cela vaut pour toutes les vidéos. Le calage des sous-titres reste propre à chaque épisode et se règle dans le lecteur.", "searching_all_sources": "recherche dans toutes les sources", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 4c402bff..118595bf 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "बाईं तरफ़ ऊपर-नीचे स्वाइप करें", "volume_gesture": "वॉल्यूम स्वाइप", "volume_gesture_desc": "दाईं तरफ़ ऊपर-नीचे स्वाइप करें", + "auto_pip": "स्वचालित पिक्चर-इन-पिक्चर", + "auto_pip_desc": "वीडियो चलते समय होम दबाने पर वह एक छोटी विंडो में चलता रहता है", "seconds_short": "{} से", "subtitle_style_note": "ये हर वीडियो पर लागू होते हैं। सबटाइटल की टाइमिंग हर एपिसोड के लिए अलग रहती है और प्लेयर के अंदर सेट होती है।", "searching_all_sources": "सभी सोर्स में खोज रहे हैं", diff --git a/assets/translations/id.json b/assets/translations/id.json index 73960bf6..1635daeb 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Geser naik-turun di sisi kiri", "volume_gesture": "Geser untuk volume", "volume_gesture_desc": "Geser naik-turun di sisi kanan", + "auto_pip": "Gambar-dalam-gambar otomatis", + "auto_pip_desc": "Menekan Beranda saat video diputar membuatnya tetap berjalan di jendela kecil", "seconds_short": "{} dtk", "subtitle_style_note": "Ini berlaku untuk semua video. Sinkron subtitle tetap per episode dan diatur di dalam pemutar.", "searching_all_sources": "mencari di semua sumber", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c74a718c..00162f81 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "Li aliyê çepê ber bi jor û jêr bişemitîne", "volume_gesture": "Şemitandina deng", "volume_gesture_desc": "Li aliyê rastê ber bi jor û jêr bişemitîne", + "auto_pip": "Wêne di nav wêneyê de bixweber", + "auto_pip_desc": "Dema vîdyo dilîze û tu Destpêkê bitikînî, ew di pencereyeke biçûk de didome", "seconds_short": "{}s", "subtitle_style_note": "Ev ji bo her vîdyoyê derbasdar in. Demkirina binnivîsê ji bo her beşê cuda dimîne û di nav lêdarê de tê sazkirin.", "searching_all_sources": "li hemû çavkaniyan tê gerîn", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b48f0473..2e37967d 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Veeg omhoog en omlaag aan de linkerkant", "volume_gesture": "Volume vegen", "volume_gesture_desc": "Veeg omhoog en omlaag aan de rechterkant", + "auto_pip": "Automatisch beeld-in-beeld", + "auto_pip_desc": "Als je tijdens een video op Home drukt, speelt hij verder in een klein venster", "seconds_short": "{} s", "subtitle_style_note": "Dit geldt voor elke video. De ondertiteltiming blijft per aflevering en wordt in de speler ingesteld.", "searching_all_sources": "alle bronnen doorzoeken", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index ea4bf092..81cb19cc 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Deslize para cima e para baixo no lado esquerdo", "volume_gesture": "Deslizar para o volume", "volume_gesture_desc": "Deslize para cima e para baixo no lado direito", + "auto_pip": "Picture-in-picture automático", + "auto_pip_desc": "Ao tocar em Início durante um vídeo, ele continua numa janela pequena", "seconds_short": "{} s", "subtitle_style_note": "Isso vale para todos os vídeos. A sincronia da legenda é por episódio e se ajusta dentro do player.", "searching_all_sources": "buscando em todas as fontes", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 454de6c9..1f90a595 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "Свайп вверх и вниз слева", "volume_gesture": "Свайп громкости", "volume_gesture_desc": "Свайп вверх и вниз справа", + "auto_pip": "Автоматическая «картинка в картинке»", + "auto_pip_desc": "Если нажать «Домой» во время просмотра, видео продолжится в маленьком окне", "seconds_short": "{} с", "subtitle_style_note": "Действуют для всех видео. Тайминг субтитров задаётся отдельно для каждой серии в плеере.", "searching_all_sources": "поиск по всем источникам", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 8c3cec9b..b6f60fc1 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "Sol tarafta yukarı ve aşağı kaydır", "volume_gesture": "Ses kaydırma", "volume_gesture_desc": "Sağ tarafta yukarı ve aşağı kaydır", + "auto_pip": "Otomatik resim içinde resim", + "auto_pip_desc": "Video oynarken Ana ekran tuşuna basınca küçük bir pencerede oynamaya devam eder", "seconds_short": "{} sn", "subtitle_style_note": "Bunlar her video için geçerlidir. Altyazı zamanlaması bölüm başına kalır ve oynatıcı içinde ayarlanır.", "searching_all_sources": "tüm kaynaklarda aranıyor", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 79885c27..92fd0d19 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "Chap tomonda yuqoriga-pastga surish", "volume_gesture": "Ovoz surish", "volume_gesture_desc": "O‘ng tomonda yuqoriga-pastga surish", + "auto_pip": "Avtomatik «rasm ichida rasm»", + "auto_pip_desc": "Video ijro etilayotganda Home tugmasi bosilsa, u kichik oynada davom etadi", "seconds_short": "{} s", "subtitle_style_note": "Bular barcha videolarga tegishli. Subtitr vaqti esa har bir qism uchun alohida va pleyer ichida sozlanadi.", "searching_all_sources": "barcha manbalar bo‘yicha qidirilmoqda", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 0df9c365..550dcf9d 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -716,6 +716,8 @@ "brightness_gesture_desc": "Vuốt lên xuống ở bên trái", "volume_gesture": "Vuốt chỉnh âm lượng", "volume_gesture_desc": "Vuốt lên xuống ở bên phải", + "auto_pip": "Tự động hình trong hình", + "auto_pip_desc": "Nhấn Màn hình chính khi đang phát video sẽ tiếp tục phát trong cửa sổ nhỏ", "seconds_short": "{} giây", "subtitle_style_note": "Áp dụng cho mọi video. Thời gian phụ đề được đặt riêng cho từng tập ngay trong trình phát.", "searching_all_sources": "đang tìm mọi nguồn", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 1dbc9d5a..0478b87d 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -714,6 +714,8 @@ "brightness_gesture_desc": "喺左邊上下掃", "volume_gesture": "音量滑動", "volume_gesture_desc": "喺右邊上下掃", + "auto_pip": "自動畫中畫", + "auto_pip_desc": "播放影片時撳主畫面,影片會喺細視窗繼續播", "seconds_short": "{}秒", "subtitle_style_note": "呢啲設定套用喺所有影片。字幕時間就每集獨立,要喺播放器入面調。", "searching_all_sources": "搜尋緊所有來源", diff --git a/ios/Runner/SystemControls.swift b/ios/Runner/SystemControls.swift index 88d8e0b1..2420247c 100644 --- a/ios/Runner/SystemControls.swift +++ b/ios/Runner/SystemControls.swift @@ -22,12 +22,26 @@ enum SystemControls { /// rebuilding it per call loses the first change of every gesture. private static var volumeView: MPVolumeView? + /// The system brightness before the player first changed it. iOS has no + /// per-window override: the change is global and outlives the app unless + /// it is put back. + private static var savedBrightness: CGFloat? + private static var resignObserver: NSObjectProtocol? + static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: channelName, binaryMessenger: registrar.messenger() ) + if resignObserver == nil { + resignObserver = NotificationCenter.default.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: .main + ) { _ in restoreBrightness() } + } + channel.setMethodCallHandler { call, result in // UIScreen and UIWindow are main-thread only, and Flutter does not // promise which thread a call arrives on. @@ -38,12 +52,12 @@ enum SystemControls { case "setBrightness": let value = doubleArg(call, "value") ?? 0.5 + if savedBrightness == nil { savedBrightness = UIScreen.main.brightness } UIScreen.main.brightness = CGFloat(max(0, min(1, value))) result(Double(UIScreen.main.brightness)) case "resetBrightness": - // No per-window override on iOS the way Android has one, so there is - // nothing to hand back to the system — the user's last swipe stands. + restoreBrightness() result(true) case "getVolume": @@ -61,6 +75,12 @@ enum SystemControls { } } + private static func restoreBrightness() { + guard let saved = savedBrightness else { return } + savedBrightness = nil + UIScreen.main.brightness = saved + } + private static func doubleArg(_ call: FlutterMethodCall, _ key: String) -> Double? { guard let args = call.arguments as? [String: Any] else { return nil } return (args[key] as? NSNumber)?.doubleValue diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index b43c6dbd..f0a8fd5c 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -307,6 +307,7 @@ class AppConstants { /// in the app that makes something private visible to other people. static const String discordPresenceKey = 'discord_presence_enabled'; static const String volumeGestureKey = 'volume_gesture'; + static const String autoPipKey = 'auto_pip'; /// Explicit on/off per player-info row, as `{fieldId: bool}`. Explicit rather /// than a list of enabled ids so a row added in a later release can be told diff --git a/lib/core/player/drm_controller.dart b/lib/core/player/drm_controller.dart index 7823107a..3a3beffb 100644 --- a/lib/core/player/drm_controller.dart +++ b/lib/core/player/drm_controller.dart @@ -170,6 +170,10 @@ class DrmController extends PlayerController { @override bool get letterboxesInternally => false; + /// DrmPlayerHost builds its ExoPlayer with handleAudioFocus on. + @override + bool get handlesAudioFocus => true; + /// Not exposed by this backend yet. /// /// ExoPlayer does have track selection, and a DRM stream is precisely the diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 2f6dfcc8..ea8c04be 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -143,6 +143,10 @@ abstract class PlayerController extends ValueNotifier { bool get letterboxesInternally; + /// Whether the engine takes and gives up Android audio focus itself. + /// Requesting it on top of one that does steals focus from it. + bool get handlesAudioFocus => false; + /// Whether this backend can enumerate and switch audio tracks at all. /// /// False for `video_player`: ExoPlayer has track selection internally but the @@ -577,6 +581,10 @@ class _NativeController extends PlayerController { @override bool get letterboxesInternally => false; + /// video_player's ExoPlayer is built with handleAudioFocus on. + @override + bool get handlesAudioFocus => true; + @override Widget buildView({BoxFit fit = BoxFit.contain}) => vp.VideoPlayer(_inner); @@ -1275,6 +1283,9 @@ class _MediaKitController extends PlayerController { @override Future play() => _fallback?.play() ?? _player.play(); + @override + bool get handlesAudioFocus => _fallback?.handlesAudioFocus ?? false; + @override Future pause() => _fallback?.pause() ?? _player.pause(); diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index f77b7103..500e3be7 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -932,6 +932,13 @@ class HiveService { await _settingsBox.put(AppConstants.volumeGestureKey, value); } + /// Android: Home while a video plays shrinks it into picture-in-picture. + bool get autoPipEnabled => + _settingsBox.get(AppConstants.autoPipKey, defaultValue: true) == true; + + Future setAutoPipEnabled(bool value) => + _settingsBox.put(AppConstants.autoPipKey, value); + /// Explicit on/off answers for the player info overlay, or null when the /// viewer has never opened the picker. /// diff --git a/lib/features/detail/domain/playback/player_system_policy.dart b/lib/features/detail/domain/playback/player_system_policy.dart new file mode 100644 index 00000000..c7b2e92c --- /dev/null +++ b/lib/features/detail/domain/playback/player_system_policy.dart @@ -0,0 +1,210 @@ +import 'dart:ui' show AppLifecycleState, Rect, Size; + +/// Whether the app leaving the foreground pauses the player. +/// +/// `inactive` is not leaving: on a phone it is the notification shade, a +/// permission dialog, split-screen focus or Control Center. The desktop keeps +/// playing and floats instead. +bool pausesOnLifecycle( + AppLifecycleState state, { + required bool desktop, + required bool inPip, +}) { + if (desktop || inPip) return false; + return state == AppLifecycleState.paused || state == AppLifecycleState.hidden; +} + +enum PipChange { none, entered, restored, dismissed } + +/// Android picture-in-picture, as the player sees it. +/// +/// [active] goes true before the system is asked, so the lifecycle callbacks +/// fired while the window shrinks never mistake it for leaving the app. +class AndroidPipTracker { + bool _active = false; + + bool get active => _active; + + void beginEnter() => _active = true; + + void enterFailed() => _active = false; + + /// The activity's PiP mode changed. [dismissed] is the window closed with + /// its X or swiped away, rather than expanded back into the app. + PipChange onModeChanged({required bool inPip, required bool dismissed}) { + if (inPip) { + _active = true; + return PipChange.entered; + } + final was = _active; + _active = false; + if (dismissed) return PipChange.dismissed; + return was ? PipChange.restored : PipChange.none; + } + + PipChange onResumed() { + if (!_active) return PipChange.none; + _active = false; + return PipChange.restored; + } +} + +enum AudioFocusEvent { gain, loss, lossTransient, duck, noisy } + +AudioFocusEvent? audioFocusEventFromWire(String? name) => switch (name) { + 'gain' => AudioFocusEvent.gain, + 'loss' => AudioFocusEvent.loss, + 'loss_transient' => AudioFocusEvent.lossTransient, + 'duck' => AudioFocusEvent.duck, + 'noisy' => AudioFocusEvent.noisy, + _ => null, +}; + +class AudioFocusResponse { + const AudioFocusResponse({ + this.pause = false, + this.resume = false, + this.volume, + }); + + static const none = AudioFocusResponse(); + + final bool pause; + final bool resume; + + /// The player's own volume to set, null to leave it. + final double? volume; + + @override + bool operator ==(Object other) => + other is AudioFocusResponse && + other.pause == pause && + other.resume == resume && + other.volume == volume; + + @override + int get hashCode => Object.hash(pause, resume, volume); + + @override + String toString() => + 'AudioFocusResponse(pause: $pause, resume: $resume, volume: $volume)'; +} + +/// What the player does about audio focus and the headphones coming out. +/// +/// A call or an alarm pauses and plays on afterwards; another app taking +/// focus for good, or unplugged headphones, pause and stay paused. +class AudioFocusPolicy { + static const double duckVolume = 0.3; + + bool _pausedForTransient = false; + bool _ducked = false; + + AudioFocusResponse on(AudioFocusEvent event, {required bool playing}) { + switch (event) { + case AudioFocusEvent.gain: + final resume = _pausedForTransient && !playing; + final volume = _ducked ? 1.0 : null; + _pausedForTransient = false; + _ducked = false; + return AudioFocusResponse(resume: resume, volume: volume); + case AudioFocusEvent.loss: + case AudioFocusEvent.noisy: + _pausedForTransient = false; + return playing + ? const AudioFocusResponse(pause: true) + : AudioFocusResponse.none; + case AudioFocusEvent.lossTransient: + if (!playing) return AudioFocusResponse.none; + _pausedForTransient = true; + return const AudioFocusResponse(pause: true); + case AudioFocusEvent.duck: + if (_ducked) return AudioFocusResponse.none; + _ducked = true; + return const AudioFocusResponse(volume: duckVolume); + } + } + + /// The viewer pressed play themselves while a call had it paused: the + /// call ending must not toggle it again. + void onPlayingChanged(bool playing) { + if (playing) _pausedForTransient = false; + } +} + +/// The viewer's brightness inside the player, kept so it can be handed back +/// to the system on the way out and applied again on the way back in. +class BrightnessKeeper { + double? _level; + + double? get level => _level; + + void userSet(double value) => _level = value.clamp(0.0, 1.0).toDouble(); + + /// Whether going to the background has a level to hand back. + bool onBackground() => _level != null; + + /// The level to apply again on return, if the viewer set one. + double? onResume() => _level; + + void onExit() => _level = null; +} + +/// The picture's rectangle on a screen of [screen], letterboxed: what Android +/// animates from when it shrinks the activity into the floating window. +Rect? pipSourceRect(Size screen, Size video) { + if (screen.isEmpty || video.isEmpty) return null; + final scale = (screen.width / video.width) < (screen.height / video.height) + ? screen.width / video.width + : screen.height / video.height; + final w = video.width * scale; + final h = video.height * scale; + return Rect.fromLTWH((screen.width - w) / 2, (screen.height - h) / 2, w, h); +} + +/// Android refuses an aspect ratio outside 1:2.39 to 2.39:1. +(int, int) pipAspect(Size video) { + final w = video.width.round(); + final h = video.height.round(); + if (w <= 0 || h <= 0) return (16, 9); + final aspect = w / h; + if (aspect < 1 / 2.39 || aspect > 2.39) return (16, 9); + return (w, h); +} + +/// Throttles playback-state updates to the media session: the system +/// extrapolates the position itself, so only a change of state, speed or a +/// jump needs sending. +class MediaSessionGate { + static const int driftMs = 1500; + + bool? _playing; + double? _speed; + int _positionMs = 0; + int _sentAtMs = 0; + + bool shouldSend({ + required bool playing, + required int positionMs, + required double speed, + required int nowMs, + }) { + var send = playing != _playing || speed != _speed; + if (!send) { + final elapsed = playing ? ((nowMs - _sentAtMs) * speed).round() : 0; + send = (positionMs - (_positionMs + elapsed)).abs() > driftMs; + } + if (send) { + _playing = playing; + _speed = speed; + _positionMs = positionMs; + _sentAtMs = nowMs; + } + return send; + } + + void reset() { + _playing = null; + _speed = null; + } +} diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index d1104cfe..c5663558 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -59,6 +59,8 @@ import 'package:soplay/features/detail/domain/playback/playback_fault.dart'; import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; import 'package:soplay/features/detail/domain/playback/skip_offers.dart'; import 'package:soplay/features/detail/domain/playback/wakelock_holds.dart'; +import 'package:soplay/features/detail/domain/playback/player_system_policy.dart'; +import 'package:soplay/core/system/system_controls.dart'; import 'package:soplay/features/detail/domain/playback/watch_progress.dart'; import 'package:soplay/features/detail/domain/player_controls_layout.dart'; import 'package:soplay/features/detail/domain/playback/party_rules.dart'; @@ -174,6 +176,7 @@ part 'player_page.reactions.dart'; part 'player_page.menus.dart'; part 'player_page.sheets.dart'; part 'player_page.native_offer.dart'; +part 'player_page.system.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; @@ -272,7 +275,18 @@ class _PlayerPageState extends State final FocusNode _keyFocus = FocusNode(debugLabel: 'desktopPlayerKeys'); final ValueNotifier _miniHover = ValueNotifier(false); bool _resumeAfterPause = false; - bool _lastPipPlaying = false; + + /// Android PiP, audio focus, media session and brightness bookkeeping. + /// See player_page.system.dart. + final AndroidPipTracker _pipTracker = AndroidPipTracker(); + final AudioFocusPolicy _audioFocusPolicy = AudioFocusPolicy(); + final BrightnessKeeper _brightnessKeeper = BrightnessKeeper(); + final MediaSessionGate _mediaGate = MediaSessionGate(); + bool _systemPlaying = false; + bool _autoPipArmed = false; + late final bool _autoPipSetting; + String? _mediaMetaKey; + bool _brightnessHandedBack = false; PlayerController? _controller; bool _preferPlatformPlayer = false; @@ -629,6 +643,10 @@ class _PlayerPageState extends State /// See _pushSystemLevels in player_page.pip.dart. bool _brightnessDirty = false; bool _volumeDirty = false; + + /// A swipe's starting level is being read; see _rebaseSwipeLevel. + bool _swipeLevelReady = true; + int _swipeToken = 0; Timer? _systemLevelTimer; final ValueNotifier<_SwipeIndicator?> _swipeIndicator = ValueNotifier<_SwipeIndicator?>(null); @@ -800,6 +818,7 @@ class _PlayerPageState extends State _longPressBoost = _hive.getLongPressBoost(); _brightnessGestureEnabled = _hive.brightnessGestureEnabled; _volumeGestureEnabled = _hive.volumeGestureEnabled; + _autoPipSetting = _hive.autoPipEnabled; _window = _window.at( widget.args.initialEpisodeIndex.clamp( 0, @@ -830,6 +849,7 @@ class _PlayerPageState extends State WidgetsBinding.instance.addObserver(this); _pipChannel.setMethodCallHandler(_onPipMethodCall); if (isIosPlatform) _iosPipChannel.setMethodCallHandler(_onIosPipCall); + _attachPlayerSystem(); unawaited(_loadSystemControlValues()); PlayerLog.instance ..clear() @@ -862,13 +882,16 @@ class _PlayerPageState extends State if (isDesktopPlatform && state == AppLifecycleState.inactive) { unawaited(_floatWhenLeaving()); } - if (!isDesktopPlatform && - c.value.isInitialized && - c.value.isPlaying && - !_isPip) { + final leaving = pausesOnLifecycle( + state, + desktop: isDesktopPlatform, + inPip: _isPip || _pipTracker.active, + ); + if (leaving && c.value.isInitialized && c.value.isPlaying) { _resumeAfterPause = true; c.pause(); } + if (leaving) _handBackBrightness(); // Backgrounded is the last callback Android reliably delivers before // it is free to kill the process, and `dispose` is not delivered at // all when it does. Keeping playing in PiP or on the desktop does not @@ -882,9 +905,11 @@ class _PlayerPageState extends State // 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. + _pipTracker.onResumed(); if (_isPip && !_compact && mounted) { setState(() => _isPip = false); } + _reapplyBrightness(); if (_resumeAfterPause && c.value.isInitialized) { c.play(); } @@ -951,6 +976,7 @@ class _PlayerPageState extends State _partyDispose(); WidgetsBinding.instance.removeObserver(this); _pipChannel.setMethodCallHandler(null); + _detachPlayerSystem(); if (isIosPlatform) { _iosPipChannel.setMethodCallHandler(null); // Leaving the player while the floating window is up: it goes too. @@ -1181,6 +1207,18 @@ class _PlayerPageState extends State child: child, ); } + if (isAndroidPlatform) { + // A phone or tablet with a hardware keyboard: Space and the arrows. + return Focus( + focusNode: _keyFocus, + autofocus: true, + onKeyEvent: (node, event) => + node.hasPrimaryFocus && _kAndroidPlayerKeys.contains(event.logicalKey) + ? _onPlayerKey(node, event) + : KeyEventResult.ignored, + child: child, + ); + } if (!isDesktopPlatform) return child; return Focus( focusNode: _keyFocus, @@ -1713,3 +1751,13 @@ const _kDockKeys = [ LogicalKeyboardKey.keyP, LogicalKeyboardKey.keyN, ]; + +/// The keys a hardware keyboard drives on an Android phone or tablet. +/// Media keys are left to the media session. +const _kAndroidPlayerKeys = [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, +]; diff --git a/lib/features/detail/presentation/pages/player_page.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index 49d23109..828d11d7 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -265,6 +265,7 @@ extension _PlayerGestures on _PlayerPageState { } else { final isLeft = start.dx < constraints.maxWidth * 0.5; _dragSwipeType = isLeft ? _SwipeType.brightness : _SwipeType.volume; + if (!isDesktopPlatform) unawaited(_rebaseSwipeLevel(_dragSwipeType!)); } } @@ -281,19 +282,52 @@ extension _PlayerGestures on _PlayerPageState { final delta = -(d.delta.dy) / (constraints.maxHeight * 0.7); if (_dragSwipeType == _SwipeType.brightness) { _brightness = (_brightness + delta).clamp(0.0, 1.0).toDouble(); - _pushSystemLevels(brightness: true); + if (_swipeLevelReady) _pushSystemLevels(brightness: true); _swipeIndicator.value = _SwipeIndicator( _SwipeType.brightness, _brightness, ); } else { _volume = (_volume + delta).clamp(0.0, 1.0).toDouble(); - _pushSystemLevels(volume: true); + if (_swipeLevelReady) _pushSystemLevels(volume: true); _swipeIndicator.value = _SwipeIndicator(_SwipeType.volume, _volume); } } } + /// The level a swipe starts from, read fresh: the volume keys and + /// auto-brightness move them while the player is open. Movement made while + /// the read is in flight is kept and added on top. + Future _rebaseSwipeLevel(_SwipeType type) async { + final token = ++_swipeToken; + final brightness = type == _SwipeType.brightness; + final startLevel = brightness ? _brightness : _volume; + _swipeLevelReady = false; + double? fresh; + try { + fresh = await _systemControlsChannel.invokeMethod( + brightness ? 'getBrightness' : 'getVolume', + ); + } catch (_) {} + if (!mounted || token != _swipeToken) return; + _swipeLevelReady = true; + final moved = (brightness ? _brightness : _volume) - startLevel; + if (fresh != null) { + final level = (fresh + moved).clamp(0.0, 1.0).toDouble(); + if (brightness) { + _brightness = level; + } else { + _volume = level; + } + if (_dragSwipeType == type) { + _swipeIndicator.value = _SwipeIndicator(type, level); + } + } + if (moved != 0) { + _pushSystemLevels(brightness: brightness, volume: !brightness); + } + } + void _onPanEnd(DragEndDetails d) { if (_dragIsHorizontal == true) { _onHDragEnd(d); diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 90271312..f9fe4954 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -1989,6 +1989,7 @@ extension _PlayerMedia on _PlayerPageState { final v = c.value; _syncWakelock(v.isPlaying); + _syncPlayerSystem(); _syncTraktScrobble(v.isPlaying); _applyEmbeddedSubtitleChoice(); _applyPendingAudioChoice(); diff --git a/lib/features/detail/presentation/pages/player_page.pip.dart b/lib/features/detail/presentation/pages/player_page.pip.dart index 55912b6a..6ebd2f8f 100644 --- a/lib/features/detail/presentation/pages/player_page.pip.dart +++ b/lib/features/detail/presentation/pages/player_page.pip.dart @@ -14,6 +14,10 @@ extension _PlayerPip on _PlayerPageState { } Future _onPipMethodCall(MethodCall call) async { + if (call.method == 'onPipModeChanged') { + if (mounted) _onAndroidPipMode((call.arguments as Map?) ?? const {}); + return; + } if (call.method != 'onPipAction') return; final action = call.arguments; if (action is! String) return; @@ -42,27 +46,23 @@ extension _PlayerPip on _PlayerPageState { } Future _refreshPipActions() async { + if (!isAndroidPlatform) return; final c = _controller; if (c == null || !c.value.isInitialized) return; - final isPlaying = c.value.isPlaying; - final hasPrev = _hasPrevEpisode; - final hasNext = _hasNextEpisode; - if (isPlaying == _lastPipPlaying) { - try { - await _pipChannel.invokeMethod('updatePiPActions', { - 'isPlaying': isPlaying, - 'hasPrev': hasPrev, - 'hasNext': hasNext, - }); - } catch (_) {} - return; - } - _lastPipPlaying = isPlaying; try { await _pipChannel.invokeMethod('updatePiPActions', { - 'isPlaying': isPlaying, - 'hasPrev': hasPrev, - 'hasNext': hasNext, + 'isPlaying': c.value.isPlaying, + 'hasPrev': widget.args.isSerial && _hasPrevEpisode, + 'hasNext': widget.args.isSerial && _hasNextEpisode, + 'seekSeconds': _seekSeconds, + 'labels': { + 'play': 'player.play'.tr(), + 'pause': 'player.pause'.tr(), + 'rewind': 'player.rewind_by'.tr(args: ['$_seekSeconds']), + 'forward': 'player.forward_by'.tr(args: ['$_seekSeconds']), + 'prev': 'player.previous'.tr(), + 'next': 'player.next_episode'.tr(), + }, }); } catch (_) {} } @@ -74,21 +74,17 @@ extension _PlayerPip on _PlayerPageState { try { final available = await _floating.isPipAvailable; if (!available) return; - final size = c.value.size; - Rational ratio = const Rational.landscape(); - if (size.width > 0 && size.height > 0) { - final w = size.width.round(); - final h = size.height.round(); - if (w > 0 && h > 0) { - final candidate = Rational(w, h); - final aspect = candidate.aspectRatio; - if (aspect >= 1 / 2.39 && aspect <= 2.39) { - ratio = candidate; - } - } - } - final result = await _floating.enable(ImmediatePiP(aspectRatio: ratio)); - if (result == PiPStatus.enabled && mounted) { + final (w, h) = pipAspect(c.value.size); + await _refreshPipActions(); + // Marked before asking, so the lifecycle callbacks fired while the + // window shrinks do not read as leaving the app and pause it. + _pipTracker.beginEnter(); + _isPip = true; + final result = await _floating.enable( + ImmediatePiP(aspectRatio: Rational(w, h)), + ); + if (result == PiPStatus.enabled) { + if (!mounted) return; setState(() { _isPip = true; _controlsVisible = false; @@ -96,10 +92,18 @@ extension _PlayerPip on _PlayerPageState { _panel = _SidePanel.none; }); _controlsAnimation.reverse(); - _lastPipPlaying = !c.value.isPlaying; - _refreshPipActions(); + } else { + _rollBackPipEntry(); } - } catch (_) {} + } catch (_) { + _rollBackPipEntry(); + } + } + + void _rollBackPipEntry() { + _pipTracker.enterFailed(); + if (!mounted) return; + setState(() => _isPip = false); } /// iOS: the episode goes to a native player the system can float — the @@ -408,7 +412,7 @@ extension _PlayerPip on _PlayerPageState { if (steps) ...[ round( Icons.fast_rewind_rounded, - 'player.rewind'.tr(), + 'player.rewind_by'.tr(args: ['$_seekSeconds']), () => _seekRelative(-_seekStep), ), gap, @@ -431,7 +435,7 @@ extension _PlayerPip on _PlayerPageState { gap, round( Icons.fast_forward_rounded, - 'player.forward'.tr(), + 'player.forward_by'.tr(args: ['$_seekSeconds']), () => _seekRelative(_seekStep), ), ], @@ -600,6 +604,8 @@ extension _PlayerPip on _PlayerPageState { await WakelockHolds.release(this); return; } + _brightnessKeeper.onExit(); + unawaited(SystemControls.resetBrightness()); try { await SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, @@ -637,6 +643,7 @@ extension _PlayerPip on _PlayerPageState { } Future _setSystemBrightness(double value) async { + _brightnessKeeper.userSet(value); try { await _systemControlsChannel.invokeMethod('setBrightness', { 'value': value, diff --git a/lib/features/detail/presentation/pages/player_page.system.dart b/lib/features/detail/presentation/pages/player_page.system.dart new file mode 100644 index 00000000..4c357303 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.system.dart @@ -0,0 +1,233 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// Android audio focus, the media session and auto-enter PiP. +const MethodChannel _playerSystemChannel = MethodChannel( + 'soplay/player_system', +); + +extension _PlayerSystem on _PlayerPageState { + void _attachPlayerSystem() { + if (!isAndroidPlatform) return; + _playerSystemChannel.setMethodCallHandler(_onPlayerSystemCall); + unawaited(_systemInvoke('attach')); + } + + void _detachPlayerSystem() { + if (!isAndroidPlatform) return; + _playerSystemChannel.setMethodCallHandler(null); + unawaited(_systemInvoke('detach')); + if (_autoPipArmed) { + _autoPipArmed = false; + unawaited(_pipInvoke('setAutoPip', {'enabled': false})); + } + } + + Future _systemInvoke(String method, [Object? args]) async { + try { + return await _playerSystemChannel.invokeMethod(method, args); + } catch (_) { + return null; + } + } + + Future _pipInvoke(String method, Object? args) async { + try { + await _pipChannel.invokeMethod(method, args); + } catch (_) {} + } + + /// From the controller listener, on every tick. + void _syncPlayerSystem() { + if (!isAndroidPlatform) return; + final c = _controller; + if (c == null) return; + final v = c.value; + final playing = v.isPlaying; + if (playing != _systemPlaying) { + _systemPlaying = playing; + _audioFocusPolicy.onPlayingChanged(playing); + if (playing && !c.handlesAudioFocus) unawaited(_requestAudioFocus(c)); + _syncAutoPip(); + if (_isPip || _autoPipArmed) unawaited(_refreshPipActions()); + } + _syncMediaSession(c); + } + + Future _requestAudioFocus(PlayerController c) async { + final granted = await _systemInvoke('requestAudioFocus'); + // Refused during a phone call: playing over it is what this prevents. + if (granted == false && identical(c, _controller) && c.value.isPlaying) { + unawaited(c.pause()); + } + } + + void _syncMediaSession(PlayerController c) { + final v = c.value; + if (!v.isInitialized) return; + final serial = widget.args.isSerial; + final metaKey = '$_episodeIndex|${v.duration.inSeconds}'; + if (metaKey != _mediaMetaKey) { + _mediaMetaKey = metaKey; + final title = _episodeTitle(); + final prefix = '${widget.args.title} · '; + unawaited( + _systemInvoke('setMetadata', { + 'title': widget.args.title, + 'subtitle': serial && title.startsWith(prefix) + ? title.substring(prefix.length) + : null, + 'durationMs': _isLive ? 0 : v.duration.inMilliseconds, + 'artworkUrl': widget.args.thumbnail, + }), + ); + _mediaGate.reset(); + } + final send = _mediaGate.shouldSend( + playing: v.isPlaying, + positionMs: v.position.inMilliseconds, + speed: v.playbackSpeed, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + if (!send) return; + unawaited( + _systemInvoke('setPlaybackState', { + 'playing': v.isPlaying, + 'positionMs': v.position.inMilliseconds, + 'speed': v.playbackSpeed, + 'hasPrev': serial && _hasPrevEpisode, + 'hasNext': serial && _hasNextEpisode, + 'seekable': !_isLive, + }), + ); + } + + Future _onPlayerSystemCall(MethodCall call) async { + if (!mounted) return; + switch (call.method) { + case 'audioFocus': + _onAudioFocusEvent(audioFocusEventFromWire(call.arguments as String?)); + case 'mediaCommand': + final args = (call.arguments as Map?) ?? const {}; + _onMediaCommand( + args['action'] as String?, + (args['positionMs'] as num?)?.toInt(), + ); + } + } + + void _onAudioFocusEvent(AudioFocusEvent? event) { + final c = _controller; + if (event == null || c == null || !c.value.isInitialized) return; + // An engine that takes focus itself reacts on its own; ours, if still + // held from an earlier stream, losing it to that engine is not a reason + // to pause. + if (c.handlesAudioFocus && event != AudioFocusEvent.noisy) return; + final response = _audioFocusPolicy.on(event, playing: c.value.isPlaying); + _plog('audio focus: ${event.name}'); + if (response.volume != null) unawaited(c.setVolume(response.volume!)); + if (response.pause) { + _resumeAfterPause = false; + unawaited(c.pause()); + } + if (response.resume) unawaited(c.play()); + } + + void _onMediaCommand(String? action, int? positionMs) { + final c = _controller; + if (action == null || c == null || !c.value.isInitialized) return; + switch (action) { + case 'play': + if (!c.value.isPlaying) _togglePlay(); + case 'pause': + if (c.value.isPlaying) _togglePlay(); + case 'seek': + if (positionMs != null) _seekTo(Duration(milliseconds: positionMs)); + case 'rewind' || 'forward' || 'prev' || 'next': + unawaited(_onPipMethodCall(MethodCall('onPipAction', action))); + } + } + + /// Auto-enter PiP on Home, armed only while a video plays here. + void _syncAutoPip() { + if (!isAndroidPlatform || isTvPlatform) return; + final c = _controller; + final want = + _autoPipSetting && + c != null && + c.value.isInitialized && + c.value.isPlaying && + !_cast.isCasting && + mounted; + if (!want) { + if (!_autoPipArmed) return; + _autoPipArmed = false; + unawaited(_pipInvoke('setAutoPip', {'enabled': false})); + return; + } + _autoPipArmed = true; + final video = c.value.size; + final (num, den) = pipAspect(video); + final view = View.of(context); + final rect = pipSourceRect(view.physicalSize, video); + unawaited( + _pipInvoke('setAutoPip', { + 'enabled': true, + 'numerator': num, + 'denominator': den, + if (rect != null) + 'sourceRect': [ + rect.left.round(), + rect.top.round(), + rect.right.round(), + rect.bottom.round(), + ], + }), + ); + } + + /// The activity entered or left PiP — by our button, by Home with + /// auto-enter, or by the viewer closing the window. + void _onAndroidPipMode(Map args) { + final change = _pipTracker.onModeChanged( + inPip: args['inPip'] == true, + dismissed: args['dismissed'] == true, + ); + switch (change) { + case PipChange.none: + return; + case PipChange.entered: + _hideTimer?.cancel(); + setState(() { + _isPip = true; + _controlsVisible = false; + _panel = _SidePanel.none; + }); + _controlsAnimation.reverse(); + unawaited(_refreshPipActions()); + case PipChange.restored: + setState(() => _isPip = false); + case PipChange.dismissed: + setState(() => _isPip = false); + // Closing the window is stopping: no audio left playing behind it. + _resumeAfterPause = false; + final c = _controller; + if (c != null && c.value.isPlaying) unawaited(c.pause()); + _saveHistory(); + } + } + + void _handBackBrightness() { + if (isDesktopPlatform || _brightnessHandedBack) return; + if (!_brightnessKeeper.onBackground()) return; + _brightnessHandedBack = true; + unawaited(SystemControls.resetBrightness()); + } + + void _reapplyBrightness() { + if (isDesktopPlatform) return; + _brightnessHandedBack = false; + final level = _brightnessKeeper.onResume(); + if (level != null) unawaited(_setSystemBrightness(level)); + } +} diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 651a103a..633ee229 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -13,6 +13,7 @@ import 'package:soplay/core/player/media_controller.dart' import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/core/player/quality_preference.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/subtitles/subtitle_languages.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; @@ -61,6 +62,7 @@ class _PlayerSettingsPageState extends State { late double _boost; late bool _brightnessGesture; late bool _volumeGesture; + late bool _autoPip; late bool _heroTrailer; late bool _keepScreenOn; late bool _incognito; @@ -111,6 +113,7 @@ class _PlayerSettingsPageState extends State { _boost = _hive.getLongPressBoost(); _brightnessGesture = _hive.brightnessGestureEnabled; _volumeGesture = _hive.volumeGestureEnabled; + _autoPip = _hive.autoPipEnabled; _heroTrailer = _hive.heroTrailerAutoplay; _keepScreenOn = _hive.keepScreenOn; _subtitle = _hive.getSubtitleStyle(); @@ -428,6 +431,19 @@ class _PlayerSettingsPageState extends State { _hive.setVolumeGestureEnabled(v); }, ), + if (isAndroidPlatform && !isTvPlatform) ...[ + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.picture_in_picture_alt_rounded, + title: 'profile.auto_pip'.tr(), + subtitle: 'profile.auto_pip_desc'.tr(), + value: _autoPip, + onChanged: (v) { + setState(() => _autoPip = v); + _hive.setAutoPipEnabled(v); + }, + ), + ], const SettingsDivider(), // A video that starts on its own, on a page somebody opened to // read — worth offering, worth being able to refuse. Turning it diff --git a/test/features/detail/player_system_policy_test.dart b/test/features/detail/player_system_policy_test.dart new file mode 100644 index 00000000..7e52215c --- /dev/null +++ b/test/features/detail/player_system_policy_test.dart @@ -0,0 +1,273 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/player_system_policy.dart'; + +void main() { + group('pausesOnLifecycle', () { + test('a phone pauses only when the app is really gone', () { + bool pauses(AppLifecycleState s) => + pausesOnLifecycle(s, desktop: false, inPip: false); + expect(pauses(AppLifecycleState.paused), isTrue); + expect(pauses(AppLifecycleState.hidden), isTrue); + expect(pauses(AppLifecycleState.inactive), isFalse); + expect(pauses(AppLifecycleState.resumed), isFalse); + expect(pauses(AppLifecycleState.detached), isFalse); + }); + + test('picture-in-picture keeps playing', () { + for (final s in AppLifecycleState.values) { + expect(pausesOnLifecycle(s, desktop: false, inPip: true), isFalse); + } + }); + + test('the desktop never pauses on lifecycle', () { + for (final s in AppLifecycleState.values) { + expect(pausesOnLifecycle(s, desktop: true, inPip: false), isFalse); + } + }); + }); + + group('AndroidPipTracker', () { + test('is active from the moment entry is asked for', () { + final t = AndroidPipTracker()..beginEnter(); + expect(t.active, isTrue); + expect(t.onModeChanged(inPip: true, dismissed: false), PipChange.entered); + expect(t.active, isTrue); + }); + + test('a refused entry rolls back', () { + final t = AndroidPipTracker() + ..beginEnter() + ..enterFailed(); + expect(t.active, isFalse); + }); + + test('auto-enter from Home is picked up from the mode change', () { + final t = AndroidPipTracker(); + expect(t.onModeChanged(inPip: true, dismissed: false), PipChange.entered); + expect(t.active, isTrue); + }); + + test('expanding back restores, closing dismisses', () { + final t = AndroidPipTracker() + ..onModeChanged(inPip: true, dismissed: false); + expect( + t.onModeChanged(inPip: false, dismissed: false), + PipChange.restored, + ); + t.onModeChanged(inPip: true, dismissed: false); + expect( + t.onModeChanged(inPip: false, dismissed: true), + PipChange.dismissed, + ); + expect(t.active, isFalse); + }); + + test('a dismissal reported after the restore still dismisses', () { + final t = AndroidPipTracker() + ..onModeChanged(inPip: true, dismissed: false); + t.onModeChanged(inPip: false, dismissed: false); + expect( + t.onModeChanged(inPip: false, dismissed: true), + PipChange.dismissed, + ); + }); + + test('resuming clears it once', () { + final t = AndroidPipTracker()..beginEnter(); + expect(t.onResumed(), PipChange.restored); + expect(t.onResumed(), PipChange.none); + expect(t.onModeChanged(inPip: false, dismissed: false), PipChange.none); + }); + }); + + group('AudioFocusPolicy', () { + test('wire names map to events', () { + expect(audioFocusEventFromWire('gain'), AudioFocusEvent.gain); + expect(audioFocusEventFromWire('loss'), AudioFocusEvent.loss); + expect( + audioFocusEventFromWire('loss_transient'), + AudioFocusEvent.lossTransient, + ); + expect(audioFocusEventFromWire('duck'), AudioFocusEvent.duck); + expect(audioFocusEventFromWire('noisy'), AudioFocusEvent.noisy); + expect(audioFocusEventFromWire('other'), isNull); + expect(audioFocusEventFromWire(null), isNull); + }); + + test('a call pauses and the end of it plays on', () { + final p = AudioFocusPolicy(); + expect( + p.on(AudioFocusEvent.lossTransient, playing: true), + const AudioFocusResponse(pause: true), + ); + expect( + p.on(AudioFocusEvent.gain, playing: false), + const AudioFocusResponse(resume: true), + ); + }); + + test('a transient loss while paused does not resume afterwards', () { + final p = AudioFocusPolicy(); + expect( + p.on(AudioFocusEvent.lossTransient, playing: false), + AudioFocusResponse.none, + ); + expect( + p.on(AudioFocusEvent.gain, playing: false), + AudioFocusResponse.none, + ); + }); + + test('playing by hand during the call cancels the resume', () { + final p = AudioFocusPolicy() + ..on(AudioFocusEvent.lossTransient, playing: true) + ..onPlayingChanged(true); + expect( + p.on(AudioFocusEvent.gain, playing: true), + AudioFocusResponse.none, + ); + }); + + test('a permanent loss pauses for good', () { + final p = AudioFocusPolicy(); + expect( + p.on(AudioFocusEvent.loss, playing: true), + const AudioFocusResponse(pause: true), + ); + expect( + p.on(AudioFocusEvent.gain, playing: false), + AudioFocusResponse.none, + ); + }); + + test('a permanent loss after a transient one forgets the resume', () { + final p = AudioFocusPolicy() + ..on(AudioFocusEvent.lossTransient, playing: true); + p.on(AudioFocusEvent.loss, playing: false); + expect( + p.on(AudioFocusEvent.gain, playing: false), + AudioFocusResponse.none, + ); + }); + + test('ducking lowers and gain restores the volume', () { + final p = AudioFocusPolicy(); + expect( + p.on(AudioFocusEvent.duck, playing: true), + const AudioFocusResponse(volume: AudioFocusPolicy.duckVolume), + ); + expect( + p.on(AudioFocusEvent.duck, playing: true), + AudioFocusResponse.none, + ); + expect( + p.on(AudioFocusEvent.gain, playing: true), + const AudioFocusResponse(volume: 1.0), + ); + }); + + test('headphones out pause, and only when playing', () { + final p = AudioFocusPolicy(); + expect( + p.on(AudioFocusEvent.noisy, playing: true), + const AudioFocusResponse(pause: true), + ); + expect( + p.on(AudioFocusEvent.noisy, playing: false), + AudioFocusResponse.none, + ); + }); + }); + + group('BrightnessKeeper', () { + test('nothing to hand back until the viewer swipes', () { + final k = BrightnessKeeper(); + expect(k.onBackground(), isFalse); + expect(k.onResume(), isNull); + }); + + test('the viewer level is handed back and applied again', () { + final k = BrightnessKeeper()..userSet(0.8); + expect(k.onBackground(), isTrue); + expect(k.onResume(), 0.8); + }); + + test('levels are clamped', () { + expect((BrightnessKeeper()..userSet(1.4)).level, 1.0); + expect((BrightnessKeeper()..userSet(-1)).level, 0.0); + }); + + test('leaving the player forgets it', () { + final k = BrightnessKeeper() + ..userSet(0.3) + ..onExit(); + expect(k.onBackground(), isFalse); + expect(k.onResume(), isNull); + }); + }); + + group('pip geometry', () { + test('the source rect letterboxes the picture', () { + expect( + pipSourceRect(const Size(2400, 1080), const Size(1920, 1080)), + const Rect.fromLTWH(240, 0, 1920, 1080), + ); + expect( + pipSourceRect(const Size(1080, 2400), const Size(1920, 1080)), + const Rect.fromLTWH(0, 896.25, 1080, 607.5), + ); + expect(pipSourceRect(Size.zero, const Size(16, 9)), isNull); + expect(pipSourceRect(const Size(16, 9), Size.zero), isNull); + }); + + test('the aspect stays inside what Android accepts', () { + expect(pipAspect(const Size(1920, 1080)), (1920, 1080)); + expect(pipAspect(const Size(1080, 1920)), (1080, 1920)); + expect(pipAspect(const Size(3000, 1000)), (16, 9)); + expect(pipAspect(Size.zero), (16, 9)); + }); + }); + + group('MediaSessionGate', () { + test('sends changes of state and speed, not ticks', () { + final g = MediaSessionGate(); + expect( + g.shouldSend(playing: true, positionMs: 0, speed: 1, nowMs: 0), + isTrue, + ); + expect( + g.shouldSend(playing: true, positionMs: 1000, speed: 1, nowMs: 1000), + isFalse, + ); + expect( + g.shouldSend(playing: false, positionMs: 1200, speed: 1, nowMs: 1200), + isTrue, + ); + expect( + g.shouldSend(playing: false, positionMs: 1200, speed: 1.5, nowMs: 1300), + isTrue, + ); + }); + + test('sends a seek', () { + final g = MediaSessionGate() + ..shouldSend(playing: true, positionMs: 0, speed: 1, nowMs: 0); + expect( + g.shouldSend(playing: true, positionMs: 60000, speed: 1, nowMs: 1000), + isTrue, + ); + }); + + test('reset sends again', () { + final g = MediaSessionGate() + ..shouldSend(playing: true, positionMs: 0, speed: 1, nowMs: 0) + ..reset(); + expect( + g.shouldSend(playing: true, positionMs: 10, speed: 1, nowMs: 10), + isTrue, + ); + }); + }); +} From 37cda384ba45eb1eb0d835377d4cb1f20c5fb9ad Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:28:43 +0500 Subject: [PATCH 16/21] Fix player logic and UX across platforms - Desktop: re-apply volume and mute on every new controller, persist them in Hive, allow up to 150% on mpv via volume-max. - Retrying the same source shows the switch screen with the attempt and Stop (ServerSwitch.retrySame); TV focuses Stop. - Error screen scrolls, has a compact landscape layout, always shows a translated headline with the raw detail under Details; Dio timeouts and HTTP 4xx/5xx are classified. - End of stream: engine completion or a playhead stuck near the end advances once. - Tap-tap seeking, double-tap centre play/pause, no tap lag when a double tap cannot follow. - Lock pill fades after 3 s; BACK while locked shows it. - Swipes from system gesture edges are ignored. - AniSkip skips go through _seekTo; OK skips on TV; Up Next on TV. - Native codec fault offers mpv for the session; mpv mid-play failure retries once with hwdec=no. - mpv Auto starts from the connection type and steps down after repeated stalls, with Undo. - Vertical clips turn the phone to portrait. - Player notices are a top-centre glass toast; skip pill and error buttons use the player glass style. --- assets/translations/ar.json | 13 +- assets/translations/ckb.json | 13 +- assets/translations/de.json | 13 +- assets/translations/en.json | 13 +- assets/translations/es.json | 13 +- assets/translations/fr.json | 13 +- assets/translations/hi.json | 13 +- assets/translations/id.json | 13 +- assets/translations/ku.json | 13 +- assets/translations/nl.json | 13 +- assets/translations/pt.json | 13 +- assets/translations/ru.json | 13 +- assets/translations/tr.json | 13 +- assets/translations/uz.json | 13 +- assets/translations/vi.json | 13 +- assets/translations/yue.json | 13 +- lib/core/constants/app_constants.dart | 2 + lib/core/player/media_controller.dart | 51 +- lib/core/player/mpv_auto_quality.dart | 97 ++++ lib/core/storage/hive_service.dart | 11 + .../domain/playback/desktop_volume.dart | 40 ++ .../detail/domain/playback/end_of_stream.dart | 66 +++ .../detail/domain/playback/lock_pill.dart | 36 ++ .../domain/playback/playback_fault.dart | 43 ++ .../detail/domain/playback/tap_seek.dart | 113 ++++ .../pages/player_page.aniskip.dart | 93 ++-- .../pages/player_page.controls.dart | 485 +++++++++++------- .../presentation/pages/player_page.dart | 87 +++- .../pages/player_page.gestures.dart | 83 ++- .../pages/player_page.history.dart | 15 +- .../presentation/pages/player_page.media.dart | 387 ++++++++++---- .../pages/player_page.models.dart | 33 ++ .../pages/player_page.panels.dart | 12 +- .../presentation/pages/player_page.party.dart | 13 +- .../pages/player_page.reactions.dart | 4 +- .../presentation/pages/player_page.sleep.dart | 10 +- .../pages/player_page.subtitles.dart | 31 -- .../presentation/pages/player_page.toast.dart | 79 +++ .../presentation/pages/player_page.tv.dart | 21 + .../pages/player_page.upnext.dart | 55 +- .../pages/player_page.widgets.dart | 155 +++++- test/core/player/mpv_auto_quality_test.dart | 113 ++++ .../detail/playback/desktop_volume_test.dart | 53 ++ .../detail/playback/end_of_stream_test.dart | 65 +++ .../detail/playback/lock_pill_test.dart | 33 ++ .../playback/retry_same_switch_test.dart | 77 +++ .../detail/playback/tap_seek_test.dart | 84 +++ test/features/detail/playback_fault_test.dart | 58 +++ 48 files changed, 2256 insertions(+), 457 deletions(-) create mode 100644 lib/core/player/mpv_auto_quality.dart create mode 100644 lib/features/detail/domain/playback/desktop_volume.dart create mode 100644 lib/features/detail/domain/playback/end_of_stream.dart create mode 100644 lib/features/detail/domain/playback/lock_pill.dart create mode 100644 lib/features/detail/domain/playback/tap_seek.dart create mode 100644 lib/features/detail/presentation/pages/player_page.toast.dart create mode 100644 test/core/player/mpv_auto_quality_test.dart create mode 100644 test/features/detail/playback/desktop_volume_test.dart create mode 100644 test/features/detail/playback/end_of_stream_test.dart create mode 100644 test/features/detail/playback/lock_pill_test.dart create mode 100644 test/features/detail/playback/retry_same_switch_test.dart create mode 100644 test/features/detail/playback/tap_seek_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e149a6d1..31a90288 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -306,7 +306,11 @@ "no_source": "لا يوجد مصدر لهذه الحلقة.", "empty_url": "لم يُرجِع هذا المصدر عنوان فيديو.", "engine_unavailable": "لم يبدأ المشغّل. أعد تشغيل التطبيق بالكامل.", - "unknown": "تعذّر تحميل الفيديو." + "unknown": "تعذّر تحميل الفيديو.", + "not_found": "لم يعد هذا الفيديو موجودًا على هذا الخادم. جرّب خادمًا آخر.", + "server_error": "يواجه خادم الفيديو مشكلة. حاول مجددًا بعد قليل أو اختر خادمًا آخر.", + "resolve_failed": "تعذّر الحصول على رابط الفيديو من هذا المصدر.", + "details": "التفاصيل" }, "play": "تشغيل", "pause": "إيقاف مؤقت", @@ -591,7 +595,12 @@ "native_offer_title": "هل تريد تجربة المشغّل الجديد؟", "native_offer_body": "أزرار وقوائم Liquid Glass من Apple. يمكنك تغيير ذلك في أي وقت من الإعدادات ← المشغّل.", "native_offer_try": "جرّبه", - "native_offer_not_now": "ليس الآن" + "native_offer_not_now": "ليس الآن", + "trying_again": "إعادة المحاولة (المحاولة {})", + "try_mpv": "جرّب باستخدام محرك mpv", + "ok_to_skip": "اضغط OK للتخطي", + "quality_lowered": "تم خفض الجودة", + "undo": "تراجع" }, "profile": { "title": "حسابي", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 9f984bfb..786e126f 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -306,7 +306,11 @@ "no_source": "هیچ سەرچاوەیەک بۆ ئەم ئەڵقەیە نییە.", "empty_url": "ئەم سەرچاوەیە هیچ ناونیشانێکی ڤیدیۆی نەگەڕاندەوە.", "engine_unavailable": "لێدەرەکە دەستی پێنەکرد. تکایە ئەپەکە بە تەواوی دابخە و دووبارە بیکەرەوە.", - "unknown": "نەتوانرا ڤیدیۆکە بار بکرێت." + "unknown": "نەتوانرا ڤیدیۆکە بار بکرێت.", + "not_found": "ئەم ڤیدیۆیە چیتر لەسەر ئەم سێرڤەرە نییە. سێرڤەرێکی تر تاقی بکەرەوە.", + "server_error": "سێرڤەری ڤیدیۆ کێشەی هەیە. کەمێکی تر دووبارە هەوڵ بدەرەوە یان سێرڤەرێکی تر هەڵبژێرە.", + "resolve_failed": "نەتوانرا بەستەری ڤیدیۆ لەم سەرچاوەیە وەربگیرێت.", + "details": "وردەکاری" }, "play": "لێدان", "pause": "وەستاندن", @@ -591,7 +595,12 @@ "native_offer_title": "لێدەری نوێ تاقی دەکەیتەوە؟", "native_offer_body": "دوگمە و لیستەکانی Liquid Glassی Apple. هەر کاتێک دەتوانیت لە ڕێکخستنەکان ← لێدەر بیگۆڕیت.", "native_offer_try": "تاقیکردنەوە", - "native_offer_not_now": "ئێستا نا" + "native_offer_not_now": "ئێستا نا", + "trying_again": "دووبارە هەوڵدانەوە (هەوڵی {})", + "try_mpv": "تاقیکردنەوە بە بزوێنەری mpv", + "ok_to_skip": "OK بۆ تێپەڕاندن", + "quality_lowered": "کوالیتی دابەزێنرا", + "undo": "پاشگەزبوونەوە" }, "profile": { "title": "پرۆفایل", diff --git a/assets/translations/de.json b/assets/translations/de.json index 24ea0d3f..2cb59df0 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -306,7 +306,11 @@ "no_source": "Keine Quelle für diese Folge.", "empty_url": "Diese Quelle hat keine Videoadresse geliefert.", "engine_unavailable": "Der Player ist nicht gestartet. Bitte starte die App vollständig neu.", - "unknown": "Video konnte nicht geladen werden." + "unknown": "Video konnte nicht geladen werden.", + "not_found": "Dieses Video ist auf diesem Server nicht mehr verfügbar. Versuche einen anderen Server.", + "server_error": "Der Videoserver hat Probleme. Versuche es gleich noch einmal oder wähle einen anderen Server.", + "resolve_failed": "Von dieser Quelle konnte kein Videolink abgerufen werden.", + "details": "Details" }, "play": "Abspielen", "pause": "Pause", @@ -591,7 +595,12 @@ "native_offer_title": "Den neuen Player ausprobieren?", "native_offer_body": "Apples Liquid-Glass-Bedienelemente und -Menüs. Du kannst das jederzeit unter Einstellungen → Player ändern.", "native_offer_try": "Ausprobieren", - "native_offer_not_now": "Nicht jetzt" + "native_offer_not_now": "Nicht jetzt", + "trying_again": "Neuer Versuch (Versuch {})", + "try_mpv": "Mit der mpv-Engine versuchen", + "ok_to_skip": "OK zum Überspringen", + "quality_lowered": "Qualität reduziert", + "undo": "Rückgängig" }, "profile": { "title": "Profil", diff --git a/assets/translations/en.json b/assets/translations/en.json index de087504..512af5bf 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -306,7 +306,11 @@ "no_source": "No source for this episode.", "empty_url": "This source returned no video address.", "engine_unavailable": "The player did not start. Please fully restart the app.", - "unknown": "Could not load video." + "unknown": "Could not load video.", + "not_found": "This video is no longer on this server. Try another server.", + "server_error": "The video server is having problems. Try again in a moment, or pick another server.", + "resolve_failed": "Couldn't get a video link from this source.", + "details": "Details" }, "play": "Play", "pause": "Pause", @@ -591,7 +595,12 @@ "native_offer_title": "Try the new player?", "native_offer_body": "Apple's Liquid Glass controls and menus. You can change this anytime in Settings → Player.", "native_offer_try": "Try it", - "native_offer_not_now": "Not now" + "native_offer_not_now": "Not now", + "trying_again": "Trying again (attempt {})", + "try_mpv": "Try with the mpv engine", + "ok_to_skip": "OK to skip", + "quality_lowered": "Lowered quality", + "undo": "Undo" }, "profile": { "title": "Profile", diff --git a/assets/translations/es.json b/assets/translations/es.json index 339931f5..f66bea3e 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -306,7 +306,11 @@ "no_source": "No hay fuente para este episodio.", "empty_url": "Esta fuente no devolvió ninguna dirección de vídeo.", "engine_unavailable": "El reproductor no arrancó. Reinicia la app por completo.", - "unknown": "No se pudo cargar el vídeo." + "unknown": "No se pudo cargar el vídeo.", + "not_found": "Este vídeo ya no está en este servidor. Prueba con otro servidor.", + "server_error": "El servidor de vídeo tiene problemas. Vuelve a intentarlo en un momento o elige otro servidor.", + "resolve_failed": "No se pudo obtener un enlace de vídeo de esta fuente.", + "details": "Detalles" }, "play": "Reproducir", "pause": "Pausar", @@ -591,7 +595,12 @@ "native_offer_title": "¿Probar el nuevo reproductor?", "native_offer_body": "Los controles y menús Liquid Glass de Apple. Puedes cambiarlo cuando quieras en Ajustes → Reproductor.", "native_offer_try": "Probar", - "native_offer_not_now": "Ahora no" + "native_offer_not_now": "Ahora no", + "trying_again": "Reintentando (intento {})", + "try_mpv": "Probar con el motor mpv", + "ok_to_skip": "OK para saltar", + "quality_lowered": "Calidad reducida", + "undo": "Deshacer" }, "profile": { "title": "Perfil", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 20dd2805..bde39061 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -306,7 +306,11 @@ "no_source": "Aucune source pour cet épisode.", "empty_url": "Cette source n’a renvoyé aucune adresse vidéo.", "engine_unavailable": "Le lecteur n’a pas démarré. Redémarre complètement l’appli.", - "unknown": "Impossible de charger la vidéo." + "unknown": "Impossible de charger la vidéo.", + "not_found": "Cette vidéo n'est plus sur ce serveur. Essayez un autre serveur.", + "server_error": "Le serveur vidéo rencontre des problèmes. Réessayez dans un instant ou choisissez un autre serveur.", + "resolve_failed": "Impossible d'obtenir un lien vidéo depuis cette source.", + "details": "Détails" }, "play": "Lire", "pause": "Pause", @@ -591,7 +595,12 @@ "native_offer_title": "Essayer le nouveau lecteur ?", "native_offer_body": "Les commandes et menus Liquid Glass d’Apple. Vous pouvez changer cela à tout moment dans Réglages → Lecteur.", "native_offer_try": "Essayer", - "native_offer_not_now": "Plus tard" + "native_offer_not_now": "Plus tard", + "trying_again": "Nouvel essai (tentative {})", + "try_mpv": "Essayer avec le moteur mpv", + "ok_to_skip": "OK pour passer", + "quality_lowered": "Qualité réduite", + "undo": "Annuler" }, "profile": { "title": "Profil", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 4c402bff..2415c31e 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -306,7 +306,11 @@ "no_source": "इस एपिसोड के लिए कोई सोर्स नहीं है।", "empty_url": "इस सोर्स से वीडियो का कोई पता नहीं मिला।", "engine_unavailable": "प्लेयर शुरू नहीं हुआ। कृपया ऐप को पूरी तरह बंद करके फिर से खोलें।", - "unknown": "वीडियो लोड नहीं हो पाया।" + "unknown": "वीडियो लोड नहीं हो पाया।", + "not_found": "यह वीडियो अब इस सर्वर पर नहीं है। कोई दूसरा सर्वर आज़माएँ।", + "server_error": "वीडियो सर्वर में समस्या है। थोड़ी देर में फिर कोशिश करें या कोई दूसरा सर्वर चुनें।", + "resolve_failed": "इस स्रोत से वीडियो लिंक नहीं मिल पाया।", + "details": "विवरण" }, "play": "चलाएँ", "pause": "रोकें", @@ -591,7 +595,12 @@ "native_offer_title": "नया प्लेयर आज़माएँ?", "native_offer_body": "Apple के Liquid Glass कंट्रोल और मेन्यू। आप इसे कभी भी सेटिंग्स → प्लेयर में बदल सकते हैं।", "native_offer_try": "आज़माएँ", - "native_offer_not_now": "अभी नहीं" + "native_offer_not_now": "अभी नहीं", + "trying_again": "फिर से कोशिश हो रही है (प्रयास {})", + "try_mpv": "mpv इंजन से आज़माएँ", + "ok_to_skip": "छोड़ने के लिए OK", + "quality_lowered": "क्वालिटी कम की गई", + "undo": "पूर्ववत करें" }, "profile": { "title": "प्रोफ़ाइल", diff --git a/assets/translations/id.json b/assets/translations/id.json index 73960bf6..cb8e572f 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -306,7 +306,11 @@ "no_source": "Tidak ada sumber untuk episode ini.", "empty_url": "Sumber ini tidak mengembalikan alamat video.", "engine_unavailable": "Pemutar tidak jalan. Mulai ulang aplikasi sepenuhnya.", - "unknown": "Video tidak bisa dimuat." + "unknown": "Video tidak bisa dimuat.", + "not_found": "Video ini sudah tidak ada di server ini. Coba server lain.", + "server_error": "Server video sedang bermasalah. Coba lagi sebentar lagi atau pilih server lain.", + "resolve_failed": "Tidak bisa mendapatkan tautan video dari sumber ini.", + "details": "Detail" }, "play": "Putar", "pause": "Jeda", @@ -591,7 +595,12 @@ "native_offer_title": "Coba pemutar baru?", "native_offer_body": "Kontrol dan menu Liquid Glass dari Apple. Anda bisa mengubahnya kapan saja di Pengaturan → Pemutar.", "native_offer_try": "Coba", - "native_offer_not_now": "Nanti saja" + "native_offer_not_now": "Nanti saja", + "trying_again": "Mencoba lagi (percobaan {})", + "try_mpv": "Coba dengan mesin mpv", + "ok_to_skip": "OK untuk melewati", + "quality_lowered": "Kualitas diturunkan", + "undo": "Urungkan" }, "profile": { "title": "Profil", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c74a718c..ef6eb141 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -306,7 +306,11 @@ "no_source": "Ji bo vê beşê çavkanî tune.", "empty_url": "Vê çavkaniyê tu navnîşana vîdyoyê neda.", "engine_unavailable": "Lêdar dest pê nekir. Ji kerema xwe sepanê bi tevahî ji nû ve bide destpêkirin.", - "unknown": "Vîdyo nehat barkirin." + "unknown": "Vîdyo nehat barkirin.", + "not_found": "Ev vîdyo êdî li ser vê serverê nîne. Serverek din biceribîne.", + "server_error": "Servera vîdyoyê pirsgirêk heye. Piştî demekê dîsa biceribîne an serverek din hilbijêre.", + "resolve_failed": "Ji vê çavkaniyê girêdana vîdyoyê nehat wergirtin.", + "details": "Hûrgilî" }, "play": "Lê bide", "pause": "Rawestîne", @@ -591,7 +595,12 @@ "native_offer_title": "Lêdara nû biceribînî?", "native_offer_body": "Bişkok û menuyên Liquid Glass ên Apple. Tu dikarî vê her dem di Mîheng → Lêdar de biguherînî.", "native_offer_try": "Biceribîne", - "native_offer_not_now": "Ne niha" + "native_offer_not_now": "Ne niha", + "trying_again": "Dîsa tê ceribandin (ceribandina {})", + "try_mpv": "Bi motora mpv biceribîne", + "ok_to_skip": "Ji bo derbaskirinê OK", + "quality_lowered": "Kalîte hat kêmkirin", + "undo": "Paşve vegerîne" }, "profile": { "title": "Profîl", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b48f0473..d6b28ab0 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -306,7 +306,11 @@ "no_source": "Geen bron voor deze aflevering.", "empty_url": "Deze bron gaf geen videoadres terug.", "engine_unavailable": "De speler is niet gestart. Start de app volledig opnieuw op.", - "unknown": "Video kon niet worden geladen." + "unknown": "Video kon niet worden geladen.", + "not_found": "Deze video staat niet meer op deze server. Probeer een andere server.", + "server_error": "De videoserver heeft problemen. Probeer het zo opnieuw of kies een andere server.", + "resolve_failed": "Kon geen videolink van deze bron krijgen.", + "details": "Details" }, "play": "Afspelen", "pause": "Pauze", @@ -591,7 +595,12 @@ "native_offer_title": "De nieuwe speler proberen?", "native_offer_body": "De Liquid Glass-bediening en -menu’s van Apple. Je kunt dit altijd wijzigen via Instellingen → Speler.", "native_offer_try": "Proberen", - "native_offer_not_now": "Niet nu" + "native_offer_not_now": "Niet nu", + "trying_again": "Opnieuw proberen (poging {})", + "try_mpv": "Proberen met de mpv-engine", + "ok_to_skip": "OK om over te slaan", + "quality_lowered": "Kwaliteit verlaagd", + "undo": "Ongedaan maken" }, "profile": { "title": "Profiel", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index ea4bf092..0576742b 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -306,7 +306,11 @@ "no_source": "Nenhuma fonte para este episódio.", "empty_url": "Esta fonte não devolveu nenhum endereço de vídeo.", "engine_unavailable": "O player não iniciou. Reinicie o app por completo.", - "unknown": "Não foi possível carregar o vídeo." + "unknown": "Não foi possível carregar o vídeo.", + "not_found": "Este vídeo não está mais neste servidor. Tente outro servidor.", + "server_error": "O servidor de vídeo está com problemas. Tente novamente em instantes ou escolha outro servidor.", + "resolve_failed": "Não foi possível obter um link de vídeo desta fonte.", + "details": "Detalhes" }, "play": "Reproduzir", "pause": "Pausar", @@ -591,7 +595,12 @@ "native_offer_title": "Experimentar o novo player?", "native_offer_body": "Os controles e menus Liquid Glass da Apple. Você pode mudar isso a qualquer momento em Configurações → Player.", "native_offer_try": "Experimentar", - "native_offer_not_now": "Agora não" + "native_offer_not_now": "Agora não", + "trying_again": "Tentando novamente (tentativa {})", + "try_mpv": "Tentar com o motor mpv", + "ok_to_skip": "OK para pular", + "quality_lowered": "Qualidade reduzida", + "undo": "Desfazer" }, "profile": { "title": "Perfil", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 454de6c9..99798d48 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -306,7 +306,11 @@ "no_source": "Для этой серии нет источника.", "empty_url": "Этот источник не вернул адрес видео.", "engine_unavailable": "Плеер не запустился. Полностью перезапустите приложение.", - "unknown": "Не удалось загрузить видео." + "unknown": "Не удалось загрузить видео.", + "not_found": "Этого видео больше нет на этом сервере. Выберите другой сервер.", + "server_error": "У видеосервера проблемы. Попробуйте чуть позже или выберите другой сервер.", + "resolve_failed": "Не удалось получить ссылку на видео из этого источника.", + "details": "Подробности" }, "play": "Воспроизвести", "pause": "Пауза", @@ -591,7 +595,12 @@ "native_offer_title": "Попробовать новый плеер?", "native_offer_body": "Кнопки и меню Liquid Glass от Apple. Это можно изменить в любой момент в Настройки → Плеер.", "native_offer_try": "Попробовать", - "native_offer_not_now": "Не сейчас" + "native_offer_not_now": "Не сейчас", + "trying_again": "Повторная попытка (попытка {})", + "try_mpv": "Попробовать с движком mpv", + "ok_to_skip": "OK — пропустить", + "quality_lowered": "Качество снижено", + "undo": "Отменить" }, "profile": { "title": "Профиль", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 8c3cec9b..8841bca5 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -306,7 +306,11 @@ "no_source": "Bu bölüm için kaynak yok.", "empty_url": "Bu kaynak hiçbir video adresi döndürmedi.", "engine_unavailable": "Oynatıcı başlamadı. Lütfen uygulamayı tamamen yeniden başlat.", - "unknown": "Video yüklenemedi." + "unknown": "Video yüklenemedi.", + "not_found": "Bu video artık bu sunucuda yok. Başka bir sunucu deneyin.", + "server_error": "Video sunucusunda sorun var. Birazdan tekrar deneyin veya başka bir sunucu seçin.", + "resolve_failed": "Bu kaynaktan video bağlantısı alınamadı.", + "details": "Ayrıntılar" }, "play": "Oynat", "pause": "Duraklat", @@ -591,7 +595,12 @@ "native_offer_title": "Yeni oynatıcıyı denemek ister misiniz?", "native_offer_body": "Apple’ın Liquid Glass kontrolleri ve menüleri. Bunu istediğiniz zaman Ayarlar → Oynatıcı bölümünden değiştirebilirsiniz.", "native_offer_try": "Dene", - "native_offer_not_now": "Şimdi değil" + "native_offer_not_now": "Şimdi değil", + "trying_again": "Yeniden deneniyor ({}. deneme)", + "try_mpv": "mpv motoruyla dene", + "ok_to_skip": "Atlamak için OK", + "quality_lowered": "Kalite düşürüldü", + "undo": "Geri al" }, "profile": { "title": "Profil", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 79885c27..12489aaf 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -306,7 +306,11 @@ "no_source": "Bu qism uchun manba topilmadi.", "empty_url": "Bu manba video manzilini qaytarmadi.", "engine_unavailable": "Pleyer ishga tushmadi. Ilovani butunlay qayta ishga tushiring.", - "unknown": "Videoni yuklab bo‘lmadi." + "unknown": "Videoni yuklab bo‘lmadi.", + "not_found": "Bu video endi ushbu serverda yo‘q. Boshqa serverni tanlang.", + "server_error": "Video serverida muammo bor. Birozdan so‘ng qayta urinib ko‘ring yoki boshqa serverni tanlang.", + "resolve_failed": "Bu manbadan video havolasini olib bo‘lmadi.", + "details": "Tafsilotlar" }, "play": "O'ynash", "pause": "To'xtatish", @@ -591,7 +595,12 @@ "native_offer_title": "Yangi pleyerni sinab ko‘rasizmi?", "native_offer_body": "Apple’ning Liquid Glass tugmalari va menyulari. Buni istalgan vaqt Sozlamalar → Pleyer bo‘limida o‘zgartirishingiz mumkin.", "native_offer_try": "Sinab ko‘rish", - "native_offer_not_now": "Hozir emas" + "native_offer_not_now": "Hozir emas", + "trying_again": "Qayta urinilmoqda ({}-urinish)", + "try_mpv": "mpv dvigateli bilan sinash", + "ok_to_skip": "O‘tkazish uchun OK", + "quality_lowered": "Sifat pasaytirildi", + "undo": "Bekor qilish" }, "profile": { "title": "Profil", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 0df9c365..34e05e04 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -306,7 +306,11 @@ "no_source": "Tập này không có nguồn.", "empty_url": "Nguồn này không trả về địa chỉ video.", "engine_unavailable": "Trình phát không khởi động được. Vui lòng khởi động lại hẳn ứng dụng.", - "unknown": "Không tải được video." + "unknown": "Không tải được video.", + "not_found": "Video này không còn trên máy chủ này. Hãy thử máy chủ khác.", + "server_error": "Máy chủ video đang gặp sự cố. Hãy thử lại sau giây lát hoặc chọn máy chủ khác.", + "resolve_failed": "Không lấy được liên kết video từ nguồn này.", + "details": "Chi tiết" }, "play": "Phát", "pause": "Tạm dừng", @@ -591,7 +595,12 @@ "native_offer_title": "Dùng thử trình phát mới?", "native_offer_body": "Nút điều khiển và menu Liquid Glass của Apple. Bạn có thể thay đổi bất cứ lúc nào trong Cài đặt → Trình phát.", "native_offer_try": "Dùng thử", - "native_offer_not_now": "Để sau" + "native_offer_not_now": "Để sau", + "trying_again": "Đang thử lại (lần {})", + "try_mpv": "Thử với trình phát mpv", + "ok_to_skip": "Nhấn OK để bỏ qua", + "quality_lowered": "Đã giảm chất lượng", + "undo": "Hoàn tác" }, "profile": { "title": "Hồ sơ", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 1dbc9d5a..ec974732 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -306,7 +306,11 @@ "no_source": "呢一集冇來源。", "empty_url": "呢個來源冇畀到影片地址。", "engine_unavailable": "播放器啟動唔到。請完全重開個 app。", - "unknown": "載入唔到影片。" + "unknown": "載入唔到影片。", + "not_found": "呢個伺服器已經冇咗呢段影片,試吓第二個伺服器。", + "server_error": "影片伺服器出咗問題,等陣再試或者揀第二個伺服器。", + "resolve_failed": "喺呢個來源攞唔到影片連結。", + "details": "詳情" }, "play": "播放", "pause": "暫停", @@ -591,7 +595,12 @@ "native_offer_title": "試吓新播放器?", "native_offer_body": "Apple 嘅 Liquid Glass 控制同選單。你隨時可以喺 設定 → 播放器 度改返。", "native_offer_try": "試吓", - "native_offer_not_now": "遲啲先" + "native_offer_not_now": "遲啲先", + "trying_again": "再試緊(第 {} 次)", + "try_mpv": "用 mpv 引擎試吓", + "ok_to_skip": "撳 OK 跳過", + "quality_lowered": "已降低畫質", + "undo": "復原" }, "profile": { "title": "個人檔案", diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index b43c6dbd..8d35defe 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -291,6 +291,8 @@ class AppConstants { static const String nativeControlsOfferedKey = 'native_controls_offered'; static const String doubleTapSeekSecondsKey = 'double_tap_seek_seconds'; static const String longPressBoostKey = 'long_press_boost'; + static const String desktopVolumeKey = 'desktop_player_volume'; + static const String desktopMutedKey = 'desktop_player_muted'; static const String brightnessGestureKey = 'brightness_gesture'; /// Whether the detail header plays a title's trailer by itself. diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 2f6dfcc8..b83b5615 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -37,6 +37,10 @@ abstract class PlayerController extends ValueNotifier { /// quality preference; [hlsBitrateFor] maps one to the other. static String mpvHlsBitrate = 'max'; + /// mpv's `hwdec` for the next stream, or null for the default. Set to 'no' + /// for a single retry after mpv failed mid-playback. + static String? mpvHwdec; + /// The `hls-bitrate` for a preferred height (see QualityPreference): the /// top rendition for Auto, the bottom for Data saver, and otherwise a /// ceiling a little above what that height usually streams at, so mpv opens @@ -62,6 +66,7 @@ abstract class PlayerController extends ValueNotifier { vp.VideoPlayerOptions? videoPlayerOptions, DrmConfig? drm, bool preferPlatform = false, + bool preferMediaKit = false, }) { // Encryption decides the backend before anything else does. libmpv cannot // decrypt CENC at all and `video_player` exposes no way to configure it, so @@ -102,7 +107,9 @@ abstract class PlayerController extends ValueNotifier { // reaches here — the player page hands off before building a controller — // but if it somehow does, falling through to the native backend is the // safe answer, not a crash. - if (!preferPlatform && _useMediaKit() && _mediaKitUsable()) { + if (!preferPlatform && + (preferMediaKit || _useMediaKit()) && + _mediaKitUsable()) { return _MediaKitController( _MediaKitSource.uri(url, httpHeaders, formatHint: formatHint), ); @@ -121,8 +128,11 @@ abstract class PlayerController extends ValueNotifier { File file, { vp.VideoPlayerOptions? videoPlayerOptions, bool preferPlatform = false, + bool preferMediaKit = false, }) { - if (!preferPlatform && _useMediaKit() && _mediaKitUsable()) { + if (!preferPlatform && + (preferMediaKit || _useMediaKit()) && + _mediaKitUsable()) { return _MediaKitController(_MediaKitSource.path(file.path)); } return _NativeController( @@ -141,6 +151,12 @@ abstract class PlayerController extends ValueNotifier { Future setVolume(double volume); Future setLooping(bool looping); + /// The loudest [setVolume] accepts: 1.0, or 1.5 where libmpv amplifies. + double get maxVolume => 1.0; + + /// Whether libmpv is the engine actually playing (not its platform fallback). + bool get isMpv => false; + bool get letterboxesInternally; /// Whether this backend can enumerate and switch audio tracks at all. @@ -778,6 +794,24 @@ class _MediaKitController extends PlayerController { // here because nothing sets a --vf filter (colour profiles and shaders // act in the VO), the one case mpv's manual says framedrop can skip // the target frame. + // Up to 150%, for quiet sources. Only claimed once mpv accepted it. + try { + await platform.setProperty( + 'volume-max', + '${(_boostMax * 100).round()}', + ); + _boostReady = true; + } catch (e) { + debugPrint('[player] volume-max not applied: $e'); + } + final hwdec = PlayerController.mpvHwdec; + if (hwdec != null) { + try { + await platform.setProperty('hwdec', hwdec); + } catch (e) { + debugPrint('[player] hwdec not applied: $e'); + } + } try { await platform.setProperty('hr-seek-framedrop', 'yes'); } catch (e) { @@ -1303,10 +1337,21 @@ class _MediaKitController extends PlayerController { Future setPlaybackSpeed(double speed) => _fallback?.setPlaybackSpeed(speed) ?? _player.setRate(speed); + static const double _boostMax = 1.5; + bool _boostReady = false; + + @override + double get maxVolume => _fallback != null + ? _fallback!.maxVolume + : (_boostReady ? _boostMax : 1.0); + + @override + bool get isMpv => _fallback == null; + @override Future setVolume(double volume) => _fallback != null ? _fallback!.setVolume(volume) - : _player.setVolume((volume * 100).clamp(0.0, 100.0)); + : _player.setVolume((volume * 100).clamp(0.0, maxVolume * 100)); @override Future setLooping(bool looping) => _fallback != null diff --git a/lib/core/player/mpv_auto_quality.dart b/lib/core/player/mpv_auto_quality.dart new file mode 100644 index 00000000..db559642 --- /dev/null +++ b/lib/core/player/mpv_auto_quality.dart @@ -0,0 +1,97 @@ +/// libmpv's "Auto" quality, which mpv itself does not have. +/// +/// mpv opens one HLS rendition (`hls-bitrate`) and never switches. Auto used to +/// mean "the top one" everywhere, which on mobile data rebuffered every few +/// seconds. Now the start follows the connection, and repeated stalls step +/// down one rung. +library; + +import 'package:soplay/core/player/media_controller.dart'; + +enum NetworkKind { unmetered, cellular, unknown } + +abstract final class MpvAutoQuality { + /// Auto's ceiling on mobile data. + static const int cellularCap = 720; + + static const List rungs = [2160, 1440, 1080, 720, 480, 360]; + + /// The `hls-bitrate` to open with. Only Auto (0) depends on the network. + static String startBitrate({ + required int preference, + required NetworkKind network, + }) { + if (preference != 0) return PlayerController.hlsBitrateFor(preference); + return network == NetworkKind.cellular + ? PlayerController.hlsBitrateFor(cellularCap) + : 'max'; + } + + /// One rung below [currentHeight], or null at the bottom already. + static String? stepDown({int? currentHeight, required String current}) { + final h = currentHeight; + if (h != null && h > 0) { + for (final r in rungs) { + if (r < h - h * 0.08) return PlayerController.hlsBitrateFor(r); + } + return current == 'min' ? null : 'min'; + } + if (current == 'min') return null; + if (current == 'max') return PlayerController.hlsBitrateFor(1080); + final bps = int.tryParse(current); + if (bps == null) return null; + for (final r in rungs) { + final b = PlayerController.hlsBitrateFor(r); + if (int.parse(b) < bps) return b; + } + return 'min'; + } +} + +/// Counts buffering stalls during playback. [stalls] of them inside [window] +/// trips it; a stall right after a seek is the seek, not the network. +class StallWatchdog { + StallWatchdog({ + this.stalls = 3, + this.window = const Duration(seconds: 60), + this.afterSeek = const Duration(seconds: 3), + }); + + final int stalls; + final Duration window; + final Duration afterSeek; + + final List _at = []; + Duration? _seekAt; + bool _disabled = false; + + bool get disabled => _disabled; + + /// After an undo the viewer has said what they want; no more stepping. + void disable() { + _disabled = true; + _at.clear(); + } + + void noteSeek(Duration now) => _seekAt = now; + + /// A stall began at [now]. True when it is the one that trips the watchdog, + /// which then starts counting afresh. + bool recordStall(Duration now) { + if (_disabled) return false; + final seek = _seekAt; + if (seek != null && now - seek <= afterSeek) return false; + _at.removeWhere((t) => now - t > window); + _at.add(now); + if (_at.length >= stalls) { + _at.clear(); + return true; + } + return false; + } + + void reset() { + _at.clear(); + _seekAt = null; + } +} diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index f77b7103..8e0cc4ca 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -757,6 +757,17 @@ class HiveService { await _settingsBox.put(AppConstants.longPressBoostKey, rate); } + /// The desktop player's own volume (1.0 = 100%, up to 1.5 on mpv) and mute. + ({Object? level, Object? muted}) getDesktopVolume() => ( + level: _settingsBox.get(AppConstants.desktopVolumeKey), + muted: _settingsBox.get(AppConstants.desktopMutedKey), + ); + + Future saveDesktopVolume(double level, bool muted) async { + await _settingsBox.put(AppConstants.desktopVolumeKey, level); + await _settingsBox.put(AppConstants.desktopMutedKey, muted); + } + /// Whether to remind before an episode on the AniList list airs. bool get airingRemindersEnabled => _settingsBox.get(AppConstants.airingRemindersKey, defaultValue: false) == diff --git a/lib/features/detail/domain/playback/desktop_volume.dart b/lib/features/detail/domain/playback/desktop_volume.dart new file mode 100644 index 00000000..892acb00 --- /dev/null +++ b/lib/features/detail/domain/playback/desktop_volume.dart @@ -0,0 +1,40 @@ +/// The in-app volume on desktop, where there is no system level to follow. +/// +/// Every controller starts at 100%, so the level and mute have to be put back +/// on each new one (a new episode, a retry, a server switch). +library; + +class DesktopVolume { + const DesktopVolume({this.level = 1.0, this.muted = false}); + + /// 1.0 is 100%. libmpv accepts up to [boostMax] with `volume-max`. + static const double boostMax = 1.5; + + final double level; + final bool muted; + + double get applied => muted ? 0 : level; + + DesktopVolume withLevel(double value, {double max = 1.0}) { + final v = value.clamp(0.0, max).toDouble(); + return DesktopVolume(level: v <= 0.001 ? level : v, muted: v <= 0.001); + } + + DesktopVolume toggleMute() => + DesktopVolume(level: level <= 0.001 ? 1.0 : level, muted: !muted); + + /// Clamped to what the engine accepts now, without forgetting the stored + /// level (a 140% set on mpv survives an episode on the platform player). + Future applyTo( + Future Function(double) setVolume, { + double max = 1.0, + }) => setVolume(applied.clamp(0.0, max).toDouble()); + + static DesktopVolume restore(Object? level, Object? muted) { + final l = level is num ? level.toDouble() : 1.0; + return DesktopVolume( + level: l <= 0.001 ? 1.0 : l.clamp(0.0, boostMax).toDouble(), + muted: muted == true, + ); + } +} diff --git a/lib/features/detail/domain/playback/end_of_stream.dart b/lib/features/detail/domain/playback/end_of_stream.dart new file mode 100644 index 00000000..0a03eb11 --- /dev/null +++ b/lib/features/detail/domain/playback/end_of_stream.dart @@ -0,0 +1,66 @@ +/// Decides when an episode has ended, once. +/// +/// The player used to trust only "two seconds left". Streams whose playlist +/// overstates the duration never get there: the engine reports completion, or +/// the playhead freezes a few seconds short and buffers forever, and the +/// episode neither advanced nor counted as finished. +library; + +class EndOfStreamDetector { + /// Inside this much of the end, the last moments are being played. + static const Duration nearEnd = Duration(seconds: 2); + + /// Inside this much of the end, a playhead that stops moving is the end. + static const Duration stallZone = Duration(seconds: 5); + + /// How long the playhead must stay put in [stallZone]. + static const Duration stallFor = Duration(seconds: 3); + + Duration? _lastPosition; + Duration? _movedAt; + bool _fired = false; + + bool get fired => _fired; + + /// True exactly once per ending. Moving back out of the last stretch (a seek + /// back) re-arms it. + bool observe({ + required Duration position, + required Duration duration, + required bool isCompleted, + required bool isPlaying, + required bool isBuffering, + required Duration now, + }) { + if (duration <= Duration.zero) return false; + final remaining = duration - position; + + if (_lastPosition != position) { + _lastPosition = position; + _movedAt = now; + } + + if (remaining > stallZone + const Duration(seconds: 1) && !isCompleted) { + _fired = false; + return false; + } + if (_fired) return false; + + final movedAt = _movedAt ?? now; + final stalled = + remaining <= stallZone && + (isPlaying || isBuffering) && + now - movedAt >= stallFor; + if (isCompleted || remaining <= nearEnd || stalled) { + _fired = true; + return true; + } + return false; + } + + void reset() { + _lastPosition = null; + _movedAt = null; + _fired = false; + } +} diff --git a/lib/features/detail/domain/playback/lock_pill.dart b/lib/features/detail/domain/playback/lock_pill.dart new file mode 100644 index 00000000..fa68542e --- /dev/null +++ b/lib/features/detail/domain/playback/lock_pill.dart @@ -0,0 +1,36 @@ +/// The unlock pill on a locked screen: shown on lock and on every tap, gone +/// [visibleFor] later. Unlocking takes a tap on the pill while it is showing, +/// so a stray touch in a pocket only brings it back. +library; + +class LockPill { + LockPill({this.visibleFor = const Duration(seconds: 3)}); + + final Duration visibleFor; + Duration? _shownAt; + + void show(Duration now) => _shownAt = now; + + void hide() => _shownAt = null; + + bool isVisible(Duration now) { + final at = _shownAt; + return at != null && now - at < visibleFor; + } + + /// When the pill should next be repainted as gone, or null if hidden. + Duration? hidesAt() { + final at = _shownAt; + return at == null ? null : at + visibleFor; + } + + /// A tap on the pill: unlocks only if it was visible. Otherwise it is shown. + bool tapPill(Duration now) { + if (isVisible(now)) { + hide(); + return true; + } + show(now); + return false; + } +} diff --git a/lib/features/detail/domain/playback/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index cf13bfbc..343aa68d 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -35,6 +35,12 @@ enum PlaybackFaultKind { /// The stream could not be fetched. The connection is the first suspect. network('player.fault.network'), + /// The server answered 404 or 410: the file is gone from this mirror. + notFound('player.fault.not_found'), + + /// The server answered 5xx or 429: its problem, not the device's. + serverError('player.fault.server_error'), + /// The resolve produced no playable source for this episode. noSourceForEpisode('player.fault.no_source'), @@ -95,6 +101,19 @@ class PlaybackFault { lower.contains('renderer')) { return PlaybackFault(PlaybackFaultKind.decoder, raw: raw); } + final status = httpStatusOf(lower); + if (status != null) { + if (status == 404 || status == 410) { + return PlaybackFault(PlaybackFaultKind.notFound, raw: raw); + } + if (status >= 500 || status == 429) { + return PlaybackFault(PlaybackFaultKind.serverError, raw: raw); + } + return PlaybackFault(PlaybackFaultKind.sourceBlocked, raw: raw); + } + if (lower.contains('5xx server error')) { + return PlaybackFault(PlaybackFaultKind.serverError, raw: raw); + } // libmpv's "Failed to open ": the host answered with a refusal // (often a 403 without the Referer it expects) or not at all. The raw // line, with its long address, read as the whole message on screen. @@ -116,6 +135,8 @@ class PlaybackFault { lower.contains('connection reset') || lower.contains('connection timed out') || lower.contains('connection errored') || + // Dio's timeouts: "The request connection took longer than 0:00:15…". + lower.contains('took longer than') || lower.contains('failed to resolve hostname') || lower.contains('failed host lookup') || lower.contains('network is unreachable') || @@ -125,6 +146,28 @@ class PlaybackFault { return PlaybackFault(PlaybackFaultKind.unknown, raw: raw); } + static final RegExp _statusAfterLabel = RegExp( + r'(?:status code(?: of)?|response code|http error|server returned|' + r'statuscode|http/\d(?:\.\d)?)[:=\s]*(\d{3})\b', + ); + static final RegExp _statusBeforeReason = RegExp( + r'\b([45]\d{2})\s*[-:]?\s*(?:forbidden|not found|gone|unauthorized|' + r'bad request|too many requests|internal server error|bad gateway|' + r'service unavailable|gateway time-?out)', + ); + + /// The HTTP status a failure names, from Dio, ExoPlayer, ffmpeg or an error + /// page's body, or null. Only 4xx and 5xx; a bare number is not enough. + static int? httpStatusOf(String lower) { + for (final re in [_statusAfterLabel, _statusBeforeReason]) { + for (final m in re.allMatches(lower)) { + final code = int.tryParse(m.group(1)!); + if (code != null && code >= 400 && code < 600) return code; + } + } + return null; + } + /// The key for what the error screen says once nothing is left to try, or /// null to say what [classify] makes of [raw]. /// diff --git a/lib/features/detail/domain/playback/tap_seek.dart b/lib/features/detail/domain/playback/tap_seek.dart new file mode 100644 index 00000000..62907595 --- /dev/null +++ b/lib/features/detail/domain/playback/tap_seek.dart @@ -0,0 +1,113 @@ +/// Taps on the picture: single taps toggle the controls, double taps on a side +/// seek, and while the seek ripple is up every further tap on that side adds +/// another step (YouTube, MX Player). No Flutter import, so it is testable on +/// its own with a fake clock. +library; + +enum TapZone { left, centre, right } + +enum TapAction { + /// Wait: this may be the first half of a double tap. Call + /// [TapSeekTracker.flush] once [TapSeekTracker.doubleTapWindow] has passed. + pending, + toggleControls, + seekBack, + seekForward, + togglePlay, +} + +class TapSeekTracker { + TapSeekTracker({ + this.doubleTapWindow = const Duration(milliseconds: 260), + this.rippleWindow = const Duration(milliseconds: 700), + }); + + final Duration doubleTapWindow; + + /// How long the ripple stays after the last step; the page's ripple timer + /// must use the same value. + final Duration rippleWindow; + + TapZone? _pendingZone; + Duration? _pendingAt; + TapZone? _seekZone; + Duration? _lastSeekAt; + int _steps = 0; + + /// Steps taken in the current run of taps on one side. + int get steps => _steps; + + bool get hasPending => _pendingZone != null; + + static TapZone zoneOf(double dx, double width) { + if (width <= 0) return TapZone.centre; + if (dx < width * 0.3) return TapZone.left; + if (dx > width * 0.7) return TapZone.right; + return TapZone.centre; + } + + bool rippleActive(Duration now) { + final last = _lastSeekAt; + return last != null && now - last <= rippleWindow; + } + + TapAction onTap( + TapZone zone, + Duration now, { + required bool canSeek, + bool centreTogglesPlay = true, + }) { + final side = zone != TapZone.centre; + if (side && canSeek && _seekZone == zone && rippleActive(now)) { + _pendingZone = null; + _pendingAt = null; + return _seek(zone, now, continuing: true); + } + + final pendingAt = _pendingAt; + if (pendingAt != null && + _pendingZone == zone && + now - pendingAt <= doubleTapWindow) { + _pendingZone = null; + _pendingAt = null; + if (side && canSeek) return _seek(zone, now, continuing: false); + if (!side && centreTogglesPlay) return TapAction.togglePlay; + return TapAction.toggleControls; + } + + final doublePlausible = side ? canSeek : centreTogglesPlay; + if (!doublePlausible) { + _pendingZone = null; + _pendingAt = null; + return TapAction.toggleControls; + } + _pendingZone = zone; + _pendingAt = now; + return TapAction.pending; + } + + /// The pending tap, resolved as a single tap once its window is over, or + /// null if nothing is pending (a second tap took it, or the window is open). + TapAction? flush(Duration now) { + final at = _pendingAt; + if (at == null || now - at < doubleTapWindow) return null; + _pendingZone = null; + _pendingAt = null; + return TapAction.toggleControls; + } + + void reset() { + _pendingZone = null; + _pendingAt = null; + _seekZone = null; + _lastSeekAt = null; + _steps = 0; + } + + TapAction _seek(TapZone zone, Duration now, {required bool continuing}) { + _steps = continuing && _seekZone == zone ? _steps + 1 : 1; + _seekZone = zone; + _lastSeekAt = now; + return zone == TapZone.left ? TapAction.seekBack : TapAction.seekForward; + } +} diff --git a/lib/features/detail/presentation/pages/player_page.aniskip.dart b/lib/features/detail/presentation/pages/player_page.aniskip.dart index d1b2f130..9939f1a2 100644 --- a/lib/features/detail/presentation/pages/player_page.aniskip.dart +++ b/lib/features/detail/presentation/pages/player_page.aniskip.dart @@ -92,7 +92,14 @@ extension _PlayerAniSkip on _PlayerPageState { if (identical(active, _activeSkip)) return; if (active == null && _activeSkip == null) return; - if (active != null && _hive.autoSkipIntro) { + // A guest who may not seek waits for the host's skip to arrive. + if (active != null && + _hive.autoSkipIntro && + !PartyRules.blocksLocalControl( + inParty: _inParty, + canControl: _canPartyControl, + applyingRemote: _applyingRemote, + )) { _takeSkip(active); return; } @@ -107,12 +114,8 @@ extension _PlayerAniSkip on _PlayerPageState { Future _takeSkip(SkipInterval interval) async { _skips.take(interval); if (mounted) setState(() => _activeSkip = null); - try { - await _controller?.seekTo(_skips.targetFor(interval)); - } catch (_) { - // A failed seek leaves playback where it was, which is survivable; the - // offer stays retired so it does not loop. - } + // Through _seekTo, so a party host's skip reaches the room. + _seekTo(_skips.targetFor(interval)); } /// The floating "Skip opening / Skip ending" button, or nothing. @@ -124,38 +127,62 @@ extension _PlayerAniSkip on _PlayerPageState { final label = active.type == 'ed' ? 'player.skip_ending'.tr() : 'player.skip_opening'.tr(); + // A remote with the controls hidden: OK takes it (see _onTvPlayerKey). + final okHint = isTvPlatform && !_controlsVisible; return Positioned( right: 20, bottom: _controlsVisible ? 92 : 28, - child: AnimatedOpacity( - opacity: 1, - duration: const Duration(milliseconds: 150), - child: Material( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(8), + child: _tvRing( + radius: 14, + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, child: InkWell( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), onTap: () => _takeSkip(active), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w700, + child: MediaGlass( + radius: 14, + dim: Colors.black.withValues(alpha: 0.6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (okHint) + Text( + 'player.ok_to_skip'.tr(), + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(width: 8), + Icon( + Icons.fast_forward_rounded, + color: AppColors.primaryLight, + size: 18, ), - ), - const SizedBox(width: 6), - const Icon( - Icons.fast_forward_rounded, - color: Colors.white, - size: 18, - ), - ], + ], + ), ), ), ), diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index f46637c4..782798aa 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -123,18 +123,26 @@ extension _PlayerControls on _PlayerPageState { ); } + /// The loudest the desktop slider goes: 150% on mpv, 100% otherwise. + double get _maxVolume => + isDesktopPlatform ? (_controller?.maxVolume ?? 1.0) : 1.0; + void _setPlayerVolume(double v) { - final clamped = v.clamp(0.0, 1.0); - setState(() => _volume = clamped); if (isDesktopPlatform) { - _controller?.setVolume(clamped); - } else { - unawaited(_setSystemVolume(clamped)); + _setDesktopVolume(_desktopVolume.withLevel(v, max: _maxVolume)); + return; } + final clamped = v.clamp(0.0, 1.0); + setState(() => _volume = clamped); + unawaited(_setSystemVolume(clamped)); _scheduleHide(); } void _toggleMute() { + if (isDesktopPlatform) { + _setDesktopVolume(_desktopVolume.toggleMute()); + return; + } if (_volume > 0.001) { _volumeBeforeMute = _volume; _setPlayerVolume(0); @@ -143,6 +151,17 @@ extension _PlayerControls on _PlayerPageState { } } + void _setDesktopVolume(DesktopVolume next) { + setState(() { + _desktopVolume = next; + _volume = next.applied; + }); + final c = _controller; + if (c != null) unawaited(next.applyTo(c.setVolume, max: c.maxVolume)); + unawaited(_hive.saveDesktopVolume(next.level, next.muted)); + _scheduleHide(); + } + void _seekRelative(Duration delta) { if (_partyBlockLocal()) return; // There is nothing to skip forward into on a broadcast, and skipping back @@ -158,6 +177,7 @@ extension _PlayerControls on _PlayerPageState { : next > c.value.duration ? c.value.duration : next; + _stallWatch.noteSeek(_now); c.seekTo(clamped); _scheduleHide(); if (!_isLive) { @@ -172,6 +192,7 @@ extension _PlayerControls on _PlayerPageState { // `Go live` is the one seek a broadcast accepts, and it asks for the very // end; anything else is a scrub bar that should not have been reachable. if (_isLive && position < c.value.duration) return; + _stallWatch.noteSeek(_now); c.seekTo(position); _scheduleHide(); if (!_isLive) { @@ -448,6 +469,216 @@ extension _PlayerControls on _PlayerPageState { ); } + /// The raw failure, when it says more than the headline. + String? get _errorDetail { + final raw = _errorRaw?.trim(); + if (raw == null || raw.isEmpty || raw == _errorMessage) return null; + return raw; + } + + /// Offered once the native engine on Android could not decode the stream. + bool get _canTryMpv => + _isCodecError && + defaultTargetPlatform == TargetPlatform.android && + !isAndroidEmulator && + !_mpvForSession && + _onPlatformPlayer && + !_isDash && + (_playSourceUrl ?? _videoUrl) != null; + + Widget _buildErrorView() { + final guest = _inParty && !_isPartyHost; + final detail = _errorDetail; + final buttons = [ + _PlayerGlassButton( + onPressed: _retry, + // Without this the error screen opens with nothing focused, so the + // remote has no landing spot and reads as frozen. + autofocus: isTvPlatform, + primary: true, + icon: Icons.refresh_rounded, + label: 'general.try_again'.tr(), + ), + if (_canTryMpv) + _PlayerGlassButton( + onPressed: _tryWithMpv, + icon: Icons.memory_rounded, + label: 'player.try_mpv'.tr(), + ), + if (_isCodecError && _videoUrl != null) + _PlayerGlassButton( + onPressed: () => launchUrl( + Uri.parse(_videoUrl!), + mode: LaunchMode.externalApplication, + ), + icon: Icons.open_in_browser_rounded, + label: 'player.play_in_browser'.tr(), + ), + // The mirrors this source already has come before other sources: a + // title on four servers where only one failed needs a server, not a + // search across every provider. + if (_affordances.hasServers && !guest) + _PlayerGlassButton( + onPressed: _openServerSheet, + icon: Icons.dns_rounded, + label: 'player.change_server'.tr(), + ), + if (!_isLive && !guest) + _PlayerGlassButton( + onPressed: _openAlternateSources, + icon: Icons.swap_horiz_rounded, + label: 'player.alt_sources'.tr(), + ), + // Tested against the RAW failure, not the translated one. + if (_cfWall != null || isCloudflareError(_errorRaw ?? _errorMessage)) + _PlayerGlassButton( + onPressed: () async { + final ok = _cfWall != null + ? await _solveStreamWall() + : await requestCloudflareSolve(context, widget.args.provider); + if (ok && mounted) _retry(); + }, + icon: Icons.shield_outlined, + label: 'cloudflare.solve'.tr(), + ), + ]; + final links = [ + TextButton.icon( + onPressed: () => context.push( + '/support/new', + extra: SupportRequestArgs( + category: SupportCategory.playback, + provider: widget.args.provider, + content: _supportContentLabel, + screen: 'player', + ), + ), + style: TextButton.styleFrom(foregroundColor: Colors.white60), + icon: const Icon(Icons.support_agent_rounded, size: 18), + label: Text('support.contact_support'.tr()), + ), + TextButton.icon( + onPressed: () => LogViewerSheet.show(context), + style: TextButton.styleFrom(foregroundColor: Colors.white60), + icon: const Icon(Icons.bug_report_outlined, size: 18), + label: Text('player.view_logs'.tr()), + ), + ]; + return ColoredBox( + color: Colors.black, + child: SafeArea( + child: LayoutBuilder( + builder: (context, box) { + final compact = box.maxHeight < 420; + final headline = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + color: Colors.white70, + size: compact ? 32 : 48, + ), + SizedBox(height: compact ? 8 : 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (detail != null) _buildErrorDetails(detail), + ], + ); + final Widget body; + if (compact) { + body = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: headline), + const SizedBox(width: 24), + Expanded( + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [...buttons, ...links], + ), + ), + ], + ); + } else { + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + headline, + const SizedBox(height: 16), + for (final b in buttons) ...[b, const SizedBox(height: 10)], + ...links, + ], + ); + } + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: compact ? 24 : 32, + vertical: 16, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: box.maxHeight - 32), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 760 : 420), + child: body, + ), + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildErrorDetails(String detail) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => + setState(() => _errorDetailsOpen = !_errorDetailsOpen), + style: TextButton.styleFrom( + foregroundColor: Colors.white54, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('player.fault.details'.tr()), + Icon( + _errorDetailsOpen + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, + ), + ], + ), + ), + if (_errorDetailsOpen) + SelectableText( + detail, + textAlign: TextAlign.center, + maxLines: 6, + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ], + ), + ); + } + 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 @@ -482,151 +713,7 @@ extension _PlayerControls on _PlayerPageState { ), ); } - if (_errorMessage != null) { - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.error_outline_rounded, - color: Colors.white70, - size: 48, - ), - const SizedBox(height: 12), - Text( - _errorMessage!, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 14), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _retry, - // Without this the error screen opens with nothing focused, - // so the remote has no landing spot and reads as frozen. - autofocus: isTvPlatform, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - ), - icon: const Icon(Icons.refresh_rounded, size: 18), - label: Text('general.try_again'.tr()), - ), - if (_isCodecError && _videoUrl != null) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () { - launchUrl( - Uri.parse(_videoUrl!), - mode: LaunchMode.externalApplication, - ); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), - ), - icon: const Icon(Icons.open_in_browser_rounded, size: 18), - label: Text('player.play_in_browser'.tr()), - ), - ], - // Offered on every playback failure, not only on the ones we - // can name. A source that has moved domain, minted a token the - // CDN now refuses, or simply lost the episode fails in a dozen - // different ways, and from the viewer's chair they are one - // problem: this show will not play here. The answer is the same - // in all of them. - // The mirrors this source already has, BEFORE offering to go - // looking at other sources. - // - // The screen jumped straight to "Try another source", which - // searches every other provider in the app — a slow, uncertain - // thing to do when the title in front of you is carried on - // four servers and only the one that was tried has failed. A - // title with several servers had no way to pick a different - // one from the error screen at all: the control exists, on the - // bar, which is not on screen when playback never started. - if (_affordances.hasServers && - !(_inParty && !_isPartyHost)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: _openServerSheet, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white38), - ), - icon: const Icon(Icons.dns_rounded, size: 18), - label: Text('player.change_server'.tr()), - ), - ], - if (!_isLive && !(_inParty && !_isPartyHost)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: _openAlternateSources, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), - ), - icon: const Icon(Icons.swap_horiz_rounded, size: 18), - label: Text('player.alt_sources'.tr()), - ), - ], - // Tested against the RAW failure, not the translated one — - // see [_errorRaw]. Against `_errorMessage` this never matched. - if (_cfWall != null || - isCloudflareError(_errorRaw ?? _errorMessage)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () async { - // The stream's own host when that is what the challenge - // stands in front of; the source's site otherwise. - final ok = _cfWall != null - ? await _solveStreamWall() - : await requestCloudflareSolve( - context, - widget.args.provider, - ); - if (ok && mounted) _retry(); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), - ), - icon: const Icon(Icons.shield_outlined, size: 18), - label: Text('cloudflare.solve'.tr()), - ), - ], - const SizedBox(height: 10), - // Straight to a playback request, with this title, source and - // the player log already attached (the viewer can remove it). - TextButton.icon( - onPressed: () => context.push( - '/support/new', - extra: SupportRequestArgs( - category: SupportCategory.playback, - provider: widget.args.provider, - content: _supportContentLabel, - screen: 'player', - ), - ), - style: TextButton.styleFrom(foregroundColor: Colors.white60), - icon: const Icon(Icons.support_agent_rounded, size: 18), - label: Text('support.contact_support'.tr()), - ), - TextButton.icon( - onPressed: () => LogViewerSheet.show(context), - style: TextButton.styleFrom(foregroundColor: Colors.white60), - icon: const Icon(Icons.bug_report_outlined, size: 18), - label: Text('player.view_logs'.tr()), - ), - ], - ), - ), - ), - ); - } + if (_errorMessage != null) return _buildErrorView(); final c = _controller; if (c == null || !c.value.isInitialized) { return const ColoredBox(color: Colors.black); @@ -1048,14 +1135,37 @@ extension _PlayerControls on _PlayerPageState { _controlsAnimation.reverse(); _hideTimer?.cancel(); }); + _showLockPill(); } void _unlock() { if (isMobilePlatform) HapticFeedback.mediumImpact(); + _lockPillTimer?.cancel(); + _lockPill.hide(); setState(() => _locked = false); } + /// Brings the unlock pill back for [LockPill.visibleFor]. + void _showLockPill() { + if (!_locked) return; + _lockPill.show(_now); + _lockPillTimer?.cancel(); + _lockPillTimer = Timer(_lockPill.visibleFor, () { + if (mounted) setState(() {}); + }); + setState(() {}); + } + + void _onUnlockPillTap() { + if (_lockPill.tapPill(_now)) { + _unlock(); + } else { + _showLockPill(); + } + } + Widget _buildLockOverlay() { + final visible = _lockPill.isVisible(_now); return Positioned( bottom: 0, left: 0, @@ -1064,41 +1174,49 @@ extension _PlayerControls on _PlayerPageState { child: Padding( padding: const EdgeInsets.only(bottom: 24), child: Center( - child: _unlockPill( - GestureDetector( - onTap: _unlock, - // Words on it, so the deeper dimming: white 12pt text keeps - // AA over a white frame. - child: MediaGlass( - radius: 24, - dim: Colors.black.withValues(alpha: 0.6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 10, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.lock_rounded, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 8), - Text( - 'player.tap_to_unlock'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: !visible + ? const SizedBox(key: ValueKey('pill-off'), height: 40) + : KeyedSubtree( + key: const ValueKey('pill-on'), + child: _unlockPill( + GestureDetector( + onTap: _onUnlockPillTap, + // Words on it, so the deeper dimming: white 12pt text keeps + // AA over a white frame. + child: MediaGlass( + radius: 24, + dim: Colors.black.withValues(alpha: 0.6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lock_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'player.tap_to_unlock'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ), ), - ], + ), ), - ), - ), - ), ), ), ), @@ -1624,7 +1742,7 @@ extension _PlayerControls on _PlayerPageState { title: 'player.tap_to_unlock'.tr(), semanticLabel: 'player.tap_to_unlock'.tr(), glyph: 13, - onPressed: _unlock, + onPressed: _onUnlockPillTap, ), ], fallback: (_) => flutter, @@ -1647,6 +1765,7 @@ extension _PlayerControls on _PlayerPageState { alignment: Alignment.centerLeft, child: _DesktopVolumeControl( volume: _volume, + max: _maxVolume, onChanged: _setPlayerVolume, onToggleMute: _toggleMute, ), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index d1104cfe..45c96353 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -55,6 +55,11 @@ import 'package:soplay/core/player/source_ladder.dart'; import 'package:soplay/features/detail/domain/download_choices.dart'; import 'package:soplay/features/detail/domain/player_affordances.dart'; import 'package:soplay/features/detail/domain/playback/episode_window.dart'; +import 'package:soplay/features/detail/domain/playback/desktop_volume.dart'; +import 'package:soplay/features/detail/domain/playback/end_of_stream.dart'; +import 'package:soplay/features/detail/domain/playback/lock_pill.dart'; +import 'package:soplay/features/detail/domain/playback/tap_seek.dart'; +import 'package:soplay/core/player/mpv_auto_quality.dart'; import 'package:soplay/features/detail/domain/playback/playback_fault.dart'; import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; import 'package:soplay/features/detail/domain/playback/skip_offers.dart'; @@ -174,6 +179,7 @@ part 'player_page.reactions.dart'; part 'player_page.menus.dart'; part 'player_page.sheets.dart'; part 'player_page.native_offer.dart'; +part 'player_page.toast.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; @@ -780,6 +786,46 @@ class _PlayerPageState extends State /// guard that protects against accidental BACK. bool _tvPopAllowed = false; + /// Monotonic time for the tap, lock and end-of-stream state machines. + final Stopwatch _clock = Stopwatch()..start(); + Duration get _now => _clock.elapsed; + + final TapSeekTracker _tapSeek = TapSeekTracker(); + Timer? _tapTimer; + + final LockPill _lockPill = LockPill(); + Timer? _lockPillTimer; + + final EndOfStreamDetector _endOfStream = EndOfStreamDetector(); + Timer? _endWatch; + + /// mpv Auto: stalls counted for a step down, and the `hls-bitrate` in force + /// after one (null = the start choice). [_autoBitrateBefore] is for Undo. + final StallWatchdog _stallWatch = StallWatchdog(); + String? _autoBitrate; + String? _autoBitrateBefore; + + /// "Try with the mpv engine" after a codec fault on the native engine. For + /// this player only; the engine setting is untouched. + bool _mpvForSession = false; + + /// mpv failed mid-playback once and was reopened with `hwdec=no`. + bool _mpvSoftwareRetried = false; + + /// The orientation was turned to portrait for a vertical clip, so a + /// landscape episode after it turns it back. + bool _autoPortrait = false; + PlayerController? _orientedFor; + + DesktopVolume _desktopVolume = const DesktopVolume(); + + final ValueNotifier<_PlayerToast?> _toastNote = ValueNotifier(null); + Timer? _toastTimer; + bool _errorDetailsOpen = false; + + final FocusNode _tvUpNextFocus = FocusNode(debugLabel: 'tvUpNext'); + bool _upNextShown = false; + @override void initState() { super.initState(); @@ -800,6 +846,12 @@ class _PlayerPageState extends State _longPressBoost = _hive.getLongPressBoost(); _brightnessGestureEnabled = _hive.brightnessGestureEnabled; _volumeGestureEnabled = _hive.volumeGestureEnabled; + if (isDesktopPlatform) { + final stored = _hive.getDesktopVolume(); + _desktopVolume = DesktopVolume.restore(stored.level, stored.muted); + _volume = _desktopVolume.applied; + } + _endWatch = Timer.periodic(const Duration(seconds: 1), (_) => _checkEnd()); _window = _window.at( widget.args.initialEpisodeIndex.clamp( 0, @@ -960,6 +1012,13 @@ class _PlayerPageState extends State _hideTimer?.cancel(); _historyTimer?.cancel(); _seekRippleTimer?.cancel(); + _tapTimer?.cancel(); + _lockPillTimer?.cancel(); + _endWatch?.cancel(); + _toastTimer?.cancel(); + _toastNote.dispose(); + _tvUpNextFocus.dispose(); + PlayerController.mpvHwdec = null; _systemLevelTimer?.cancel(); _tvSeekCommit?.cancel(); _tvRootFocus.dispose(); @@ -1014,8 +1073,13 @@ class _PlayerPageState extends State // controls -> exit) rather than dumping the viewer out mid-episode. // _exit() latches _tvPopAllowed when the exit is genuinely intended. // Phone and desktop keep the unconditional pop they ship today. - canPop: !isTvPlatform || _tvPopAllowed, + // Locked, a phone's BACK only brings the unlock pill back. + canPop: (!isTvPlatform && !_locked) || _tvPopAllowed, onPopInvokedWithResult: (didPop, _) { + if (!didPop && _locked && !isTvPlatform) { + _showLockPill(); + return; + } 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. @@ -1059,23 +1123,27 @@ class _PlayerPageState extends State // As an ancestor these recognisers are in the hit-test path for // every touch, whatever is drawn on top. behavior: HitTestBehavior.opaque, - onTap: _locked + // Touch screens resolve taps themselves (_onVideoTap), so a + // single tap waits only when a double tap could follow. + onTap: _tapTap ? null + : _locked + ? _showLockPill : _compact ? _togglePlay : _toggleControls, - onDoubleTapDown: _locked || isDesktopPlatform - ? null - : (d) => _onDoubleTapDown(d, constraints), - onDoubleTap: _locked + onTapUp: _tapTap + ? (d) => _locked + ? _showLockPill() + : _onVideoTap(d.localPosition, constraints) + : null, + onDoubleTap: _locked || _tapTap ? null : _docked ? _expandFromDock : _desktopMini ? _toggleDesktopMini - : isDesktopPlatform - ? _toggleFullscreen - : () {}, + : _toggleFullscreen, // Scale, not pan: Flutter asserts if both are registered, since // scale is a superset. One finger is routed straight back into // the old pan handlers — see _onScaleUpdate — so brightness, @@ -1148,6 +1216,7 @@ class _PlayerPageState extends State _buildSidePanel(), if (!_locked && _inParty) _buildPartyReactionsLayer(), if (_compact) _buildDesktopMiniOverlay(), + if (!_compact) _buildPlayerToast(), // 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 diff --git a/lib/features/detail/presentation/pages/player_page.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index 49d23109..8fdabd7a 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -2,24 +2,53 @@ part of 'player_page.dart'; extension _PlayerGestures on _PlayerPageState { - void _onDoubleTapDown(TapDownDetails details, BoxConstraints constraints) { - final dx = details.localPosition.dx; - final width = constraints.maxWidth; - final leftEdge = width * 0.3; - final rightEdge = width * 0.7; - final step = _seekStep; - if (dx < leftEdge) { - _seekRelative(-step); - _showSeekRipple(-1); - } else if (dx > rightEdge) { - _seekRelative(step); - _showSeekRipple(1); - } else { + /// Phones and tablets: taps are resolved here rather than by a double-tap + /// recogniser, which held every single tap back by 300 ms. + bool get _tapTap => !isDesktopPlatform && !_compact; + + void _onVideoTap(Offset at, BoxConstraints constraints) { + final c = _controller; + final ready = + c != null && + c.value.isInitialized && + !_initializing && + _errorMessage == null; + final action = _tapSeek.onTap( + TapSeekTracker.zoneOf(at.dx, constraints.maxWidth), + _now, + canSeek: ready && !_isLive && _panel == _SidePanel.none, + centreTogglesPlay: ready && _panel == _SidePanel.none, + ); + _tapTimer?.cancel(); + if (action == TapAction.pending) { + _tapTimer = Timer(_tapSeek.doubleTapWindow, () { + if (!mounted) return; + final late = _tapSeek.flush(_now); + if (late != null) _runTapAction(late); + }); return; } - // The ripple is on the far side of the thumb; the tick is what says the - // double tap landed while the eyes stay on the picture. - if (isMobilePlatform) HapticFeedback.selectionClick(); + _runTapAction(action); + } + + void _runTapAction(TapAction action) { + switch (action) { + case TapAction.pending: + return; + case TapAction.toggleControls: + _toggleControls(); + case TapAction.togglePlay: + _togglePlay(); + if (isMobilePlatform) HapticFeedback.selectionClick(); + case TapAction.seekBack: + case TapAction.seekForward: + final dir = action == TapAction.seekBack ? -1 : 1; + _seekRelative(_seekStep * dir); + _showSeekRipple(dir); + // The ripple is on the far side of the thumb; the tick is what says + // the tap landed while the eyes stay on the picture. + if (isMobilePlatform) HapticFeedback.selectionClick(); + } } /// [initialDeltaPx]: how far the finger has already travelled from where @@ -127,7 +156,7 @@ extension _PlayerGestures on _PlayerPageState { setState(() => _seekRippleDirection = direction); _seekRippleController.forward(from: 0); _seekRippleTimer?.cancel(); - _seekRippleTimer = Timer(const Duration(milliseconds: 700), () { + _seekRippleTimer = Timer(_tapSeek.rippleWindow, () { if (!mounted) return; setState(() { _seekRippleDirection = 0; @@ -229,6 +258,19 @@ extension _PlayerGestures on _PlayerPageState { } void _onPanStart(DragStartDetails d, BoxConstraints constraints) { + // A swipe from the system's own gesture edges (back, home, the shade) + // belongs to the system, not to brightness, volume or the scrub. + final from = _pointerDownAt ?? d.localPosition; + if (startsInSystemGestureArea( + from, + constraints.biggest, + MediaQuery.of(context).systemGestureInsets, + )) { + _dragStart = null; + _dragIsHorizontal = null; + _dragSwipeType = null; + return; + } _dragStart = d.localPosition; _dragIsHorizontal = null; _dragSwipeType = null; @@ -320,3 +362,10 @@ extension _PlayerGestures on _PlayerPageState { _dragSwipeType = null; } } + +/// Whether [at] lies in the strips the system keeps for its own gestures. +bool startsInSystemGestureArea(Offset at, Size size, EdgeInsets insets) => + at.dx < insets.left || + at.dx > size.width - insets.right || + at.dy < insets.top || + at.dy > size.height - insets.bottom; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 71434454..88213dff 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -453,12 +453,7 @@ extension _PlayerHistory on _PlayerPageState { if (!mounted) return null; if (sniffed == null || sniffed.url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('detail.download_needs_playback'.tr()), - behavior: SnackBarBehavior.floating, - ), - ); + _toast('detail.download_needs_playback'.tr()); return null; } return DownloadChoice( @@ -535,12 +530,6 @@ extension _PlayerHistory on _PlayerPageState { ), ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(downloadOutcomeMessage(outcome)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast(downloadOutcomeMessage(outcome), icon: Icons.download_rounded); } } diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 90271312..299d4f24 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -263,6 +263,8 @@ extension _PlayerMedia on _PlayerPageState { // row would count as one. _countedComplete = false; _endHandled = false; + _endOfStream.reset(); + _stallWatch.reset(); _videoTrackAppliedFor = null; // The previous episode's playhead is no place to retry this one from. _lastPlayedPosition = Duration.zero; @@ -348,16 +350,8 @@ extension _PlayerMedia on _PlayerPageState { final raw = error.toString().replaceFirst('Exception: ', ''); setState(() { _initializing = false; - // img_2e4886 is Dio's own English sentence about a reset connection - // ("…This indicates an error which most likely cannot be solved by - // the library."), wrong about the remedy as well as in the wrong - // language. A dropped connection is said as the network fault in the - // viewer's language; a provider's own message (no mirrors, a 422) - // keeps its words. - final fault = PlaybackFault.classify(raw); - _errorMessage = fault.kind == PlaybackFaultKind.network - ? fault.messageKey.tr() - : raw; + // Translated headline; the provider's own words go under Details. + _errorMessage = _resolveFailureMessage(raw); _errorRaw = raw; }); return null; @@ -670,7 +664,7 @@ extension _PlayerMedia on _PlayerPageState { bool get _onPlatformPlayer => _preferPlatformPlayer || isAndroidEmulator || - resolvePlayerEngine() == PlayerEngine.native; + (resolvePlayerEngine() == PlayerEngine.native && !_mpvForSession); /// Starts a fresh walk. Called wherever what is playing genuinely changes — /// a new episode, a new movie, an explicit pick — never on a retry, which is @@ -1553,6 +1547,7 @@ extension _PlayerMedia on _PlayerPageState { controller = PlayerController.networkUrl( fileUri, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, formatHint: VideoFormat.hls, videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: false), ); @@ -1564,6 +1559,7 @@ extension _PlayerMedia on _PlayerPageState { controller = PlayerController.file( file, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: false), ); _headers = const {}; @@ -1588,13 +1584,22 @@ extension _PlayerMedia on _PlayerPageState { : null; if (drm != null) _plog('drm: $drm'); - PlayerController.mpvHlsBitrate = PlayerController.hlsBitrateFor( - _qualityPreference, - ); + PlayerController.mpvHlsBitrate = + (_qualityPreference == QualityPreference.auto + ? _autoBitrate + : null) ?? + MpvAutoQuality.startBitrate( + preference: _qualityPreference, + network: _qualityPreference == QualityPreference.auto + ? await _networkKind() + : NetworkKind.unknown, + ); + if (!mounted || generation != _mediaGeneration) return; controller = PlayerController.networkUrl( uri, httpHeaders: mergedHeaders, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, formatHint: isHls ? VideoFormat.hls : isDash @@ -1697,6 +1702,7 @@ extension _PlayerMedia on _PlayerPageState { _playbackSpeed = party.rate; } await controller.setPlaybackSpeed(_playbackSpeed); + await _reapplyVolume(controller); if (!mounted || generation != _mediaGeneration) return; if (!_isLive) { final expected = party.expectedPositionAt(DateTime.now()); @@ -1712,6 +1718,7 @@ extension _PlayerMedia on _PlayerPageState { } } else { await controller.setPlaybackSpeed(_playbackSpeed); + await _reapplyVolume(controller); if (!mounted || generation != _mediaGeneration) return; if (resumeAt > Duration.zero && !_isLive) { await controller.seekTo(resumeAt); @@ -1914,13 +1921,17 @@ extension _PlayerMedia on _PlayerPageState { /// A failure, in the viewer's language. /// /// Classification lives in [PlaybackFault] where it can be tested without a - /// widget; this only renders it. `unknown` keeps the engine's own words — - /// an untranslated detail is more use than a translated non-answer. - String _faultMessage(PlaybackFault fault) { - if (fault.kind == PlaybackFaultKind.unknown && fault.raw.isNotEmpty) { - return fault.raw; - } - return fault.messageKey.tr(); + /// widget; this only renders it. The engine's own words are kept in + /// [_errorRaw] and shown under Details on the error screen. + String _faultMessage(PlaybackFault fault) => fault.messageKey.tr(); + + /// A failed resolve, as a headline: a named fault when there is one, else + /// "no link from this source". The raw message is the error's details. + String _resolveFailureMessage(String raw) { + final fault = PlaybackFault.classify(raw); + return fault.kind == PlaybackFaultKind.unknown + ? 'player.fault.resolve_failed'.tr() + : fault.messageKey.tr(); } String _humanizeError(String raw) => @@ -2049,6 +2060,24 @@ extension _PlayerMedia on _PlayerPageState { // deliberately returns false for it, so without this the walk stopped // here with untried mirrors left and the viewer had to open Quality and // pick one by hand. + // mpv that played and then failed: once, the same stream at the same + // place with software decoding, before this server is given up on. + if (!_autoRetrying && + !_isLive && + c.isMpv && + !_mpvSoftwareRetried && + _lastPlayedPosition > Duration.zero && + PlaybackFault.classify(msg).kind != PlaybackFaultKind.network) { + _mpvSoftwareRetried = true; + PlayerController.mpvHwdec = 'no'; + _autoRetrying = true; + _plog( + 'mpv failed mid-playback — reopening with hwdec=no', + level: LogLevel.warn, + ); + unawaited(_reopenAt(_lastPlayedPosition, autoplay: true)); + return; + } if (!_autoRetrying && _isDecoderError(msg)) { _decoderAvoidCodec = _currentSourceIndex >= 0 && @@ -2105,6 +2134,14 @@ extension _PlayerMedia on _PlayerPageState { if (v.isBuffering != _wasBuffering) { _wasBuffering = v.isBuffering; changed = true; + if (v.isBuffering) _onBufferingStall(c); + } + if (v.isInitialized && + !identical(_orientedFor, c) && + v.size.width > 0 && + v.size.height > 0) { + _orientedFor = c; + _autoOrient(v.size); } _maybeOfferNativeControls(); @@ -2129,44 +2166,200 @@ extension _PlayerMedia on _PlayerPageState { _tickEpisodeSocial(v.position); _maybePrefetchNext(v.position, v.duration); - final remaining = v.duration - v.position; - final isEnding = remaining <= const Duration(seconds: 2); - if (isEnding) { - // Guests in a party never self-advance — they wait for the host's - // next party:content. The host auto-advances and emits it. - final guestInParty = _inParty && !_isPartyHost; - // Auto-advance is opt-out, not opt-in: it is what the player has - // always done. Turning it off leaves the episode parked on its last - // frame, which is also what makes the history entry below correct. - // A sleep timer set to "end of episode" parks here rather than - // advancing. It deliberately reuses the auto-advance-off path below, - // so the episode is marked finished exactly as it would be for someone - // who turned auto-advance off — the timer changes when playback stops, - // not what counts as watched. - if (!guestInParty && - !_sleepAtEpisodeEnd && - !_upNextDismissed && - _hive.autoPlayNextEpisode && - widget.args.isSerial && - _hasNextEpisode) { - _saveHistoryForNextEpisode(); - // Marks the next load as a continuation rather than an opening, so - // "start paused" does not stop a run that is already going. - _autoAdvanced = true; - _loadEpisode(_episodeIndex + 1); - return; - } - if (!_endHandled) { - _endHandled = true; - _recordFinishedWithoutAdvancing(); - } - if (_sleepAtEpisodeEnd) unawaited(_fireSleepTimer()); - } + if (_checkEnd()) return; } if (changed && mounted) setState(() {}); } + /// Looks for the end of the episode: the last two seconds, the engine's + /// completion, or a playhead stuck just short of the end (an overstated + /// duration). Also run once a second, since a stuck playhead sends no + /// updates. True when it moved on to the next episode. + bool _checkEnd() { + final c = _controller; + if (c == null || !mounted || _isLive) return false; + final v = c.value; + if (!v.isInitialized || v.hasError || v.duration <= Duration.zero) { + return false; + } + final ended = _endOfStream.observe( + position: v.position, + duration: v.duration, + isCompleted: v.isCompleted, + isPlaying: v.isPlaying, + isBuffering: v.isBuffering, + now: _now, + ); + if (!ended) return false; + _plog('episode ended at ${v.position} of ${v.duration}'); + return _onEpisodeEnded(); + } + + bool _onEpisodeEnded() { + // Guests in a party never self-advance — they wait for the host's next + // party:content. The host auto-advances and emits it. + final guestInParty = _inParty && !_isPartyHost; + // Auto-advance is opt-out. A sleep timer set to "end of episode" parks + // here instead, through the same path as auto-advance being off, so the + // episode is marked finished either way. + if (!guestInParty && + !_sleepAtEpisodeEnd && + !_upNextDismissed && + _hive.autoPlayNextEpisode && + widget.args.isSerial && + _hasNextEpisode) { + _saveHistoryForNextEpisode(); + // A continuation, so "start paused" does not stop a run in progress. + _autoAdvanced = true; + _loadEpisode(_episodeIndex + 1); + return true; + } + if (!_endHandled) { + _endHandled = true; + _recordFinishedWithoutAdvancing(); + } + if (_sleepAtEpisodeEnd) unawaited(_fireSleepTimer()); + return false; + } + + /// mpv Auto: a stall during playback counts toward stepping down a rung. + void _onBufferingStall(PlayerController c) { + if (!c.isMpv || + _isLive || + !_isHls || + _qualityPreference != QualityPreference.auto || + _lastPlayedPosition <= Duration.zero || + _autoRetrying) { + return; + } + if (_stallWatch.recordStall(_now)) unawaited(_lowerAutoQuality(c)); + } + + Future _lowerAutoQuality(PlayerController c) async { + final current = PlayerController.mpvHlsBitrate; + final size = c.value.size; + final next = MpvAutoQuality.stepDown( + currentHeight: QualityPreference.displayHeight( + size.width.round(), + size.height.round(), + ), + current: current, + ); + if (next == null) return; + _autoBitrateBefore ??= current; + _autoBitrate = next; + _plog( + 'repeated stalls — hls-bitrate $current -> $next', + level: LogLevel.warn, + ); + _toast( + 'player.quality_lowered'.tr(), + icon: Icons.network_check_rounded, + duration: const Duration(seconds: 6), + action: 'player.undo'.tr(), + onAction: () => unawaited(_undoLowerQuality()), + ); + _autoRetrying = true; + await _reopenAt( + _retryPosition, + autoplay: c.value.isPlaying || c.value.isBuffering, + ); + } + + Future _undoLowerQuality() async { + final before = _autoBitrateBefore; + if (before == null) return; + _stallWatch.disable(); + _autoBitrate = before; + _autoBitrateBefore = null; + _plog('lowered quality undone — hls-bitrate $before'); + final v = _controller?.value; + await _reopenAt( + _retryPosition, + autoplay: v == null || v.isPlaying || v.isBuffering, + ); + } + + /// Wi-Fi or a cable, mobile data, or unknown — for Auto's starting rung. + Future _networkKind() async { + try { + final r = await Connectivity().checkConnectivity().timeout( + const Duration(milliseconds: 600), + ); + if (r.contains(ConnectivityResult.wifi) || + r.contains(ConnectivityResult.ethernet)) { + return NetworkKind.unmetered; + } + if (r.contains(ConnectivityResult.mobile)) return NetworkKind.cellular; + } catch (_) {} + return NetworkKind.unknown; + } + + /// Opens what is playing again, on the same server, at [at]: a lower mpv + /// rung, software decoding, or another engine. + Future _reopenAt(Duration at, {required bool autoplay}) async { + if (!mounted) return; + final url = _playSourceUrl ?? _videoUrl; + if (url == null) { + _autoRetrying = false; + return; + } + final headers = _playSourceUrl != null ? _playSourceHeaders : _headers; + final type = _playSourceType; + _autoplayOverride = autoplay; + setState(() { + _initializing = true; + _stage = _LoadingStage.loading; + _errorMessage = null; + _errorRaw = null; + _isCodecError = false; + }); + final generation = await _disposeController(); + if (!mounted || generation != _mediaGeneration) return; + await _initializeWith( + intentGeneration: generation, + url: url, + headers: headers, + type: type, + resumeAt: at, + ); + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } + + /// "Try with the mpv engine" on the error screen: this stream, from where it + /// was, on media_kit. Only for this player. + Future _tryWithMpv() async { + _plog('codec fault on the native engine — trying mpv for this session'); + _mpvForSession = true; + _preferPlatformPlayer = false; + await _reopenAt(_retryPosition, autoplay: true); + } + + /// A vertical clip turns the phone to portrait, and the next landscape + /// video turns it back. A choice the viewer made by hand is left alone. + void _autoOrient(Size size) { + if (!isMobilePlatform || isTvPlatform || _isPip) return; + final vertical = size.width / size.height < 1; + if (vertical && !_isPortrait) { + _autoPortrait = true; + unawaited(_toggleOrientation()); + } else if (!vertical && _isPortrait && _autoPortrait) { + _autoPortrait = false; + unawaited(_toggleOrientation()); + } + } + + Future _reapplyVolume(PlayerController controller) async { + if (!isDesktopPlatform) return; + try { + await _desktopVolume.applyTo( + controller.setVolume, + max: controller.maxVolume, + ); + } catch (_) {} + } + /// What history says once an episode has played out and nothing advanced. /// /// This used to call `_history.remove(contentUrl)` for everything, on every @@ -2270,6 +2463,7 @@ extension _PlayerMedia on _PlayerPageState { _currentQuality = next.quality; _serverSwitch = step; }); + _tvFocusStopSwitch(); try { final generation = await _disposeController(); await Future.delayed(const Duration(milliseconds: 350)); @@ -2291,6 +2485,13 @@ extension _PlayerMedia on _PlayerPageState { return; } + // Nothing else to switch to: the same source again, re-resolved for a + // serial or reopened for a film, on the same screen as a switch, with the + // attempt count and Stop. + final again = ServerSwitch.retrySame( + to: _currentServer ?? _episodeTitle(), + attempt: _lifetimeRetries, + ); setState(() { _initializing = true; _stage = widget.args.isSerial @@ -2299,35 +2500,52 @@ extension _PlayerMedia on _PlayerPageState { _errorMessage = null; _errorRaw = null; _isCodecError = false; + _serverSwitch = again; }); - final generation = await _disposeController(); - await Future.delayed(const Duration(milliseconds: 350)); - if (!mounted || generation != _mediaGeneration) return; - if (widget.args.isSerial) { - _autoRetrying = false; - await _loadEpisode( - _episodeIndex, - keepRetryCount: true, - resumeAt: keepPosition, - ); - return; - } else if (_videoUrl != null) { + _tvFocusStopSwitch(); + try { + final generation = await _disposeController(); + await Future.delayed(const Duration(milliseconds: 350)); if (!mounted || generation != _mediaGeneration) return; - await _initializeWith( - intentGeneration: generation, - // Same reason as the manual retry: re-run what produced the stream, - // not the stream. See [_playSourceUrl]. - url: _playSourceUrl ?? _videoUrl!, - headers: _playSourceUrl != null ? _playSourceHeaders : _headers, - type: _playSourceType, - resumeAt: keepPosition, - ); - } else { - _autoRetrying = false; - await _bootstrap(); - return; + if (widget.args.isSerial) { + _autoRetrying = false; + await _loadEpisode( + _episodeIndex, + keepRetryCount: true, + resumeAt: keepPosition, + ); + return; + } else if (_videoUrl != null) { + await _initializeWith( + intentGeneration: generation, + // Same reason as the manual retry: re-run what produced the stream, + // not the stream. See [_playSourceUrl]. + url: _playSourceUrl ?? _videoUrl!, + headers: _playSourceUrl != null ? _playSourceHeaders : _headers, + type: _playSourceType, + resumeAt: keepPosition, + ); + } else { + _autoRetrying = false; + await _bootstrap(); + return; + } + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } finally { + if (mounted && identical(_serverSwitch, again)) { + setState(() => _serverSwitch = null); + } } - if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } + + /// On TV, the Stop of an automatic switch takes focus as it appears. + void _tvFocusStopSwitch() { + if (!isTvPlatform) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _tvStopSwitchFocus.context != null) { + _tvStopSwitchFocus.requestFocus(); + } + }); } /// The viewer's Stop on an automatic server switch. @@ -2411,14 +2629,7 @@ extension _PlayerMedia on _PlayerPageState { _errorRaw = null; _isCodecError = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('player.reconnecting'.tr()), - backgroundColor: Colors.black87, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast('player.reconnecting'.tr(), icon: Icons.wifi_rounded); await Future.delayed(RetryPolicy.networkBackoff(attempt - 1)); await _awaitConnectivity(const Duration(seconds: 30)); if (!mounted || intent != _mediaGeneration) return; diff --git a/lib/features/detail/presentation/pages/player_page.models.dart b/lib/features/detail/presentation/pages/player_page.models.dart index d215e715..91b59382 100644 --- a/lib/features/detail/presentation/pages/player_page.models.dart +++ b/lib/features/detail/presentation/pages/player_page.models.dart @@ -31,6 +31,7 @@ class ServerSwitch { required this.to, this.attempt, this.maxAttempts, + this.sameServer = false, }); /// One step of the automatic ladder after a failure, counted against @@ -51,6 +52,19 @@ class ServerSwitch { ); } + /// The same source again (re-resolved or reopened) after the ladder ran out + /// of other servers. Still automatic, so the viewer can stop it. + factory ServerSwitch.retrySame({required String to, required int attempt}) { + final step = ServerSwitch.automatic(from: to, to: to, attempt: attempt); + return ServerSwitch( + from: null, + to: to, + attempt: step.attempt, + maxAttempts: step.maxAttempts, + sameServer: true, + ); + } + /// Null when nothing was playing yet, so the overlay shows a destination /// rather than a journey. final String? from; @@ -60,9 +74,28 @@ class ServerSwitch { final int? attempt; final int? maxAttempts; + /// Set by [ServerSwitch.retrySame]: "Trying again", not "Switching". + final bool sameServer; + bool get automatic => attempt != null; } +/// The player's one notice at a time: a glass toast at the top centre, with an +/// optional action (Undo). +class _PlayerToast { + const _PlayerToast( + this.message, { + required this.icon, + this.action, + this.onAction, + }); + + final String message; + final IconData icon; + final String? action; + final VoidCallback? onAction; +} + /// `zoom` carries a scale factor (1.0-3.0), not a 0-1 level like the other /// two, and is drawn as a centre pill rather than an edge bar. enum _SwipeType { brightness, volume, zoom } diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 25b8f16a..5db305c6 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -539,13 +539,7 @@ extension _PlayerPanels on _PlayerPageState { _hive.setIncognito(false); if (!mounted) return; setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.incognito_off'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast('profile.incognito_off'.tr()); }, ), // Hidden rather than disabled when the stream cannot be cast — a @@ -829,9 +823,7 @@ extension _PlayerPanels on _PlayerPageState { subtitles: handed, ); if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('player.external_player_none'.tr())), - ); + _toast('player.external_player_none'.tr()); } } diff --git a/lib/features/detail/presentation/pages/player_page.party.dart b/lib/features/detail/presentation/pages/player_page.party.dart index 92206a58..7595d3b9 100644 --- a/lib/features/detail/presentation/pages/player_page.party.dart +++ b/lib/features/detail/presentation/pages/player_page.party.dart @@ -290,14 +290,7 @@ extension _PlayerParty on _PlayerPageState { void _partyToast(String key) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(key.tr()), - backgroundColor: Colors.black87, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast(key.tr(), icon: Icons.groups_rounded); } // --------------------------------------------------------------------------- @@ -438,9 +431,11 @@ extension _PlayerParty on _PlayerPageState { if (defaultIdx >= 0) _loadSubtitle(defaultIdx, remember: false); } case Failure(:final error): + final raw = error.toString().replaceFirst('Exception: ', ''); setState(() { _initializing = false; - _errorMessage = error.toString().replaceFirst('Exception: ', ''); + _errorMessage = _resolveFailureMessage(raw); + _errorRaw = raw; }); } } diff --git a/lib/features/detail/presentation/pages/player_page.reactions.dart b/lib/features/detail/presentation/pages/player_page.reactions.dart index 424bde42..17094eeb 100644 --- a/lib/features/detail/presentation/pages/player_page.reactions.dart +++ b/lib/features/detail/presentation/pages/player_page.reactions.dart @@ -105,9 +105,7 @@ extension _PlayerEpisodeSocial on _PlayerPageState { void _socialSnack(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), - ); + _toast(message); } Future _openReactionPicker() async { diff --git a/lib/features/detail/presentation/pages/player_page.sleep.dart b/lib/features/detail/presentation/pages/player_page.sleep.dart index 04bea2a2..a2b5c349 100644 --- a/lib/features/detail/presentation/pages/player_page.sleep.dart +++ b/lib/features/detail/presentation/pages/player_page.sleep.dart @@ -120,12 +120,10 @@ extension _PlayerSleepTimer on _PlayerPageState { setState(() => _controlsVisible = true); _controlsAnimation.forward(); } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('player.sleep_fired'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 4), - ), + _toast( + 'player.sleep_fired'.tr(), + icon: Icons.bedtime_rounded, + duration: const Duration(seconds: 4), ); } diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index f9fc8c7a..89dcc3aa 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -488,37 +488,6 @@ extension _PlayerSubtitles on _PlayerPageState { ); } - void _toast(String message, {IconData icon = Icons.info_outline_rounded}) { - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - messenger.clearSnackBars(); - messenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon(icon, size: 18, color: AppColors.primaryLight), - const SizedBox(width: 10), - Expanded( - child: Text( - message, - style: const TextStyle(fontSize: 13, color: Colors.white), - ), - ), - ], - ), - backgroundColor: const Color(0xFF1C1C1E), - behavior: SnackBarBehavior.floating, - elevation: 8, - margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), - ), - duration: const Duration(seconds: 2), - ), - ); - } - /// TMDB id + type parsed from the content url, for finding and publishing /// translations by the media they belong to. ({String id, String type})? _tmdbRef() { diff --git a/lib/features/detail/presentation/pages/player_page.toast.dart b/lib/features/detail/presentation/pages/player_page.toast.dart new file mode 100644 index 00000000..9527d9ca --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.toast.dart @@ -0,0 +1,79 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The player's notices, drawn by the page rather than as SnackBars: those sat +/// at the bottom, over the seek bar the viewer was reaching for. +extension _PlayerToasts on _PlayerPageState { + void _toast( + String message, { + IconData icon = Icons.info_outline_rounded, + Duration duration = const Duration(seconds: 2), + String? action, + VoidCallback? onAction, + }) { + if (!mounted) return; + _toastTimer?.cancel(); + _toastNote.value = _PlayerToast( + message, + icon: icon, + action: action, + onAction: onAction, + ); + _toastTimer = Timer(duration, () { + if (mounted) _toastNote.value = null; + }); + } + + void _dismissToast() { + _toastTimer?.cancel(); + _toastNote.value = null; + } + + Widget _buildPlayerToast() { + return Positioned( + top: 0, + left: 16, + right: 16, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Center( + child: ValueListenableBuilder<_PlayerToast?>( + valueListenable: _toastNote, + builder: (context, toast, _) => AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + transitionBuilder: (child, a) => FadeTransition( + opacity: a, + child: SlideTransition( + position: Tween( + begin: const Offset(0, -0.3), + end: Offset.zero, + ).animate(a), + child: child, + ), + ), + child: toast == null + ? const SizedBox.shrink() + : ConstrainedBox( + key: ObjectKey(toast), + constraints: const BoxConstraints(maxWidth: 520), + child: IgnorePointer( + ignoring: toast.action == null, + child: _PlayerToastView( + toast: toast, + onAction: () { + _dismissToast(); + toast.onAction?.call(); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.tv.dart b/lib/features/detail/presentation/pages/player_page.tv.dart index 210a009c..75c3d984 100644 --- a/lib/features/detail/presentation/pages/player_page.tv.dart +++ b/lib/features/detail/presentation/pages/player_page.tv.dart @@ -113,6 +113,11 @@ extension _PlayerTv on _PlayerPageState { /// dead until the next tap. void _tvHandOffFocusToRoot() { if (!isTvPlatform) return; + // The Up Next card keeps focus while the controls come and go; it leaves + // with the card. + if (_upNextShown && !_upNextDismissed && _tvUpNextFocus.hasPrimaryFocus) { + return; + } final current = FocusManager.instance.primaryFocus; if (current != null && current != _tvRootFocus) { // Disposition.scope hands primary focus to the enclosing scope, which is @@ -189,6 +194,11 @@ extension _PlayerTv on _PlayerPageState { _tvReveal(force: true); return; } + if (_upNextShown && !_upNextDismissed) { + _dismissUpNext(); + _tvHandOffFocusToRoot(); + return; + } if (_panel != _SidePanel.none) { // The focused panel row is about to be unmounted, so focus must be moved // deliberately rather than left to fall wherever it lands. @@ -276,6 +286,10 @@ extension _PlayerTv on _PlayerPageState { unawaited(_stopAutoSwitch()); return KeyEventResult.handled; } + if (_isTvSelect(k) && _tvUpNextFocus.hasPrimaryFocus) { + _playNextNow(); + return KeyEventResult.handled; + } if (_locked) { // The lock affordance is not built on TV (see _buildControlsOverlay), so @@ -312,6 +326,13 @@ extension _PlayerTv on _PlayerPageState { return KeyEventResult.handled; } if (_isTvSelect(k)) { + // An offered skip is what OK means while it is on screen; the pill + // says so. + final skip = _activeSkip; + if (skip != null && _controlsCanShowSkip) { + unawaited(_takeSkip(skip)); + return KeyEventResult.handled; + } _togglePlay(); _tvReveal(); return KeyEventResult.handled; diff --git a/lib/features/detail/presentation/pages/player_page.upnext.dart b/lib/features/detail/presentation/pages/player_page.upnext.dart index cab343c4..1d00e9bf 100644 --- a/lib/features/detail/presentation/pages/player_page.upnext.dart +++ b/lib/features/detail/presentation/pages/player_page.upnext.dart @@ -70,6 +70,22 @@ extension _PlayerUpNext on _PlayerPageState { if (mounted) setState(() => _upNextDismissed = true); } + /// On TV the card takes focus as it appears, so OK plays now and BACK + /// cancels; focus goes back to the player when it leaves. + void _noteUpNextShown(bool shown) { + if (shown == _upNextShown) return; + _upNextShown = shown; + if (!isTvPlatform) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (shown && _tvUpNextFocus.context != null) { + _tvUpNextFocus.requestFocus(); + } else if (!shown && _tvUpNextFocus.hasFocus) { + _tvHandOffFocusToRoot(); + } + }); + } + Widget _buildUpNextPrompt() { final controller = _controller; final countdown = _automation?.upNextSeconds ?? 0; @@ -77,21 +93,23 @@ extension _PlayerUpNext on _PlayerPageState { countdown <= 0 || _initializing || _isPip || - isTvPlatform || !_willAutoAdvance || !_window.contains(_episodeIndex + 1)) { + _noteUpNextShown(false); return const SizedBox.shrink(); } final next = _episodes[_episodeIndex + 1]; return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { - if (!value.isInitialized) return const SizedBox.shrink(); - final left = NextUp.promptSecondsLeft( - position: value.position, - duration: value.duration, - countdownSeconds: countdown, - ); + final left = value.isInitialized + ? NextUp.promptSecondsLeft( + position: value.position, + duration: value.duration, + countdownSeconds: countdown, + ) + : null; + _noteUpNextShown(left != null); if (left == null) return const SizedBox.shrink(); final base = _controlsVisible ? 92.0 : 28.0; return Positioned( @@ -105,6 +123,7 @@ extension _PlayerUpNext on _PlayerPageState { countdown: countdown, onPlayNow: _playNextNow, onCancel: _dismissUpNext, + playFocus: _tvUpNextFocus, ), ); }, @@ -127,6 +146,7 @@ class _UpNextCard extends StatelessWidget { required this.countdown, required this.onPlayNow, required this.onCancel, + this.playFocus, }); final String label; @@ -134,6 +154,7 @@ class _UpNextCard extends StatelessWidget { final int countdown; final VoidCallback onPlayNow; final VoidCallback onCancel; + final FocusNode? playFocus; @override Widget build(BuildContext context) { @@ -219,15 +240,19 @@ class _UpNextCard extends StatelessWidget { child: Text('general.cancel'.tr()), ), const SizedBox(width: 6), - FilledButton.icon( - onPressed: onPlayNow, - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - visualDensity: VisualDensity.compact, + _tvRing( + radius: 20, + FilledButton.icon( + onPressed: onPlayNow, + focusNode: playFocus, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + visualDensity: VisualDensity.compact, + ), + icon: const Icon(Icons.skip_next_rounded, size: 18), + label: Text('automation.play_now'.tr()), ), - icon: const Icon(Icons.skip_next_rounded, size: 18), - label: Text('automation.play_now'.tr()), ), ], ), diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 612d6099..3e007b68 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -269,6 +269,144 @@ class _LoadingOverlay extends StatelessWidget { } } +/// The player's own button: glass, radius 14, the primary colour for the one +/// thing to do next. Used on the error screen. +class _PlayerGlassButton extends StatelessWidget { + const _PlayerGlassButton({ + required this.onPressed, + required this.icon, + required this.label, + this.primary = false, + this.autofocus = false, + }); + + final VoidCallback onPressed; + final IconData icon; + final String label; + final bool primary; + final bool autofocus; + + static const double radius = 14; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: primary ? Colors.white : AppColors.primaryLight, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 13.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + return _tvRing( + radius: radius, + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(radius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + autofocus: autofocus, + borderRadius: BorderRadius.circular(radius), + child: primary + ? DecoratedBox( + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(radius), + ), + child: content, + ) + : MediaGlass( + radius: radius, + dim: Colors.white.withValues(alpha: 0.10), + child: content, + ), + ), + ), + ); + } +} + +/// The player's notices: a glass toast at the top centre, clear of the seek +/// bar and the bottom controls. One at a time. +class _PlayerToastView extends StatelessWidget { + const _PlayerToastView({required this.toast, this.onAction}); + + final _PlayerToast toast; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final action = toast.action; + return Semantics( + liveRegion: true, + child: MediaGlass( + radius: 14, + dim: Colors.black.withValues(alpha: 0.62), + child: Padding( + padding: EdgeInsets.fromLTRB(14, 10, action == null ? 16 : 6, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(toast.icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 10), + Flexible( + child: Text( + toast.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + if (action != null) ...[ + const SizedBox(width: 6), + _tvRing( + radius: 10, + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryLight, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + child: Text(action), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + class _IconButton extends StatelessWidget { const _IconButton({ required this.icon, @@ -457,9 +595,13 @@ class _DesktopVolumeControl extends StatelessWidget { required this.volume, required this.onChanged, required this.onToggleMute, + this.max = 1.0, }); final double volume; + + /// 1.5 where the engine amplifies past 100%. + final double max; final ValueChanged onChanged; final VoidCallback onToggleMute; @@ -475,7 +617,7 @@ class _DesktopVolumeControl extends StatelessWidget { onPointerSignal: (e) { if (e is PointerScrollEvent) { final step = e.scrollDelta.dy < 0 ? 0.05 : -0.05; - onChanged((volume + step).clamp(0.0, 1.0)); + onChanged((volume + step).clamp(0.0, max)); } }, child: Row( @@ -494,7 +636,8 @@ class _DesktopVolumeControl extends StatelessWidget { overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), child: Slider( - value: volume.clamp(0.0, 1.0), + value: volume.clamp(0.0, max), + max: max, onChanged: onChanged, ), ), @@ -2074,7 +2217,7 @@ class _ServerSwitchOverlay extends StatelessWidget { // The one being left is dimmed rather than hidden: the // journey is the information, and "from nothing to X" is // what a first load looks like. - if (from != null) ...[ + if (from != null && !switch_.sameServer) ...[ Opacity( opacity: 0.35, child: ServerBadge(name: from, size: 44), @@ -2097,14 +2240,16 @@ class _ServerSwitchOverlay extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'player.switching_server'.tr(), + switch_.sameServer + ? 'player.trying_again'.tr(args: ['${attempt ?? 1}']) + : 'player.switching_server'.tr(), style: const TextStyle( color: Colors.white60, fontSize: 13, fontWeight: FontWeight.w500, ), ), - if (attempt != null) ...[ + if (attempt != null && !switch_.sameServer) ...[ const SizedBox(height: 4), Text( 'player.auto_switch_attempt'.tr( diff --git a/test/core/player/mpv_auto_quality_test.dart b/test/core/player/mpv_auto_quality_test.dart new file mode 100644 index 00000000..77d52fe1 --- /dev/null +++ b/test/core/player/mpv_auto_quality_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/player/mpv_auto_quality.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + group('starting rung', () { + test('Auto opens on the top rendition on Wi-Fi', () { + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.unmetered, + ), + 'max', + ); + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.unknown, + ), + 'max', + ); + }); + + test('Auto is capped at 720p on mobile data', () { + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.cellular, + ), + PlayerController.hlsBitrateFor(720), + ); + }); + + test('a pinned height ignores the network', () { + expect( + MpvAutoQuality.startBitrate( + preference: 1080, + network: NetworkKind.cellular, + ), + PlayerController.hlsBitrateFor(1080), + ); + }); + }); + + group('step down', () { + test('one rung below what is playing', () { + expect( + MpvAutoQuality.stepDown(currentHeight: 1080, current: 'max'), + PlayerController.hlsBitrateFor(720), + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 720, current: '3500000'), + PlayerController.hlsBitrateFor(480), + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 360, current: '1000000'), + 'min', + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 240, current: 'min'), + isNull, + ); + }); + + test('without a height, one bitrate rung', () { + expect( + MpvAutoQuality.stepDown(current: 'max'), + PlayerController.hlsBitrateFor(1080), + ); + expect( + MpvAutoQuality.stepDown(current: PlayerController.hlsBitrateFor(720)), + PlayerController.hlsBitrateFor(480), + ); + expect(MpvAutoQuality.stepDown(current: 'min'), isNull); + }); + }); + + group('stall watchdog', () { + test('three stalls inside a minute trip it, then it counts afresh', () { + final w = StallWatchdog(); + expect(w.recordStall(s(0)), isFalse); + expect(w.recordStall(s(20)), isFalse); + expect(w.recordStall(s(40)), isTrue); + expect(w.recordStall(s(45)), isFalse); + }); + + test('stalls spread over more than a minute do not', () { + final w = StallWatchdog(); + expect(w.recordStall(s(0)), isFalse); + expect(w.recordStall(s(40)), isFalse); + expect(w.recordStall(s(70)), isFalse); + expect(w.recordStall(s(130)), isFalse); + }); + + test('the buffering after a seek is not counted', () { + final w = StallWatchdog(); + w.recordStall(s(0)); + w.recordStall(s(10)); + w.noteSeek(s(19)); + expect(w.recordStall(s(20)), isFalse); + expect(w.recordStall(s(30)), isTrue); + }); + + test('after an undo it stays quiet', () { + final w = StallWatchdog()..disable(); + for (var i = 0; i < 5; i++) { + expect(w.recordStall(s(i)), isFalse); + } + }); + }); +} diff --git a/test/features/detail/playback/desktop_volume_test.dart b/test/features/detail/playback/desktop_volume_test.dart new file mode 100644 index 00000000..28f7807b --- /dev/null +++ b/test/features/detail/playback/desktop_volume_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/desktop_volume.dart'; + +void main() { + test('every new controller gets the level back', () async { + const volume = DesktopVolume(level: 0.4); + final seen = []; + for (var i = 0; i < 3; i++) { + await volume.applyTo((v) async => seen.add(v)); + } + expect(seen, [0.4, 0.4, 0.4]); + }); + + test( + 'mute survives a new controller, and unmute restores the level', + () async { + final muted = const DesktopVolume(level: 0.7).toggleMute(); + final seen = []; + await muted.applyTo((v) async => seen.add(v)); + expect(seen.single, 0); + final back = muted.toggleMute(); + expect(back.applied, 0.7); + }, + ); + + test('up to 150% on mpv, clamped to 100% elsewhere', () async { + final loud = const DesktopVolume().withLevel( + 1.4, + max: DesktopVolume.boostMax, + ); + expect(loud.level, 1.4); + final seen = []; + await loud.applyTo((v) async => seen.add(v), max: 1.0); + await loud.applyTo((v) async => seen.add(v), max: 1.5); + expect(seen, [1.0, 1.4]); + expect(const DesktopVolume().withLevel(9, max: 1.5).level, 1.5); + }); + + test('dragging to zero mutes without losing the level', () { + final v = const DesktopVolume(level: 0.6).withLevel(0); + expect(v.muted, isTrue); + expect(v.toggleMute().applied, 0.6); + }); + + test('restores what was stored, with sane fallbacks', () { + final r = DesktopVolume.restore(0.3, true); + expect(r.level, 0.3); + expect(r.muted, isTrue); + expect(DesktopVolume.restore(null, null).applied, 1.0); + expect(DesktopVolume.restore(7, false).level, DesktopVolume.boostMax); + expect(DesktopVolume.restore(0, false).level, 1.0); + }); +} diff --git a/test/features/detail/playback/end_of_stream_test.dart b/test/features/detail/playback/end_of_stream_test.dart new file mode 100644 index 00000000..c9e9aa7d --- /dev/null +++ b/test/features/detail/playback/end_of_stream_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/end_of_stream.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + late EndOfStreamDetector d; + setUp(() => d = EndOfStreamDetector()); + + bool tick( + num pos, { + num dur = 1400, + bool completed = false, + bool playing = true, + bool buffering = false, + required num at, + }) => d.observe( + position: s(pos), + duration: s(dur), + isCompleted: completed, + isPlaying: playing, + isBuffering: buffering, + now: s(at), + ); + + test('the last two seconds end the episode, once', () { + expect(tick(1390, at: 0), isFalse); + expect(tick(1398.5, at: 8), isTrue); + expect(tick(1399, at: 9), isFalse); + expect(tick(1400, at: 10), isFalse); + }); + + test('completion from the engine ends it even short of the duration', () { + expect(tick(1380, completed: true, playing: false, at: 0), isTrue); + expect(tick(1380, completed: true, playing: false, at: 1), isFalse); + }); + + test('a playhead stuck within 5 s of the end ends it after 3 s', () { + expect(tick(1396, at: 0), isFalse); + expect(tick(1396, buffering: true, at: 1), isFalse); + expect(tick(1396, buffering: true, at: 2.9), isFalse); + expect(tick(1396, buffering: true, at: 3.1), isTrue); + expect(tick(1396, buffering: true, at: 5), isFalse); + }); + + test('paused near the end is not a stall', () { + expect(tick(1396, playing: false, at: 0), isFalse); + expect(tick(1396, playing: false, at: 10), isFalse); + }); + + test('a stall further from the end is just buffering', () { + expect(tick(1300, buffering: true, at: 0), isFalse); + expect(tick(1300, buffering: true, at: 30), isFalse); + }); + + test('seeking back re-arms it', () { + expect(tick(1399, at: 0), isTrue); + expect(tick(600, at: 1), isFalse); + expect(tick(1399, at: 2), isTrue); + }); + + test('a stream without a duration never ends here', () { + expect(tick(10, dur: 0, completed: true, at: 0), isFalse); + }); +} diff --git a/test/features/detail/playback/lock_pill_test.dart b/test/features/detail/playback/lock_pill_test.dart new file mode 100644 index 00000000..77b4927b --- /dev/null +++ b/test/features/detail/playback/lock_pill_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/lock_pill.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + test('the pill fades 3 s after it is shown', () { + final pill = LockPill()..show(s(0)); + expect(pill.isVisible(s(2.9)), isTrue); + expect(pill.isVisible(s(3)), isFalse); + expect(pill.hidesAt(), s(3)); + }); + + test('a tap brings it back for another 3 s', () { + final pill = LockPill()..show(s(0)); + pill.show(s(10)); + expect(pill.isVisible(s(12)), isTrue); + expect(pill.isVisible(s(13.1)), isFalse); + }); + + test('a tap on the visible pill unlocks', () { + final pill = LockPill()..show(s(0)); + expect(pill.tapPill(s(1)), isTrue); + expect(pill.isVisible(s(1)), isFalse); + }); + + test('a tap where the faded pill was only shows it', () { + final pill = LockPill()..show(s(0)); + expect(pill.tapPill(s(5)), isFalse); + expect(pill.isVisible(s(5.5)), isTrue); + expect(pill.tapPill(s(6)), isTrue); + }); +} diff --git a/test/features/detail/playback/retry_same_switch_test.dart b/test/features/detail/playback/retry_same_switch_test.dart new file mode 100644 index 00000000..393f9e6e --- /dev/null +++ b/test/features/detail/playback/retry_same_switch_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; +import 'package:soplay/features/detail/presentation/pages/player_page.dart'; + +void main() { + group('retrying the same source', () { + test('is automatic, so Stop is offered', () { + final step = ServerSwitch.retrySame(to: 'Server 1', attempt: 3); + expect(step.automatic, isTrue); + expect(step.sameServer, isTrue); + expect(step.attempt, 3); + expect(step.maxAttempts, RetryPolicy.maxLifetimeRetries); + }); + + test('shows one server, not a journey to itself', () { + final step = ServerSwitch.retrySame(to: 'Server 1', attempt: 1); + expect(step.from, isNull); + expect(step.to, 'Server 1'); + }); + + test('never reads attempt 0', () { + expect(ServerSwitch.retrySame(to: 'A', attempt: 0).attempt, 1); + }); + + test('a switch to another server is not a retry', () { + expect( + ServerSwitch.automatic(from: 'A', to: 'B', attempt: 1).sameServer, + isFalse, + ); + }); + + test('the last fallback of the ladder uses it inside try/finally', () { + final media = File( + 'lib/features/detail/presentation/pages/player_page.media.dart', + ).readAsStringSync(); + final start = media.indexOf('Future _autoRetry()'); + final end = media.indexOf('\n }\n', start); + final body = media.substring(start, end); + expect(body, contains('ServerSwitch.retrySame')); + expect(body, contains('identical(_serverSwitch, again)')); + }); + }); + + group('system gesture edges', () { + const size = Size(800, 400); + const insets = EdgeInsets.fromLTRB(24, 0, 24, 20); + + test('a drag from an edge strip belongs to the system', () { + expect( + startsInSystemGestureArea(const Offset(10, 200), size, insets), + isTrue, + ); + expect( + startsInSystemGestureArea(const Offset(790, 200), size, insets), + isTrue, + ); + expect( + startsInSystemGestureArea(const Offset(400, 390), size, insets), + isTrue, + ); + }); + + test('anywhere else is the player\'s', () { + expect( + startsInSystemGestureArea(const Offset(400, 200), size, insets), + isFalse, + ); + expect( + startsInSystemGestureArea(const Offset(10, 200), size, EdgeInsets.zero), + isFalse, + ); + }); + }); +} diff --git a/test/features/detail/playback/tap_seek_test.dart b/test/features/detail/playback/tap_seek_test.dart new file mode 100644 index 00000000..b7f4ee12 --- /dev/null +++ b/test/features/detail/playback/tap_seek_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/tap_seek.dart'; + +Duration ms(int v) => Duration(milliseconds: v); + +void main() { + late TapSeekTracker t; + setUp(() => t = TapSeekTracker()); + + test('zones split the width 30 / 40 / 30', () { + expect(TapSeekTracker.zoneOf(10, 100), TapZone.left); + expect(TapSeekTracker.zoneOf(50, 100), TapZone.centre); + expect(TapSeekTracker.zoneOf(90, 100), TapZone.right); + }); + + test('a single tap toggles the controls once the double-tap window ends', () { + expect(t.onTap(TapZone.right, ms(0), canSeek: true), TapAction.pending); + expect(t.flush(ms(100)), isNull); + expect(t.flush(ms(300)), TapAction.toggleControls); + expect(t.flush(ms(400)), isNull); + }); + + test('a double tap on a side seeks', () { + t.onTap(TapZone.left, ms(0), canSeek: true); + expect(t.onTap(TapZone.left, ms(150), canSeek: true), TapAction.seekBack); + expect(t.steps, 1); + expect(t.flush(ms(1000)), isNull); + }); + + test('while the ripple is up, each single tap on that side adds a step', () { + t.onTap(TapZone.right, ms(0), canSeek: true); + t.onTap(TapZone.right, ms(150), canSeek: true); + expect( + t.onTap(TapZone.right, ms(500), canSeek: true), + TapAction.seekForward, + ); + expect( + t.onTap(TapZone.right, ms(900), canSeek: true), + TapAction.seekForward, + ); + expect(t.steps, 3); + // The ripple has gone: back to a single tap. + expect(t.onTap(TapZone.right, ms(2000), canSeek: true), TapAction.pending); + }); + + test('the other side during a ripple starts afresh', () { + t.onTap(TapZone.right, ms(0), canSeek: true); + t.onTap(TapZone.right, ms(150), canSeek: true); + expect(t.onTap(TapZone.left, ms(400), canSeek: true), TapAction.pending); + expect(t.onTap(TapZone.left, ms(500), canSeek: true), TapAction.seekBack); + expect(t.steps, 1); + }); + + test('a double tap in the centre toggles play', () { + t.onTap(TapZone.centre, ms(0), canSeek: true); + expect( + t.onTap(TapZone.centre, ms(120), canSeek: true), + TapAction.togglePlay, + ); + }); + + test('no wait when a double tap could not do anything', () { + expect( + t.onTap(TapZone.left, ms(0), canSeek: false, centreTogglesPlay: false), + TapAction.toggleControls, + ); + expect( + t.onTap( + TapZone.centre, + ms(1000), + canSeek: true, + centreTogglesPlay: false, + ), + TapAction.toggleControls, + ); + expect(t.hasPending, isFalse); + }); + + test('two slow taps are two single taps', () { + t.onTap(TapZone.left, ms(0), canSeek: true); + expect(t.flush(ms(400)), TapAction.toggleControls); + expect(t.onTap(TapZone.left, ms(400), canSeek: true), TapAction.pending); + }); +} diff --git a/test/features/detail/playback_fault_test.dart b/test/features/detail/playback_fault_test.dart index b5b3d002..8d223d33 100644 --- a/test/features/detail/playback_fault_test.dart +++ b/test/features/detail/playback_fault_test.dart @@ -80,6 +80,64 @@ void main() { } }); + test("Dio's timeouts are network faults", () { + for (final raw in const [ + 'The request connection took longer than 0:00:15.000000 and it was ' + 'aborted.', + 'The receiving data took longer than 0:00:30.000000.', + ]) { + expect( + PlaybackFault.classify(raw).kind, + PlaybackFaultKind.network, + reason: raw, + ); + } + }); + + test('HTTP statuses are classified by what they mean', () { + const cases = { + 'This exception was thrown because the response has a status code of ' + '404 and RequestOptions.validateStatus was configured to throw': + PlaybackFaultKind.notFound, + 'Source error: InvalidResponseCodeException: Response code: 410': + PlaybackFaultKind.notFound, + 'HTTP error 403 Forbidden': PlaybackFaultKind.sourceBlocked, + 'Response code: 401': PlaybackFaultKind.sourceBlocked, + 'Server returned 503 Service Unavailable': + PlaybackFaultKind.serverError, + 'status code of 429': PlaybackFaultKind.serverError, + 'Server returned 5XX Server Error reply': PlaybackFaultKind.serverError, + }; + cases.forEach((raw, kind) { + expect(PlaybackFault.classify(raw).kind, kind, reason: raw); + }); + }); + + test('error page bodies name their status', () { + expect( + PlaybackFault.classify( + '403 Forbidden', + ).kind, + PlaybackFaultKind.sourceBlocked, + ); + expect( + PlaybackFault.classify('

404 Not Found

nginx').kind, + PlaybackFaultKind.notFound, + ); + expect( + PlaybackFault.classify('502 Bad Gateway').kind, + PlaybackFaultKind.serverError, + ); + }); + + test('a number in an address is not a status', () { + expect( + PlaybackFault.httpStatusOf('failed: https://cdn.example/404/ep.m3u8'), + isNull, + ); + expect(PlaybackFault.httpStatusOf('status code of 200'), isNull); + }); + test('anything unrecognised falls through to unknown', () { expect( PlaybackFault.classify('something nobody has seen yet').kind, From 3bd4a9c5f7fd1a0b394f9fbfc31341e8d1f704bb Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:47:05 +0500 Subject: [PATCH 17/21] App-wide data, reliability and performance fixes - Watch Later / Watched: persisted outbox of pending add/remove ops, coalesced per item and replayed oldest first before a fetch replaces the cache and on connectivity regain; unsent ops are laid over the server copy. A failed fetch with no cache shows an error with Retry. - Alternate-source sheet: one shrink-wrapped CustomScrollView with SliverGrid/SliverList so only visible tiles build; posters decode at tile width x dpr (also in the relink sheet). - Always-on blurs (classic nav bar, floating pill, home top bar, search, My List, profile and view-all headers) fall back to a translucent fill when GlassPowerReading.reducesEffects (Go/low RAM, GL ES 2, Battery Saver). - HistoryService: decoded-row identity cache plus sorted-list memo, invalidated on writes and box events; count without decoding. - View-all/actor grids: loadMoreFailed footer with Retry and exponential backoff for scroll-driven requests. Pull-to-refresh on History. - Decode sizes for comment avatars, shorts images, aniyomi logo and local posters; remove the unused lib/features/my_list/my_list duplicate. - Off Android: rotating local crash log under app support, shown in the log viewer and appended to support diagnostics; friendly release ErrorWidget with Go home. - Tablets/foldables: no portrait lock at shortestSide >= 600, nav rail on expanded-width mobile, grids pass context to the responsive delegate. --- assets/translations/ar.json | 14 +- assets/translations/ckb.json | 14 +- assets/translations/de.json | 14 +- assets/translations/en.json | 14 +- assets/translations/es.json | 14 +- assets/translations/fr.json | 14 +- assets/translations/hi.json | 14 +- assets/translations/id.json | 14 +- assets/translations/ku.json | 14 +- assets/translations/nl.json | 14 +- assets/translations/pt.json | 14 +- assets/translations/ru.json | 14 +- assets/translations/tr.json | 14 +- assets/translations/uz.json | 14 +- assets/translations/vi.json | 14 +- assets/translations/yue.json | 14 +- lib/core/di/injection.dart | 3 + lib/core/diagnostics/crash_log.dart | 117 ++++++ .../diagnostics/friendly_error_widget.dart | 70 ++++ lib/core/diagnostics/log_viewer_sheet.dart | 91 ++++- lib/core/glass/glass_power.dart | 29 +- lib/core/glass/reducible_blur.dart | 34 ++ lib/core/system/app_orientation.dart | 78 ++++ lib/core/widgets/load_more_failed_footer.dart | 43 ++ .../pages/aniyomi_sources_page.dart | 1 + .../presentation/widgets/comment_avatar.dart | 2 + .../detail/presentation/pages/actor_page.dart | 10 + .../widgets/alternate_source_sheet.dart | 103 +++-- .../presentation/widgets/detail_cast_tab.dart | 1 + .../widgets/detail_preview_skeleton.dart | 1 + .../presentation/widgets/detail_related.dart | 1 + .../widgets/detail_screenshots.dart | 1 + .../presentation/widgets/detail_skeleton.dart | 1 + .../widgets/relink_source_sheet.dart | 3 + .../history/data/history_service.dart | 129 ++++-- .../presentation/pages/history_page.dart | 380 +++++++++--------- .../bloc/view_all/view_all_bloc.dart | 39 +- .../bloc/view_all/view_all_event.dart | 11 +- .../bloc/view_all/view_all_state.dart | 12 +- .../presentation/widgets/home_content.dart | 10 +- .../widgets/home_shared_widgets.dart | 26 +- .../presentation/widgets/home_top_bar.dart | 7 +- .../widgets/view_all_widgets.dart | 21 +- .../main/presentation/pages/main_page.dart | 15 +- .../my_list_remote_data_source.dart | 41 -- .../my_list/data/models/favorite_model.dart | 19 - .../repositories/my_list_repository_impl.dart | 70 ---- .../domain/entities/favorite_entity.dart | 15 - .../domain/entities/my_list_failure.dart | 6 - .../repositories/my_list_repository.dart | 8 - .../domain/usecases/add_favorite_usecase.dart | 12 - .../usecases/get_favorites_usecase.dart | 11 - .../usecases/remove_favorite_usecase.dart | 11 - .../presentation/bloc/my_list_bloc.dart | 53 --- .../presentation/bloc/my_list_event.dart | 16 - .../presentation/bloc/my_list_state.dart | 47 --- .../presentation/pages/my_list_page.dart | 192 --------- .../presentation/widgets/favorite_card.dart | 107 ----- .../widgets/my_list_background.dart | 21 - .../presentation/widgets/my_list_header.dart | 63 --- .../widgets/my_list_skeleton.dart | 51 --- .../widgets/my_list_state_views.dart | 160 -------- .../presentation/widgets/my_list_header.dart | 7 +- .../presentation/pages/activity_page.dart | 2 +- .../presentation/pages/profile_page.dart | 10 +- .../pages/profile_page.sections.dart | 2 +- .../presentation/widgets/search_header.dart | 6 +- .../presentation/widgets/short_reel_item.dart | 12 + .../support/data/support_diagnostics.dart | 27 +- .../presentation/pages/support_new_page.dart | 9 + .../user_lists_repository_impl.dart | 236 +++++++++-- .../presentation/pages/user_lists_page.dart | 64 ++- lib/main.dart | 11 +- test/core/diagnostics/crash_log_test.dart | 84 ++++ test/core/glass/reduced_effects_test.dart | 75 ++++ test/core/system/app_orientation_test.dart | 32 ++ .../history/history_decode_cache_test.dart | 105 +++++ .../home/view_all_load_more_failure_test.dart | 185 +++++++++ .../user_lists/user_lists_outbox_test.dart | 341 ++++++++++++++++ 79 files changed, 2253 insertions(+), 1321 deletions(-) create mode 100644 lib/core/diagnostics/crash_log.dart create mode 100644 lib/core/diagnostics/friendly_error_widget.dart create mode 100644 lib/core/glass/reducible_blur.dart create mode 100644 lib/core/widgets/load_more_failed_footer.dart delete mode 100644 lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart delete mode 100644 lib/features/my_list/my_list/data/models/favorite_model.dart delete mode 100644 lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart delete mode 100644 lib/features/my_list/my_list/domain/entities/favorite_entity.dart delete mode 100644 lib/features/my_list/my_list/domain/entities/my_list_failure.dart delete mode 100644 lib/features/my_list/my_list/domain/repositories/my_list_repository.dart delete mode 100644 lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart delete mode 100644 lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart delete mode 100644 lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart delete mode 100644 lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart delete mode 100644 lib/features/my_list/my_list/presentation/bloc/my_list_event.dart delete mode 100644 lib/features/my_list/my_list/presentation/bloc/my_list_state.dart delete mode 100644 lib/features/my_list/my_list/presentation/pages/my_list_page.dart delete mode 100644 lib/features/my_list/my_list/presentation/widgets/favorite_card.dart delete mode 100644 lib/features/my_list/my_list/presentation/widgets/my_list_background.dart delete mode 100644 lib/features/my_list/my_list/presentation/widgets/my_list_header.dart delete mode 100644 lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart delete mode 100644 lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart create mode 100644 test/core/diagnostics/crash_log_test.dart create mode 100644 test/core/glass/reduced_effects_test.dart create mode 100644 test/core/system/app_orientation_test.dart create mode 100644 test/features/history/history_decode_cache_test.dart create mode 100644 test/features/home/view_all_load_more_failure_test.dart create mode 100644 test/features/user_lists/user_lists_outbox_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e149a6d1..872f67f3 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -34,7 +34,9 @@ "remove": "إزالة", "show": "إظهار", "hide": "إخفاء", - "installed": "مثبّت" + "installed": "مثبّت", + "load_more_failed": "تعذّر تحميل المزيد", + "go_home": "العودة إلى الرئيسية" }, "auth": { "login": "تسجيل الدخول", @@ -591,7 +593,9 @@ "native_offer_title": "هل تريد تجربة المشغّل الجديد؟", "native_offer_body": "أزرار وقوائم Liquid Glass من Apple. يمكنك تغيير ذلك في أي وقت من الإعدادات ← المشغّل.", "native_offer_try": "جرّبه", - "native_offer_not_now": "ليس الآن" + "native_offer_not_now": "ليس الآن", + "crash_log": "سجل الأعطال", + "crash_log_empty": "لم تُسجَّل أي أعطال" }, "profile": { "title": "حسابي", @@ -2075,7 +2079,8 @@ "title": "قوائمي", "remove": "إزالة", "empty_title": "لا شيء محفوظ بعد", - "empty_subtitle": "ستظهر هنا الأعمال التي تحفظها." + "empty_subtitle": "ستظهر هنا الأعمال التي تحفظها.", + "load_failed": "تعذّر تحميل هذه القائمة." }, "torrent": { "title": "التورنت", @@ -3157,7 +3162,8 @@ "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", "suggest": "تتكرر المشكلة؟ راسل الدعم", "error_offline": "أنت غير متصل أو انقطع الاتصال. تحقق من الإنترنت ثم حاول مرة أخرى.", - "not_sent": "لم يُرسل: {}" + "not_sent": "لم يُرسل: {}", + "diag_crash_log": "عرض سجل الأعطال" }, "zangetsu": { "title": "مصادر Zangetsu", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 9f984bfb..1f6cdfa7 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -34,7 +34,9 @@ "remove": "لابردن", "show": "پیشاندان", "hide": "شاردنەوە", - "installed": "دامەزراوە" + "installed": "دامەزراوە", + "load_more_failed": "نەتوانرا زیاتر باربکرێت", + "go_home": "بڕۆ بۆ سەرەکی" }, "auth": { "login": "چوونەژوورەوە", @@ -591,7 +593,9 @@ "native_offer_title": "لێدەری نوێ تاقی دەکەیتەوە؟", "native_offer_body": "دوگمە و لیستەکانی Liquid Glassی Apple. هەر کاتێک دەتوانیت لە ڕێکخستنەکان ← لێدەر بیگۆڕیت.", "native_offer_try": "تاقیکردنەوە", - "native_offer_not_now": "ئێستا نا" + "native_offer_not_now": "ئێستا نا", + "crash_log": "تۆماری تێکچوون", + "crash_log_empty": "هیچ تێکچوونێک تۆمار نەکراوە" }, "profile": { "title": "پرۆفایل", @@ -2071,7 +2075,8 @@ "title": "لیستەکانم", "remove": "لابردن", "empty_title": "هێشتا هیچ شتێک پاشەکەوت نەکراوە", - "empty_subtitle": "ئەو ناونیشانانەی پاشەکەوتیان دەکەیت لێرە دەردەکەون." + "empty_subtitle": "ئەو ناونیشانانەی پاشەکەوتیان دەکەیت لێرە دەردەکەون.", + "load_failed": "ئەم لیستە بارنەکرا." }, "torrent": { "title": "تۆرێنتەکان", @@ -3153,7 +3158,8 @@ "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە", "error_offline": "تۆ دەرهێڵیت یان پەیوەندی پچڕا. ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", - "not_sent": "نەنێردرا: {}" + "not_sent": "نەنێردرا: {}", + "diag_crash_log": "بینینی تۆماری تێکچوون" }, "zangetsu": { "title": "سەرچاوەکانی Zangetsu", diff --git a/assets/translations/de.json b/assets/translations/de.json index 24ea0d3f..e793f6d5 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -34,7 +34,9 @@ "remove": "Entfernen", "show": "Anzeigen", "hide": "Ausblenden", - "installed": "Installiert" + "installed": "Installiert", + "load_more_failed": "Mehr konnte nicht geladen werden", + "go_home": "Zur Startseite" }, "auth": { "login": "Anmelden", @@ -591,7 +593,9 @@ "native_offer_title": "Den neuen Player ausprobieren?", "native_offer_body": "Apples Liquid-Glass-Bedienelemente und -Menüs. Du kannst das jederzeit unter Einstellungen → Player ändern.", "native_offer_try": "Ausprobieren", - "native_offer_not_now": "Nicht jetzt" + "native_offer_not_now": "Nicht jetzt", + "crash_log": "Absturzprotokoll", + "crash_log_empty": "Keine Abstürze aufgezeichnet" }, "profile": { "title": "Profil", @@ -2071,7 +2075,8 @@ "title": "Meine Listen", "remove": "Entfernen", "empty_title": "Noch nichts gespeichert", - "empty_subtitle": "Titel, die du speicherst, erscheinen hier." + "empty_subtitle": "Titel, die du speicherst, erscheinen hier.", + "load_failed": "Diese Liste konnte nicht geladen werden." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", "suggest": "Passiert das öfter? Schreib dem Support", "error_offline": "Du bist offline oder die Verbindung ist abgebrochen. Prüfe dein Internet und versuche es erneut.", - "not_sent": "Nicht gesendet: {}" + "not_sent": "Nicht gesendet: {}", + "diag_crash_log": "Absturzprotokoll ansehen" }, "zangetsu": { "title": "Zangetsu-Quellen", diff --git a/assets/translations/en.json b/assets/translations/en.json index de087504..48ba85c6 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -34,7 +34,9 @@ "remove": "Remove", "show": "Show", "hide": "Hide", - "installed": "Installed" + "installed": "Installed", + "load_more_failed": "Couldn't load more", + "go_home": "Go home" }, "auth": { "login": "Login", @@ -591,7 +593,9 @@ "native_offer_title": "Try the new player?", "native_offer_body": "Apple's Liquid Glass controls and menus. You can change this anytime in Settings → Player.", "native_offer_try": "Try it", - "native_offer_not_now": "Not now" + "native_offer_not_now": "Not now", + "crash_log": "Crash log", + "crash_log_empty": "No crashes recorded" }, "profile": { "title": "Profile", @@ -2071,7 +2075,8 @@ "title": "My Lists", "remove": "Remove", "empty_title": "Nothing saved yet", - "empty_subtitle": "Titles you save show up here." + "empty_subtitle": "Titles you save show up here.", + "load_failed": "Couldn't load this list." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "At most 5 screenshots per message.", "suggest": "Keeps happening? Write to support", "error_offline": "You're offline or the connection dropped. Check your internet and try again.", - "not_sent": "Not sent: {}" + "not_sent": "Not sent: {}", + "diag_crash_log": "View crash log" }, "zangetsu": { "title": "Zangetsu Sources", diff --git a/assets/translations/es.json b/assets/translations/es.json index 339931f5..c68b9084 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -34,7 +34,9 @@ "remove": "Quitar", "show": "Mostrar", "hide": "Ocultar", - "installed": "Instalado" + "installed": "Instalado", + "load_more_failed": "No se pudo cargar más", + "go_home": "Ir al inicio" }, "auth": { "login": "Iniciar sesión", @@ -591,7 +593,9 @@ "native_offer_title": "¿Probar el nuevo reproductor?", "native_offer_body": "Los controles y menús Liquid Glass de Apple. Puedes cambiarlo cuando quieras en Ajustes → Reproductor.", "native_offer_try": "Probar", - "native_offer_not_now": "Ahora no" + "native_offer_not_now": "Ahora no", + "crash_log": "Registro de fallos", + "crash_log_empty": "No hay fallos registrados" }, "profile": { "title": "Perfil", @@ -2071,7 +2075,8 @@ "title": "Mis listas", "remove": "Quitar", "empty_title": "Nada guardado todavía", - "empty_subtitle": "Los títulos que guardes aparecerán aquí." + "empty_subtitle": "Los títulos que guardes aparecerán aquí.", + "load_failed": "No se pudo cargar esta lista." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Como máximo 5 capturas por mensaje.", "suggest": "¿Sigue pasando? Escribe a soporte", "error_offline": "No tienes conexión o se cortó. Revisa tu internet e inténtalo de nuevo.", - "not_sent": "No se envió: {}" + "not_sent": "No se envió: {}", + "diag_crash_log": "Ver registro de fallos" }, "zangetsu": { "title": "Fuentes de Zangetsu", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 20dd2805..c1a47ebd 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -34,7 +34,9 @@ "remove": "Retirer", "show": "Afficher", "hide": "Masquer", - "installed": "Installé" + "installed": "Installé", + "load_more_failed": "Impossible d’en charger plus", + "go_home": "Retour à l’accueil" }, "auth": { "login": "Connexion", @@ -591,7 +593,9 @@ "native_offer_title": "Essayer le nouveau lecteur ?", "native_offer_body": "Les commandes et menus Liquid Glass d’Apple. Vous pouvez changer cela à tout moment dans Réglages → Lecteur.", "native_offer_try": "Essayer", - "native_offer_not_now": "Plus tard" + "native_offer_not_now": "Plus tard", + "crash_log": "Journal des plantages", + "crash_log_empty": "Aucun plantage enregistré" }, "profile": { "title": "Profil", @@ -2071,7 +2075,8 @@ "title": "Mes listes", "remove": "Retirer", "empty_title": "Rien d’enregistré pour l’instant", - "empty_subtitle": "Les titres que tu enregistres apparaissent ici." + "empty_subtitle": "Les titres que tu enregistres apparaissent ici.", + "load_failed": "Impossible de charger cette liste." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "5 captures maximum par message.", "suggest": "Ça se reproduit ? Écris au support", "error_offline": "Vous êtes hors ligne ou la connexion a été coupée. Vérifiez votre connexion et réessayez.", - "not_sent": "Non envoyé : {}" + "not_sent": "Non envoyé : {}", + "diag_crash_log": "Voir le journal des plantages" }, "zangetsu": { "title": "Sources Zangetsu", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 4c402bff..e26f2810 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -34,7 +34,9 @@ "remove": "हटाएँ", "show": "दिखाएँ", "hide": "छिपाएँ", - "installed": "इंस्टॉल है" + "installed": "इंस्टॉल है", + "load_more_failed": "और लोड नहीं हो सका", + "go_home": "होम पर जाएँ" }, "auth": { "login": "साइन इन", @@ -591,7 +593,9 @@ "native_offer_title": "नया प्लेयर आज़माएँ?", "native_offer_body": "Apple के Liquid Glass कंट्रोल और मेन्यू। आप इसे कभी भी सेटिंग्स → प्लेयर में बदल सकते हैं।", "native_offer_try": "आज़माएँ", - "native_offer_not_now": "अभी नहीं" + "native_offer_not_now": "अभी नहीं", + "crash_log": "क्रैश लॉग", + "crash_log_empty": "कोई क्रैश दर्ज नहीं" }, "profile": { "title": "प्रोफ़ाइल", @@ -2071,7 +2075,8 @@ "title": "मेरी लिस्ट", "remove": "हटाएँ", "empty_title": "अभी तक कुछ सेव नहीं किया", - "empty_subtitle": "आपके सेव किए गए टाइटल यहाँ दिखेंगे।" + "empty_subtitle": "आपके सेव किए गए टाइटल यहाँ दिखेंगे।", + "load_failed": "यह सूची लोड नहीं हो सकी।" }, "torrent": { "title": "टोरेंट", @@ -3153,7 +3158,8 @@ "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें", "error_offline": "आप ऑफ़लाइन हैं या कनेक्शन टूट गया। इंटरनेट जाँचें और फिर से कोशिश करें।", - "not_sent": "नहीं भेजा गया: {}" + "not_sent": "नहीं भेजा गया: {}", + "diag_crash_log": "क्रैश लॉग देखें" }, "zangetsu": { "title": "Zangetsu स्रोत", diff --git a/assets/translations/id.json b/assets/translations/id.json index 73960bf6..3a093e41 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -34,7 +34,9 @@ "remove": "Hapus", "show": "Tampilkan", "hide": "Sembunyikan", - "installed": "Terpasang" + "installed": "Terpasang", + "load_more_failed": "Tidak dapat memuat lebih banyak", + "go_home": "Ke beranda" }, "auth": { "login": "Masuk", @@ -591,7 +593,9 @@ "native_offer_title": "Coba pemutar baru?", "native_offer_body": "Kontrol dan menu Liquid Glass dari Apple. Anda bisa mengubahnya kapan saja di Pengaturan → Pemutar.", "native_offer_try": "Coba", - "native_offer_not_now": "Nanti saja" + "native_offer_not_now": "Nanti saja", + "crash_log": "Log kerusakan", + "crash_log_empty": "Tidak ada kerusakan tercatat" }, "profile": { "title": "Profil", @@ -2071,7 +2075,8 @@ "title": "Daftar saya", "remove": "Singkirkan", "empty_title": "Belum ada yang disimpan", - "empty_subtitle": "Judul yang kamu simpan muncul di sini." + "empty_subtitle": "Judul yang kamu simpan muncul di sini.", + "load_failed": "Tidak dapat memuat daftar ini." }, "torrent": { "title": "Torrent", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", "suggest": "Terus terjadi? Hubungi dukungan", "error_offline": "Kamu sedang offline atau koneksi terputus. Periksa internet lalu coba lagi.", - "not_sent": "Tidak terkirim: {}" + "not_sent": "Tidak terkirim: {}", + "diag_crash_log": "Lihat log kerusakan" }, "zangetsu": { "title": "Sumber Zangetsu", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c74a718c..6847b802 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -34,7 +34,9 @@ "remove": "Rake", "show": "Nîşan bide", "hide": "Veşêre", - "installed": "Sazkirî" + "installed": "Sazkirî", + "load_more_failed": "Bêtir nehat barkirin", + "go_home": "Biçe destpêkê" }, "auth": { "login": "Têkeve", @@ -591,7 +593,9 @@ "native_offer_title": "Lêdara nû biceribînî?", "native_offer_body": "Bişkok û menuyên Liquid Glass ên Apple. Tu dikarî vê her dem di Mîheng → Lêdar de biguherînî.", "native_offer_try": "Biceribîne", - "native_offer_not_now": "Ne niha" + "native_offer_not_now": "Ne niha", + "crash_log": "Tomara têkçûnan", + "crash_log_empty": "Tu têkçûn nehatiye tomarkirin" }, "profile": { "title": "Profîl", @@ -2071,7 +2075,8 @@ "title": "Lîsteyên min", "remove": "Rake", "empty_title": "Hîn tiştek nehatiye tomarkirin", - "empty_subtitle": "Sernavên ku tu tomar dikî li vir xuya dibin." + "empty_subtitle": "Sernavên ku tu tomar dikî li vir xuya dibin.", + "load_failed": "Ev lîste nehat barkirin." }, "torrent": { "title": "Torrent", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", "suggest": "Dubare dibe? Ji piştgiriyê re binivîse", "error_offline": "Tu offline î an girêdan qut bû. Înternetê kontrol bike û dîsa biceribîne.", - "not_sent": "Nehat şandin: {}" + "not_sent": "Nehat şandin: {}", + "diag_crash_log": "Tomara têkçûnan bibîne" }, "zangetsu": { "title": "Çavkaniyên Zangetsu", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b48f0473..30c96e64 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -34,7 +34,9 @@ "remove": "Verwijderen", "show": "Tonen", "hide": "Verbergen", - "installed": "Geïnstalleerd" + "installed": "Geïnstalleerd", + "load_more_failed": "Kon niet meer laden", + "go_home": "Naar start" }, "auth": { "login": "Inloggen", @@ -591,7 +593,9 @@ "native_offer_title": "De nieuwe speler proberen?", "native_offer_body": "De Liquid Glass-bediening en -menu’s van Apple. Je kunt dit altijd wijzigen via Instellingen → Speler.", "native_offer_try": "Proberen", - "native_offer_not_now": "Niet nu" + "native_offer_not_now": "Niet nu", + "crash_log": "Crashlogboek", + "crash_log_empty": "Geen crashes vastgelegd" }, "profile": { "title": "Profiel", @@ -2071,7 +2075,8 @@ "title": "Mijn lijsten", "remove": "Verwijderen", "empty_title": "Nog niets opgeslagen", - "empty_subtitle": "Titels die je opslaat verschijnen hier." + "empty_subtitle": "Titels die je opslaat verschijnen hier.", + "load_failed": "Kon deze lijst niet laden." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Maximaal 5 screenshots per bericht.", "suggest": "Blijft het gebeuren? Schrijf support", "error_offline": "Je bent offline of de verbinding is verbroken. Controleer je internet en probeer het opnieuw.", - "not_sent": "Niet verzonden: {}" + "not_sent": "Niet verzonden: {}", + "diag_crash_log": "Crashlogboek bekijken" }, "zangetsu": { "title": "Zangetsu-bronnen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index ea4bf092..b52adf7c 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -34,7 +34,9 @@ "remove": "Remover", "show": "Mostrar", "hide": "Ocultar", - "installed": "Instalado" + "installed": "Instalado", + "load_more_failed": "Não foi possível carregar mais", + "go_home": "Ir para o início" }, "auth": { "login": "Entrar", @@ -591,7 +593,9 @@ "native_offer_title": "Experimentar o novo player?", "native_offer_body": "Os controles e menus Liquid Glass da Apple. Você pode mudar isso a qualquer momento em Configurações → Player.", "native_offer_try": "Experimentar", - "native_offer_not_now": "Agora não" + "native_offer_not_now": "Agora não", + "crash_log": "Registro de falhas", + "crash_log_empty": "Nenhuma falha registrada" }, "profile": { "title": "Perfil", @@ -2071,7 +2075,8 @@ "title": "Minhas listas", "remove": "Remover", "empty_title": "Nada salvo ainda", - "empty_subtitle": "Os títulos que você salvar aparecem aqui." + "empty_subtitle": "Os títulos que você salvar aparecem aqui.", + "load_failed": "Não foi possível carregar esta lista." }, "torrent": { "title": "Torrents", @@ -3153,7 +3158,8 @@ "error_attachment_count": "No máximo 5 capturas por mensagem.", "suggest": "Continua acontecendo? Fale com o suporte", "error_offline": "Você está offline ou a conexão caiu. Verifique a internet e tente novamente.", - "not_sent": "Não enviado: {}" + "not_sent": "Não enviado: {}", + "diag_crash_log": "Ver registro de falhas" }, "zangetsu": { "title": "Fontes Zangetsu", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 454de6c9..84dad747 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -34,7 +34,9 @@ "remove": "Удалить", "show": "Показать", "hide": "Скрыть", - "installed": "Установлено" + "installed": "Установлено", + "load_more_failed": "Не удалось загрузить ещё", + "go_home": "На главную" }, "auth": { "login": "Войти", @@ -591,7 +593,9 @@ "native_offer_title": "Попробовать новый плеер?", "native_offer_body": "Кнопки и меню Liquid Glass от Apple. Это можно изменить в любой момент в Настройки → Плеер.", "native_offer_try": "Попробовать", - "native_offer_not_now": "Не сейчас" + "native_offer_not_now": "Не сейчас", + "crash_log": "Журнал сбоев", + "crash_log_empty": "Сбоев не зафиксировано" }, "profile": { "title": "Профиль", @@ -2072,7 +2076,8 @@ "title": "Мои списки", "remove": "Удалить", "empty_title": "Пока ничего нет", - "empty_subtitle": "Сохранённое появится здесь." + "empty_subtitle": "Сохранённое появится здесь.", + "load_failed": "Не удалось загрузить список." }, "torrent": { "title": "Торренты", @@ -3154,7 +3159,8 @@ "error_attachment_count": "Не больше 5 скриншотов в сообщении.", "suggest": "Повторяется? Напишите в поддержку", "error_offline": "Нет интернета или соединение прервалось. Проверьте подключение и попробуйте снова.", - "not_sent": "Не отправлено: {}" + "not_sent": "Не отправлено: {}", + "diag_crash_log": "Открыть журнал сбоев" }, "zangetsu": { "title": "Источники Zangetsu", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 8c3cec9b..859b1e7c 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -34,7 +34,9 @@ "remove": "Kaldır", "show": "Göster", "hide": "Gizle", - "installed": "Yüklendi" + "installed": "Yüklendi", + "load_more_failed": "Daha fazlası yüklenemedi", + "go_home": "Ana sayfaya dön" }, "auth": { "login": "Giriş yap", @@ -591,7 +593,9 @@ "native_offer_title": "Yeni oynatıcıyı denemek ister misiniz?", "native_offer_body": "Apple’ın Liquid Glass kontrolleri ve menüleri. Bunu istediğiniz zaman Ayarlar → Oynatıcı bölümünden değiştirebilirsiniz.", "native_offer_try": "Dene", - "native_offer_not_now": "Şimdi değil" + "native_offer_not_now": "Şimdi değil", + "crash_log": "Çökme kaydı", + "crash_log_empty": "Kayıtlı çökme yok" }, "profile": { "title": "Profil", @@ -2071,7 +2075,8 @@ "title": "Listelerim", "remove": "Kaldır", "empty_title": "Henüz bir şey kaydedilmedi", - "empty_subtitle": "Kaydettiğin yapımlar burada görünür." + "empty_subtitle": "Kaydettiğin yapımlar burada görünür.", + "load_failed": "Bu liste yüklenemedi." }, "torrent": { "title": "Torrentler", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", "suggest": "Tekrarlıyor mu? Desteğe yaz", "error_offline": "Çevrimdışısın veya bağlantı koptu. İnternetini kontrol edip tekrar dene.", - "not_sent": "Gönderilmedi: {}" + "not_sent": "Gönderilmedi: {}", + "diag_crash_log": "Çökme kaydını görüntüle" }, "zangetsu": { "title": "Zangetsu Kaynakları", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 79885c27..259ac858 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -34,7 +34,9 @@ "remove": "Olib tashlash", "show": "Ko‘rsatish", "hide": "Yashirish", - "installed": "O‘rnatilgan" + "installed": "O‘rnatilgan", + "load_more_failed": "Ko‘proq yuklab bo‘lmadi", + "go_home": "Bosh sahifaga" }, "auth": { "login": "Kirish", @@ -591,7 +593,9 @@ "native_offer_title": "Yangi pleyerni sinab ko‘rasizmi?", "native_offer_body": "Apple’ning Liquid Glass tugmalari va menyulari. Buni istalgan vaqt Sozlamalar → Pleyer bo‘limida o‘zgartirishingiz mumkin.", "native_offer_try": "Sinab ko‘rish", - "native_offer_not_now": "Hozir emas" + "native_offer_not_now": "Hozir emas", + "crash_log": "Nosozliklar jurnali", + "crash_log_empty": "Nosozliklar qayd etilmagan" }, "profile": { "title": "Profil", @@ -2071,7 +2075,8 @@ "title": "Ro‘yxatlarim", "remove": "O‘chirish", "empty_title": "Hozircha hech narsa yo‘q", - "empty_subtitle": "Saqlaganlaringiz shu yerda ko‘rinadi." + "empty_subtitle": "Saqlaganlaringiz shu yerda ko‘rinadi.", + "load_failed": "Bu ro‘yxatni yuklab bo‘lmadi." }, "torrent": { "title": "Torrentlar", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", "suggest": "Takrorlanyaptimi? Support’ga yozing", "error_offline": "Internet yo'q yoki aloqa uzildi. Internetni tekshirib, qayta urinib ko'ring.", - "not_sent": "Yuborilmadi: {}" + "not_sent": "Yuborilmadi: {}", + "diag_crash_log": "Nosozliklar jurnalini ko‘rish" }, "zangetsu": { "title": "Zangetsu manbalari", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 0df9c365..46fcb766 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -34,7 +34,9 @@ "remove": "Gỡ", "show": "Hiện", "hide": "Ẩn", - "installed": "Đã cài" + "installed": "Đã cài", + "load_more_failed": "Không thể tải thêm", + "go_home": "Về trang chủ" }, "auth": { "login": "Đăng nhập", @@ -591,7 +593,9 @@ "native_offer_title": "Dùng thử trình phát mới?", "native_offer_body": "Nút điều khiển và menu Liquid Glass của Apple. Bạn có thể thay đổi bất cứ lúc nào trong Cài đặt → Trình phát.", "native_offer_try": "Dùng thử", - "native_offer_not_now": "Để sau" + "native_offer_not_now": "Để sau", + "crash_log": "Nhật ký sự cố", + "crash_log_empty": "Chưa ghi nhận sự cố nào" }, "profile": { "title": "Hồ sơ", @@ -2071,7 +2075,8 @@ "title": "Danh sách của tôi", "remove": "Xóa", "empty_title": "Chưa lưu gì", - "empty_subtitle": "Các phim bạn lưu sẽ hiện ở đây." + "empty_subtitle": "Các phim bạn lưu sẽ hiện ở đây.", + "load_failed": "Không thể tải danh sách này." }, "torrent": { "title": "Torrent", @@ -3153,7 +3158,8 @@ "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ", "error_offline": "Bạn đang ngoại tuyến hoặc kết nối bị ngắt. Hãy kiểm tra internet rồi thử lại.", - "not_sent": "Chưa gửi được: {}" + "not_sent": "Chưa gửi được: {}", + "diag_crash_log": "Xem nhật ký sự cố" }, "zangetsu": { "title": "Nguồn Zangetsu", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 1dbc9d5a..77898cd3 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -34,7 +34,9 @@ "remove": "移除", "show": "顯示", "hide": "隱藏", - "installed": "已安裝" + "installed": "已安裝", + "load_more_failed": "載入唔到更多", + "go_home": "返去主頁" }, "auth": { "login": "登入", @@ -591,7 +593,9 @@ "native_offer_title": "試吓新播放器?", "native_offer_body": "Apple 嘅 Liquid Glass 控制同選單。你隨時可以喺 設定 → 播放器 度改返。", "native_offer_try": "試吓", - "native_offer_not_now": "遲啲先" + "native_offer_not_now": "遲啲先", + "crash_log": "當機記錄", + "crash_log_empty": "冇當機記錄" }, "profile": { "title": "個人檔案", @@ -2071,7 +2075,8 @@ "title": "我嘅清單", "remove": "移除", "empty_title": "仲未儲存任何嘢", - "empty_subtitle": "你儲存咗嘅作品會喺呢度出現。" + "empty_subtitle": "你儲存咗嘅作品會喺呢度出現。", + "load_failed": "載入唔到呢個清單。" }, "torrent": { "title": "Torrent", @@ -3153,7 +3158,8 @@ "error_attachment_count": "每條訊息最多 5 張截圖。", "suggest": "成日都係咁?搵支援", "error_offline": "你而家離線,或者連線斷咗。請檢查網絡再試。", - "not_sent": "未送出:{}" + "not_sent": "未送出:{}", + "diag_crash_log": "睇當機記錄" }, "zangetsu": { "title": "Zangetsu 來源", diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index b619ed60..48b85148 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -886,6 +886,9 @@ Future configureDependencies() async { UserListsRepositoryImpl( getIt(), getIt(), + reconnects: Connectivity().onConnectivityChanged.where( + (r) => r.any((c) => c != ConnectivityResult.none), + ), ), ); diff --git a/lib/core/diagnostics/crash_log.dart b/lib/core/diagnostics/crash_log.dart new file mode 100644 index 00000000..97f95fa7 --- /dev/null +++ b/lib/core/diagnostics/crash_log.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'player_log.dart'; + +/// Uncaught errors, written to a few size-capped files under app support. +/// +/// Off Android only: Android reports through Crashlytics. Nothing here leaves +/// the device unless the viewer attaches it to a support ticket. +class CrashLog { + CrashLog(this.dir, {this.maxBytes = 128 * 1024, this.maxFiles = 3}); + + final Directory dir; + + /// Size at which the current file is rotated out. + final int maxBytes; + + /// Files kept in total, current one included. + final int maxFiles; + + static CrashLog? instance; + + File _file(int i) => File( + '${dir.path}${Platform.pathSeparator}crash${i == 0 ? '' : '.$i'}.log', + ); + + /// Newest first: `crash.log`, `crash.1.log`, ... + List get files => [ + for (var i = 0; i < maxFiles; i++) + if (_file(i).existsSync()) _file(i), + ]; + + /// Synchronous on purpose: the process may be about to die. + void record(Object error, StackTrace? stack, {String? source}) { + try { + if (!dir.existsSync()) dir.createSync(recursive: true); + final entry = StringBuffer() + ..writeln( + '--- ${DateTime.now().toIso8601String()}' + '${source == null ? '' : ' [$source]'}', + ) + ..writeln(PlayerLog.redact('$error')); + if (stack != null) entry.writeln(PlayerLog.redact('$stack')); + final text = entry.toString(); + final current = _file(0); + final size = current.existsSync() ? current.lengthSync() : 0; + if (size > 0 && size + text.length > maxBytes) _rotate(); + _file(0).writeAsStringSync( + text.length > maxBytes ? text.substring(0, maxBytes) : text, + mode: FileMode.append, + flush: true, + ); + } catch (_) { + // A crash log that crashes helps nobody. + } + } + + void _rotate() { + final oldest = _file(maxFiles - 1); + if (oldest.existsSync()) oldest.deleteSync(); + for (var i = maxFiles - 2; i >= 0; i--) { + final f = _file(i); + if (f.existsSync()) f.renameSync(_file(i + 1).path); + } + } + + /// Everything kept, oldest first, cut to the newest [maxChars]. + String read({int? maxChars}) { + final buf = StringBuffer(); + for (final f in files.reversed) { + try { + buf.write(f.readAsStringSync()); + } catch (_) {} + } + final all = buf.toString(); + if (maxChars == null || all.length <= maxChars) return all; + return all.substring(all.length - maxChars); + } + + void clear() { + for (final f in files) { + try { + f.deleteSync(); + } catch (_) {} + } + } + + /// Routes Flutter's and the platform dispatcher's uncaught errors here. + static Future install() async { + if (kIsWeb || Platform.isAndroid) return; + try { + final support = await getApplicationSupportDirectory(); + instance = CrashLog( + Directory('${support.path}${Platform.pathSeparator}crash_logs'), + ); + } catch (_) { + return; + } + final log = instance!; + final previous = FlutterError.onError; + FlutterError.onError = (details) { + log.record(details.exception, details.stack, source: 'flutter'); + if (previous != null) { + previous(details); + } else { + FlutterError.presentError(details); + } + }; + PlatformDispatcher.instance.onError = (error, stack) { + if (kDebugMode) debugPrint('[uncaught] $error\n$stack'); + log.record(error, stack, source: 'platform'); + return true; + }; + } +} diff --git a/lib/core/diagnostics/friendly_error_widget.dart b/lib/core/diagnostics/friendly_error_widget.dart new file mode 100644 index 00000000..afe5d5bc --- /dev/null +++ b/lib/core/diagnostics/friendly_error_widget.dart @@ -0,0 +1,70 @@ +import 'dart:ui' as ui; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// What a widget that failed to build shows in release, instead of a grey box. +class FriendlyErrorWidget extends StatelessWidget { + const FriendlyErrorWidget({super.key, required this.onGoHome}); + + final VoidCallback onGoHome; + + /// Release only: debug and profile keep the framework's red screen. + static void install({required VoidCallback onGoHome}) { + if (!kReleaseMode) return; + ErrorWidget.builder = (_) => FriendlyErrorWidget(onGoHome: onGoHome); + } + + static String _t(String key, String fallback) { + try { + final v = key.tr(); + return v == key ? fallback : v; + } catch (_) { + return fallback; + } + } + + @override + Widget build(BuildContext context) { + // May be built above MaterialApp, so it brings its own direction and + // material instead of assuming them. + final direction = Directionality.maybeOf(context) ?? ui.TextDirection.ltr; + return Directionality( + textDirection: direction, + child: Material( + color: const Color(0xFF0E0E0E), + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.white54, + size: 40, + ), + const SizedBox(height: 12), + Text( + _t('general.error', 'Something went wrong'), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: onGoHome, + child: Text(_t('general.go_home', 'Go home')), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/diagnostics/log_viewer_sheet.dart b/lib/core/diagnostics/log_viewer_sheet.dart index 0603f76b..c8b95ad6 100644 --- a/lib/core/diagnostics/log_viewer_sheet.dart +++ b/lib/core/diagnostics/log_viewer_sheet.dart @@ -4,12 +4,16 @@ import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/system/responsive.dart'; +import 'crash_log.dart'; import 'player_log.dart'; class LogViewerSheet extends StatefulWidget { - const LogViewerSheet({super.key}); + const LogViewerSheet({super.key, this.crash = false}); - static Future show(BuildContext context) { + /// Opens on the crash log instead of the player log. + final bool crash; + + static Future show(BuildContext context, {bool crash = false}) { return showAdaptiveModal( context: context, backgroundColor: const Color(0xFF0E0E0E), @@ -17,7 +21,7 @@ class LogViewerSheet extends StatefulWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (_) => const LogViewerSheet(), + builder: (_) => LogViewerSheet(crash: crash), ); } @@ -29,9 +33,36 @@ class _LogViewerSheetState extends State { final _scroll = ScrollController(); final _log = PlayerLog.instance; + /// Showing the crash log rather than the player log. Only offered where a + /// crash log exists (off Android). + bool _crash = false; + String _crashText = ''; + + void _toggleCrash() { + setState(() { + _crash = !_crash; + if (_crash) _crashText = CrashLog.instance?.read() ?? ''; + }); + } + + String get _shareText => _crash ? _crashText : _log.formatForShare(); + + void _clear() { + if (_crash) { + CrashLog.instance?.clear(); + setState(() => _crashText = ''); + } else { + _log.clear(); + } + } + @override void initState() { super.initState(); + if (widget.crash && CrashLog.instance != null) { + _crash = true; + _crashText = CrashLog.instance!.read(); + } _log.revision.addListener(_onChange); WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToEnd()); } @@ -55,7 +86,7 @@ class _LogViewerSheetState extends State { } Future _copy() async { - await Clipboard.setData(ClipboardData(text: _log.formatForShare())); + await Clipboard.setData(ClipboardData(text: _shareText)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -66,7 +97,10 @@ class _LogViewerSheetState extends State { } Future _share() async { - await Share.share(_log.formatForShare(), subject: 'Sozo player logs'); + await Share.share( + _shareText, + subject: _crash ? 'Sozo crash log' : 'Sozo player logs', + ); } Color _colorFor(LogLevel level) => switch (level) { @@ -78,6 +112,8 @@ class _LogViewerSheetState extends State { @override Widget build(BuildContext context) { final lines = _log.lines; + final hasCrashLog = CrashLog.instance != null; + final empty = _crash ? _crashText.isEmpty : lines.isEmpty; final height = MediaQuery.of(context).size.height; return SafeArea( child: ConstrainedBox( @@ -96,7 +132,9 @@ class _LogViewerSheetState extends State { ), const SizedBox(width: 10), Text( - 'Player logs (${lines.length})', + _crash + ? 'player.crash_log'.tr() + : 'Player logs (${lines.length})', style: const TextStyle( color: Colors.white, fontSize: 15, @@ -104,6 +142,22 @@ class _LogViewerSheetState extends State { ), ), const Spacer(), + if (hasCrashLog) + IconButton( + tooltip: 'player.crash_log'.tr(), + isSelected: _crash, + icon: const Icon( + Icons.report_gmailerrorred_rounded, + color: Colors.white70, + size: 20, + ), + selectedIcon: const Icon( + Icons.report_rounded, + color: Color(0xFFFF6B6B), + size: 20, + ), + onPressed: _toggleCrash, + ), IconButton( tooltip: 'general.copy'.tr(), icon: const Icon( @@ -111,7 +165,7 @@ class _LogViewerSheetState extends State { color: Colors.white70, size: 20, ), - onPressed: lines.isEmpty ? null : _copy, + onPressed: empty ? null : _copy, ), IconButton( tooltip: 'general.share'.tr(), @@ -120,7 +174,7 @@ class _LogViewerSheetState extends State { color: Colors.white70, size: 20, ), - onPressed: lines.isEmpty ? null : _share, + onPressed: empty ? null : _share, ), IconButton( tooltip: 'general.clear'.tr(), @@ -129,21 +183,36 @@ class _LogViewerSheetState extends State { color: Colors.white70, size: 20, ), - onPressed: lines.isEmpty ? null : _log.clear, + onPressed: empty ? null : _clear, ), ], ), ), const Divider(color: Colors.white12, height: 1), Flexible( - child: lines.isEmpty + child: empty ? Padding( padding: const EdgeInsets.symmetric(vertical: 48), child: Text( - 'player.logs_empty'.tr(), + _crash + ? 'player.crash_log_empty'.tr() + : 'player.logs_empty'.tr(), style: const TextStyle(color: Colors.white38), ), ) + : _crash + ? SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 14), + child: SelectableText( + _crashText, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11.5, + height: 1.35, + color: Colors.white70, + ), + ), + ) : Scrollbar( controller: _scroll, child: ListView.builder( diff --git a/lib/core/glass/glass_power.dart b/lib/core/glass/glass_power.dart index ec250c85..d59cdbf2 100644 --- a/lib/core/glass/glass_power.dart +++ b/lib/core/glass/glass_power.dart @@ -143,6 +143,18 @@ final class GlassPowerReading { } /// What one frame has on this phone's panel ([GlassStepDown]). + /// Whether the always-on blurs (nav bars, scroll headers) should give way + /// to a plain translucent fill. Narrower than [affordsRefraction]: a phone + /// that only lacks Vulkan blurs fine, while a small, Go or GL ES 2 phone, or + /// one in Battery Saver, pays for every blurred frame. + bool get reducesEffects { + if (lowRam) return true; + if (powerSave) return true; + if (glEs != null && glEs! < 3) return true; + if (memoryBytes != null && memoryBytes! < leastMemoryBytes) return true; + return false; + } + GlassFrameBudget get budget => refreshHz == null ? GlassFrameBudget.sixty : GlassFrameBudget.ofRefreshRate(refreshHz!); @@ -227,6 +239,19 @@ class GlassPower extends StatefulWidget { /// frame was drawn, and what the frames it drew then showed /// ([GlassFrameWatch]). Both are read off the scope rather than one off a /// static, so that a glass depending on this is told when either turns. + /// See [GlassPowerReading.reducesEffects]. + static bool reducesEffectsAt(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType<_GlassPowerScope>() + ?.reading + .reducesEffects ?? + false; + + /// Puts [reading] in scope without asking the platform. + @visibleForTesting + static Widget withReading(GlassPowerReading reading, {required Widget child}) => + _GlassPowerScope(reading: reading, steppedDown: false, child: child); + static bool affordsAt(BuildContext context) => context.dependOnInheritedWidgetOfExactType<_GlassPowerScope>()?.affords ?? true; @@ -333,7 +358,9 @@ class _GlassPowerScope extends InheritedWidget { /// that changed in some field the gate does not look at — a core count, a /// refresh rate — is no reason to build every glass on the screen again. @override - bool updateShouldNotify(_GlassPowerScope old) => old.affords != affords; + bool updateShouldNotify(_GlassPowerScope old) => + old.affords != affords || + old.reading.reducesEffects != reading.reducesEffects; } /// The measured half of the gate: whether the phone the gate let through is diff --git a/lib/core/glass/reducible_blur.dart b/lib/core/glass/reducible_blur.dart new file mode 100644 index 00000000..517c01ec --- /dev/null +++ b/lib/core/glass/reducible_blur.dart @@ -0,0 +1,34 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +import 'glass_power.dart'; + +/// A backdrop blur that drops to a plain translucent fill on phones where +/// [GlassPowerReading.reducesEffects] holds. The caller still clips it. +class ReducibleBlur extends StatelessWidget { + const ReducibleBlur({ + super.key, + required this.sigma, + required this.child, + this.reducedFill, + }); + + final double sigma; + final Widget child; + + /// Painted under [child] instead of the blur, to keep the surface legible. + final Color? reducedFill; + + @override + Widget build(BuildContext context) { + if (GlassPower.reducesEffectsAt(context)) { + final fill = reducedFill; + return fill == null ? child : ColoredBox(color: fill, child: child); + } + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ); + } +} diff --git a/lib/core/system/app_orientation.dart b/lib/core/system/app_orientation.dart index 96400377..5d9c9a24 100644 --- a/lib/core/system/app_orientation.dart +++ b/lib/core/system/app_orientation.dart @@ -1,6 +1,9 @@ import 'dart:io' show Platform; +import 'dart:ui' show PlatformDispatcher, Size; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver; import 'package:soplay/core/system/platform_utils.dart'; class AppOrientation { @@ -8,12 +11,64 @@ class AppOrientation { static const _channel = MethodChannel('app/orientation'); + static const List appDefault = [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]; + + /// What was last asked for, so a fold or unfold can re-apply it. + static List? _requested; + static _MetricsWatch? _watch; + + /// A tablet or an unfolded foldable: 600dp is where Material stops treating + /// a window as a phone. + @visibleForTesting + static bool isLargeScreen(Size logicalSize) => + !logicalSize.isEmpty && logicalSize.shortestSide >= 600; + + /// A portrait-only request is the phone layout's lock; a large screen is + /// left free to rotate instead. Landscape requests (the player) are kept. + @visibleForTesting + static List resolve( + List requested, { + required bool largeScreen, + }) { + final portraitOnly = + requested.isNotEmpty && + requested.every( + (o) => + o == DeviceOrientation.portraitUp || + o == DeviceOrientation.portraitDown, + ); + if (largeScreen && portraitOnly) return DeviceOrientation.values; + return requested; + } + + static Size _screenSize() { + final views = PlatformDispatcher.instance.views; + if (views.isEmpty) return Size.zero; + final view = views.first; + try { + final display = view.display; + if (!display.size.isEmpty) return display.size / display.devicePixelRatio; + } catch (_) {} + return view.physicalSize / view.devicePixelRatio; + } + static Future set(List orientations) async { if (!Platform.isAndroid && !Platform.isIOS) return; // A television is fixed landscape and cannot be rotated. Applying the app's // portrait lock there letterboxes the whole UI into a phone-shaped column // in the middle of the screen, so TV keeps the system orientation. if (isTvPlatform) return; + _requested = orientations; + _watch ??= _MetricsWatch()..attach(); + await _apply( + resolve(orientations, largeScreen: isLargeScreen(_screenSize())), + ); + } + + static Future _apply(List orientations) async { if (Platform.isIOS) { try { await _channel.invokeMethod('set', { @@ -34,3 +89,26 @@ class AppOrientation { DeviceOrientation.landscapeRight => 'landscapeRight', }; } + +/// Re-applies the last request when a foldable opens or closes. +class _MetricsWatch with WidgetsBindingObserver { + bool? _large; + + void attach() { + try { + WidgetsBinding.instance.addObserver(this); + } catch (_) {} + } + + @override + void didChangeMetrics() { + final large = AppOrientation.isLargeScreen(AppOrientation._screenSize()); + if (large == _large) return; + _large = large; + final requested = AppOrientation._requested; + if (requested == null) return; + AppOrientation._apply( + AppOrientation.resolve(requested, largeScreen: large), + ).catchError((Object _) {}); + } +} diff --git a/lib/core/widgets/load_more_failed_footer.dart b/lib/core/widgets/load_more_failed_footer.dart new file mode 100644 index 00000000..d6af9556 --- /dev/null +++ b/lib/core/widgets/load_more_failed_footer.dart @@ -0,0 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// "Couldn't load more · Retry", where a paged grid's bottom spinner was. +class LoadMoreFailedFooter extends StatelessWidget { + const LoadMoreFailedFooter({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + 'general.load_more_failed'.tr(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 13), + ), + ), + const Text( + ' · ', + style: TextStyle(color: AppColors.textHint, fontSize: 13), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: const Size(48, 40), + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ); + } +} diff --git a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart index 32867ea4..b79ab911 100644 --- a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart +++ b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart @@ -228,6 +228,7 @@ class _AniyomiSourcesPageState extends State { width: size, height: size, fit: BoxFit.cover, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(), errorBuilder: (_, _, _) => Container( width: size, height: size, diff --git a/lib/features/comments/presentation/widgets/comment_avatar.dart b/lib/features/comments/presentation/widgets/comment_avatar.dart index 996c0e55..4aac6e27 100644 --- a/lib/features/comments/presentation/widgets/comment_avatar.dart +++ b/lib/features/comments/presentation/widgets/comment_avatar.dart @@ -33,6 +33,8 @@ class CommentAvatar extends StatelessWidget { ? Image.network( url, fit: BoxFit.cover, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, _, _) => _placeholder(), loadingBuilder: (_, child, chunk) => chunk == null ? child : _placeholder(), diff --git a/lib/features/detail/presentation/pages/actor_page.dart b/lib/features/detail/presentation/pages/actor_page.dart index b122530e..feb43e99 100644 --- a/lib/features/detail/presentation/pages/actor_page.dart +++ b/lib/features/detail/presentation/pages/actor_page.dart @@ -14,6 +14,7 @@ import 'package:soplay/features/home/presentation/bloc/view_all/view_all_state.d import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/home/presentation/widgets/home_ui_helpers.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/core/widgets/load_more_failed_footer.dart'; class ActorArgs { final String id; @@ -304,6 +305,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { childCount: state.items.length, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, mainAxisSpacing: 14, crossAxisSpacing: 8, @@ -327,6 +329,14 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), ), + if (state.loadMoreFailed) + SliverToBoxAdapter( + child: LoadMoreFailedFooter( + onRetry: () => context.read().add( + ViewAllLoadMore(retry: true), + ), + ), + ), ]; } } diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index 29b3a134..721d6498 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -15,7 +15,6 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/matching/title_match.dart'; import 'package:soplay/core/network/image_headers.dart'; import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/features/detail/data/source_choice_store.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; @@ -749,13 +748,17 @@ class _AlternateSourceSheetState extends State { ), ), const Divider(color: Colors.white12, height: 1), + // One viewport for everything, so the grid and the unmatched list + // build only the rows on screen. Still shrink-wrapped, so a short + // result keeps the sheet short. Flexible( - child: ListView( + child: CustomScrollView( shrinkWrap: true, - children: [ - if (!_reads && (list.isNotEmpty || _searching)) _autoTile(), + slivers: [ + if (!_reads && (list.isNotEmpty || _searching)) + SliverToBoxAdapter(child: _autoTile()), if (list.isNotEmpty) _matchGrid(list), - if (list.isEmpty) _emptyBlock(), + if (list.isEmpty) SliverToBoxAdapter(child: _emptyBlock()), if (quiet.isNotEmpty) ..._silentSection(quiet), ], ), @@ -778,21 +781,25 @@ class _AlternateSourceSheetState extends State { /// Sized by the widest a tile may be rather than by a column count, so a /// phone lands on three and a tablet or a desktop window takes four or more /// without a second layout being written for them. - Widget _matchGrid(List<_Row> rows) => GridView.builder( + static const double _tileMaxWidth = 132; + + Widget _matchGrid(List<_Row> rows) => SliverPadding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: rows.length, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 132, - mainAxisSpacing: 12, - crossAxisSpacing: 10, - // The poster's own 2:3 plus room for two lines of caption, which grows - // with the text scale rather than clipping at it. - mainAxisExtent: - 132 * 1.5 + 34 + MediaQuery.textScalerOf(context).scale(12) * 1.6, + sliver: SliverGrid.builder( + itemCount: rows.length, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _tileMaxWidth, + mainAxisSpacing: 12, + crossAxisSpacing: 10, + // The poster's own 2:3 plus room for two lines of caption, which grows + // with the text scale rather than clipping at it. + mainAxisExtent: + _tileMaxWidth * 1.5 + + 34 + + MediaQuery.textScalerOf(context).scale(12) * 1.6, + ), + itemBuilder: (context, i) => _matchTile(rows[i]), ), - itemBuilder: (context, i) => _matchTile(rows[i]), ); Widget _matchTile(_Row row) { @@ -828,6 +835,10 @@ class _AlternateSourceSheetState extends State { imageUrl: poster, httpHeaders: posterImageHeaders(poster), fit: BoxFit.cover, + memCacheWidth: + (_tileMaxWidth * + MediaQuery.devicePixelRatioOf(context)) + .round(), fadeInDuration: const Duration(milliseconds: 180), errorWidget: (_, _, _) => const Icon( Icons.broken_image_outlined, @@ -1028,6 +1039,17 @@ class _AlternateSourceSheetState extends State { /// source whose automatic match failed is the single most likely place for a /// correction to be needed. List _silentSection(List quiet) { + return [ + SliverList.list(children: _silentHeader(quiet)), + if (_showSilent) + SliverList.builder( + itemCount: quiet.length, + itemBuilder: (context, i) => _silentTile(quiet[i]), + ), + ]; + } + + List _silentHeader(List quiet) { return [ const Divider(color: Colors.white12, height: 1), InkWell( @@ -1058,7 +1080,7 @@ class _AlternateSourceSheetState extends State { ), ), ), - if (_showSilent) ...[ + if (_showSilent) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( @@ -1066,31 +1088,30 @@ class _AlternateSourceSheetState extends State { style: const TextStyle(color: Colors.white38, fontSize: 11), ), ), - for (final p in quiet) - ListTile( - dense: true, - enabled: _preparing == null && _walk == null, - onTap: () => _correct(ProviderRef.fromEntity(p)), - leading: const Icon( - Icons.search_off_rounded, - color: Colors.white30, - size: 20, - ), - title: Text( - p.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70, fontSize: 13), - ), - subtitle: Text( - 'player.alt_search_by_hand'.tr(), - style: const TextStyle(color: Colors.white38, fontSize: 11), - ), - ), - ], ]; } + Widget _silentTile(ProviderEntity p) => ListTile( + dense: true, + enabled: _preparing == null && _walk == null, + onTap: () => _correct(ProviderRef.fromEntity(p)), + leading: const Icon( + Icons.search_off_rounded, + color: Colors.white30, + size: 20, + ), + title: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + subtitle: Text( + 'player.alt_search_by_hand'.tr(), + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ); + Widget _emptyBlock() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), diff --git a/lib/features/detail/presentation/widgets/detail_cast_tab.dart b/lib/features/detail/presentation/widgets/detail_cast_tab.dart index c62e7850..fd6f4e7f 100644 --- a/lib/features/detail/presentation/widgets/detail_cast_tab.dart +++ b/lib/features/detail/presentation/widgets/detail_cast_tab.dart @@ -44,6 +44,7 @@ class DetailCastTab extends StatelessWidget { // 0.78 left the cell exactly as tall as its contents, so any // two-line name or a bumped system font size overflowed it. gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart b/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart index c38d61e2..8ecc6673 100644 --- a/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart +++ b/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart @@ -230,6 +230,7 @@ class _RelatedGridSk extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/detail/presentation/widgets/detail_related.dart b/lib/features/detail/presentation/widgets/detail_related.dart index b4191ed9..962aa977 100644 --- a/lib/features/detail/presentation/widgets/detail_related.dart +++ b/lib/features/detail/presentation/widgets/detail_related.dart @@ -31,6 +31,7 @@ class DetailRelatedSection extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/detail/presentation/widgets/detail_screenshots.dart b/lib/features/detail/presentation/widgets/detail_screenshots.dart index ad7d01e8..c5a1f230 100644 --- a/lib/features/detail/presentation/widgets/detail_screenshots.dart +++ b/lib/features/detail/presentation/widgets/detail_screenshots.dart @@ -29,6 +29,7 @@ class DetailScreenshotsSection extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 2, crossAxisSpacing: 10, mainAxisSpacing: 10, diff --git a/lib/features/detail/presentation/widgets/detail_skeleton.dart b/lib/features/detail/presentation/widgets/detail_skeleton.dart index 4eafa28c..fe9c61ac 100644 --- a/lib/features/detail/presentation/widgets/detail_skeleton.dart +++ b/lib/features/detail/presentation/widgets/detail_skeleton.dart @@ -186,6 +186,7 @@ class _PosterGridSk extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/download/presentation/widgets/relink_source_sheet.dart b/lib/features/download/presentation/widgets/relink_source_sheet.dart index a61c4d9f..ae554aea 100644 --- a/lib/features/download/presentation/widgets/relink_source_sheet.dart +++ b/lib/features/download/presentation/widgets/relink_source_sheet.dart @@ -397,6 +397,9 @@ class _SourceRow extends StatelessWidget { imageUrl: poster, httpHeaders: posterImageHeaders(poster), fit: BoxFit.cover, + memCacheWidth: + (52 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorWidget: (_, _, _) => const Icon( Icons.broken_image_outlined, color: AppColors.textHint, diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 3528a633..19cabc4e 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -15,21 +15,85 @@ import 'package:soplay/features/my_list/data/private_list_service.dart'; class HistoryService { final ValueNotifier revision = ValueNotifier(0); - Box get _box => Hive.box(ProfileScope.box(AppConstants.historyBox)); + /// Decoded rows, keyed by box key and reused while the stored string is the + /// same instance. Hive keeps values in memory, so an unchanged row hands back + /// the identical string and its decode can be skipped. + final Map _decoded = {}; + + /// The last [getAll] result, dropped on any change to the box. + List? _sorted; + Box? _watched; + StreamSubscription? _watch; + + Box get _box { + final box = Hive.box(ProfileScope.box(AppConstants.historyBox)); + if (!identical(box, _watched)) { + // A profile switch or a reopened box: nothing cached describes it. + _watch?.cancel(); + _watched = box; + _invalidate(); + _watch = box.watch().listen((event) { + _decoded.remove(event.key); + _sorted = null; + }); + } + return box; + } + + void _invalidate([Object? key]) { + if (key == null) { + _decoded.clear(); + } else { + _decoded.remove(key); + } + _sorted = null; + } + + /// Decode count, for tests. + @visibleForTesting + int decodes = 0; + + HistoryItem? _decode(Object? key, Object? raw) { + if (raw is! String) return null; + final hit = _decoded[key]; + if (hit != null && identical(hit.$1, raw)) return hit.$2; + try { + decodes++; + final item = HistoryItem.fromJson(jsonDecode(raw) as Map); + _decoded[key] = (raw, item); + return item; + } catch (_) { + return null; + } + } + + /// How many rows are stored, without decoding any of them. The box also + /// holds the manga reader's chapter marks, which are not rows. + int get count { + final box = _box; + final cached = _sorted; + if (cached != null) return cached.length; + var n = 0; + for (final key in box.keys) { + if (key is String && key.startsWith(_chapterMarkPrefix)) continue; + n++; + } + return n; + } + + static const _chapterMarkPrefix = 'chapters_read:'; List getAll() { + final box = _box; + final cached = _sorted; + if (cached != null) return List.of(cached); final items = []; - for (final key in _box.keys) { - try { - final raw = _box.get(key); - if (raw is String) { - items.add( - HistoryItem.fromJson(jsonDecode(raw) as Map), - ); - } - } catch (_) {} + for (final key in box.keys) { + final item = _decode(key, box.get(key)); + if (item != null) items.add(item); } items.sort((a, b) => b.watchedAt.compareTo(a.watchedAt)); + _sorted = List.unmodifiable(items); return items; } @@ -40,12 +104,10 @@ class HistoryService { episodeIndex: episodeIndex, episodeNumber: episodeNumber, ); - final raw = _box.get(key) ?? _box.get(contentUrl); - if (raw is String) { - try { - return HistoryItem.fromJson(jsonDecode(raw) as Map); - } catch (_) {} - } + final box = _box; + final direct = + _decode(key, box.get(key)) ?? _decode(contentUrl, box.get(contentUrl)); + if (direct != null) return direct; if (episodeIndex == null && episodeNumber == null) { return _getLatestForContent(contentUrl); } @@ -55,18 +117,14 @@ class HistoryService { HistoryItem? _getLatestForContent(String contentUrl) { final prefix = '$contentUrl::episode::'; HistoryItem? latest; - for (final key in _box.keys) { + final box = _box; + for (final key in box.keys) { if (key is! String || !key.startsWith(prefix)) continue; - try { - final raw = _box.get(key); - if (raw is! String) continue; - final item = HistoryItem.fromJson( - jsonDecode(raw) as Map, - ); - if (latest == null || item.watchedAt > latest.watchedAt) { - latest = item; - } - } catch (_) {} + final item = _decode(key, box.get(key)); + if (item == null) continue; + if (latest == null || item.watchedAt > latest.watchedAt) { + latest = item; + } } return latest; } @@ -106,7 +164,9 @@ class HistoryService { getIt().contains(item.contentUrl)) { return; } + _invalidate(item.storageKey); await _box.put(item.storageKey, jsonEncode(item.toJson())); + _invalidate(item.storageKey); await _trimIfNeeded(); if (notify) { _quietPending = false; @@ -118,15 +178,7 @@ class HistoryService { /// Reads a row back so a delete can be described to the server before the /// local copy is gone. - HistoryItem? _read(String key) { - final raw = _box.get(key); - if (raw is! String) return null; - try { - return HistoryItem.fromJson(jsonDecode(raw) as Map); - } catch (_) { - return null; - } - } + HistoryItem? _read(String key) => _decode(key, _box.get(key)); /// Deleting locally is only half of it: the row also exists on the account, /// and until the server is told, signing out and back in simply downloads it @@ -147,6 +199,7 @@ class HistoryService { final item = _read(key); if (item != null) await _rememberDeleted([item]); await _box.delete(key); + _invalidate(key); revision.value++; } @@ -160,6 +213,7 @@ class HistoryService { ); for (final k in keys) { await _box.delete(k); + _invalidate(k); } if (keys.isNotEmpty) revision.value++; } @@ -172,6 +226,7 @@ class HistoryService { /// for each one into whichever account signs in next. Future clearLocalOnly() async { await _box.clear(); + _invalidate(); revision.value++; } @@ -182,6 +237,7 @@ class HistoryService { unawaited(sync.sync()); } await _box.clear(); + _invalidate(); revision.value++; } @@ -203,6 +259,7 @@ class HistoryService { ]); for (final key in doomed) { await _box.delete(key); + _invalidate(key); } } } diff --git a/lib/features/history/presentation/pages/history_page.dart b/lib/features/history/presentation/pages/history_page.dart index e9cedcd1..76c38f66 100644 --- a/lib/features/history/presentation/pages/history_page.dart +++ b/lib/features/history/presentation/pages/history_page.dart @@ -41,6 +41,13 @@ class _HistoryPageState extends State { super.dispose(); } + /// Pull-to-refresh: waits out a sync already running, then runs one. + Future _refresh() async { + await _syncService.settle(); + await _syncService.sync(); + _reload(); + } + void _reload() { final items = _historyService.getAll(); if (!mounted) return; @@ -120,79 +127,83 @@ class _HistoryPageState extends State { return Scaffold( backgroundColor: AppColors.background, - body: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Pinned: "Clear all" and the back button are the only controls on - // this screen, and a long history scrolled them out of reach. - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - titleSpacing: 16, - title: Row( - children: [ - _CircleBackButton( - onTap: () => context.canPop() - ? context.pop() - : context.go('/main'), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'profile.watch_history'.tr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 22, - fontWeight: FontWeight.w800, - ), - ), - ), - if (_items.isNotEmpty) - _PillButton( - label: 'history.clear_all'.tr(), - onTap: _clearHistory, + body: RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _refresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Pinned: "Clear all" and the back button are the only controls on + // this screen, and a long history scrolled them out of reach. + SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + titleSpacing: 16, + title: Row( + children: [ + _CircleBackButton( + onTap: () => + context.canPop() ? context.pop() : context.go('/main'), ), - ], - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (_items.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: _EmptyState(), - ) - else - SliverList.separated( - itemCount: _items.length, - separatorBuilder: (_, _) => - Divider(color: AppColors.divider, height: 1, indent: 82), - itemBuilder: (_, i) { - final item = _items[i]; - return _HistoryRow( - item: item, - onTap: () { - context.push( - '/detail', - extra: DetailArgs( - contentUrl: item.contentUrl, - autoPlay: true, - resumeEpisodeIndex: item.episodeIndex, - provider: item.provider, + const SizedBox(width: 12), + Expanded( + child: Text( + 'profile.watch_history'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, ), - ); - }, - onDismissed: () => _removeItem(item), - ); - }, + ), + ), + if (_items.isNotEmpty) + _PillButton( + label: 'history.clear_all'.tr(), + onTap: _clearHistory, + ), + ], + ), ), - SliverToBoxAdapter(child: SizedBox(height: bottomPad + 24)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 16)), + if (_items.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyState(), + ) + else + SliverList.separated( + itemCount: _items.length, + separatorBuilder: (_, _) => + Divider(color: AppColors.divider, height: 1, indent: 82), + itemBuilder: (_, i) { + final item = _items[i]; + return _HistoryRow( + item: item, + onTap: () { + context.push( + '/detail', + extra: DetailArgs( + contentUrl: item.contentUrl, + autoPlay: true, + resumeEpisodeIndex: item.episodeIndex, + provider: item.provider, + ), + ); + }, + onDismissed: () => _removeItem(item), + ); + }, + ), + SliverToBoxAdapter(child: SizedBox(height: bottomPad + 24)), + ], + ), ), ); } @@ -330,125 +341,125 @@ class _HistoryRow extends StatelessWidget { @override Widget build(BuildContext context) { final row = InkWell( - onTap: onTap, - onLongPress: isDesktopPlatform ? null : () => _showActions(context), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 54, - height: 76, - child: Stack( - fit: StackFit.expand, - children: [ - HomeNetworkImage( - url: item.thumbnail, - borderRadius: BorderRadius.zero, - placeholderIcon: Icons.movie_outlined, - ), - if (item.progress > 0) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: LinearProgressIndicator( - value: item.progress, - minHeight: 3, - backgroundColor: Colors.black45, - valueColor: AlwaysStoppedAnimation( - AppColors.primary, - ), + onTap: onTap, + onLongPress: isDesktopPlatform ? null : () => _showActions(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 54, + height: 76, + child: Stack( + fit: StackFit.expand, + children: [ + HomeNetworkImage( + url: item.thumbnail, + borderRadius: BorderRadius.zero, + placeholderIcon: Icons.movie_outlined, + ), + if (item.progress > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: item.progress, + minHeight: 3, + backgroundColor: Colors.black45, + valueColor: AlwaysStoppedAnimation( + AppColors.primary, ), ), - ], - ), + ), + ], ), ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.3, - ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.3, ), - const SizedBox(height: 4), - Row( - children: [ - if (item.isSerial && item.episodeNumber != null) ...[ - Text( - 'EP ${item.episodeNumber}', - style: TextStyle( - color: AppColors.primary, - fontSize: 11, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 4), + Row( + children: [ + if (item.isSerial && item.episodeNumber != null) ...[ + Text( + 'EP ${item.episodeNumber}', + style: TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, ), - if (item.episodeLabel != null && - item.episodeLabel!.trim().isNotEmpty) - Expanded( - child: Text( - ' · ${item.episodeLabel}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 11, - ), + ), + if (item.episodeLabel != null && + item.episodeLabel!.trim().isNotEmpty) + Expanded( + child: Text( + ' · ${item.episodeLabel}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, ), ), - ], - if (!item.isSerial && item.durationMs > 0) - Text( - _formatProgress(item), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 11, - ), ), ], + if (!item.isSerial && item.durationMs > 0) + Text( + _formatProgress(item), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + _watchedAgo(context, item.watchedAt), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10, ), - const SizedBox(height: 2), - Text( - _watchedAgo(context, item.watchedAt), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 10, - ), - ), - ], - ), + ), + ], ), - const SizedBox(width: 8), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: Icon( - Icons.play_arrow_rounded, - color: AppColors.primary, - size: 20, - ), + ), + const SizedBox(width: 8), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, ), - ], - ), + child: Icon( + Icons.play_arrow_rounded, + color: AppColors.primary, + size: 20, + ), + ), + ], ), - ); + ), + ); if (isDesktopPlatform) { return Row( @@ -456,8 +467,10 @@ class _HistoryRow extends StatelessWidget { Expanded(child: row), IconButton( tooltip: 'history.remove'.tr(), - icon: const Icon(Icons.delete_outline_rounded, - color: AppColors.textHint), + icon: const Icon( + Icons.delete_outline_rounded, + color: AppColors.textHint, + ), onPressed: onDismissed, ), const SizedBox(width: 8), @@ -516,7 +529,11 @@ class _SwipeToRemoveBackground extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.delete_outline_rounded, color: Colors.white, size: 22), + const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 22, + ), const SizedBox(width: 8), Text( label, @@ -541,8 +558,11 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.history_rounded, - color: AppColors.textHint, size: 52), + const Icon( + Icons.history_rounded, + color: AppColors.textHint, + size: 52, + ), const SizedBox(height: 14), Text( 'history.empty_title'.tr(), diff --git a/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart b/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart index f5ba3575..20221dca 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter_bloc/flutter_bloc.dart' show Bloc, Emitter; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/home/domain/entities/view_all_paging_entity.dart'; @@ -9,10 +11,28 @@ import '../../../domain/usecase/view_all_usecase.dart'; class ViewAllBloc extends Bloc { final ViewAllUseCase useCase; + final DateTime Function() _now; String _key = ''; String _slug = ''; - ViewAllBloc({required this.useCase}) : super(ViewAllInitial()) { + /// Consecutive failed next-page requests, and when the scroll may try again. + int _failures = 0; + DateTime? _retryAfter; + + static const Duration firstBackoff = Duration(seconds: 2); + static const Duration maxBackoff = Duration(seconds: 60); + + static Duration backoffFor(int failures) { + if (failures <= 0) return Duration.zero; + final ms = firstBackoff.inMilliseconds * math.pow(2, failures - 1); + return Duration( + milliseconds: math.min(ms.toInt(), maxBackoff.inMilliseconds), + ); + } + + ViewAllBloc({required this.useCase, DateTime Function()? now}) + : _now = now ?? DateTime.now, + super(ViewAllInitial()) { on(_onLoad); on(_onLoadMore); } @@ -20,6 +40,8 @@ class ViewAllBloc extends Bloc { Future _onLoad(ViewAllLoad event, Emitter emit) async { _key = event.key; _slug = event.slug ?? ''; + _failures = 0; + _retryAfter = null; emit(ViewAllLoading()); final Result result = await useCase(_key, _slug, 1); @@ -38,6 +60,11 @@ class ViewAllBloc extends Bloc { Future _onLoadMore(ViewAllLoadMore event, Emitter emit) async { final current = state; if (current is! ViewAllLoaded || !current.hasMore || current.isLoadingMore) return; + // The scroll listener fires on every frame near the bottom. After a + // failure those are refused until the backoff runs out; the footer's + // retry is not. + final wait = _retryAfter; + if (!event.retry && wait != null && _now().isBefore(wait)) return; emit(ViewAllLoaded( items: current.items, @@ -48,6 +75,8 @@ class ViewAllBloc extends Bloc { final result = await useCase(_key, _slug, current.currentPage + 1); if (result.isSuccess) { + _failures = 0; + _retryAfter = null; final data = result.getOrNull()!; emit(ViewAllLoaded( items: [...current.items, ...data.items], @@ -55,13 +84,15 @@ class ViewAllBloc extends Bloc { totalPages: data.totalPages, )); } else { - // A failed *pagination* append must not wipe the already-loaded grid and - // scroll position — just clear the bottom spinner and keep what we have. + // A failed append keeps the grid and scroll position; the footer says + // what happened instead of a spinner that just disappears. + _failures++; + _retryAfter = _now().add(backoffFor(_failures)); emit(ViewAllLoaded( items: current.items, currentPage: current.currentPage, totalPages: current.totalPages, - isLoadingMore: false, + loadMoreFailed: true, )); } } diff --git a/lib/features/home/presentation/bloc/view_all/view_all_event.dart b/lib/features/home/presentation/bloc/view_all/view_all_event.dart index f38b8ee9..1c441438 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_event.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_event.dart @@ -5,7 +5,16 @@ class ViewAllEvent extends Equatable { List get props => []; } -class ViewAllLoadMore extends ViewAllEvent {} +class ViewAllLoadMore extends ViewAllEvent { + /// From the retry footer: skips the backoff a failed page imposes on the + /// scroll-driven requests. + final bool retry; + + ViewAllLoadMore({this.retry = false}); + + @override + List get props => [retry]; +} class ViewAllLoad extends ViewAllEvent { final String? slug; diff --git a/lib/features/home/presentation/bloc/view_all/view_all_state.dart b/lib/features/home/presentation/bloc/view_all/view_all_state.dart index bcc0ac06..a3fc3a9a 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_state.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_state.dart @@ -26,6 +26,9 @@ class ViewAllLoaded extends ViewAllState { final int totalPages; final bool isLoadingMore; + /// The last next-page request failed; the grid shows a retry footer. + final bool loadMoreFailed; + bool get hasMore => currentPage < totalPages; ViewAllLoaded({ @@ -33,8 +36,15 @@ class ViewAllLoaded extends ViewAllState { required this.currentPage, required this.totalPages, this.isLoadingMore = false, + this.loadMoreFailed = false, }); @override - List get props => [items, currentPage, totalPages, isLoadingMore]; + List get props => [ + items, + currentPage, + totalPages, + isLoadingMore, + loadMoreFailed, + ]; } diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 69a87844..ab0a3121 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -119,10 +119,12 @@ class _HomeContentState extends State { /// sat at the front of Watch's Home, and a film at the front of Manga's. void _loadHistory() { final mode = ContentMode.fromId(getIt().getContentMode()); - final items = [ - for (final item in _historyService.getAll()) - if (item.provider.contentMode == mode) item, - ]; + final items = _historyService.count == 0 + ? const [] + : [ + for (final item in _historyService.getAll()) + if (item.provider.contentMode == mode) item, + ]; if (!mounted) return; setState(() => _historyItems = items); } diff --git a/lib/features/home/presentation/widgets/home_shared_widgets.dart b/lib/features/home/presentation/widgets/home_shared_widgets.dart index ec756210..0ece14aa 100644 --- a/lib/features/home/presentation/widgets/home_shared_widgets.dart +++ b/lib/features/home/presentation/widgets/home_shared_widgets.dart @@ -57,13 +57,25 @@ class HomeNetworkImage extends StatelessWidget { : File(imageUrl); return ClipRRect( borderRadius: borderRadius, - child: Image.file( - file, - fit: fit, - width: double.infinity, - height: double.infinity, - errorBuilder: (_, _, _) => - HomeImagePlaceholder(icon: placeholderIcon), + child: LayoutBuilder( + builder: (context, constraints) { + final w = + memCacheWidth ?? + (constraints.maxWidth.isFinite + ? (constraints.maxWidth * + MediaQuery.devicePixelRatioOf(context)) + .round() + : null); + return Image.file( + file, + fit: fit, + width: double.infinity, + height: double.infinity, + cacheWidth: (w != null && w > 0) ? w : null, + errorBuilder: (_, _, _) => + HomeImagePlaceholder(icon: placeholderIcon), + ); + }, ), ); } diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index 5736e006..fe614481 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -1,7 +1,6 @@ import 'package:soplay/core/content/catalogue.dart'; import 'package:soplay/core/content/catalogue_logo.dart'; import 'dart:async'; -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -20,6 +19,7 @@ import 'package:soplay/features/notifications/domain/repositories/notifications_ import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profile/presentation/widgets/provider_quick_switch.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -110,8 +110,9 @@ class _TopBarBackground extends StatelessWidget { } return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 14 * progress, sigmaY: 14 * progress), + child: ReducibleBlur( + sigma: 14 * progress, + reducedFill: AppColors.navBackground.withValues(alpha: 0.2 * progress), child: DecoratedBox( decoration: BoxDecoration( color: AppColors.navBackground.withValues(alpha: 0.72 * progress), diff --git a/lib/features/home/presentation/widgets/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index 1e910a9d..30d99e19 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -1,4 +1,3 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -10,10 +9,15 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/sources/domain/source_failure.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_event.dart'; import 'package:soplay/features/home/presentation/bloc/view_all/view_all_state.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/home/presentation/widgets/home_ui_helpers.dart'; import 'package:soplay/core/widgets/poster_hero.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; +import 'package:soplay/core/widgets/load_more_failed_footer.dart'; class ViewAllAppBar extends StatelessWidget { const ViewAllAppBar({ @@ -75,8 +79,9 @@ class ViewAllAppBar extends StatelessWidget { if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.background.withValues(alpha: 0.08 * progress), child: content, ), ); @@ -147,6 +152,7 @@ class ViewAllGrid extends StatelessWidget { childCount: state.items.length, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, // 14 read as a gap between unrelated blocks rather than the // seam between two rows of one grid. @@ -172,6 +178,14 @@ class ViewAllGrid extends StatelessWidget { ), ), ), + if (state.loadMoreFailed) + SliverToBoxAdapter( + child: LoadMoreFailedFooter( + onRetry: () => context.read().add( + ViewAllLoadMore(retry: true), + ), + ), + ), SliverToBoxAdapter(child: SizedBox(height: bottomPad + 16)), ], ); @@ -354,6 +368,7 @@ class ViewAllSkeleton extends StatelessWidget { childCount: 15, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, mainAxisSpacing: 10, crossAxisSpacing: 8, diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index a6d5f20e..10ec6d14 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import '../widgets/scroll_compact_navigation.dart'; -import 'dart:ui'; import 'dart:ui' as ui show TextDirection; import 'package:easy_localization/easy_localization.dart'; @@ -33,6 +32,7 @@ import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; import '../../../../core/navigation/nav_controller.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; /// Addressed by name, never through `ShowcaseView.get()`: that returns the most recently /// registered instance, so opening a detail page — which registers its own private scope — @@ -505,7 +505,8 @@ class _MainPageState extends State with WidgetsBindingObserver { }, child: isTvPlatform ? _buildTvShell(tabs, defs) - : isDesktopPlatform + // A tablet or unfolded foldable in landscape gets the rail. + : isDesktopPlatform || SozoWidth.of(context).isExpanded ? _buildDesktopShell(tabs, defs) : Scaffold( backgroundColor: AppColors.background, @@ -1208,8 +1209,9 @@ class _SozoClassicBar extends StatelessWidget { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: ReducibleBlur( + sigma: 22, + reducedFill: AppColors.surface.withValues(alpha: 0.6), child: Container( height: 68 + bottomPad, decoration: BoxDecoration( @@ -1415,8 +1417,9 @@ class _SozoFloatingNav extends StatelessWidget { ), child: ClipRRect( borderRadius: radius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: ReducibleBlur( + sigma: 22, + reducedFill: AppColors.surface.withValues(alpha: 0.6), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart b/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart deleted file mode 100644 index 80328b77..00000000 --- a/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:soplay/features/my_list/data/models/favorite_model.dart'; - -class MyListRemoteDataSource { - const MyListRemoteDataSource({required this.dio}); - - final Dio dio; - - Future> getFavorites() async { - final response = await dio.get('/auth/favorites'); - final data = response.data; - final items = data is Map ? data['items'] : null; - if (items is! List) return const []; - return items - .whereType() - .map((e) => FavoriteModel.fromJson(e.cast())) - .where((e) => e.contentUrl.trim().isNotEmpty) - .toList(growable: false); - } - - Future addFavorite({ - required String provider, - required String contentUrl, - required String title, - required String thumbnail, - }) async { - await dio.post( - '/auth/favorites', - data: { - 'provider': provider, - 'contentUrl': contentUrl, - 'title': title, - 'thumbnail': thumbnail, - }, - ); - } - - Future removeFavorite(String contentUrl) async { - await dio.delete('/auth/favorites', data: {'contentUrl': contentUrl}); - } -} diff --git a/lib/features/my_list/my_list/data/models/favorite_model.dart b/lib/features/my_list/my_list/data/models/favorite_model.dart deleted file mode 100644 index be3d43f6..00000000 --- a/lib/features/my_list/my_list/data/models/favorite_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -class FavoriteModel extends FavoriteEntity { - const FavoriteModel({ - required super.provider, - required super.contentUrl, - required super.title, - required super.thumbnail, - super.description, - }); - - factory FavoriteModel.fromJson(Map json) => FavoriteModel( - provider: json['provider'] as String? ?? '', - contentUrl: json['contentUrl'] as String? ?? '', - title: json['title'] as String? ?? '', - thumbnail: json['thumbnail'] as String? ?? '', - description: json['description'] as String? ?? '', - ); -} diff --git a/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart b/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart deleted file mode 100644 index 2afd0ea2..00000000 --- a/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/data/datasources/my_list_remote_data_source.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/entities/my_list_failure.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class MyListRepositoryImpl implements MyListRepository { - const MyListRepositoryImpl(this.dataSource); - - final MyListRemoteDataSource dataSource; - - @override - Future>> getFavorites() async { - try { - return Success(await dataSource.getFavorites()); - } on DioException catch (e) { - if (e.response?.statusCode == 401) { - return const Failure(MyListUnauthorizedException()); - } - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future> addFavorite(FavoriteEntity entity) async { - try { - await dataSource.addFavorite( - provider: entity.provider, - contentUrl: entity.contentUrl, - title: entity.title, - thumbnail: entity.thumbnail, - ); - return Success(null); - } on DioException catch (e) { - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future> removeFavorite(String contentUrl) async { - try { - await dataSource.removeFavorite(contentUrl); - return Success(null); - } on DioException catch (e) { - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future syncAfterLogin() async {} - - String _messageFrom(DioException e) { - final data = e.response?.data; - if (data is Map) { - final message = data['message']; - if (message is String && message.trim().isNotEmpty) return message; - if (message is List && message.isNotEmpty) { - return message.first.toString(); - } - } - return e.message ?? 'Request failed'; - } -} diff --git a/lib/features/my_list/my_list/domain/entities/favorite_entity.dart b/lib/features/my_list/my_list/domain/entities/favorite_entity.dart deleted file mode 100644 index 6de72c7d..00000000 --- a/lib/features/my_list/my_list/domain/entities/favorite_entity.dart +++ /dev/null @@ -1,15 +0,0 @@ -class FavoriteEntity { - final String provider; - final String contentUrl; - final String title; - final String thumbnail; - final String description; - - const FavoriteEntity({ - required this.provider, - required this.contentUrl, - required this.title, - required this.thumbnail, - this.description = '', - }); -} diff --git a/lib/features/my_list/my_list/domain/entities/my_list_failure.dart b/lib/features/my_list/my_list/domain/entities/my_list_failure.dart deleted file mode 100644 index 61a6dd09..00000000 --- a/lib/features/my_list/my_list/domain/entities/my_list_failure.dart +++ /dev/null @@ -1,6 +0,0 @@ -class MyListUnauthorizedException implements Exception { - const MyListUnauthorizedException(); - - @override - String toString() => 'Token kerak'; -} diff --git a/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart b/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart deleted file mode 100644 index 1d9c0df7..00000000 --- a/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -abstract class MyListRepository { - Future>> getFavorites(); - Future> addFavorite(FavoriteEntity entity); - Future> removeFavorite(String contentUrl); -} diff --git a/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart b/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart deleted file mode 100644 index a98c1e51..00000000 --- a/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class AddFavoriteUseCase { - const AddFavoriteUseCase(this.repository); - - final MyListRepository repository; - - Future> call(FavoriteEntity entity) => - repository.addFavorite(entity); -} diff --git a/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart b/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart deleted file mode 100644 index 18844db7..00000000 --- a/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class GetFavoritesUseCase { - final MyListRepository repository; - - const GetFavoritesUseCase(this.repository); - - Future>> call() => repository.getFavorites(); -} diff --git a/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart b/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart deleted file mode 100644 index cc95d870..00000000 --- a/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class RemoveFavoriteUseCase { - const RemoveFavoriteUseCase(this.repository); - - final MyListRepository repository; - - Future> call(String contentUrl) => - repository.removeFavorite(contentUrl); -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart deleted file mode 100644 index ef1d898a..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/entities/my_list_failure.dart'; -import 'package:soplay/features/my_list/domain/usecases/get_favorites_usecase.dart'; - -import 'my_list_event.dart'; -import 'my_list_state.dart'; - -class MyListBloc extends Bloc { - final GetFavoritesUseCase useCase; - - MyListBloc({required this.useCase}) : super(const MyListInitial()) { - on(_onLoad); - on(_onRefresh); - } - - Future _onLoad(MyListLoad event, Emitter emit) async { - emit(const MyListLoading()); - await _load(emit); - } - - Future _onRefresh( - MyListRefresh event, - Emitter emit, - ) async { - final current = state; - if (current is MyListLoaded) { - emit(current.copyWith(refreshing: true)); - } else { - emit(const MyListLoading()); - } - await _load(emit); - } - - Future _load(Emitter emit) async { - final result = await useCase(); - switch (result) { - case Success>(:final value): - emit(MyListLoaded(items: value)); - case Failure>(:final error): - if (error is MyListUnauthorizedException) { - emit(const MyListUnauthorized()); - return; - } - emit(MyListError(_friendlyError(error))); - } - } - - String _friendlyError(Object error) { - return error.toString().replaceFirst('Exception: ', ''); - } -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart deleted file mode 100644 index 8be88255..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:equatable/equatable.dart'; - -sealed class MyListEvent extends Equatable { - const MyListEvent(); - - @override - List get props => []; -} - -class MyListLoad extends MyListEvent { - const MyListLoad(); -} - -class MyListRefresh extends MyListEvent { - const MyListRefresh(); -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart deleted file mode 100644 index 614f012e..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -sealed class MyListState extends Equatable { - const MyListState(); - - @override - List get props => []; -} - -class MyListInitial extends MyListState { - const MyListInitial(); -} - -class MyListLoading extends MyListState { - const MyListLoading(); -} - -class MyListLoaded extends MyListState { - final List items; - final bool refreshing; - - const MyListLoaded({required this.items, this.refreshing = false}); - - MyListLoaded copyWith({List? items, bool? refreshing}) { - return MyListLoaded( - items: items ?? this.items, - refreshing: refreshing ?? this.refreshing, - ); - } - - @override - List get props => [items, refreshing]; -} - -class MyListUnauthorized extends MyListState { - const MyListUnauthorized(); -} - -class MyListError extends MyListState { - final String message; - - const MyListError(this.message); - - @override - List get props => [message]; -} diff --git a/lib/features/my_list/my_list/presentation/pages/my_list_page.dart b/lib/features/my_list/my_list/presentation/pages/my_list_page.dart deleted file mode 100644 index 730ef7f3..00000000 --- a/lib/features/my_list/my_list/presentation/pages/my_list_page.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:soplay/core/di/injection.dart'; -import 'package:soplay/core/navigation/nav_controller.dart'; -import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/detail/domain/entities/detail_args.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/usecases/get_favorites_usecase.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_bloc.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_event.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_state.dart'; -import 'package:soplay/features/my_list/presentation/widgets/favorite_card.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_background.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_header.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_skeleton.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_state_views.dart'; - -class MyListPage extends StatelessWidget { - const MyListPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => - MyListBloc(useCase: getIt()) - ..add(const MyListLoad()), - child: const _MyListView(), - ); - } -} - -class _MyListView extends StatefulWidget { - const _MyListView(); - - @override - State<_MyListView> createState() => _MyListViewState(); -} - -class _MyListViewState extends State<_MyListView> - with AutomaticKeepAliveClientMixin { - static const _tabIndex = 3; - - final _scrollController = ScrollController(); - final _headerBlur = ValueNotifier(0.0); - late final NavController _navController; - - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - _navController = getIt(); - _navController.index.addListener(_onNavChange); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _navController.index.removeListener(_onNavChange); - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); - _headerBlur.dispose(); - super.dispose(); - } - - void _onNavChange() { - if (_navController.index.value != _tabIndex) return; - final bloc = context.read(); - if (bloc.state is MyListLoading) return; - bloc.add(const MyListLoad()); - } - - void _onScroll() { - if (!_scrollController.hasClients) return; - final next = (_scrollController.offset / 80.0).clamp(0.0, 1.0); - if ((next - _headerBlur.value).abs() > 0.01) { - _headerBlur.value = next; - } - } - - Future _refresh() async { - final bloc = context.read(); - final done = bloc.stream.firstWhere(_isSettled); - bloc.add(const MyListRefresh()); - await done.timeout( - const Duration(seconds: 20), - onTimeout: () => bloc.state, - ); - } - - bool _isSettled(MyListState state) { - return switch (state) { - MyListLoaded(:final refreshing) => !refreshing, - MyListUnauthorized() || MyListError() => true, - _ => false, - }; - } - - void _openDetail(FavoriteEntity item) { - context.push('/detail', extra: DetailArgs(contentUrl: item.contentUrl)); - } - - @override - Widget build(BuildContext context) { - super.build(context); - final topPad = MediaQuery.paddingOf(context).top; - final bottomPad = MediaQuery.paddingOf(context).bottom; - final headerH = topPad + MyListHeader.contentHeight; - - return Scaffold( - backgroundColor: AppColors.background, - body: Stack( - children: [ - const MyListBackground(), - RefreshIndicator( - onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, - edgeOffset: headerH, - child: BlocBuilder( - builder: (context, state) { - return CustomScrollView( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: SizedBox(height: headerH + 16)), - _buildBody(state, bottomPad), - ], - ); - }, - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: ValueListenableBuilder( - valueListenable: _headerBlur, - builder: (_, blur, _) => - MyListHeader(topPad: topPad, blurProgress: blur), - ), - ), - ], - ), - ); - } - - Widget _buildBody(MyListState state, double bottomPad) { - return switch (state) { - MyListInitial() || MyListLoading() => const MyListSkeleton(), - MyListLoaded(:final items) => - items.isEmpty - ? const SliverFillRemaining( - hasScrollBody: false, - child: MyListEmptyView(), - ) - : SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomPad + 96), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 142, - mainAxisSpacing: 16, - crossAxisSpacing: 10, - childAspectRatio: 0.56, - ), - delegate: SliverChildBuilderDelegate( - (_, i) => FavoriteCard( - item: items[i], - onTap: () => _openDetail(items[i]), - ), - childCount: items.length, - ), - ), - ), - MyListUnauthorized() => SliverFillRemaining( - hasScrollBody: false, - child: MyListUnauthorizedView(onLogin: () => context.push('/login')), - ), - MyListError(:final message) => SliverFillRemaining( - hasScrollBody: false, - child: MyListErrorView( - message: message, - onRetry: () => context.read().add(const MyListLoad()), - ), - ), - }; - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart b/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart deleted file mode 100644 index 4a875667..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/core/localization/line_height.dart'; -import 'package:soplay/core/content/catalogue.dart'; -import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -class FavoriteCard extends StatelessWidget { - const FavoriteCard({super.key, required this.item, required this.onTap}); - - final FavoriteEntity item; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final title = item.title.trim().isEmpty ? 'Untitled' : item.title.trim(); - final description = item.description.trim(); - final meta = description.isNotEmpty - ? description - : Catalogue.displayName(item.provider.trim()); - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - HomeNetworkImage( - url: item.thumbnail, - borderRadius: BorderRadius.zero, - placeholderIcon: Icons.movie_outlined, - ), - const Positioned( - left: 0, - right: 0, - bottom: 0, - child: SizedBox( - height: 44, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Color(0xB0000000), Color(0x00000000)], - ), - ), - ), - ), - ), - Positioned( - top: 6, - right: 6, - child: Container( - width: 26, - height: 26, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.52), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.bookmark_rounded, - color: Colors.white, - size: 16, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 7), - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - height: lineHeightFor(context, 1.18), - ), - ), - if (meta.isNotEmpty) ...[ - const SizedBox(height: 3), - Text( - meta, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 10.5, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - ], - ], - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart deleted file mode 100644 index 4c65367c..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListBackground extends StatelessWidget { - const MyListBackground({super.key}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [const Color(0xFF161616), AppColors.background, const Color(0xFF101010)], - stops: const [0, 0.42, 1], - ), - ), - child: const SizedBox.expand(), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart deleted file mode 100644 index f74cfa45..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'dart:ui'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListHeader extends StatelessWidget { - const MyListHeader({ - super.key, - required this.topPad, - required this.blurProgress, - }); - - final double topPad; - final double blurProgress; - - static const double contentHeight = 58.0; - - @override - Widget build(BuildContext context) { - final progress = blurProgress.clamp(0.0, 1.0); - - final content = Container( - padding: EdgeInsetsDirectional.fromSTEB(20, topPad + 14, 16, 14), - decoration: BoxDecoration( - color: AppColors.navBackground.withValues(alpha: 0.78 * progress), - border: progress > 0.05 - ? Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.07 * progress), - width: 0.5, - ), - ) - : null, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - 'home.my_list'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w900, - height: 1.05, - ), - ), - ), - ], - ), - ); - - if (progress < 0.01) return content; - - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), - child: content, - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart deleted file mode 100644 index 7441d404..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; - -class MyListSkeleton extends StatelessWidget { - const MyListSkeleton({super.key}); - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 142, - mainAxisSpacing: 16, - crossAxisSpacing: 10, - childAspectRatio: 0.56, - ), - delegate: SliverChildBuilderDelegate( - (_, _) => const _SkeletonCard(), - childCount: 9, - ), - ), - ); - } -} - -class _SkeletonCard extends StatelessWidget { - const _SkeletonCard(); - - @override - Widget build(BuildContext context) { - return const ShimmerWrapper( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: HomeSkeletonBox( - width: double.infinity, - height: 1, - radius: 10, - ), - ), - SizedBox(height: 7), - HomeSkeletonBox(width: double.infinity, height: 12, radius: 4), - SizedBox(height: 5), - HomeSkeletonBox(width: 72, height: 10, radius: 4), - ], - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart deleted file mode 100644 index 28bc53b8..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListEmptyView extends StatelessWidget { - const MyListEmptyView({super.key}); - - @override - Widget build(BuildContext context) { - return const MyListStateView( - icon: Icons.bookmark_border_rounded, - title: 'Your list is empty', - message: 'Tap the + button on any title to save it here.', - ); - } -} - -class MyListUnauthorizedView extends StatelessWidget { - const MyListUnauthorizedView({super.key, required this.onLogin}); - - final VoidCallback onLogin; - - @override - Widget build(BuildContext context) { - return MyListStateView( - icon: Icons.lock_outline_rounded, - title: 'Sign in to view your list', - message: 'Your saved titles will appear here once you sign in.', - actionLabel: 'Sign In', - onAction: onLogin, - ); - } -} - -class MyListErrorView extends StatelessWidget { - const MyListErrorView({ - super.key, - required this.message, - required this.onRetry, - }); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return MyListStateView( - icon: Icons.wifi_off_rounded, - title: 'Could not load list', - message: message, - actionLabel: 'Try Again', - onAction: onRetry, - ); - } -} - -class MyListStateView extends StatelessWidget { - const MyListStateView({ - super.key, - required this.icon, - required this.title, - required this.message, - this.actionLabel, - this.onAction, - }); - - final IconData icon; - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 120), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.09)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _IconCircle(icon: icon), - const SizedBox(height: 18), - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w800, - height: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - height: 1.5, - ), - ), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(height: 22), - SizedBox( - height: 46, - width: double.infinity, - child: ElevatedButton( - onPressed: onAction, - child: Text(actionLabel!), - ), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } -} - -class _IconCircle extends StatelessWidget { - const _IconCircle({required this.icon}); - - final IconData icon; - - @override - Widget build(BuildContext context) { - return Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.primary.withValues(alpha: 0.22), - AppColors.primary.withValues(alpha: 0.06), - ], - ), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.22)), - ), - child: Icon(icon, color: AppColors.primaryLight, size: 30), - ); - } -} diff --git a/lib/features/my_list/presentation/widgets/my_list_header.dart b/lib/features/my_list/presentation/widgets/my_list_header.dart index bbe227e6..0d19d4bf 100644 --- a/lib/features/my_list/presentation/widgets/my_list_header.dart +++ b/lib/features/my_list/presentation/widgets/my_list_header.dart @@ -1,10 +1,10 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; /// The order My List is shown in. Remembered on the device. enum MyListSort { @@ -112,8 +112,9 @@ class MyListHeader extends StatelessWidget { if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.navBackground.withValues(alpha: 0.15 * progress), child: content, ), ); diff --git a/lib/features/profile/presentation/pages/activity_page.dart b/lib/features/profile/presentation/pages/activity_page.dart index 8f984f45..42077338 100644 --- a/lib/features/profile/presentation/pages/activity_page.dart +++ b/lib/features/profile/presentation/pages/activity_page.dart @@ -57,7 +57,7 @@ class _ActivityPageState extends State { SettingsNavTile( icon: Icons.history_rounded, title: 'profile.watch_history'.tr(), - value: countLabel(_history.getAll().length), + value: countLabel(_history.count), onTap: () => context.push('/history'), ), const SettingsDivider(), diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 5177a08e..7b9db246 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -53,6 +52,7 @@ import 'package:soplay/features/achievements/presentation/widgets/achievements_e import 'package:soplay/features/progress/data/progress_service.dart'; import 'package:soplay/features/progress/domain/progress.dart'; import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; // Split into parts the way player_page.dart is: the page was 2721 lines // across nine sections, which is past the point where anyone can find @@ -297,10 +297,10 @@ class _ProfileViewState extends State<_ProfileView> { ); if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 20 * progress, - sigmaY: 20 * progress, + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.navBackground.withValues( + alpha: 0.15 * progress, ), child: content, ), diff --git a/lib/features/profile/presentation/pages/profile_page.sections.dart b/lib/features/profile/presentation/pages/profile_page.sections.dart index 244e79a7..057e0c6b 100644 --- a/lib/features/profile/presentation/pages/profile_page.sections.dart +++ b/lib/features/profile/presentation/pages/profile_page.sections.dart @@ -55,7 +55,7 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { void _reload() { if (!mounted) return; setState(() { - _historyCount = _historyService.getAll().length; + _historyCount = _historyService.count; _downloadCount = _downloadService().length; }); } diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 3a2fdba1..2efd6533 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -1,9 +1,9 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/tv/tv.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; class SearchStickyHeader extends StatelessWidget { const SearchStickyHeader({ @@ -124,8 +124,8 @@ class SearchStickyHeader extends StatelessWidget { if (!blurred) return surface; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, child: surface, ), ); diff --git a/lib/features/shorts/presentation/widgets/short_reel_item.dart b/lib/features/shorts/presentation/widgets/short_reel_item.dart index f9be5ec1..8548bcb3 100644 --- a/lib/features/shorts/presentation/widgets/short_reel_item.dart +++ b/lib/features/shorts/presentation/widgets/short_reel_item.dart @@ -378,6 +378,13 @@ class _ShortReelItemState extends State child: Image.network( thumb, fit: BoxFit.cover, + // Blurred behind the video: a third of the screen's pixels is + // more than the blur leaves visible. + cacheWidth: + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context) / + 3) + .round(), errorBuilder: (_, e, s) => const ColoredBox(color: Colors.black), ), ), @@ -685,6 +692,8 @@ class _ShortReelItemState extends State Image.network( s.contentThumbnail, fit: BoxFit.cover, + cacheWidth: (48 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, e, st) => Container( color: AppColors.surfaceVariant, child: const Icon( @@ -860,6 +869,9 @@ class _ShortReelItemState extends State ? Image.network( s.contentThumbnail, fit: BoxFit.cover, + cacheWidth: + (36 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, e, st) => const Center( child: Icon( Icons.movie_rounded, diff --git a/lib/features/support/data/support_diagnostics.dart b/lib/features/support/data/support_diagnostics.dart index d17e596d..d07b7260 100644 --- a/lib/features/support/data/support_diagnostics.dart +++ b/lib/features/support/data/support_diagnostics.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; import 'package:soplay/core/diagnostics/player_log.dart'; import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/features/support/data/support_models.dart'; @@ -57,13 +58,33 @@ class SupportDiagnostics { } if (includeLog) { final log = PlayerLog.instance; - if (log.lines.isNotEmpty) { - put('log', log.formatForSupport(maxLines: logLines)); - } + final player = log.lines.isNotEmpty + ? log.formatForSupport(maxLines: logLines) + : null; + put( + 'log', + withCrashLog(player, CrashLog.instance?.read(maxChars: crashChars)), + ); } return out; } + /// The crash log's newest end, appended to the player log. The server keeps + /// only named fields and trims a long `log` from the front, so riding at the + /// end of `log` is how it arrives intact without a server change. + static const int crashChars = 16 * 1024; + + static const String crashHeader = '===== crash log ====='; + + static String? withCrashLog(String? playerLog, String? crash) { + final c = crash?.trim(); + if (c == null || c.isEmpty) return playerLog; + final head = playerLog == null || playerLog.trim().isEmpty + ? '' + : '${playerLog.trimRight()}\n\n'; + return '$head$crashHeader\n$c'; + } + static String _utcOffset(Duration offset) { final sign = offset.isNegative ? '-' : '+'; final m = offset.inMinutes.abs(); diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index 8921972b..40ee40dd 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -13,6 +13,7 @@ import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/support/presentation/support_style.dart'; import 'package:soplay/features/support/presentation/widgets/support_screenshots.dart'; +import 'package:soplay/core/diagnostics/log_viewer_sheet.dart'; /// A new request to support. /// @@ -420,6 +421,14 @@ class _DiagnosticsCard extends StatelessWidget { ), ), ), + if (log != null && + log.contains(SupportDiagnostics.crashHeader)) + TextButton.icon( + onPressed: () => + LogViewerSheet.show(context, crash: true), + icon: const Icon(Icons.report_rounded, size: 18), + label: Text('support.diag_crash_log'.tr()), + ), ], ), ), diff --git a/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart b/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart index b98c7474..b514b7e4 100644 --- a/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart +++ b/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:hive_flutter/hive_flutter.dart'; @@ -10,24 +11,89 @@ import 'package:soplay/features/user_lists/data/datasources/user_lists_remote_da import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; -/// Cache-first, server-authoritative. -/// -/// The same shape [MyListRepositoryImpl] uses, and for the same reason: these -/// lists are read on screens that must render instantly and must not go blank -/// on a flaky connection. So every read serves the cache, a successful fetch -/// replaces it, and a failed fetch leaves the last good copy in place. +/// Thrown by [UserListsRepositoryImpl.getList] when the server cannot be +/// reached and there is no cached copy to fall back on. +class UserListUnavailable implements Exception { + const UserListUnavailable(); +} + +/// One local edit the server has not confirmed yet. +class PendingListOp { + const PendingListOp({ + required this.kind, + required this.isAdd, + required this.entity, + required this.at, + }); + + final UserListKind kind; + final bool isAdd; + final FavoriteEntity entity; + final int at; + + String get key => '${kind.slug}|${entity.contentUrl}'; + + Map toJson() => { + 'kind': kind.slug, + 'op': isAdd ? 'add' : 'remove', + 'provider': entity.provider, + 'contentUrl': entity.contentUrl, + 'title': entity.title, + 'thumbnail': entity.thumbnail, + 'at': at, + }; + + static PendingListOp? fromJson(Map m) { + final kind = UserListKind.values + .where((k) => k.slug == m['kind']) + .firstOrNull; + final url = (m['contentUrl'] ?? '').toString(); + if (kind == null || url.isEmpty) return null; + return PendingListOp( + kind: kind, + isAdd: m['op'] == 'add', + entity: FavoriteEntity( + provider: (m['provider'] ?? '').toString(), + contentUrl: url, + title: (m['title'] ?? '').toString(), + thumbnail: (m['thumbnail'] ?? '').toString(), + ), + at: (m['at'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Cache-first, server-authoritative, with an outbox for offline edits. /// -/// Writes are applied to the cache FIRST so the button flips immediately, then -/// pushed. A failed push leaves the local edit standing — the next successful -/// [getList] reconciles it against the server, which is the authority. +/// Writes land in the cache and in a persisted outbox, then are pushed. The +/// outbox holds at most one op per (list, item) — the newest — and is replayed +/// oldest first before every fetch and when connectivity returns. Whatever is +/// still unsent after a fetch is laid back over the server's copy, so a fetch +/// can never silently undo an edit the server has not seen. class UserListsRepositoryImpl implements UserListsRepository { - UserListsRepositoryImpl(this.remote, this.hive); + UserListsRepositoryImpl( + this.remote, + this.hive, { + Stream? reconnects, + int Function()? clock, + }) : _clock = clock ?? (() => DateTime.now().millisecondsSinceEpoch) { + _reconnectSub = reconnects?.listen((_) => unawaited(flushOutbox())); + } final UserListsRemoteDataSource remote; final HiveService hive; + final int Function() _clock; + StreamSubscription? _reconnectSub; + Future? _flushing; + + static const outboxKey = '__pending_ops__'; Box get _box => Hive.box(ProfileScope.box(AppConstants.userListsBox)); + void dispose() => _reconnectSub?.cancel(); + + bool _hasCache(UserListKind kind) => _box.get(kind.slug) is String; + List _cached(UserListKind kind) { final raw = _box.get(kind.slug); if (raw is! String || raw.isEmpty) return const []; @@ -66,27 +132,118 @@ class UserListsRepositoryImpl implements UserListsRepository { ); } + /// Pending ops, oldest first. + List pendingOps() { + final raw = _box.get(outboxKey); + if (raw is! String || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + final ops = decoded + .whereType() + .map(PendingListOp.fromJson) + .whereType() + .toList(); + ops.sort((a, b) => a.at.compareTo(b.at)); + return ops; + } catch (_) { + return []; + } + } + + Future _saveOps(List ops) async { + if (ops.isEmpty) { + await _box.delete(outboxKey); + return; + } + await _box.put(outboxKey, jsonEncode([for (final o in ops) o.toJson()])); + } + + Future _enqueue(PendingListOp op) async { + final ops = pendingOps()..removeWhere((o) => o.key == op.key); + ops.add(op); + await _saveOps(ops); + } + + /// Sends pending ops oldest first. Stops at the first failure so the order + /// the user made the edits in is the order the server sees them. + Future flushOutbox() { + if (!hive.isLoggedIn) return Future.value(); + return _flushing ??= _flush().whenComplete(() => _flushing = null); + } + + Future _flush() async { + while (true) { + final ops = pendingOps(); + if (ops.isEmpty) return; + final op = ops.first; + try { + if (op.isAdd) { + await remote.add( + op.kind, + provider: op.entity.provider, + contentUrl: op.entity.contentUrl, + title: op.entity.title, + thumbnail: op.entity.thumbnail, + ); + } else { + await remote.remove(op.kind, op.entity.contentUrl); + } + } catch (_) { + return; + } + // Re-read: an edit made while the request was in flight replaces the op + // under the same key with a newer one, which must stay queued. + final now = pendingOps() + ..removeWhere((o) => o.key == op.key && o.at == op.at); + await _saveOps(now); + } + } + + List _overlay( + UserListKind kind, + List items, + ) { + var out = items; + for (final op in pendingOps()) { + final touches = + op.kind == kind || + (op.isAdd && + op.kind == UserListKind.watched && + kind == UserListKind.watchLater); + if (!touches) continue; + out = [ + if (op.isAdd && op.kind == kind) op.entity, + ...out.where((e) => e.contentUrl != op.entity.contentUrl), + ]; + } + return out; + } + @override Future>> getList(UserListKind kind) async { // These lists live on the account, so a signed-out user has none. Return // the (empty) cache rather than an error — the UI shows a sign-in prompt. if (!hive.isLoggedIn) return Success(_cached(kind)); + await flushOutbox(); try { final server = await remote.getList(kind); - final items = server - .map( - (m) => FavoriteEntity( - provider: m.provider, - contentUrl: m.contentUrl, - title: m.title, - thumbnail: m.thumbnail, - ), - ) - .toList(growable: false); + final items = _overlay(kind, [ + for (final m in server) + FavoriteEntity( + provider: m.provider, + contentUrl: m.contentUrl, + title: m.title, + thumbnail: m.thumbnail, + ), + ]); await _store(kind, items); return Success(items); } catch (_) { - return Success(_cached(kind)); + if (_hasCache(kind) || pendingOps().any((o) => o.kind == kind)) { + return Success(_cached(kind)); + } + return const Failure(UserListUnavailable()); } } @@ -111,31 +268,40 @@ class UserListsRepositoryImpl implements UserListsRepository { } if (hive.isLoggedIn) { - try { - await remote.add( - kind, - provider: entity.provider, - contentUrl: entity.contentUrl, - title: entity.title, - thumbnail: entity.thumbnail, - ); - } catch (_) { - // Kept locally; reconciled on the next successful getList. - } + await _enqueue( + PendingListOp(kind: kind, isAdd: true, entity: entity, at: _clock()), + ); + await flushOutbox(); } return const Success(null); } @override Future> remove(UserListKind kind, String contentUrl) async { + final existing = _cached( + kind, + ).where((e) => e.contentUrl == contentUrl).firstOrNull; await _store( kind, _cached(kind).where((e) => e.contentUrl != contentUrl).toList(growable: false), ); if (hive.isLoggedIn) { - try { - await remote.remove(kind, contentUrl); - } catch (_) {} + await _enqueue( + PendingListOp( + kind: kind, + isAdd: false, + entity: + existing ?? + FavoriteEntity( + provider: '', + contentUrl: contentUrl, + title: '', + thumbnail: '', + ), + at: _clock(), + ), + ); + await flushOutbox(); } return const Success(null); } diff --git a/lib/features/user_lists/presentation/pages/user_lists_page.dart b/lib/features/user_lists/presentation/pages/user_lists_page.dart index b0335ebd..b54f2a98 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -92,6 +92,7 @@ class _UserListTab extends StatefulWidget { class _UserListTabState extends State<_UserListTab> with AutomaticKeepAliveClientMixin { List? _items; + bool _failed = false; @override bool get wantKeepAlive => true; @@ -107,7 +108,21 @@ class _UserListTabState extends State<_UserListTab> Future _load() async { final result = await getIt().getList(widget.kind); if (!mounted) return; - setState(() => _items = result.getOrNull() ?? const []); + setState(() { + final items = result.getOrNull(); + if (items != null) { + _items = items; + _failed = false; + } else if (_items == null) { + // Nothing cached: an empty list here would claim the list is empty. + _failed = true; + } + }); + } + + void _retry() { + setState(() => _failed = false); + _load(); } Future _remove(FavoriteEntity item) async { @@ -131,6 +146,9 @@ class _UserListTabState extends State<_UserListTab> Widget build(BuildContext context) { super.build(context); final items = _items; + if (items == null && _failed) { + return UserListLoadError(onRetry: _retry); + } if (items == null) { return Center( child: CircularProgressIndicator(color: AppColors.primary), @@ -241,6 +259,50 @@ class _UserListRow extends StatelessWidget { } } +/// Shown when the list could not be fetched and nothing was cached. +class UserListLoadError extends StatelessWidget { + const UserListLoadError({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_rounded, + color: AppColors.textHint, + size: 48, + ), + const SizedBox(height: 14), + Text( + 'user_lists.load_failed'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: onRetry, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ), + ); + } +} + class _EmptyState extends StatelessWidget { const _EmptyState(); diff --git a/lib/main.dart b/lib/main.dart index 1f25fd14..dd20e36a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -49,6 +49,8 @@ import 'package:soplay/core/storage/secure_boxes.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_server_store.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/app_lock/presentation/app_lock_gate.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; +import 'package:soplay/core/diagnostics/friendly_error_widget.dart'; import 'app.dart'; /// [args] is what the OS handed the process, and on Windows and Linux that is @@ -119,6 +121,9 @@ void main(List args) async { PlatformInAppWebViewController.debugLoggingSettings.enabled = false; await _initFirebaseSafely(); + // Off Android, where Crashlytics does not run: a local crash log instead. + await CrashLog.install(); + FriendlyErrorWidget.install(onGoHome: () => AppRouter.router.go('/main')); await configureDependencies(); // Before anything can navigate — the deep-link and push handlers below, and // the first frame — so every way in lands under the lock. @@ -163,10 +168,8 @@ void main(List args) async { () => AppRouter.router.routerDelegate.navigatorKey.currentContext, ); unawaited( - AppOrientation.set([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]).catchError((Object _) {}), + // Phones only: a tablet or an unfolded foldable is left free to rotate. + AppOrientation.set(AppOrientation.appDefault).catchError((Object _) {}), ); unawaited( SystemChrome.setEnabledSystemUIMode( diff --git a/test/core/diagnostics/crash_log_test.dart b/test/core/diagnostics/crash_log_test.dart new file mode 100644 index 00000000..c5782940 --- /dev/null +++ b/test/core/diagnostics/crash_log_test.dart @@ -0,0 +1,84 @@ +// Off Android there is no Crashlytics; the crash log is the only record. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; +import 'package:soplay/features/support/data/support_diagnostics.dart'; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo_crash_'); + }); + + tearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + test('records an error with its stack', () { + final log = CrashLog(dir); + log.record(StateError('boom'), StackTrace.fromString('#0 main')); + final text = log.read(); + expect(text, contains('boom')); + expect(text, contains('#0 main')); + }); + + test('rotates at the size cap and keeps at most maxFiles', () { + final log = CrashLog(dir, maxBytes: 300, maxFiles: 3); + for (var i = 0; i < 40; i++) { + log.record('error number $i ${'x' * 60}', null); + } + final files = log.files; + expect(files, hasLength(3)); + for (final f in files) { + expect(f.lengthSync(), lessThanOrEqualTo(300)); + } + expect( + dir.listSync().whereType().length, + 3, + reason: 'nothing past the oldest is left behind', + ); + final text = log.read(); + expect(text, contains('error number 39 '), reason: 'newest kept'); + expect(text, isNot(contains('error number 0 ')), reason: 'oldest gone'); + // Oldest first in what is read back. + expect( + text.indexOf('error number 38 '), + lessThan(text.indexOf('error number 39 ')), + ); + }); + + test('read can be cut to its newest end', () { + final log = CrashLog(dir); + log.record('first', null); + log.record('second', null); + final tail = log.read(maxChars: 20); + expect(tail.length, 20); + expect(tail, contains('second')); + }); + + test('redacts what the player log redacts', () { + final log = CrashLog(dir); + log.record('GET https://x.test/v?token=abcdef123456 failed', null); + expect(log.read(), isNot(contains('abcdef123456'))); + }); + + test('clear removes every file', () { + final log = CrashLog(dir, maxBytes: 100); + for (var i = 0; i < 10; i++) { + log.record('e$i ${'y' * 50}', null); + } + log.clear(); + expect(log.files, isEmpty); + expect(log.read(), isEmpty); + }); + + test('support diagnostics carry it at the end of the log field', () { + expect(SupportDiagnostics.withCrashLog('player', null), 'player'); + expect(SupportDiagnostics.withCrashLog(null, ''), isNull); + final both = SupportDiagnostics.withCrashLog('player', 'crash')!; + expect(both, startsWith('player')); + expect(both, endsWith('${SupportDiagnostics.crashHeader}\ncrash')); + }); +} diff --git a/test/core/glass/reduced_effects_test.dart b/test/core/glass/reduced_effects_test.dart new file mode 100644 index 00000000..9b51d2bc --- /dev/null +++ b/test/core/glass/reduced_effects_test.dart @@ -0,0 +1,75 @@ +// The always-on blurs (nav bars, scroll headers) give way to a plain fill on +// phones that pay for every blurred frame. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_power.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; + +const _gb = 1024 * 1024 * 1024; + +void main() { + group('the decision', () { + test('an unanswered reading keeps the blur', () { + expect(GlassPowerReading.ample.reducesEffects, isFalse); + }); + + test('a capable phone keeps it', () { + const r = GlassPowerReading( + memoryBytes: 8 * _gb, + sdk: 34, + glEs: 3, + refreshHz: 120, + ); + expect(r.reducesEffects, isFalse); + }); + + test('no Vulkan alone loses the refraction but not the blur', () { + const r = GlassPowerReading(memoryBytes: 6 * _gb, vulkan11: false); + expect(r.affordsRefraction, isFalse); + expect(r.reducesEffects, isFalse); + }); + + test('Go flag, small RAM, GL ES 2 or Battery Saver reduce', () { + expect(const GlassPowerReading(lowRam: true).reducesEffects, isTrue); + expect( + const GlassPowerReading(memoryBytes: 2 * _gb).reducesEffects, + isTrue, + ); + expect(const GlassPowerReading(glEs: 2).reducesEffects, isTrue); + expect(const GlassPowerReading(powerSave: true).reducesEffects, isTrue); + }); + }); + + group('ReducibleBlur', () { + Widget host(GlassPowerReading reading) => Directionality( + textDirection: TextDirection.ltr, + child: GlassPower.withReading( + reading, + child: const ReducibleBlur( + sigma: 20, + reducedFill: Color(0x80000000), + child: SizedBox(width: 10, height: 10), + ), + ), + ); + + testWidgets('blurs when effects are not reduced', (tester) async { + await tester.pumpWidget(host(GlassPowerReading.ample)); + expect(find.byType(BackdropFilter), findsOneWidget); + }); + + testWidgets('fills instead when they are', (tester) async { + await tester.pumpWidget(host(const GlassPowerReading(lowRam: true))); + expect(find.byType(BackdropFilter), findsNothing); + final fill = tester.widget(find.byType(ColoredBox)); + expect(fill.color, const Color(0x80000000)); + }); + + testWidgets('flips when Battery Saver turns on', (tester) async { + await tester.pumpWidget(host(GlassPowerReading.ample)); + expect(find.byType(BackdropFilter), findsOneWidget); + await tester.pumpWidget(host(const GlassPowerReading(powerSave: true))); + expect(find.byType(BackdropFilter), findsNothing); + }); + }); +} diff --git a/test/core/system/app_orientation_test.dart b/test/core/system/app_orientation_test.dart new file mode 100644 index 00000000..127ffda0 --- /dev/null +++ b/test/core/system/app_orientation_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/system/app_orientation.dart'; + +void main() { + test('600dp shortest side is a large screen', () { + expect(AppOrientation.isLargeScreen(const Size(393, 852)), isFalse); + expect(AppOrientation.isLargeScreen(const Size(852, 393)), isFalse); + expect(AppOrientation.isLargeScreen(const Size(820, 1180)), isTrue); + expect(AppOrientation.isLargeScreen(const Size(673, 841)), isTrue); + expect(AppOrientation.isLargeScreen(Size.zero), isFalse); + }); + + test('phones keep the portrait lock', () { + expect( + AppOrientation.resolve(AppOrientation.appDefault, largeScreen: false), + AppOrientation.appDefault, + ); + }); + + test('large screens drop it, but keep the player\'s landscape', () { + expect( + AppOrientation.resolve(AppOrientation.appDefault, largeScreen: true), + DeviceOrientation.values, + ); + const landscape = [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]; + expect(AppOrientation.resolve(landscape, largeScreen: true), landscape); + }); +} diff --git a/test/features/history/history_decode_cache_test.dart b/test/features/history/history_decode_cache_test.dart new file mode 100644 index 00000000..edd119d6 --- /dev/null +++ b/test/features/history/history_decode_cache_test.dart @@ -0,0 +1,105 @@ +// History is read on every Home rebuild, every profile visit and every +// Continue rail refresh. Decoding the whole box each time was the cost; the +// cache must never hand back a row the box no longer holds. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/features/history/data/history_service.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +HistoryItem _item(String url, int at) => + HistoryItem(contentUrl: url, provider: 'p', title: url, watchedAt: at); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory dir; + + setUpAll(() async { + await getIt.reset(); + dir = await Directory.systemTemp.createTemp('sozo_history_cache_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.historyBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + setUp(() async => Hive.box(AppConstants.historyBox).clear()); + + test('a second read decodes nothing', () async { + final service = HistoryService(); + await service.save(_item('a', 1)); + await service.save(_item('b', 2)); + + expect(service.getAll().map((e) => e.contentUrl), ['b', 'a']); + final after = service.decodes; + expect(service.getAll().map((e) => e.contentUrl), ['b', 'a']); + expect(service.decodes, after); + }); + + test('a save, a remove and a clear are seen at once', () async { + final service = HistoryService(); + await service.save(_item('a', 1)); + service.getAll(); + + await service.save(_item('a', 5).copyWithPosition(10)); + expect(service.getAll().single.positionMs, 10); + + await service.save(_item('b', 6)); + expect(service.count, 2); + await service.remove(_item('b', 6).storageKey); + expect(service.getAll().map((e) => e.contentUrl), ['a']); + + await service.clearLocalOnly(); + expect(service.getAll(), isEmpty); + expect(service.count, 0); + }); + + test('a write to the box from elsewhere drops the cached row', () async { + final service = HistoryService(); + await service.save(_item('a', 1)); + service.getAll(); + + final box = Hive.box(AppConstants.historyBox); + await box.put( + _item('a', 1).storageKey, + jsonEncode(_item('a', 1).copyWithPosition(42).toJson()), + ); + await pumpEventQueue(); + + expect(service.getAll().single.positionMs, 42); + }); + + test('count skips the reader\'s chapter marks and decodes nothing', () async { + final service = HistoryService(); + await service.save(_item('a', 1)); + await Hive.box(AppConstants.historyBox).put('chapters_read:p|m', [1, 2]); + final before = service.decodes; + expect(service.count, 1); + expect(service.decodes, before); + }); + + test('the returned list cannot corrupt the cache', () async { + final service = HistoryService(); + await service.save(_item('a', 1)); + service.getAll().clear(); + expect(service.getAll(), hasLength(1)); + }); +} + +extension on HistoryItem { + HistoryItem copyWithPosition(int ms) => HistoryItem( + contentUrl: contentUrl, + provider: provider, + title: title, + watchedAt: watchedAt, + positionMs: ms, + durationMs: 100, + ); +} diff --git a/test/features/home/view_all_load_more_failure_test.dart b/test/features/home/view_all_load_more_failure_test.dart new file mode 100644 index 00000000..2e7a360e --- /dev/null +++ b/test/features/home/view_all_load_more_failure_test.dart @@ -0,0 +1,185 @@ +// A failed next page used to clear the spinner and leave nothing, and every +// scroll frame near the bottom fired another request. +import 'dart:convert'; +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:soplay/core/error/result.dart'; +import 'package:soplay/core/widgets/load_more_failed_footer.dart'; +import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/home/domain/entities/view_all_paging_entity.dart'; +import 'package:soplay/features/home/domain/repositories/home_repository.dart'; +import 'package:soplay/features/home/domain/usecase/view_all_usecase.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_event.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_state.dart'; + +MovieEntity _movie(int i) => MovieEntity( + externalId: '$i', + title: 'Title $i', + description: '', + slug: 'title-$i', + url: 'https://example.test/$i', + provider: 'p', + thumbnail: '', + year: 2024, + rating: null, + qualities: const [], + category: '', +); + +class _Pages implements HomeRepository { + bool failMore = true; + final requested = []; + + @override + Future> loadViewAll({ + required String key, + required String slug, + int page = 1, + }) async { + requested.add(page); + if (page > 1 && failMore) return Failure(Exception('timeout')); + return Success( + ViewAllPagingEntity( + page: page, + totalPages: 5, + provider: 'p', + items: [for (var i = 0; i < 3; i++) _movie(page * 10 + i)], + ), + ); + } + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +class _Translations extends AssetLoader { + const _Translations(); + @override + Future> load(String path, Locale locale) async => + jsonDecode(File('assets/translations/en.json').readAsStringSync()) + as Map; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + group('backoff', () { + test('doubles and is capped', () { + expect(ViewAllBloc.backoffFor(0), Duration.zero); + expect(ViewAllBloc.backoffFor(1), const Duration(seconds: 2)); + expect(ViewAllBloc.backoffFor(2), const Duration(seconds: 4)); + expect(ViewAllBloc.backoffFor(3), const Duration(seconds: 8)); + expect(ViewAllBloc.backoffFor(20), ViewAllBloc.maxBackoff); + }); + + test('a failed page sets loadMoreFailed and keeps the grid', () async { + final repo = _Pages(); + var now = DateTime(2026); + final bloc = ViewAllBloc(useCase: ViewAllUseCase(repo), now: () => now); + bloc.add(ViewAllLoad(key: 'k')); + await pumpEventQueue(); + bloc.add(ViewAllLoadMore()); + await pumpEventQueue(); + + final s = bloc.state as ViewAllLoaded; + expect(s.loadMoreFailed, isTrue); + expect(s.items, hasLength(3)); + expect(repo.requested, [1, 2]); + + // Scroll frames inside the backoff send nothing. + for (var i = 0; i < 10; i++) { + bloc.add(ViewAllLoadMore()); + } + await pumpEventQueue(); + expect(repo.requested, [1, 2]); + + // After it, one scroll tries once — and fails, so the wait doubles. + now = now.add(const Duration(seconds: 3)); + bloc.add(ViewAllLoadMore()); + await pumpEventQueue(); + expect(repo.requested, [1, 2, 2]); + now = now.add(const Duration(seconds: 3)); + bloc.add(ViewAllLoadMore()); + await pumpEventQueue(); + expect(repo.requested, [1, 2, 2], reason: 'second backoff is 4s'); + + // Retry from the footer ignores the backoff. + repo.failMore = false; + bloc.add(ViewAllLoadMore(retry: true)); + await pumpEventQueue(); + final ok = bloc.state as ViewAllLoaded; + expect(repo.requested, [1, 2, 2, 2]); + expect(ok.loadMoreFailed, isFalse); + expect(ok.items, hasLength(6)); + await bloc.close(); + }); + }); + + testWidgets('the footer reads "Couldn\'t load more · Retry" and retries', ( + tester, + ) async { + final repo = _Pages(); + late final ViewAllBloc bloc; + await tester.runAsync(() async { + bloc = ViewAllBloc(useCase: ViewAllUseCase(repo)); + bloc.add(ViewAllLoad(key: 'k')); + await pumpEventQueue(); + bloc.add(ViewAllLoadMore()); + await pumpEventQueue(); + }); + expect((bloc.state as ViewAllLoaded).loadMoreFailed, isTrue); + + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + startLocale: const Locale('en'), + path: 'assets/translations', + assetLoader: const _Translations(), + saveLocale: false, + child: Builder( + builder: (context) => MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: Scaffold( + body: BlocBuilder( + bloc: bloc, + builder: (context, state) => + state is ViewAllLoaded && state.loadMoreFailed + ? LoadMoreFailedFooter( + onRetry: () => bloc.add(ViewAllLoadMore(retry: true)), + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text("Couldn't load more"), findsOneWidget); + expect(find.text(' · '), findsOneWidget); + + repo.failMore = false; + await tester.tap(find.text('Retry')); + await tester.runAsync(() => pumpEventQueue()); + await tester.pump(); + + expect(find.byType(LoadMoreFailedFooter), findsNothing); + expect(repo.requested, [1, 2, 2]); + await tester.runAsync(bloc.close); + }); +} diff --git a/test/features/user_lists/user_lists_outbox_test.dart b/test/features/user_lists/user_lists_outbox_test.dart new file mode 100644 index 00000000..d7cc9cec --- /dev/null +++ b/test/features/user_lists/user_lists_outbox_test.dart @@ -0,0 +1,341 @@ +// Watch Later / Watched edits made offline must survive the next fetch. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/error/result.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/my_list/data/models/favorite_model.dart'; +import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; +import 'package:soplay/features/user_lists/data/datasources/user_lists_remote_data_source.dart'; +import 'package:soplay/features/user_lists/data/repositories/user_lists_repository_impl.dart'; +import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; +import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; +import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; + +class _SignedIn implements HiveService { + @override + bool get isLoggedIn => true; + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +/// A server that can be taken offline and records what reached it. +class _Server implements UserListsRemoteDataSource { + bool offline = false; + final calls = []; + final lists = >{ + for (final k in UserListKind.values) k: [], + }; + + @override + Future> getList(UserListKind kind) async { + if (offline) throw const SocketException('offline'); + return List.of(lists[kind]!); + } + + @override + Future add( + UserListKind kind, { + required String provider, + required String contentUrl, + required String title, + required String thumbnail, + }) async { + if (offline) throw const SocketException('offline'); + calls.add('add ${kind.slug} $contentUrl'); + lists[kind]!.removeWhere((e) => e.contentUrl == contentUrl); + lists[kind]!.insert( + 0, + FavoriteModel( + provider: provider, + contentUrl: contentUrl, + title: title, + thumbnail: thumbnail, + ), + ); + } + + @override + Future remove(UserListKind kind, String contentUrl) async { + if (offline) throw const SocketException('offline'); + calls.add('remove ${kind.slug} $contentUrl'); + lists[kind]!.removeWhere((e) => e.contentUrl == contentUrl); + } + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +FavoriteEntity _item(String url) => + FavoriteEntity(provider: 'p', contentUrl: url, title: url, thumbnail: ''); + +class _Translations extends AssetLoader { + const _Translations(); + @override + Future> load(String path, Locale locale) async => + jsonDecode(File('assets/translations/en.json').readAsStringSync()) + as Map; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory dir; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('sozo_user_lists_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.userListsBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + setUp(() async => Hive.box(AppConstants.userListsBox).clear()); + + group('outbox', () { + test('an offline add is replayed before the server list replaces the ' + 'cache', () async { + final server = _Server()..offline = true; + var t = 0; + final repo = UserListsRepositoryImpl( + server, + _SignedIn(), + clock: () => ++t, + ); + + await repo.add(UserListKind.watchLater, _item('a')); + expect(repo.pendingOps(), hasLength(1)); + + server.offline = false; + final list = (await repo.getList(UserListKind.watchLater)).getOrNull()!; + + expect(server.calls, ['add ${UserListKind.watchLater.slug} a']); + expect(list.map((e) => e.contentUrl), ['a']); + expect(repo.pendingOps(), isEmpty); + }); + + test('add then remove offline sends only the newest op', () async { + final server = _Server()..offline = true; + server.lists[UserListKind.watched]!.add( + const FavoriteModel( + provider: 'p', + contentUrl: 'x', + title: 'x', + thumbnail: '', + ), + ); + var t = 0; + final repo = UserListsRepositoryImpl( + server, + _SignedIn(), + clock: () => ++t, + ); + + await repo.add(UserListKind.watched, _item('a')); + await repo.remove(UserListKind.watched, 'a'); + await repo.remove(UserListKind.watched, 'x'); + + final ops = repo.pendingOps(); + expect( + ops.map((o) => '${o.isAdd ? 'add' : 'remove'} ${o.entity.contentUrl}'), + ['remove a', 'remove x'], + ); + + server.offline = false; + final list = (await repo.getList(UserListKind.watched)).getOrNull()!; + final slug = UserListKind.watched.slug; + expect(server.calls, ['remove $slug a', 'remove $slug x']); + expect(list, isEmpty); + }); + + test( + 'ops replay oldest first, and a failed fetch keeps them overlaid', + () async { + final server = _Server()..offline = true; + var t = 0; + final repo = UserListsRepositoryImpl( + server, + _SignedIn(), + clock: () => ++t, + ); + + await repo.add(UserListKind.watchLater, _item('a')); + await repo.add(UserListKind.watched, _item('a')); + await repo.add(UserListKind.watchLater, _item('b')); + + // Still offline: the cache, not an error, and the edits are all there. + final offline = (await repo.getList( + UserListKind.watchLater, + )).getOrNull()!; + expect(offline.map((e) => e.contentUrl), ['b']); + + server.offline = false; + await repo.flushOutbox(); + final later = UserListKind.watchLater.slug; + final watched = UserListKind.watched.slug; + expect(server.calls, [ + 'add $later a', + 'add $watched a', + 'add $later b', + ]); + }, + ); + + test('a server copy without unsent edits does not undo them', () async { + // The push fails but the fetch that follows works: a flaky link. + final server = _Server(); + var failAdds = true; + var t = 0; + final repo = UserListsRepositoryImpl( + _FlakyAdds(server, () => failAdds), + _SignedIn(), + clock: () => ++t, + ); + + await repo.add(UserListKind.watchLater, _item('a')); + final list = (await repo.getList(UserListKind.watchLater)).getOrNull()!; + expect(list.map((e) => e.contentUrl), ['a']); + expect(repo.pendingOps(), hasLength(1)); + + failAdds = false; + await repo.flushOutbox(); + expect(repo.pendingOps(), isEmpty); + expect(server.lists[UserListKind.watchLater]!.map((e) => e.contentUrl), [ + 'a', + ]); + }); + + test('connectivity regain flushes the outbox', () async { + final server = _Server()..offline = true; + final reconnects = StreamController(); + final repo = UserListsRepositoryImpl( + server, + _SignedIn(), + reconnects: reconnects.stream, + ); + await repo.add(UserListKind.watchLater, _item('a')); + server.offline = false; + reconnects.add(null); + await pumpEventQueue(); + expect(repo.pendingOps(), isEmpty); + expect(server.calls, hasLength(1)); + repo.dispose(); + await reconnects.close(); + }); + }); + + group('no cache', () { + test('a failed fetch is a failure, not an empty list', () async { + final repo = UserListsRepositoryImpl( + _Server()..offline = true, + _SignedIn(), + ); + final result = await repo.getList(UserListKind.watched); + expect(result.isError, isTrue); + }); + + test('with a cache it is the cache', () async { + final server = _Server(); + final repo = UserListsRepositoryImpl(server, _SignedIn()); + await repo.getList(UserListKind.watched); + server.offline = true; + final result = await repo.getList(UserListKind.watched); + expect(result.getOrNull(), isEmpty); + }); + + testWidgets('the tab shows Retry, and Retry loads', (tester) async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + await getIt.reset(); + final repo = _ScriptedRepo(); + getIt.registerSingleton(repo); + + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + startLocale: const Locale('en'), + path: 'assets/translations', + assetLoader: const _Translations(), + saveLocale: false, + child: Builder( + builder: (context) => MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: const UserListsPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(UserListLoadError), findsOneWidget); + expect(find.text('Nothing saved yet'), findsNothing); + + repo.fail = false; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(find.byType(UserListLoadError), findsNothing); + expect(find.text('a'), findsOneWidget); + await getIt.reset(); + }); + }); +} + +class _FlakyAdds implements UserListsRemoteDataSource { + _FlakyAdds(this.inner, this.failing); + final _Server inner; + final bool Function() failing; + + @override + Future> getList(UserListKind kind) => inner.getList(kind); + + @override + Future add( + UserListKind kind, { + required String provider, + required String contentUrl, + required String title, + required String thumbnail, + }) async { + if (failing()) throw const SocketException('flaky'); + return inner.add( + kind, + provider: provider, + contentUrl: contentUrl, + title: title, + thumbnail: thumbnail, + ); + } + + @override + Future remove(UserListKind kind, String contentUrl) => + inner.remove(kind, contentUrl); + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +class _ScriptedRepo implements UserListsRepository { + bool fail = true; + + @override + Future>> getList(UserListKind kind) async => fail + ? const Failure(UserListUnavailable()) + : Success(kind == UserListKind.watchLater ? [_item('a')] : const []); + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} From 9e66cdac58995b1b36faaa2cf5b1009841cdad98 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:00:43 +0500 Subject: [PATCH 18/21] Open deep links over Home from one consumer, keeping the viewer's place Flutter's own deep linking is off on Android and iOS, so DeeplinkService is the only thing that handles a link. It opens the target over /main with typed args, so back returns Home, and holds a link that lands during splash, onboarding or the profile picker until they finish. /play links carry ep and t through DetailArgs to the episode list and the player's start position. The provider is passed per navigation instead of being saved as the current source. Routes that need an extra redirect to /main without one. The share link builder can emit ep and t for the player. The manifest also opts in to OnBackInvokedCallback for predictive back. The first download started without notification permission on Android offers the priming sheet, with download-specific copy in all locales. --- android/app/src/main/AndroidManifest.xml | 8 +- assets/translations/ar.json | 2 + assets/translations/ckb.json | 2 + assets/translations/de.json | 2 + assets/translations/en.json | 2 + assets/translations/es.json | 2 + assets/translations/fr.json | 2 + assets/translations/hi.json | 2 + assets/translations/id.json | 2 + assets/translations/ku.json | 2 + assets/translations/nl.json | 2 + assets/translations/pt.json | 2 + assets/translations/ru.json | 2 + assets/translations/tr.json | 2 + assets/translations/uz.json | 2 + assets/translations/vi.json | 2 + assets/translations/yue.json | 2 + deeplink/README.md | 18 ++ deeplink/detail.html | 5 +- ios/Runner/Info.plist | 2 + lib/core/deeplink/deeplink_links.dart | 133 +++++++++ lib/core/deeplink/deeplink_service.dart | 262 ++++++++++++------ lib/core/router/app_router.dart | 14 +- lib/core/storage/hive_service.dart | 7 + .../detail/domain/entities/detail_args.dart | 8 + .../detail/domain/entities/episodes_args.dart | 7 + .../presentation/pages/detail_page.dart | 61 +++- .../presentation/pages/episodes_page.dart | 50 +++- .../widgets/notification_priming.dart | 59 +++- test/core/deeplink/deeplink_links_test.dart | 126 +++++++++ test/core/deeplink/deeplink_service_test.dart | 133 +++++++++ .../go_router_deeplink_navigator_test.dart | 35 +++ .../app_router_extra_redirect_test.dart | 70 +++++ 33 files changed, 918 insertions(+), 112 deletions(-) create mode 100644 lib/core/deeplink/deeplink_links.dart create mode 100644 test/core/deeplink/deeplink_links_test.dart create mode 100644 test/core/deeplink/deeplink_service_test.dart create mode 100644 test/core/deeplink/go_router_deeplink_navigator_test.dart create mode 100644 test/core/router/app_router_extra_redirect_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8cb51c74..04266d17 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,7 +67,8 @@ android:hardwareAccelerated="true" android:supportsPictureInPicture="true" android:resizeableActivity="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:enableOnBackInvokedCallback="true"> + diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e149a6d1..0e0f71e2 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -2929,6 +2929,8 @@ "priming_title": "لا تفوّت أي حلقة جديدة", "priming_body": "يخبرك Sozo فور صدور حلقة أو فصل جديد لما تتابعه — ولا شيء لم تطلبه.", "priming_body_title": "اعرف فور صدور حلقة جديدة من «{}» — ولا شيء لم تطلبه.", + "priming_download_title": "اعرف متى يكتمل التنزيل", + "priming_download_body": "يعرض Sozo تقدّم التنزيل في الإشعارات ويخبرك عندما يصبح جاهزًا للمشاهدة دون اتصال. ويمكنه أيضًا إعلامك بالحلقات الجديدة لما تتابعه.", "priming_point_releases": "حلقات جديدة مع الملصق", "priming_point_quiet": "ساعات الهدوء", "priming_point_control": "كتم أي عمل", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 9f984bfb..ae2c6d53 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2925,6 +2925,8 @@ "priming_title": "هیچ ئەڵقەیەکی نوێ لەدەست مەدە", "priming_body": "Sozo هەر کە شتێک کە بەدوایدا دەچیت ئەڵقە یان بەشێکی نوێی هەبوو پێت دەڵێت — و هیچ شتێک کە داوات نەکردبێت.", "priming_body_title": "هەر کە {} ئەڵقەیەکی نوێی هەبوو ئاگادار بکرێیتەوە — و هیچ شتێک کە داوات نەکردبێت.", + "priming_download_title": "بزانە کەی داگرتنەکەت تەواو دەبێت", + "priming_download_body": "Sozo پێشکەوتنی داگرتن لە ئاگادارکردنەوەکاندا پیشان دەدات و پێت دەڵێت کەی ئامادەیە بۆ سەیرکردنی بێ ئینتەرنێت. هەروەها دەتوانێت ئاگادارت بکاتەوە لە ئەڵقە نوێیەکانی ئەوەی بەدوایدا دەچیت.", "priming_point_releases": "ئەڵقە نوێکان، لەگەڵ پۆستەرەکە", "priming_point_quiet": "کاتژمێرە بێدەنگەکان", "priming_point_control": "هەر ناونیشانێک بێدەنگ بکە", diff --git a/assets/translations/de.json b/assets/translations/de.json index 24ea0d3f..ee594687 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -2925,6 +2925,8 @@ "priming_title": "Keine neue Folge mehr verpassen", "priming_body": "Sozo sagt dir Bescheid, sobald etwas, dem du folgst, eine neue Folge oder ein neues Kapitel bekommt – und sonst nichts, worum du nicht gebeten hast.", "priming_body_title": "Erfahre sofort, wenn „{}“ eine neue Folge bekommt – und sonst nichts, worum du nicht gebeten hast.", + "priming_download_title": "Erfahre, wann dein Download fertig ist", + "priming_download_body": "Sozo zeigt den Download-Fortschritt in deinen Benachrichtigungen und sagt dir, wann du offline schauen kannst. Auf Wunsch auch, wenn Titel, denen du folgst, neue Folgen bekommen.", "priming_point_releases": "Neue Folgen mit Poster", "priming_point_quiet": "Ruhezeit", "priming_point_control": "Jeden Titel stummschalten", diff --git a/assets/translations/en.json b/assets/translations/en.json index de087504..b3ffca45 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -2925,6 +2925,8 @@ "priming_title": "Never miss a new episode", "priming_body": "Sozo tells you the moment something you follow gets a new episode or chapter — and nothing you haven't asked for.", "priming_body_title": "Get a heads-up the moment {} gets a new episode — and nothing you haven't asked for.", + "priming_download_title": "Know when your download is done", + "priming_download_body": "Sozo shows download progress in your notifications and tells you when it's ready to watch offline. It can also tell you about new episodes of what you follow.", "priming_point_releases": "New episodes, with the poster", "priming_point_quiet": "Quiet hours", "priming_point_control": "Mute any title", diff --git a/assets/translations/es.json b/assets/translations/es.json index 339931f5..26d2dd05 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -2925,6 +2925,8 @@ "priming_title": "No te pierdas ni un episodio", "priming_body": "Sozo te avisa en cuanto algo que sigues tenga un episodio o capítulo nuevo, y nada que no hayas pedido.", "priming_body_title": "Entérate en cuanto «{}» tenga un episodio nuevo, y nada que no hayas pedido.", + "priming_download_title": "Entérate cuando termine tu descarga", + "priming_download_body": "Sozo muestra el progreso de la descarga en tus notificaciones y te avisa cuando esté lista para ver sin conexión. También puede avisarte de nuevos episodios de lo que sigues.", "priming_point_releases": "Episodios nuevos, con póster", "priming_point_quiet": "Horas de silencio", "priming_point_control": "Silencia cualquier título", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 20dd2805..8b1ed7f5 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -2925,6 +2925,8 @@ "priming_title": "Ne ratez plus aucun épisode", "priming_body": "Sozo vous prévient dès qu’un titre que vous suivez a un nouvel épisode ou chapitre — et rien que vous n’ayez pas demandé.", "priming_body_title": "Soyez prévenu dès que « {} » a un nouvel épisode — et rien que vous n’ayez pas demandé.", + "priming_download_title": "Sachez quand votre téléchargement est terminé", + "priming_download_body": "Sozo affiche la progression du téléchargement dans vos notifications et vous prévient quand il est prêt à regarder hors ligne. Il peut aussi vous signaler les nouveaux épisodes de ce que vous suivez.", "priming_point_releases": "Nouveaux épisodes, avec l’affiche", "priming_point_quiet": "Heures calmes", "priming_point_control": "Coupez n’importe quel titre", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 4c402bff..1ad410ef 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -2925,6 +2925,8 @@ "priming_title": "कोई नया एपिसोड न छूटे", "priming_body": "आपकी फ़ॉलो की हुई किसी चीज़ का नया एपिसोड या चैप्टर आते ही Sozo आपको बताता है — और ऐसा कुछ नहीं जो आपने माँगा न हो।", "priming_body_title": "{} का नया एपिसोड आते ही आपको ख़बर मिलेगी — और ऐसा कुछ नहीं जो आपने माँगा न हो।", + "priming_download_title": "जानें कि आपका डाउनलोड कब पूरा हुआ", + "priming_download_body": "Sozo सूचनाओं में डाउनलोड की प्रगति दिखाता है और बताता है कि ऑफ़लाइन देखने के लिए कब तैयार है। यह आपके फ़ॉलो किए गए शो के नए एपिसोड के बारे में भी बता सकता है।", "priming_point_releases": "नए एपिसोड, पोस्टर के साथ", "priming_point_quiet": "शांत समय", "priming_point_control": "कोई भी टाइटल म्यूट करें", diff --git a/assets/translations/id.json b/assets/translations/id.json index 73960bf6..84610e50 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -2925,6 +2925,8 @@ "priming_title": "Jangan lewatkan episode baru", "priming_body": "Sozo memberi tahu begitu judul yang kamu ikuti punya episode atau bab baru — tanpa hal lain yang tidak kamu minta.", "priming_body_title": "Langsung tahu begitu “{}” punya episode baru — tanpa hal lain yang tidak kamu minta.", + "priming_download_title": "Ketahui saat unduhanmu selesai", + "priming_download_body": "Sozo menampilkan progres unduhan di notifikasi dan memberi tahu saat siap ditonton offline. Sozo juga bisa memberi tahu episode baru dari yang kamu ikuti.", "priming_point_releases": "Episode baru, lengkap dengan poster", "priming_point_quiet": "Jam tenang", "priming_point_control": "Bisukan judul mana pun", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index c74a718c..5b5abb8c 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2925,6 +2925,8 @@ "priming_title": "Tu carî beşeke nû ji dest nede", "priming_body": "Sozo di kêliya ku tiştekî ku tu dişopînî beşeke an fesleke nû distîne de ji te re dibêje — û tiştekî ku te nexwestiye na.", "priming_body_title": "Di kêliya ku {} beşeke nû distîne de agahdar bibe — û tiştekî ku te nexwestiye na.", + "priming_download_title": "Bizane kengê daxistina te diqede", + "priming_download_body": "Sozo pêşketina daxistinê di agahdariyan de nîşan dide û dema ku ji bo temaşekirina bê înternet amade be ji te re dibêje. Ew dikare li ser beşên nû yên tiştên ku tu dişopînî jî te agahdar bike.", "priming_point_releases": "Beşên nû, bi posterê", "priming_point_quiet": "Saetên bêdeng", "priming_point_control": "Her sernavê bêdeng bike", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index b48f0473..d5a7271d 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -2925,6 +2925,8 @@ "priming_title": "Mis nooit meer een nieuwe aflevering", "priming_body": "Sozo laat het je weten zodra iets wat je volgt een nieuwe aflevering of een nieuw hoofdstuk krijgt — en niets waar je niet om hebt gevraagd.", "priming_body_title": "Hoor het meteen wanneer ‘{}’ een nieuwe aflevering krijgt — en niets waar je niet om hebt gevraagd.", + "priming_download_title": "Weet wanneer je download klaar is", + "priming_download_body": "Sozo toont de downloadvoortgang in je meldingen en laat weten wanneer je offline kunt kijken. Ook kan Sozo je melden wanneer iets wat je volgt een nieuwe aflevering krijgt.", "priming_point_releases": "Nieuwe afleveringen, met poster", "priming_point_quiet": "Stille uren", "priming_point_control": "Elke titel dempen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index ea4bf092..10bf8f22 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -2925,6 +2925,8 @@ "priming_title": "Nunca perca um episódio novo", "priming_body": "O Sozo avisa assim que algo que você segue ganha um episódio ou capítulo novo — e nada que você não tenha pedido.", "priming_body_title": "Saiba na hora quando “{}” ganhar um episódio novo — e nada que você não tenha pedido.", + "priming_download_title": "Saiba quando seu download terminar", + "priming_download_body": "O Sozo mostra o progresso do download nas notificações e avisa quando estiver pronto para assistir offline. Também pode avisar sobre novos episódios do que você segue.", "priming_point_releases": "Episódios novos, com o pôster", "priming_point_quiet": "Horário silencioso", "priming_point_control": "Silencie qualquer título", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 454de6c9..30b769c2 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -2926,6 +2926,8 @@ "priming_title": "Не пропускайте новые серии", "priming_body": "Sozo сообщит, как только у того, за чем вы следите, выйдет новая серия или глава, — и ничего сверх того, о чём вы просили.", "priming_body_title": "Узнайте первым, когда у «{}» выйдет новая серия, — и ничего лишнего.", + "priming_download_title": "Узнайте, когда загрузка завершится", + "priming_download_body": "Sozo показывает ход загрузки в уведомлениях и сообщает, когда можно смотреть офлайн. А ещё — о новых сериях того, за чем вы следите.", "priming_point_releases": "Новые серии с постером", "priming_point_quiet": "Тихие часы", "priming_point_control": "Отключение для любого тайтла", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 8c3cec9b..6a36a632 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -2925,6 +2925,8 @@ "priming_title": "Hiçbir yeni bölümü kaçırma", "priming_body": "Takip ettiğin bir şeye yeni bölüm ya da kısım gelir gelmez Sozo haber verir — istemediğin hiçbir şey gelmez.", "priming_body_title": "“{}” yeni bölüm alır almaz haberin olsun — istemediğin hiçbir şey gelmez.", + "priming_download_title": "İndirmenin bittiğini öğren", + "priming_download_body": "Sozo indirme ilerlemesini bildirimlerde gösterir ve çevrimdışı izlemeye hazır olduğunda haber verir. Takip ettiklerinin yeni bölümlerini de bildirebilir.", "priming_point_releases": "Afişiyle yeni bölümler", "priming_point_quiet": "Sessiz saatler", "priming_point_control": "İstediğin yapımı sessize al", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 79885c27..e2d9f780 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -2925,6 +2925,8 @@ "priming_title": "Yangi qismni o‘tkazib yubormang", "priming_body": "Kuzatayotgan asaringizda yangi qism yoki bob chiqishi bilan Sozo xabar beradi — so‘ramagan narsangiz bilan bezovta qilmaydi.", "priming_body_title": "«{}»ning yangi qismi chiqishi bilan xabar oling — ortiqcha hech narsa yo‘q.", + "priming_download_title": "Yuklab olish tugaganini biling", + "priming_download_body": "Sozo yuklab olish jarayonini bildirishnomalarda ko‘rsatadi va oflayn ko‘rishga tayyor bo‘lganda xabar beradi. Kuzatayotganlaringizning yangi qismlari haqida ham aytib turadi.", "priming_point_releases": "Yangi qismlar, posteri bilan", "priming_point_quiet": "Sokin soatlar", "priming_point_control": "Istalgan asarni ovozsiz qilish", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 0df9c365..07d7cc79 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -2925,6 +2925,8 @@ "priming_title": "Không bỏ lỡ tập mới nào", "priming_body": "Sozo báo ngay khi nội dung bạn theo dõi có tập hoặc chương mới — và không gửi gì bạn chưa yêu cầu.", "priming_body_title": "Nhận thông báo ngay khi {} có tập mới — và không gửi gì bạn chưa yêu cầu.", + "priming_download_title": "Biết khi nào tải xuống hoàn tất", + "priming_download_body": "Sozo hiển thị tiến trình tải xuống trong thông báo và báo cho bạn khi có thể xem ngoại tuyến. Sozo cũng có thể báo tập mới của những gì bạn theo dõi.", "priming_point_releases": "Tập mới, kèm áp phích", "priming_point_quiet": "Giờ yên tĩnh", "priming_point_control": "Tắt tiếng từng phim", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 1dbc9d5a..4995d0e6 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -2925,6 +2925,8 @@ "priming_title": "唔好再錯過新一集", "priming_body": "你追緊嘅作品一出新一集或者新一話,Sozo 就即刻話你知 — 你冇要求嘅就唔會煩你。", "priming_body_title": "「{}」一出新一集就即刻知 — 你冇要求嘅就唔會煩你。", + "priming_download_title": "下載完成即刻知", + "priming_download_body": "Sozo 會喺通知度顯示下載進度,可以離線睇嘅時候話你知。亦都可以通知你追緊嘅劇有新一集。", "priming_point_releases": "新一集連海報", "priming_point_quiet": "靜音時段", "priming_point_control": "任何作品都可以靜音", diff --git a/deeplink/README.md b/deeplink/README.md index 7b2771fb..49cef83f 100644 --- a/deeplink/README.md +++ b/deeplink/README.md @@ -85,3 +85,21 @@ So'ng `sozo.azamov.me` DNS yozuvini backend'ning IP'siga yo'naltirish kerak `detail.html` ichida iOS App Store URL'i hozircha `id0000000000` — haqiqiy `id...` ni ilova chiqqach almashtiring. + +## `/play` links (for sozo-web) + +The app now reads these parameters on `/play` (and `/detail`): + +| Param | Meaning | +|---|---| +| `url` | content URL (required) | +| `provider` | source id; used for this navigation only, never saved as the current source | +| `ep` | episode number as shown to the viewer (1-based), `/play` only | +| `t` | start position in seconds (`95`, `1:35` and `1m35s` are all accepted), `/play` only | + +Example: `https://sozo.azamov.me/play?url=…&provider=hdrezka&ep=12&t=754` + +The web fallback page for `/play` (and `/detail`) must forward the **whole** +query string to `sozo://play…`, not only `url`. `detail.html` in this folder +does that now; the copy deployed by the sozo-web repo needs the same change. +`apple-app-site-association` already lists `/play` and `/play/*`. diff --git a/deeplink/detail.html b/deeplink/detail.html index 0c354880..69da7935 100644 --- a/deeplink/detail.html +++ b/deeplink/detail.html @@ -34,10 +34,9 @@

Sozo'da oching