diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 759d7a02..10da7425 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -579,7 +579,18 @@ class _MediaKitController extends PlayerController { // first, so by here it is a cheap already-done flag — kept because a // controller built any other way still has to initialise before Player(). _mediaKitUsable(); - _player = mk.Player(); + _player = mk.Player( + configuration: mk.PlayerConfiguration( + // demuxer-max-bytes and demuxer-max-back-bytes: media_kit sets both + // from this. A seek inside the demuxer cache is answered from memory + // (demuxer-seekable-cache is on whenever the cache is, and media_kit + // turns the cache on); one outside it waits for a whole HLS segment + // to download before a frame can be decoded. media_kit's 32 MiB held + // only ~35–45 s of a 1080p stream either side of the playhead, so + // most swipes missed it. Not on TV boxes, the devices short on RAM. + bufferSize: isTvPlatform ? 32 * 1024 * 1024 : 64 * 1024 * 1024, + ), + ); // No androidAttachSurfaceAfterVideoParameters here, deliberately. // // Setting it to false does fix the black first frame — the picture was @@ -623,6 +634,61 @@ class _MediaKitController extends PlayerController { final _closed = Completer(); String? _error; + /// How often mpv's playhead reaches listeners. + /// + /// mpv reports `time-pos` on every displayed frame, 24 to 60 times a + /// second, and each report rebuilt the seek row, the scrub preview, the + /// subtitle layer and the page listener. video_player polls ExoPlayer every + /// 100 ms, so this is parity with the platform engine, not a loss: at 100 ms + /// the thumb of an episode moves well under a pixel per tick, and + /// downloaded subtitles are timed exactly as they are on ExoPlayer. + static const Duration _positionInterval = Duration(milliseconds: 100); + + /// How long after a seek a far-off `time-pos` is treated as one that was + /// already on its way when the seek was issued, and how far off counts. + static const Duration _seekLandingWindow = Duration(seconds: 2); + static const Duration _seekLandingSlack = Duration(milliseconds: 1500); + + final Stopwatch _sincePosition = Stopwatch(); + final Stopwatch _sinceSeek = Stopwatch(); + Duration? _heldPosition; + Duration? _seekTarget; + Timer? _positionFlush; + + void _onPosition(Duration p) { + final target = _seekTarget; + if (target != null) { + // Showing a pre-seek tick would snap the thumb back to where the + // finger left it, then forward again. + final stale = (p - target).abs() > _seekLandingSlack; + if (stale && _sinceSeek.elapsed < _seekLandingWindow) return; + _seekTarget = null; + } + if (_sincePosition.isRunning && + _sincePosition.elapsed < _positionInterval) { + _heldPosition = p; + // Trailing edge, so the last position before a pause or the end of + // the file still lands. + _positionFlush ??= Timer(_positionInterval - _sincePosition.elapsed, () { + _positionFlush = null; + final held = _heldPosition; + if (held != null) _flushPosition(held); + }); + return; + } + _flushPosition(p); + } + + void _flushPosition(Duration p) { + _positionFlush?.cancel(); + _positionFlush = null; + _heldPosition = null; + _sincePosition + ..reset() + ..start(); + _emit(value.copyWith(position: p)); + } + @override Future initialize() async { _wire(); @@ -639,6 +705,21 @@ class _MediaKitController extends PlayerController { } catch (e) { debugPrint('[player] hls-bitrate not applied: $e'); } + // media_kit creates every Player with hr-seek=yes and + // hr-seek-framedrop=no. hr-seek stays: every seek here is an absolute + // one, and a keyframe seek on a stream with 6–10 s GOPs can land a + // +10 s skip back where it started. framedrop goes back to mpv's own + // default: the frames between the keyframe and the target are thrown + // away anyway, and with it off the decoder had to decode every one of + // them first — the freeze after each swipe and seek-bar release. Safe + // 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. + try { + await platform.setProperty('hr-seek-framedrop', 'yes'); + } catch (e) { + debugPrint('[player] hr-seek-framedrop not applied: $e'); + } } await _player.open( mk.Media( @@ -736,11 +817,7 @@ class _MediaKitController extends PlayerController { void _wire() { _subs - ..add( - _player.stream.position.listen( - (p) => _emit(value.copyWith(position: p)), - ), - ) + ..add(_player.stream.position.listen(_onPosition)) ..add( _player.stream.duration.listen( (d) => _emit(value.copyWith(duration: d)), @@ -953,8 +1030,25 @@ class _MediaKitController extends PlayerController { Future pause() => _fallback?.pause() ?? _player.pause(); @override - Future seekTo(Duration position) => - _fallback?.seekTo(position) ?? _player.seek(position); + Future seekTo(Duration position) { + final fallback = _fallback; + if (fallback != null) return fallback.seekTo(position); + // Shown now rather than when mpv's next time-pos arrives, as video_player + // does for ExoPlayer: the seek bar lets go of the thumb on release, and + // a second double-tap adds its step to the new position, not the old one. + final duration = value.duration; + final shown = position < Duration.zero + ? Duration.zero + : duration > Duration.zero && position > duration + ? duration + : position; + _seekTarget = shown; + _sinceSeek + ..reset() + ..start(); + _flushPosition(shown); + return _player.seek(position); + } @override Future setPlaybackSpeed(double speed) => @@ -1168,6 +1262,10 @@ class _MediaKitController extends PlayerController { Future _teardownMpv() => _mpvDisposal ??= _releaseMpv(); Future _releaseMpv() async { + // A held tick must not land in `value` after the swap has handed it to + // the fallback. + _positionFlush?.cancel(); + _positionFlush = null; for (final s in _subs) { await s.cancel(); } diff --git a/lib/core/preview/frame_preview_service.dart b/lib/core/preview/frame_preview_service.dart index 88c3ef27..6f08f42d 100644 --- a/lib/core/preview/frame_preview_service.dart +++ b/lib/core/preview/frame_preview_service.dart @@ -172,7 +172,10 @@ class FramePreviewService { headers: headers, durationMs: durationMs, count: gridCount(durationMs, metered: metered), - pace: Duration(milliseconds: metered ? 900 : 250), + // One frame a second on Wi-Fi: each is a second hardware decoder and a + // segment download while the real stream plays, and back-to-back + // extraction competed with it on high-bitrate files. + pace: Duration(milliseconds: metered ? 900 : 1000), cacheKey: cacheKey, positionMs: positionMs, )..start(); diff --git a/lib/features/detail/domain/scrub_curve.dart b/lib/features/detail/domain/scrub_curve.dart index 05c905c6..4805b29b 100644 --- a/lib/features/detail/domain/scrub_curve.dart +++ b/lib/features/detail/domain/scrub_curve.dart @@ -2,38 +2,52 @@ import 'dart:math' as math; /// Where a horizontal swipe on the video lands. /// -/// Fine near the finger's start, coarse far from it, as VLC does: about two -/// seconds per centimetre for the first few, then a cubic that reaches half -/// the video (at least a minute, at most twenty) by eight centimetres. It -/// used to be 90 seconds per screen width, clamped to one width, so a single -/// swipe could never move more than a minute and a half — and from the middle -/// of the screen, about 45 seconds — whatever the length of the video. +/// Linear, as in MX Player: the same travel always moves the same time, so a +/// viewer can aim, and a still thumb's wobble moves a fraction of a second, +/// not several. One swipe across the whole screen covers [spanSec] — about +/// 4·√duration seconds: half a minute on a short clip, two and a half on an +/// episode, three at most on a film. The seek bar is there for longer jumps. /// -/// Within [cancelZoneCm] of the start the answer is the start itself: a +/// It used to be a VLC-style cubic in centimetres that reached half the +/// video by eight of them, counted at 38 logical pixels a centimetre — +/// Flutter's desktop figure; a phone has about 60. On a Galaxy S22 two +/// centimetres of travel already moved over a minute of a film, and past +/// three every pixel was four seconds. +/// +/// Within [deadZonePx] of the start the answer is the start itself: a /// finger brought back to where it began means "never mind". class ScrubCurve { ScrubCurve._(); - /// Logical pixels in a centimetre, near enough for a phone. - static const double pxPerCm = 38; - static const double cancelZoneCm = 0.3; + /// Travel that still means "never mind", in logical pixels. + static const double deadZonePx = 16; + + /// Seconds that one swipe across the whole screen moves. + static double spanSec(int durationMs) => + (4.0 * math.sqrt(durationMs / 1000)).clamp(30.0, 180.0).toDouble(); + /// [widthPx] is the long side of the screen, so turning the phone does not + /// change the rate. static int targetMs({ required int baselineMs, required int durationMs, required double deltaPx, + required double widthPx, }) { - if (durationMs <= 0) return baselineMs; - final cm = deltaPx / pxPerCm; - final a = cm.abs(); - if (a < cancelZoneCm) return baselineMs.clamp(0, durationMs); - final reach = (durationMs / 1000 * 0.5).clamp(60.0, 1200.0); - final seconds = 2.0 * a + reach * math.pow(a / 8, 3); - final target = baselineMs + (cm.sign * seconds * 1000).round(); + if (durationMs <= 0 || !widthPx.isFinite || widthPx <= 0) { + return baselineMs; + } + // Measured from the edge of the dead zone, so leaving it starts at zero + // instead of jumping by the zone's width. + final travel = deltaPx.abs() - deadZonePx; + if (travel <= 0) return baselineMs.clamp(0, durationMs); + // Whole seconds: the label reads m:ss, and the seek should land on what + // it said. + final seconds = (travel / widthPx * spanSec(durationMs)).round(); + final target = baselineMs + deltaPx.sign.toInt() * seconds * 1000; return target.clamp(0, durationMs); } - /// Whether [deltaPx] is inside the cancel zone. - static bool cancels(double deltaPx) => - (deltaPx / pxPerCm).abs() < cancelZoneCm; + /// Whether [deltaPx] is inside the dead zone. + static bool cancels(double deltaPx) => deltaPx.abs() < deadZonePx; } diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index dedc7a4b..42f42492 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -201,6 +201,9 @@ extension _PlayerControls on _PlayerPageState { } _controller?.addListener(listener); + // Checked once now as well: media_kit shows the seek target at once, so + // the position may already be there and no later change would say so. + listener(); Future.delayed(const Duration(seconds: 1), () { // The user can pop the player within this second — _sliderDragValue is // disposed by then, so touching it would throw. @@ -561,12 +564,18 @@ extension _PlayerControls on _PlayerPageState { child: ColoredBox( color: Colors.black, // Clipped, so a zoomed frame stays inside the video box and never - // paints under the controls or the subtitles. At 1.0 Transform.scale is - // an identity and costs nothing, so there is no branch for it. + // paints under the controls or the subtitles. child: ClipRect( child: Transform.scale( scale: _videoZoom, - filterQuality: FilterQuality.medium, + // Only while zoomed. With ANY filterQuality, RenderTransform paints + // through an ImageFilterLayer even at scale 1.0 (its identity + // shortcut is taken only when filterQuality is null), so the whole + // video texture went through an offscreen pass and a resample on + // every frame the screen drew. + filterQuality: _videoZoom == _kMinZoom + ? null + : FilterQuality.medium, child: _FittedVideo(controller: c, fit: _fit), ), ), @@ -1186,30 +1195,37 @@ extension _PlayerControls on _PlayerPageState { ? ExcludeFocus(excluding: !_controlsVisible, child: overlay) : overlay; - if (!isBuffering) return gated; // A stall while the controls are hidden used to show nothing at all — the // spinner lived inside the overlay and faded out with it. This one sits // outside the fade and cross-fades against it, so exactly one buffering // indicator is on screen: this, or the ring on the play button. - Widget spinner = const IgnorePointer( - child: Center( - child: SizedBox( - width: 44, - height: 44, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.8, + Widget? spinner; + if (isBuffering) { + spinner = const IgnorePointer( + child: Center( + child: SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.8, + ), ), ), - ), - ); - if (!isDesktopPlatform) { - spinner = FadeTransition( - opacity: ReverseAnimation(_controlsAnimation), - child: spinner, ); + if (!isDesktopPlatform) { + spinner = FadeTransition( + opacity: ReverseAnimation(_controlsAnimation), + child: spinner, + ); + } } - return Stack(fit: StackFit.expand, children: [gated, spinner]); + // One shape whether buffering or not. Returning `gated` bare in one state + // and inside a Stack in the other changed the widget type at this slot, so + // every buffering flip unmounted and re-inflated the whole overlay — + // slider, buttons, focus and all. media_kit reports buffering around every + // seek, so each ±10 s tap rebuilt the controls from scratch twice. + return Stack(fit: StackFit.expand, children: [gated, ?spinner]); } /// One control of the cluster around play, or null when it does not apply. @@ -2116,67 +2132,73 @@ extension _PlayerControls on _PlayerPageState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - 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, + // Its own layer: the thumb moves on every playhead tick and on + // 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, + ), ), ), - ), - 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), if (isDesktopPlatform) diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index b51f03f0..614570dc 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -205,6 +205,10 @@ class _PlayerPageState extends State DateTime? _sleepDeadline; bool _sleepAtEpisodeEnd = false; + /// The next episode's prefetch key when it turned out to be downloaded, so + /// the per-tick check does not stat the file again. See _maybePrefetchNext. + String? _prefetchLocalKey; + final PrefetchSlot _nextResolve = PrefetchSlot(); @@ -548,6 +552,12 @@ class _PlayerPageState extends State double _brightness = 0.5; double _volume = 1.0; + + /// Swipe levels not yet handed to the system, and the timer pacing them. + /// See _pushSystemLevels in player_page.pip.dart. + bool _brightnessDirty = false; + bool _volumeDirty = false; + Timer? _systemLevelTimer; final ValueNotifier<_SwipeIndicator?> _swipeIndicator = ValueNotifier<_SwipeIndicator?>(null); @@ -850,6 +860,7 @@ class _PlayerPageState extends State _hideTimer?.cancel(); _historyTimer?.cancel(); _seekRippleTimer?.cancel(); + _systemLevelTimer?.cancel(); _tvSeekCommit?.cancel(); _tvRootFocus.dispose(); _tvPanelFocus.dispose(); diff --git a/lib/features/detail/presentation/pages/player_page.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index 9d8e7da7..f60c652f 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -19,7 +19,11 @@ extension _PlayerGestures on _PlayerPageState { /// [initialDeltaPx]: how far the finger has already travelled from where /// it touched down, by the time the swipe was recognised as horizontal. - void _onHDragStart(DragStartDetails _, {double initialDeltaPx = 0}) { + void _onHDragStart( + DragStartDetails _, { + double initialDeltaPx = 0, + required double widthPx, + }) { final c = _controller; if (c == null || !c.value.isInitialized) return; _hideTimer?.cancel(); @@ -40,6 +44,7 @@ extension _PlayerGestures on _PlayerPageState { baseline: c.value.position, duration: c.value.duration, deltaPx: initialDeltaPx, + widthPx: widthPx, ); } @@ -248,6 +253,8 @@ extension _PlayerGestures on _PlayerPageState { localPosition: d.localPosition, ), initialDeltaPx: d.localPosition.dx - from.dx, + // The long side, so the rate is the same held either way. + widthPx: constraints.biggest.longestSide, ); return; } else { @@ -269,14 +276,14 @@ 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(); - unawaited(_setSystemBrightness(_brightness)); + _pushSystemLevels(brightness: true); _swipeIndicator.value = _SwipeIndicator( _SwipeType.brightness, _brightness, ); } else { _volume = (_volume + delta).clamp(0.0, 1.0).toDouble(); - unawaited(_setSystemVolume(_volume)); + _pushSystemLevels(volume: true); _swipeIndicator.value = _SwipeIndicator(_SwipeType.volume, _volume); } } diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 3c571a7e..5c7ac698 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -13,7 +13,9 @@ extension _PlayerHistory on _PlayerPageState { void _scheduleHistorySave() { _historyTimer?.cancel(); _historyTimer = Timer.periodic(const Duration(seconds: 5), (_) { - _saveHistory(); + // Quiet: this tick is for surviving a kill, not for the screens under + // the player, which catch up from the save on pause and on exit. + _saveHistory(notify: false); _jellyfinProgress(); // Deliberately not inside _saveHistory: that returns early for a // finished episode, a title with no url and a session under ten seconds, @@ -81,7 +83,11 @@ extension _PlayerHistory on _PlayerPageState { return label.isEmpty ? 'Episode ${ep.episode}' : label; } - void _saveHistory() { + void _saveHistory({bool notify = true}) { + // A notifying save can still write nothing — an episode played to its + // end returns below — so the rows the quiet ticks wrote are announced + // first, or the screens under the player keep their old progress. + if (notify) _history.flushQuiet(); if (!WatchProgress.countsAsAViewing(_playbackWatch.elapsed)) return; // Time watched since the last save, banked before anything can return @@ -113,26 +119,29 @@ extension _PlayerHistory on _PlayerPageState { // PlayerArgs.offlineEpisodeNumber. final offlineEp = widget.args.offlineEpisodeNumber; - _history.save( - HistoryItem( - contentUrl: contentUrl, - provider: widget.args.provider, - title: widget.args.title, - thumbnail: widget.args.thumbnail, - isSerial: widget.args.isSerial || offlineEp != null, - episodeIndex: widget.args.isSerial ? _episodeIndex : null, - episodeNumber: ep?.episode ?? offlineEp, - episodeLabel: ep?.label ?? widget.args.offlineEpisodeLabel, - positionMs: posMs, - durationMs: durMs, - watchedAt: DateTime.now().millisecondsSinceEpoch, - // The last episode on offer: finishing it finishes the series. - isFinale: - widget.args.isSerial && - _episodes.length > 1 && - _episodeIndex == _episodes.length - 1, - ), + final item = HistoryItem( + contentUrl: contentUrl, + provider: widget.args.provider, + title: widget.args.title, + thumbnail: widget.args.thumbnail, + isSerial: widget.args.isSerial || offlineEp != null, + episodeIndex: widget.args.isSerial ? _episodeIndex : null, + episodeNumber: ep?.episode ?? offlineEp, + episodeLabel: ep?.label ?? widget.args.offlineEpisodeLabel, + positionMs: posMs, + durationMs: durMs, + watchedAt: DateTime.now().millisecondsSinceEpoch, + // The last episode on offer: finishing it finishes the series. + isFinale: + widget.args.isSerial && + _episodes.length > 1 && + _episodeIndex == _episodes.length - 1, ); + if (notify) { + _history.save(item); + } else { + _history.saveQuietly(item); + } } /// Adds the stretch since the last tick to the running totals. @@ -252,7 +261,9 @@ extension _PlayerHistory on _PlayerPageState { /// Trakt is for films and series: not live channels, not trailers. bool get _traktApplies => - !_isLive && widget.args.provider != 'trailer' && widget.args.contentUrl != null; + !_isLive && + widget.args.provider != 'trailer' && + widget.args.contentUrl != null; double get _playbackPercent { final v = _controller?.value; diff --git a/lib/features/detail/presentation/pages/player_page.models.dart b/lib/features/detail/presentation/pages/player_page.models.dart index a8fb0a1c..e3778207 100644 --- a/lib/features/detail/presentation/pages/player_page.models.dart +++ b/lib/features/detail/presentation/pages/player_page.models.dart @@ -107,16 +107,21 @@ class _ScrubState { final Duration duration; final double deltaPx; + /// The screen's long side, what a full swipe is measured against. + final double widthPx; + const _ScrubState({ required this.baseline, required this.duration, required this.deltaPx, + required this.widthPx, }); _ScrubState copyWith({double? deltaPx}) => _ScrubState( baseline: baseline, duration: duration, deltaPx: deltaPx ?? this.deltaPx, + widthPx: widthPx, ); /// Where releasing now would land; see [ScrubCurve]. @@ -125,11 +130,15 @@ class _ScrubState { baselineMs: baseline.inMilliseconds, durationMs: duration.inMilliseconds, deltaPx: deltaPx, + widthPx: widthPx, ), ); - /// Back where it started: releasing now changes nothing. - bool get cancels => ScrubCurve.cancels(deltaPx); + /// Back where it started, or closer to it than the two seconds the label + /// shows: releasing now changes nothing, as the card says. + bool get cancels => + ScrubCurve.cancels(deltaPx) || + (previewPosition() - baseline).inSeconds.abs() < 2; } class _VttThumbnail { diff --git a/lib/features/detail/presentation/pages/player_page.pip.dart b/lib/features/detail/presentation/pages/player_page.pip.dart index d5a0032f..31dbce4d 100644 --- a/lib/features/detail/presentation/pages/player_page.pip.dart +++ b/lib/features/detail/presentation/pages/player_page.pip.dart @@ -391,6 +391,31 @@ extension _PlayerPip on _PlayerPageState { await WakelockHolds.release(this); } + /// A swipe asks for a new level on every touch sample — 120 a second on a + /// fast panel — and each call was a window relayout or an AudioManager IPC + /// on the thread that draws the next frame. The first goes at once, then at + /// most one per 50 ms; the trailing call lands wherever the finger stopped. + void _pushSystemLevels({bool brightness = false, bool volume = false}) { + _brightnessDirty |= brightness; + _volumeDirty |= volume; + if (_systemLevelTimer?.isActive ?? false) return; + _flushSystemLevels(); + _systemLevelTimer = Timer(const Duration(milliseconds: 50), () { + if (mounted) _flushSystemLevels(); + }); + } + + void _flushSystemLevels() { + if (_brightnessDirty) { + _brightnessDirty = false; + unawaited(_setSystemBrightness(_brightness)); + } + if (_volumeDirty) { + _volumeDirty = false; + unawaited(_setSystemVolume(_volume)); + } + } + Future _setSystemBrightness(double value) async { try { await _systemControlsChannel.invokeMethod('setBrightness', { diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 285f68c1..2b5b0b06 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -2000,62 +2000,66 @@ extension _PlayerSubtitles on _PlayerPageState { left: 16, right: 16, bottom: _subtitleBottomOffset, - child: IgnorePointer( - child: ListenableBuilder( - listenable: Listenable.merge([ - _subtitleOffsetMs, - _subtitleRate, - c, - ?embedded, - ]), - builder: (_, _) { - final rate = _subtitleRate.value; - var position = - c.value.position - - Duration(milliseconds: _subtitleOffsetMs.value); - // Map the video clock onto the subtitle's own timeline: a 25fps - // subtitle over 23.976fps content needs its timestamps stretched, - // which is a division here, not a shift. - if (rate != 1.0 && rate > 0) { - position = Duration( - microseconds: (position.inMicroseconds / rate).round(), + // Its own layer: the line changes on its own clock, and nothing else on + // the page has to repaint with it. + child: RepaintBoundary( + child: IgnorePointer( + child: ListenableBuilder( + listenable: Listenable.merge([ + _subtitleOffsetMs, + _subtitleRate, + c, + ?embedded, + ]), + builder: (_, _) { + final rate = _subtitleRate.value; + var position = + c.value.position - + Duration(milliseconds: _subtitleOffsetMs.value); + // Map the video clock onto the subtitle's own timeline: a 25fps + // subtitle over 23.976fps content needs its timestamps stretched, + // which is a division here, not a shift. + if (rate != 1.0 && rate > 0) { + position = Duration( + microseconds: (position.inMicroseconds / rate).round(), + ); + } + final fromStream = + captions == null && + _activeSubtitleIndex == -1 && + c.activeSubtitleTrackId != null + ? embedded?.value ?? '' + : ''; + final primary = captions == null + ? fromStream + : _captionTextAt(captions, position); + final secondary = _secondaryCaptionFile == null + ? '' + : _captionTextAt(_secondaryCaptionFile!, position); + if (primary.isEmpty && secondary.isEmpty) { + return const SizedBox.shrink(); + } + return Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The second track sits ABOVE the first, and smaller. The + // primary is the one being read along with the audio and + // keeps the position the eye is trained on; a translation + // pushed underneath it would move the line everyone actually + // reads every time the other track has something to say. + if (secondary.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _styledSubtitle(secondary, secondary: true), + ), + if (primary.isNotEmpty) _styledSubtitle(primary), + ], + ), ); - } - final fromStream = - captions == null && - _activeSubtitleIndex == -1 && - c.activeSubtitleTrackId != null - ? embedded?.value ?? '' - : ''; - final primary = captions == null - ? fromStream - : _captionTextAt(captions, position); - final secondary = _secondaryCaptionFile == null - ? '' - : _captionTextAt(_secondaryCaptionFile!, position); - if (primary.isEmpty && secondary.isEmpty) { - return const SizedBox.shrink(); - } - return Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // The second track sits ABOVE the first, and smaller. The - // primary is the one being read along with the audio and - // keeps the position the eye is trained on; a translation - // pushed underneath it would move the line everyone actually - // reads every time the other track has something to say. - if (secondary.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: _styledSubtitle(secondary, secondary: true), - ), - if (primary.isNotEmpty) _styledSubtitle(primary), - ], - ), - ); - }, + }, + ), ), ), ); @@ -2155,10 +2159,7 @@ extension _PlayerSubtitles on _PlayerPageState { ), ]; case SubtitleEdge.outline: - strokePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2.5 - ..color = const Color(0xFF000000); + strokePaint = _kSubtitleStroke; } Widget textWidget = Text( @@ -2254,3 +2255,11 @@ class _AiTranslateChip extends StatelessWidget { ); } } + +/// One stroke for every outlined subtitle line. `Paint` compares by identity +/// and TextStyle treats a different `foreground` as a LAYOUT change, so a new +/// Paint per build re-shaped the line on every position tick. +final Paint _kSubtitleStroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..color = const Color(0xFF000000); diff --git a/lib/features/detail/presentation/pages/player_page.upnext.dart b/lib/features/detail/presentation/pages/player_page.upnext.dart index 6442802b..cab343c4 100644 --- a/lib/features/detail/presentation/pages/player_page.upnext.dart +++ b/lib/features/detail/presentation/pages/player_page.upnext.dart @@ -32,10 +32,17 @@ extension _PlayerUpNext on _PlayerPageState { final index = _episodeIndex + 1; if (!_window.contains(index)) return; final ep = _episodes[index]; - if (ep.mediaRef.isEmpty || _localEpisode(ep) != null) return; + if (ep.mediaRef.isEmpty) return; final lang = _resolveLangForEpisode(ep); final key = _prefetchKey(ep, lang); - if (_nextResolve.covers(key)) return; + // Asked on every position tick past the threshold. Finding a download is a + // synchronous existsSync, and nothing is prefetched for it to short- + // circuit, so the answer is remembered. + if (_nextResolve.covers(key) || key == _prefetchLocalKey) return; + if (_localEpisode(ep) != null) { + _prefetchLocalKey = key; + return; + } _plog('prefetching next episode ref=${ep.mediaRef}'); unawaited( _nextResolve.fill(key, () async { diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index 1579aa1b..056a0ca3 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/features/extensions/presentation/pages/source_catalog_page.dart'; import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/matching/title_match.dart'; @@ -887,6 +888,22 @@ class _AlternateSourceSheetState extends State { // help, and a dead end is the wrong thing to leave them with. if (!_searching) ...[ const SizedBox(height: 14), + // The message just said "add one that does"; this is where. + if (_outcome?.asked == 0) + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + SourceCatalogPage.open( + context, + initialItemType: SourceCatalogPage.itemTypeFor( + widget.provider.contentMode, + ), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.white70), + icon: const Icon(Icons.add_rounded, size: 18), + label: Text('ext.add_source'.tr()), + ), TextButton.icon( onPressed: () { Navigator.of(context).pop(); diff --git a/lib/features/extensions/presentation/pages/source_catalog_page.dart b/lib/features/extensions/presentation/pages/source_catalog_page.dart index 25cec959..4568f5d7 100644 --- a/lib/features/extensions/presentation/pages/source_catalog_page.dart +++ b/lib/features/extensions/presentation/pages/source_catalog_page.dart @@ -43,9 +43,22 @@ class SourceCatalogPage extends StatefulWidget { /// own title and its repositories button are the hub's when it is embedded. final bool embedded; - static Future open(BuildContext context) => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const SourceCatalogPage())); + static Future open( + BuildContext context, { + CatalogItemType? initialItemType, + }) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SourceCatalogPage(initialItemType: initialItemType), + ), + ); + + /// The filter the catalogue opens on for a mode. Video has no single type — + /// anime, films and series all play — so it opens unfiltered. + static CatalogItemType? itemTypeFor(ContentMode mode) => switch (mode) { + ContentMode.manga => CatalogItemType.manga, + ContentMode.novel => CatalogItemType.novel, + ContentMode.video => null, + }; @override State createState() => _SourceCatalogPageState(); @@ -316,7 +329,9 @@ class _SourceCatalogPageState extends State { actions: [ IconButton( onPressed: _manage, - icon: const Icon(Icons.more_horiz), + // Repositories, not "more": this is where a repo URL is added, + // and three dots read as an overflow menu nobody opens. + icon: const Icon(Icons.folder_copy_outlined), tooltip: 'source_manager.manage_repositories'.tr(), ), ], diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 481c137c..3528a633 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -13,7 +13,6 @@ import 'package:soplay/features/history/domain/history_trim.dart'; 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)); @@ -61,8 +60,9 @@ class HistoryService { try { final raw = _box.get(key); if (raw is! String) continue; - final item = - HistoryItem.fromJson(jsonDecode(raw) as Map); + final item = HistoryItem.fromJson( + jsonDecode(raw) as Map, + ); if (latest == null || item.watchedAt > latest.watchedAt) { latest = item; } @@ -71,7 +71,29 @@ class HistoryService { return latest; } - Future save(HistoryItem item) async { + Future save(HistoryItem item) => _write(item, notify: true); + + /// [save] without telling listeners. For the player's five-second progress + /// tick: every listener — Home's Continue rail, the episode list, the detail + /// header, the home-screen widget — re-reads and re-decodes the box and + /// rebuilds, under a player that covers all of them. They catch up from the + /// save on pause and on exit. + Future saveQuietly(HistoryItem item) => _write(item, notify: false); + + /// Whether a quiet write has landed that listeners have not been told of. + bool _quietPending = false; + + /// Tells listeners about quiet writes, if there were any since the last + /// notification. For the moments the player hands the screen back — pause, + /// exit — when its own final save may write nothing (an episode played to + /// its end), and the rows the quiet ticks wrote would otherwise never show. + void flushQuiet() { + if (!_quietPending) return; + _quietPending = false; + revision.value++; + } + + Future _write(HistoryItem item, {required bool notify}) async { // Incognito is enforced here rather than at the call sites. History is // written from the player, the detail page, shorts and the sync service's // own merge, and a mode whose guarantee depends on every one of those @@ -86,7 +108,12 @@ class HistoryService { } await _box.put(item.storageKey, jsonEncode(item.toJson())); await _trimIfNeeded(); - revision.value++; + if (notify) { + _quietPending = false; + revision.value++; + } else { + _quietPending = true; + } } /// Reads a row back so a delete can be described to the server before the diff --git a/lib/features/onboarding/presentation/pages/onboarding_badges_page.dart b/lib/features/onboarding/presentation/pages/onboarding_badges_page.dart index f6036140..e6a2de7f 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_badges_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_badges_page.dart @@ -28,9 +28,14 @@ class OnboardingBadgesPage extends StatelessWidget { padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Column( children: [ - const SizedBox(height: 250, child: _MedalStage()), + const SizedBox( + height: 250, + // Its own layer: the stage redraws every frame, and without one + // the heading, the week and the button were re-recorded with it. + child: RepaintBoundary(child: _MedalStage()), + ), const SizedBox(height: 18), - const _WeekToBadge(), + const RepaintBoundary(child: _WeekToBadge()), const SizedBox(height: 26), OnboardingHeading( center: true, @@ -128,25 +133,32 @@ class _MedalStageState extends State<_MedalStage> ), ), ), - for (final (i, (id, tier, at, size)) in _around.indexed) - Align( - alignment: at, - child: Transform.translate( - offset: Offset(0, math.sin(t + i * 1.7) * 5), - child: Transform.scale( - scale: struck(i + 1), - child: Opacity( - opacity: 0.92, - child: AchievementBadge( - id: id, - tier: tier, - size: size, - light: light, + // One layer for the four rather than one each: they never + // overlap, so fading them together looks the same and costs one + // offscreen pass a frame instead of four. + Opacity( + opacity: 0.92, + child: Stack( + children: [ + for (final (i, (id, tier, at, size)) in _around.indexed) + Align( + alignment: at, + child: Transform.translate( + offset: Offset(0, math.sin(t + i * 1.7) * 5), + child: Transform.scale( + scale: struck(i + 1), + child: AchievementBadge( + id: id, + tier: tier, + size: size, + light: light, + ), + ), ), ), - ), - ), + ], ), + ), Transform.translate( offset: Offset(0, math.sin(t) * 3), child: Transform.scale( diff --git a/lib/features/onboarding/presentation/pages/onboarding_genres_page.dart b/lib/features/onboarding/presentation/pages/onboarding_genres_page.dart index e8f2bf2d..a5d6ca53 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_genres_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_genres_page.dart @@ -32,6 +32,10 @@ class OnboardingGenresPage extends StatefulWidget { class _OnboardingGenresPageState extends State { final OnboardingController _c = getIt(); + + // Built once: the kinds cannot change on this step, and the same instance + // lets a chip tap's setState skip the backdrop entirely. + late final Widget _backdrop = _BlurredPosters(posters: postersFor(_c.kinds)); late final GenreCatalog _catalog = widget.catalog ?? getIt(); List? _genres; bool _offline = false; @@ -82,7 +86,7 @@ class _OnboardingGenresPageState extends State { return OnboardingScaffold( step: OnboardingStep.genres, glow: accent, - background: _BlurredPosters(posters: postersFor(kinds)), + background: _backdrop, onSkip: () => onboardingNext(context), body: CustomScrollView( slivers: [ @@ -452,50 +456,88 @@ class _ChipsLoading extends StatelessWidget { } /// The picked kinds' posters, tilted, blurred and darkened into a backdrop. -class _BlurredPosters extends StatelessWidget { +class _BlurredPosters extends StatefulWidget { const _BlurredPosters({required this.posters}); final List posters; + @override + State<_BlurredPosters> createState() => _BlurredPostersState(); +} + +class _BlurredPostersState extends State<_BlurredPosters> { + // Impeller keeps no raster cache, so a live 26-sigma blur over more than + // the whole screen was worked out again on every frame anything on the page + // moved — the route fade, the chips' entrance, the shimmer. The picture + // never changes, so it is blurred once and kept as an image. + final _snapshot = SnapshotController(); + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_requested) return; + _requested = true; + // Taken once every poster has decoded and been drawn: a snapshot is not + // retaken when its child repaints, so an early one would keep the gaps. + Future.wait([ + for (final p in widget.posters) + precacheImage(ResizeImage(AssetImage(p), width: 200), context), + ]).then((_) => WidgetsBinding.instance.endOfFrame).then((_) { + if (mounted) _snapshot.allowSnapshotting = true; + }); + } + + @override + void dispose() { + _snapshot.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final posters = widget.posters; return RepaintBoundary( child: Stack( fit: StackFit.expand, children: [ - ImageFiltered( - imageFilter: ui.ImageFilter.blur(sigmaX: 26, sigmaY: 26), - child: OverflowBox( - maxWidth: double.infinity, - maxHeight: double.infinity, - child: Transform.rotate( - angle: -0.12, - child: Transform.scale( - scale: 1.3, - child: LayoutBuilder( - builder: (context, _) { - final size = MediaQuery.sizeOf(context); - final tileW = size.width / 3.2; - return SizedBox( - width: size.width * 1.2, - height: size.height * 1.2, - child: Wrap( - children: [ - for (final p in posters) - SizedBox( - width: tileW, - height: tileW * 1.45, - child: Image.asset( - p, - fit: BoxFit.cover, - cacheWidth: 200, - excludeFromSemantics: true, + SnapshotWidget( + controller: _snapshot, + autoresize: true, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur(sigmaX: 26, sigmaY: 26), + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Transform.rotate( + angle: -0.12, + child: Transform.scale( + scale: 1.3, + child: LayoutBuilder( + builder: (context, _) { + final size = MediaQuery.sizeOf(context); + final tileW = size.width / 3.2; + return SizedBox( + width: size.width * 1.2, + height: size.height * 1.2, + child: Wrap( + children: [ + for (final p in posters) + SizedBox( + width: tileW, + height: tileW * 1.45, + child: Image.asset( + p, + fit: BoxFit.cover, + cacheWidth: 200, + excludeFromSemantics: true, + ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ), ), diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 216b66c0..91ba0fce 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -37,7 +37,10 @@ class _OnboardingPageState extends State { static const _autoAdvance = Duration(seconds: 6); final _pageController = PageController(); - int _page = 0; + // Read by the dots and the key handler only. A setState here rebuilt the + // whole intro — header, pager, both visible slides, buttons — on the frame + // a swipe crossed halfway, and laid the pager out again mid-drag. + final _page = ValueNotifier(0); bool _leaving = false; Timer? _timer; @@ -56,12 +59,13 @@ class _OnboardingPageState extends State { void dispose() { _timer?.cancel(); _pageController.dispose(); + _page.dispose(); super.dispose(); } void _turn(int by, {bool wrap = false}) { if (!_pageController.hasClients) return; - var to = _page + by; + var to = _page.value + by; if (wrap) to %= _slides; if (to < 0 || to >= _slides) return; _pageController.animateToPage( @@ -110,7 +114,7 @@ class _OnboardingPageState extends State { child: Stack( fit: StackFit.expand, children: [ - _Backdrops(controller: _pageController, page: _page), + _Backdrops(controller: _pageController, page: _page.value), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -145,13 +149,17 @@ class _OnboardingPageState extends State { child: PageView.builder( controller: _pageController, itemCount: _slides, - onPageChanged: (i) => setState(() => _page = i), + onPageChanged: (i) => _page.value = i, itemBuilder: (context, i) => _Slide(index: i, controller: _pageController), ), ), const SizedBox(height: 16), - _Dots(count: _slides, active: _page), + ValueListenableBuilder( + valueListenable: _page, + builder: (context, active, _) => + _Dots(count: _slides, active: active), + ), const SizedBox(height: 26), Center( child: ConstrainedBox( @@ -267,14 +275,47 @@ class _SkipChip extends StatelessWidget { /// Cross-fades the three backdrops against the swipe itself, so the artwork /// changes with the finger rather than snapping when the page settles. -class _Backdrops extends StatelessWidget { +class _Backdrops extends StatefulWidget { const _Backdrops({required this.controller, required this.page}); final PageController controller; final int page; + @override + State<_Backdrops> createState() => _BackdropsState(); +} + +class _BackdropsState extends State<_Backdrops> { + // Each wall starts and stops its own clock from these. A TickerMode did it + // before, but every Image under one depends on it, so each flip re-resolved + // a hundred-odd posters on the first and last frame of a swipe; and a muted + // ticker's clock keeps counting, so a wall jumped ahead when it settled. + final _running = [for (var i = 0; i < 3; i++) ValueNotifier(false)]; + + // Built once: a swipe frame rebuilds the fade and the scale around them, + // never the walls themselves. + late final List _walls = [ + // The page's scrim already fades them into the background. + PosterWall( + posters: kMoviePosters, + fadeBottom: false, + running: _running[0], + ), + AnimeRibbons(fadeBottom: false, running: _running[1]), + TvShowcase(running: _running[2]), + ]; + + @override + void dispose() { + for (final r in _running) { + r.dispose(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { + final controller = widget.controller; return AnimatedBuilder( animation: controller, builder: (context, _) { @@ -282,28 +323,30 @@ class _Backdrops extends StatelessWidget { // truth available then. final position = controller.hasClients && controller.position.haveDimensions - ? controller.page ?? page.toDouble() - : page.toDouble(); + ? controller.page ?? widget.page.toDouble() + : widget.page.toDouble(); final opacities = backdropOpacities(position); // Mid-swipe the walls hold still: the frame then only has the // crossfade to draw, not two moving walls under it as well. final settled = (position - position.roundToDouble()).abs() < 0.001; + for (var i = 0; i < 3; i++) { + // Only starts or stops controllers; nothing listening rebuilds. + _running[i].value = settled && opacities[i] > 0.001; + } return Stack( fit: StackFit.expand, children: [ + // All three stay mounted, unpainted at zero opacity: mounting one + // on the first frame of a drag built a whole wall inside it. for (var i = 0; i < 3; i++) - // Neighbours are mounted ahead, paused and unpainted, so their - // posters are decoded before the finger reaches them. - if ((position - i).abs() < 1.99) - _Backdrop( - key: ValueKey(i), - index: i, - moving: settled, - opacity: opacities[i], - distance: (position - i).abs().clamp(0.0, 1.0), - ), + _Backdrop( + key: ValueKey(i), + opacity: opacities[i], + distance: (position - i).abs().clamp(0.0, 1.0), + child: _walls[i], + ), ], ); }, @@ -327,36 +370,22 @@ List backdropOpacities(double position) { class _Backdrop extends StatelessWidget { const _Backdrop({ super.key, - required this.index, - required this.moving, required this.opacity, required this.distance, + required this.child, }); - final int index; - final bool moving; final double opacity; final double distance; + final Widget child; @override Widget build(BuildContext context) { - final shown = opacity > 0.001; - return TickerMode( - enabled: shown && moving, - child: Opacity( - opacity: opacity, - child: RepaintBoundary( - // Eases forward as it takes focus. - child: Transform.scale( - scale: 1 + distance * 0.06, - child: switch (index) { - // The page's scrim already fades them into the background. - 0 => const PosterWall(posters: kMoviePosters, fadeBottom: false), - 1 => const AnimeRibbons(fadeBottom: false), - _ => TvShowcase(active: shown), - }, - ), - ), + return Opacity( + opacity: opacity, + child: RepaintBoundary( + // Eases forward as it takes focus. + child: Transform.scale(scale: 1 + distance * 0.06, child: child), ), ); } diff --git a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart index b23f2d74..db46bb4d 100644 --- a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart +++ b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/onboarding/data/onboarding_posters.dart'; @@ -18,6 +19,7 @@ class AnimeRibbons extends StatefulWidget { this.rows = 6, this.tilesPerRow = 11, this.fadeBottom = true, + this.running, }); final int rows; @@ -26,6 +28,10 @@ class AnimeRibbons extends StatefulWidget { /// Off where the page already fades the ribbons into its background. final bool fadeBottom; + /// Starts and stops the drift from outside, as the intro does mid-swipe. + /// Null: it drifts whenever animations are on. + final ValueListenable? running; + @override State createState() => _AnimeRibbonsState(); } @@ -35,13 +41,35 @@ class _AnimeRibbonsState extends State late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(seconds: 48), - )..repeat(); + ); + bool _reduced = false; + + @override + void initState() { + super.initState(); + widget.running?.addListener(_sync); + } + + @override + void didUpdateWidget(AnimeRibbons old) { + super.didUpdateWidget(old); + if (old.running == widget.running) return; + old.running?.removeListener(_sync); + widget.running?.addListener(_sync); + _sync(); + } @override void didChangeDependencies() { super.didChangeDependencies(); - final reduced = MediaQuery.disableAnimationsOf(context); - if (reduced) { + _reduced = MediaQuery.disableAnimationsOf(context); + _sync(); + } + + // Stopped, not muted: a muted ticker's clock keeps counting, so the wall + // jumped ahead by however long it had been held once it was let go. + void _sync() { + if (_reduced || !(widget.running?.value ?? true)) { _controller.stop(); } else if (!_controller.isAnimating) { _controller.repeat(); @@ -50,6 +78,7 @@ class _AnimeRibbonsState extends State @override void dispose() { + widget.running?.removeListener(_sync); _controller.dispose(); super.dispose(); } diff --git a/lib/features/onboarding/presentation/widgets/poster_wall.dart b/lib/features/onboarding/presentation/widgets/poster_wall.dart index 9f0324f3..6627d25b 100644 --- a/lib/features/onboarding/presentation/widgets/poster_wall.dart +++ b/lib/features/onboarding/presentation/widgets/poster_wall.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/onboarding/data/onboarding_posters.dart'; @@ -16,6 +17,7 @@ class PosterWall extends StatefulWidget { this.tilesPerColumn = 11, this.fadeTop = false, this.fadeBottom = true, + this.running, }); final List posters; @@ -30,6 +32,10 @@ class PosterWall extends StatefulWidget { /// is a full-screen offscreen pass on every frame of the drift. final bool fadeBottom; + /// Starts and stops the drift from outside, as the intro does mid-swipe. + /// Null: it drifts whenever animations are on. + final ValueListenable? running; + @override State createState() => _PosterWallState(); } @@ -44,13 +50,35 @@ class _PosterWallState extends State late final AnimationController _controller = AnimationController( vsync: this, duration: _cycle, - )..repeat(); + ); + bool _reduced = false; + + @override + void initState() { + super.initState(); + widget.running?.addListener(_sync); + } + + @override + void didUpdateWidget(PosterWall old) { + super.didUpdateWidget(old); + if (old.running == widget.running) return; + old.running?.removeListener(_sync); + widget.running?.addListener(_sync); + _sync(); + } @override void didChangeDependencies() { super.didChangeDependencies(); - final reduced = MediaQuery.disableAnimationsOf(context); - if (reduced) { + _reduced = MediaQuery.disableAnimationsOf(context); + _sync(); + } + + // Stopped, not muted: a muted ticker's clock keeps counting, so the wall + // jumped ahead by however long it had been held once it was let go. + void _sync() { + if (_reduced || !(widget.running?.value ?? true)) { _controller.stop(); } else if (!_controller.isAnimating) { _controller.repeat(); @@ -59,6 +87,7 @@ class _PosterWallState extends State @override void dispose() { + widget.running?.removeListener(_sync); _controller.dispose(); super.dispose(); } diff --git a/lib/features/onboarding/presentation/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index abb8dce0..25e76afc 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/onboarding/data/onboarding_posters.dart'; @@ -10,11 +11,12 @@ import 'package:soplay/features/onboarding/data/onboarding_posters.dart'; /// pairing are the two things people never guess the app can do, and neither /// of them looks like a poster. class TvShowcase extends StatefulWidget { - const TvShowcase({super.key, this.active = true}); + const TvShowcase({super.key, this.running}); - /// The entrance waits for this, so a showcase mounted ahead of time does - /// not spend it off screen. - final bool active; + /// Starts and stops the scene from outside. The entrance waits for it too, + /// so a showcase mounted ahead of time does not spend it off screen. + /// Null: it runs whenever animations are on. + final ValueListenable? running; @override State createState() => _TvShowcaseState(); @@ -24,71 +26,92 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { late final AnimationController _loop = AnimationController( vsync: this, duration: const Duration(seconds: 9), - )..repeat(); + ); // The line-up gets its own clock. On the screen's 9-second loop a strip of // forty marks flies past far too fast to read a single one. late final AnimationController _strips = AnimationController( vsync: this, duration: const Duration(seconds: 78), - )..repeat(); + ); late final AnimationController _entrance = AnimationController( vsync: this, duration: const Duration(milliseconds: 1300), ); + // Made once. A CurvedAnimation hangs a status listener on its parent that + // only dispose() takes off, and these were made on every build — five more + // on each frame of a swipe. + late final List _steps = [ + for (final start in const [0.0, 0.1, 0.24, 0.34, 0.44]) + CurvedAnimation( + parent: _entrance, + curve: Interval( + start, + (start + 0.5).clamp(0.0, 1.0), + curve: Curves.easeOutCubic, + ), + ), + ]; + + bool _reduced = false; + @override void initState() { super.initState(); - if (widget.active) _entrance.forward(); + widget.running?.addListener(_sync); } @override void didUpdateWidget(TvShowcase old) { super.didUpdateWidget(old); - if (widget.active && _entrance.value == 0 && !_entrance.isAnimating) { - _entrance.forward(); - } + if (old.running == widget.running) return; + old.running?.removeListener(_sync); + widget.running?.addListener(_sync); + _sync(); } @override void didChangeDependencies() { super.didChangeDependencies(); - final reduced = MediaQuery.disableAnimationsOf(context); - if (reduced) { - _loop.stop(); - } else if (!_loop.isAnimating) { - _loop.repeat(); - } - if (reduced) { - _strips.stop(); - } else if (!_strips.isAnimating) { - _strips.repeat(); + _reduced = MediaQuery.disableAnimationsOf(context); + _sync(); + } + + // Stopped, not muted: a muted ticker's clock keeps counting, so the scene + // jumped ahead by however long it had been held once it was let go. + void _sync() { + final run = !_reduced && (widget.running?.value ?? true); + for (final c in [_loop, _strips]) { + if (!run) { + c.stop(); + } else if (!c.isAnimating) { + c.repeat(); + } } - if (reduced) { + if (_reduced) { _entrance.stop(); _entrance.value = 1; + } else if (!run) { + _entrance.stop(); + } else if (_entrance.value < 1 && !_entrance.isAnimating) { + _entrance.forward(); } } @override void dispose() { + widget.running?.removeListener(_sync); + for (final s in _steps) { + s.dispose(); + } _loop.dispose(); _strips.dispose(); _entrance.dispose(); super.dispose(); } - Animation _step(double start) => CurvedAnimation( - parent: _entrance, - curve: Interval( - start, - (start + 0.5).clamp(0.0, 1.0), - curve: Curves.easeOutCubic, - ), - ); - @override Widget build(BuildContext context) { return RepaintBoundary( @@ -100,41 +123,70 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { // The set assembles itself: screen, then the line-up it carries, // then the phone that drives it — the order the sentence reads in. _Rise( - animation: _step(0), + animation: _steps[0], from: 26, child: _Television(controller: _loop), ), - _Rise(animation: _step(0.1), from: 20, child: const _Stand()), + _Rise(animation: _steps[1], from: 20, child: const _Stand()), const SizedBox(height: 30), - _Rise( - animation: _step(0.24), - from: 22, - child: _ChannelStrip( - controller: _strips, - reverse: false, - speed: 1, - ), - ), - const SizedBox(height: 9), - _Rise( - animation: _step(0.34), - from: 22, - child: _ChannelStrip( - controller: _strips, - reverse: true, - speed: 0.78, - offset: 0.33, - ), - ), - const SizedBox(height: 9), - _Rise( - animation: _step(0.44), - from: 22, - child: _ChannelStrip( - controller: _strips, - reverse: false, - speed: 0.91, - offset: 0.66, + // One mask for the three strips rather than one each: the edges + // fade to transparent, so the backdrop behind shows through during + // the crossfade, and it is one small offscreen pass instead of + // three. + ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (rect) => const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + Colors.white, + Colors.white, + Colors.transparent, + ], + stops: [0, 0.12, 0.88, 1], + ).createShader(rect), + // Down to where the last strip starts its rise, 22 px lower: + // the mask covers only this box, and a strip still rising below + // it would show hard edges. + child: Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Rise( + animation: _steps[2], + from: 22, + child: _ChannelStrip( + controller: _strips, + reverse: false, + speed: 1, + ), + ), + const SizedBox(height: 9), + _Rise( + animation: _steps[3], + from: 22, + child: _ChannelStrip( + controller: _strips, + reverse: true, + speed: 0.78, + offset: 0.33, + ), + ), + const SizedBox(height: 9), + _Rise( + animation: _steps[4], + from: 22, + child: _ChannelStrip( + controller: _strips, + reverse: false, + speed: 0.91, + offset: 0.66, + ), + ), + ], + ), ), ), ], @@ -392,68 +444,52 @@ class _ChannelStrip extends StatelessWidget { return SizedBox( height: _tileHeight, - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (rect) => const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.transparent, - Colors.white, - Colors.white, - Colors.transparent, - ], - stops: [0, 0.12, 0.88, 1], - ).createShader(rect), - child: ClipRect( - child: OverflowBox( - maxWidth: double.infinity, - alignment: AlignmentDirectional.centerStart, - child: AnimatedBuilder( - animation: controller, - builder: (context, child) { - // Wrapped rather than scaled, so a slower row still closes its - // own loop without a jump. - final t = (controller.value * speed) % 1.0; - return Transform.translate( - offset: Offset(reverse ? t * strip - strip : -t * strip, 0), - child: child, - ); - }, - child: RepaintBoundary( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var pass = 0; pass < 2; pass++) - for (final logo in logos) - Padding( - padding: const EdgeInsetsDirectional.only(end: _gap), - child: Container( - width: _tileWidth, - height: _tileHeight, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - decoration: BoxDecoration( - color: AppColors.card.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(7), - border: Border.all( - color: Colors.white.withValues(alpha: 0.08), - ), - ), - child: Image.asset( - logo, - fit: BoxFit.contain, - cacheWidth: (_tileWidth * devicePixelRatio) - .round(), - errorBuilder: (_, _, _) => - const SizedBox.shrink(), + child: ClipRect( + child: OverflowBox( + maxWidth: double.infinity, + alignment: AlignmentDirectional.centerStart, + child: AnimatedBuilder( + animation: controller, + builder: (context, child) { + // Wrapped rather than scaled, so a slower row still closes its + // own loop without a jump. + final t = (controller.value * speed) % 1.0; + return Transform.translate( + offset: Offset(reverse ? t * strip - strip : -t * strip, 0), + child: child, + ); + }, + child: RepaintBoundary( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var pass = 0; pass < 2; pass++) + for (final logo in logos) + Padding( + padding: const EdgeInsetsDirectional.only(end: _gap), + child: Container( + width: _tileWidth, + height: _tileHeight, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppColors.card.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), ), ), + child: Image.asset( + logo, + fit: BoxFit.contain, + cacheWidth: (_tileWidth * devicePixelRatio).round(), + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/profile/presentation/pages/sources_page.dart b/lib/features/profile/presentation/pages/sources_page.dart index 0c693b73..ce4b6a88 100644 --- a/lib/features/profile/presentation/pages/sources_page.dart +++ b/lib/features/profile/presentation/pages/sources_page.dart @@ -32,9 +32,11 @@ class SourcesPage extends StatelessWidget { static List extensionRows(BuildContext context) => [ // Ungated: the catalog is read from the backend, so it works everywhere; // what it can offer still depends on what this platform runs. + // Named for what it does. "Sources by language" with a translate icon + // read as a filter, and this is the row that installs sources. SettingsNavTile( - icon: Icons.translate_rounded, - title: 'catalog.title'.tr(), + icon: Icons.add_circle_outline_rounded, + title: 'ext.add_source'.tr(), subtitle: 'catalog.subtitle'.tr(), onTap: () => SourceCatalogPage.open(context), ), diff --git a/lib/features/profile/presentation/widgets/provider_quick_switch.dart b/lib/features/profile/presentation/widgets/provider_quick_switch.dart index 91a1f59e..dc19162e 100644 --- a/lib/features/profile/presentation/widgets/provider_quick_switch.dart +++ b/lib/features/profile/presentation/widgets/provider_quick_switch.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/features/extensions/presentation/pages/source_catalog_page.dart'; import 'package:soplay/features/home/data/datasources/home_data_source.dart'; import 'package:soplay/features/sources/domain/source_ecosystem.dart'; import 'package:soplay/features/sources/domain/source_scope.dart'; @@ -248,6 +249,14 @@ Future _openSwitcher( ), ); if (result == null || !context.mounted) return; + if (result == _kAddSourceAction) { + // Straight to the catalogue, opened on this mode's kind of source. + await SourceCatalogPage.open( + context, + initialItemType: SourceCatalogPage.itemTypeFor(mode), + ); + return; + } if (result == _kAllProvidersAction) { openProviderPicker(context, bloc); return; @@ -288,6 +297,11 @@ Future _openSwitcher( const String _kAllProvidersAction = '__all_providers__'; +/// "Open the catalogue to install a source". Its own answer rather than +/// [_kAllProvidersAction]: that one goes to the installed list, and adding +/// from there is several screens further on. +const String _kAddSourceAction = '__add_source__'; + /// Marks a sheet result as "make this catalogue the home", the same way /// [_kModePrefix] marks a mode. Three kinds of answer share one string channel. const String _kCataloguePrefix = '__cat__:'; @@ -853,13 +867,47 @@ class ProviderQuickSwitchSheetState extends State { ), ), const Divider(height: 1), - ListTile( - // "Add" first, because that is what somebody who cannot find their - // source is looking for, and the word was nowhere on this sheet. - leading: const Icon(Icons.add_rounded), - title: Text('ux.add_or_manage_sources'.tr()), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => Navigator.of(context).pop(_kAllProvidersAction), + // Two actions, not one row that claims both. "Add or manage sources" + // opened the installed list, which since v3.2.0 has had no add button, + // so adding was three screens further on. Adding is the primary + // action and now goes straight to the catalogue. + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + Expanded( + flex: 3, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).pop(_kAddSourceAction), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(0, 48), + ), + icon: const Icon(Icons.add_rounded), + label: Text( + 'ext.add_source'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: TextButton( + onPressed: () => + Navigator.of(context).pop(_kAllProvidersAction), + style: TextButton.styleFrom(minimumSize: const Size(0, 48)), + child: Text( + 'source_manager.manage'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), ), ], ); diff --git a/lib/features/sources/presentation/pages/sources_hub_page.dart b/lib/features/sources/presentation/pages/sources_hub_page.dart index 92c4749a..1d741e73 100644 --- a/lib/features/sources/presentation/pages/sources_hub_page.dart +++ b/lib/features/sources/presentation/pages/sources_hub_page.dart @@ -26,7 +26,6 @@ import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/extensions/presentation/pages/source_catalog_page.dart'; -import 'package:soplay/features/extensions/domain/entities/catalog_source_entity.dart'; import 'package:soplay/features/jellyfin/presentation/pages/jellyfin_servers_page.dart'; import 'package:soplay/features/profile/presentation/pages/sources_page.dart'; import 'package:soplay/features/sources/data/source_browse_repository.dart'; @@ -303,6 +302,31 @@ class _SourcesHubPageState extends State context, ).push(MaterialPageRoute(builder: (_) => const SourcesPage())); + /// Straight to the installable catalogue, opened on this tab's kind of + /// source. + /// + /// v3.2.0 removed this and left only "Manage", so adding a source meant + /// Manage, then a settings row called "Sources by language", from the page + /// people open in order to add one. + Future _openInstaller() async { + final hive = getIt(); + final before = hive.getContentMode(); + await SourceCatalogPage.open( + context, + initialItemType: SourceCatalogPage.itemTypeFor( + ContentMode.values[_tabs.index], + ), + ); + if (!mounted) return; + // "Use" in the catalogue can switch mode. Only then land on the tab of + // the source now in use; otherwise stay on the tab the viewer came from, + // where what they just installed is listed. _onTabMoved resets the scope. + final after = hive.getContentMode(); + if (after == before) return; + final now = ContentMode.fromId(after).index; + if (_tabs.index != now) _tabs.index = now; + } + /// One per tab. A shared controller would carry the Manga tab's offset into /// the Watch tab the moment the two were alive at once, which is every /// swipe. @@ -318,13 +342,6 @@ class _SourcesHubPageState extends State int _lastTab = -1; - /// Which half of the screen you are in: what you have, or what you could - /// have. They were two screens with the same name and a button between - /// them — "Sources" listing what was installed, "Add source" listing what - /// could be, and a third page behind that for the repositories both came - /// from. - bool _adding = false; - /// Opening on the source in use is a one-time move, per tab. Re-running it /// after a keystroke in the search field would drag the list out from under /// the typing. @@ -403,8 +420,6 @@ class _SourcesHubPageState extends State ), child: open != null ? _SourceBrowseView(key: ValueKey(open.id), source: open) - : _adding - ? _addLevel() : _hub(), ), ), @@ -467,28 +482,34 @@ class _SourcesHubPageState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - _LevelPill( - label: 'sources.installed'.tr(), - active: !_adding, - onTap: () => setState(() => _adding = false), + // The primary action, labelled, in the header that never + // scrolls away. It opens the catalogue directly; "Manage" next + // to it is for repositories, DNS and ecosystems. + Flexible( + child: FilledButton.icon( + onPressed: _openInstaller, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + ), + icon: const Icon(Icons.add_rounded, size: 18), + label: Text( + 'ext.add_source'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), const SizedBox(width: 6), - // Manage, not "Add source". - // - // This pill used to flip the same screen over to the - // installable catalogue — a second list, on the same page, - // under the same title, with its own search and its own - // chips. Two lists behind one pair of pills is where this - // screen got confusing, and it buried the page that actually - // organises sources: the ecosystems, the by-language browser - // and DNS all live on [SourcesPage], which had become - // reachable only through an overflow menu on a page you had - // to flip to first. The pill opens that instead, and the - // catalogue is reached from there, where it belongs. - _LevelPill( - label: 'source_manager.manage'.tr(), - active: false, - onTap: _openExtensions, + Flexible( + child: _LevelPill( + label: 'source_manager.manage'.tr(), + active: false, + onTap: _openExtensions, + ), ), ], ), @@ -498,50 +519,6 @@ class _SourcesHubPageState extends State ); } - /// Everything you could install, and the repositories it comes from. - /// - /// The catalogue is the same screen it always was, minus its app bar: this - /// one already has a title, and two stacked app bars is not a screen. The - /// repositories are a row under it rather than a page behind a ⋯ button in - /// a corner of it. - Widget _addLevel() { - return Column( - children: [ - Expanded( - child: SourceCatalogPage( - embedded: true, - initialItemType: switch (ContentMode.values[_tabs.index]) { - ContentMode.manga => CatalogItemType.manga, - ContentMode.novel => CatalogItemType.novel, - ContentMode.video => null, - }, - ), - ), - const Divider(height: 1), - SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.folder_copy_outlined), - title: Text('source_manager.manage_repositories'.tr()), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: _openExtensions, - ), - ListTile( - leading: const Icon(Icons.dns_outlined), - title: Text('jellyfin.connect_server'.tr()), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => JellyfinServersPage.open(context), - ), - ], - ), - ), - ], - ); - } - PreferredSizeWidget _browseBar(ProviderEntity source) { return AppBar( backgroundColor: AppColors.background, @@ -1025,8 +1002,10 @@ class _SourcesHubPageState extends State // An empty mode is a dead end without this. Manga and novels are both // extension ecosystems, so a fresh install has nothing in either tab // and the only way out is a gear icon the message never mentions. - actionLabel: needle.isEmpty ? 'source_manager.manage'.tr() : null, - onAction: needle.isEmpty ? _openExtensions : null, + // Straight to the catalogue on this tab's kind; an empty tab is + // exactly when somebody wants to add, not to manage. + actionLabel: needle.isEmpty ? 'ext.add_source'.tr() : null, + onAction: needle.isEmpty ? _openInstaller : null, ); } diff --git a/test/features/detail/player_history_cadence_test.dart b/test/features/detail/player_history_cadence_test.dart index 40f73806..82a93241 100644 --- a/test/features/detail/player_history_cadence_test.dart +++ b/test/features/detail/player_history_cadence_test.dart @@ -78,7 +78,7 @@ void main() { }); test('the floor is the shared rule, not a number spelled out here', () { - final body = bodyOf(history, 'void _saveHistory()'); + final body = bodyOf(history, 'void _saveHistory({bool notify = true})'); expect(body, contains('WatchProgress.countsAsAViewing')); expect( body, diff --git a/test/features/detail/scrub_curve_test.dart b/test/features/detail/scrub_curve_test.dart index 936ac3a1..edff9e1d 100644 --- a/test/features/detail/scrub_curve_test.dart +++ b/test/features/detail/scrub_curve_test.dart @@ -1,51 +1,43 @@ -// Where a horizontal swipe on the video lands: fine near the start, far with -// a long swipe, and nothing at all when the finger comes back. +// Where a horizontal swipe on the video lands: linear, minutes per full +// swipe rather than the whole video, and nothing when the finger comes back. import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/features/detail/domain/scrub_curve.dart'; void main() { const episode = 24 * 60 * 1000; - int at(double cm, {int from = 600000, int duration = episode}) => + const film = 2 * 60 * 60 * 1000; + const width = 832.0; // Galaxy S22, landscape, logical px + int at(double px, {int from = 600000, int duration = episode}) => ScrubCurve.targetMs( baselineMs: from, durationMs: duration, - deltaPx: cm * ScrubCurve.pxPerCm, + deltaPx: px, + widthPx: width, ); test('a finger back at its start changes nothing', () { expect(at(0), 600000); - expect(at(0.2), 600000); - expect(at(-0.2), 600000); - expect(ScrubCurve.cancels(0.2 * ScrubCurve.pxPerCm), isTrue); - expect(ScrubCurve.cancels(0.5 * ScrubCurve.pxPerCm), isFalse); + expect(at(ScrubCurve.deadZonePx - 1), 600000); + expect(at(-(ScrubCurve.deadZonePx - 1)), 600000); + expect(ScrubCurve.cancels(ScrubCurve.deadZonePx - 1), isTrue); + expect(ScrubCurve.cancels(ScrubCurve.deadZonePx + 1), isFalse); }); - test('fine near the start, far with a long swipe', () { - // About two seconds a centimetre at first. - expect(at(1) - 600000, inInclusiveRange(2000, 5000)); - // A long swipe covers minutes — the old 90 s-per-width cap is gone. - expect(at(8) - 600000, greaterThan(10 * 60 * 1000)); - // Backwards is the mirror of forwards. - expect(600000 - at(-2), at(2) - 600000); + test('linear: twice the travel is twice the time, both ways', () { + final one = at(ScrubCurve.deadZonePx + 100) - 600000; + final two = at(ScrubCurve.deadZonePx + 200) - 600000; + expect((two - 2 * one).abs(), lessThanOrEqualTo(1000)); + expect(600000 - at(-(ScrubCurve.deadZonePx + 100)), one); }); - test('keeps growing with distance and stays inside the video', () { - var last = 600000; - for (var cm = 0.5; cm <= 20; cm += 0.5) { - final t = at(cm); - expect(t, greaterThanOrEqualTo(last)); - last = t; - } - expect(at(40), episode); - expect(at(-40), 0); + test('a full swipe is minutes, not the whole video', () { + expect(at(width, from: 0), inInclusiveRange(120000, 180000)); + expect(at(width, from: 0, duration: film), inInclusiveRange(150000, 180000)); + expect(at(width, from: 0, duration: 90000), inInclusiveRange(20000, 45000)); }); - test('a short clip still reaches a minute; a film is capped at 20', () { - expect( - at(8, from: 0, duration: 90 * 1000), - greaterThanOrEqualTo(60 * 1000), - ); - final film = 3 * 60 * 60 * 1000; - expect(at(8, from: 0, duration: film), lessThanOrEqualTo(21 * 60 * 1000)); + test('stays inside the video', () { + expect(at(40 * width), episode); + expect(at(-40 * width), 0); }); } diff --git a/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart index fa946827..86851cc1 100644 --- a/test/features/mobile_ux_test.dart +++ b/test/features/mobile_ux_test.dart @@ -337,7 +337,7 @@ void main() { await tester.enterText(find.byType(TextField), 'not installed anywhere'); await tester.pumpAndSettle(); expect(find.text('No sources match your search.'), findsOneWidget); - expect(find.text('Add or manage sources'), findsOneWidget); + expect(find.text('Add source'), findsOneWidget); expect(tester.takeException(), isNull); });