From 35f1d822bbb0fe493b4b94f5d73f6dd71719d662 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:34:15 +0500 Subject: [PATCH 1/7] Swipe-seek: linear and predictable, a few minutes per full swipe The mapping was a VLC-style cubic that reached half the video by eight 'centimetres', counted at Flutter's desktop 38 px/cm; a phone has about 60. On a Galaxy S22 two real centimetres moved over a minute of a film, and past three every pixel of thumb wobble was four seconds. Now linear: one swipe across the screen's long side moves about 4*sqrt(duration) seconds (30 s to 3 min), whole seconds, with a 16 px dead zone. A release under the 2 s the card calls 'release to cancel' no longer seeks. --- lib/features/detail/domain/scrub_curve.dart | 54 ++++++++++++------- .../pages/player_page.gestures.dart | 9 +++- .../pages/player_page.models.dart | 13 ++++- test/features/detail/scrub_curve_test.dart | 54 ++++++++----------- 4 files changed, 76 insertions(+), 54 deletions(-) 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.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index 9d8e7da7..e9cdd5af 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 { 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/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); }); } From 9d43825fa2f7efaef3409ae7fa6b2f09fdbea616 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:34:15 +0500 Subject: [PATCH 2/7] Put 'Add source' back where sources are picked v3.2.0 replaced the Sources hub's 'Add source' button with 'Manage', so from the home source chip adding one took five taps (chip, 'Add or manage sources', Manage, 'Sources by language', Add) with no 'add' label after the first. - Source picker sheet: '+ Add source' opens the catalogue directly, 'Manage' goes to the installed list. - Sources hub header and an empty tab: '+ Add source' to the catalogue on that tab's kind. - The catalogue row on Sources is called 'Add source'; the repositories button no longer looks like an overflow menu. - The player's 'other sources' sheet offers the button its message asks for. --- .../widgets/alternate_source_sheet.dart | 17 +++ .../pages/source_catalog_page.dart | 23 +++- .../presentation/pages/sources_page.dart | 6 +- .../widgets/provider_quick_switch.dart | 62 ++++++++- .../presentation/pages/sources_hub_page.dart | 126 +++++++----------- test/features/mobile_ux_test.dart | 2 +- 6 files changed, 146 insertions(+), 90 deletions(-) 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/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..ee3034c2 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,26 @@ 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 { + await SourceCatalogPage.open( + context, + initialItemType: SourceCatalogPage.itemTypeFor( + ContentMode.values[_tabs.index], + ), + ); + if (!mounted) return; + // "Use" in the catalogue can switch mode. Land on the tab of the source + // now in use so it is visible; _onTabMoved resets the scope. + final now = ContentMode.fromId(getIt().getContentMode()).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 +337,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 +415,6 @@ class _SourcesHubPageState extends State ), child: open != null ? _SourceBrowseView(key: ValueKey(open.id), source: open) - : _adding - ? _addLevel() : _hub(), ), ), @@ -467,28 +477,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 +514,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 +997,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/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); }); From 7ac7d94e5e8f1eaa49c9ae7adaa249582a63e8ef Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:35:57 +0500 Subject: [PATCH 3/7] media_kit: smooth seeking and a lighter playhead - mpv reported the playhead on every displayed frame (24-60 Hz) and each report rebuilt the seek row, preview, subtitles and page listener. It now reaches listeners every 100 ms, like ExoPlayer, and a seek shows its target at once, ignoring the stale ticks still in flight. - hr-seek-framedrop=yes: media_kit's default (no) decoded every frame from the keyframe to the target first, the freeze after each release. - 64 MiB demuxer cache on phones (32 on TV), so most swipes land inside it instead of waiting for a fresh HLS segment. - The seek row is its own layer, so a tick or a drag event repaints the bar, not the whole controls overlay. --- lib/core/player/media_controller.dart | 114 +++++++++++++++-- .../pages/player_page.controls.dart | 121 ++++++++++-------- 2 files changed, 171 insertions(+), 64 deletions(-) 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/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index dedc7a4b..ffdc5e55 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. @@ -2116,67 +2119,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) From 5e6053d714fe9301b96f3d47d2604a74c3ec484d Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:41:01 +0500 Subject: [PATCH 4/7] Player: less work per frame while playing - The video went through an offscreen ImageFilterLayer at zoom 1.0, because Transform.scale always had a filterQuality; it is set only while zoomed. - The controls overlay kept one shape whether buffering or not; it was bare in one state and inside a Stack in the other, so every buffering flip (media_kit reports one around each seek) rebuilt it from scratch. - The five-second history save no longer wakes Home, the episode list, the detail header and the home-screen widget under the player; they catch up from the save on pause and on exit. - Brightness and volume swipes reach the system at most every 50 ms instead of on every touch sample. - Outlined subtitles share one stroke Paint (a new one per build forced a relayout every tick) and the subtitle overlay is its own layer. - A downloaded next episode is not re-stat'ed on every position tick. - The preview grid fills at one frame a second on Wi-Fi rather than four, each being a second hardware decoder next to the playing one. --- lib/core/preview/frame_preview_service.dart | 5 +- .../pages/player_page.controls.dart | 51 ++++--- .../presentation/pages/player_page.dart | 11 ++ .../pages/player_page.gestures.dart | 4 +- .../pages/player_page.history.dart | 51 ++++--- .../presentation/pages/player_page.pip.dart | 25 ++++ .../pages/player_page.subtitles.dart | 127 ++++++++++-------- .../pages/player_page.upnext.dart | 11 +- .../history/data/history_service.dart | 19 ++- .../detail/player_history_cadence_test.dart | 2 +- 10 files changed, 195 insertions(+), 111 deletions(-) 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/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index ffdc5e55..42f42492 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -564,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), ), ), @@ -1189,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. 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 e9cdd5af..f60c652f 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -276,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..2e286ef7 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,7 @@ extension _PlayerHistory on _PlayerPageState { return label.isEmpty ? 'Episode ${ep.episode}' : label; } - void _saveHistory() { + void _saveHistory({bool notify = true}) { if (!WatchProgress.countsAsAViewing(_playbackWatch.elapsed)) return; // Time watched since the last save, banked before anything can return @@ -113,26 +115,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 +257,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.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/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 481c137c..ab81688d 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,16 @@ 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); + + 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 +95,7 @@ class HistoryService { } await _box.put(item.storageKey, jsonEncode(item.toJson())); await _trimIfNeeded(); - revision.value++; + if (notify) revision.value++; } /// Reads a row back so a delete can be described to the server before the 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, From 308c0cc0b48c7ef1a6536aa1d4122a68f8c98268 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:47:34 +0500 Subject: [PATCH 5/7] Onboarding: smooth swipes and step transitions - The backdrops' tickers are stopped and restarted from a notifier instead of muted with TickerMode. Every Image depends on TickerMode, so each flip re-resolved a hundred-odd posters on the first and last frame of a swipe, and a muted ticker's clock kept counting, so a wall jumped forward as the swipe settled. - All three backdrops are built once and stay mounted; the TV scene was built on the first drag frame and rebuilt on every swipe frame, leaking five CurvedAnimations each time (now made once). - The page index no longer setStates the whole intro mid-swipe; only the dots listen. - Genres: the 26-sigma full-screen blur is taken once as a snapshot after the posters decode, not recomputed every frame. - TV strips fade their edges with a foreground gradient instead of three ShaderMask offscreen passes. - Badges: the four side medals fade as one layer, and the moving stage and week row are their own layers. --- .../pages/onboarding_badges_page.dart | 48 ++++--- .../pages/onboarding_genres_page.dart | 110 ++++++++++----- .../presentation/pages/onboarding_page.dart | 107 +++++++++------ .../presentation/widgets/anime_ribbons.dart | 35 ++++- .../presentation/widgets/poster_wall.dart | 35 ++++- .../presentation/widgets/tv_showcase.dart | 125 +++++++++++------- 6 files changed, 315 insertions(+), 145 deletions(-) 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..bc0ab83d 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,14 +123,14 @@ 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), + animation: _steps[2], from: 22, child: _ChannelStrip( controller: _strips, @@ -117,7 +140,7 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { ), const SizedBox(height: 9), _Rise( - animation: _step(0.34), + animation: _steps[3], from: 22, child: _ChannelStrip( controller: _strips, @@ -128,7 +151,7 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { ), const SizedBox(height: 9), _Rise( - animation: _step(0.44), + animation: _steps[4], from: 22, child: _ChannelStrip( controller: _strips, @@ -392,19 +415,25 @@ 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), + // The edges fade by painting the page's colour over them, not by + // masking: a ShaderMask is an offscreen pass per strip on every frame, + // and it kept the whole scene from taking the crossfade's opacity + // directly, making the fade one more full-screen layer. + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + AppColors.background, + AppColors.background.withValues(alpha: 0), + AppColors.background.withValues(alpha: 0), + AppColors.background, + ], + stops: const [0, 0.12, 0.88, 1], + ), + ), child: ClipRect( child: OverflowBox( maxWidth: double.infinity, From 4cb091a00281929f6cb486f1a66e4509596ac064 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:02:00 +0500 Subject: [PATCH 6/7] TV scene: one edge mask for the three channel strips The per-strip page-coloured overlay painted opaque blocks over the anime ribbons while slides 2 and 3 crossfaded. One ShaderMask around the three strips fades their edges to transparent again, at one small offscreen pass instead of the original three. --- .../presentation/widgets/tv_showcase.dart | 189 +++++++++--------- 1 file changed, 95 insertions(+), 94 deletions(-) diff --git a/lib/features/onboarding/presentation/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index bc0ab83d..077fa87f 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -129,35 +129,58 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { ), _Rise(animation: _steps[1], from: 20, child: const _Stand()), const SizedBox(height: 30), - _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, + // 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), + 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, + ), + ), + ], ), ), ], @@ -415,74 +438,52 @@ class _ChannelStrip extends StatelessWidget { return SizedBox( height: _tileHeight, - // The edges fade by painting the page's colour over them, not by - // masking: a ShaderMask is an offscreen pass per strip on every frame, - // and it kept the whole scene from taking the crossfade's opacity - // directly, making the fade one more full-screen layer. - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - AppColors.background, - AppColors.background.withValues(alpha: 0), - AppColors.background.withValues(alpha: 0), - AppColors.background, - ], - stops: const [0, 0.12, 0.88, 1], - ), - ), - 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(), + ), ), - ], - ), + ), + ], ), ), ), From ca15cb613598bc9c0f0fe13ad8aa06dd4108245d Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:11:19 +0500 Subject: [PATCH 7/7] Review fixes: history after a finished episode, hub tab, strip mask - An episode watched to its end (nothing to advance to) never announced the rows the quiet five-second ticks wrote, since the final save returns early in the last two seconds. A notifying save now flushes pending quiet writes first, so Home and the episode list update. - Closing the catalogue from the Sources hub stays on the tab the viewer came from unless the catalogue actually switched the content mode. - The channel-strip mask reaches the 22 px the last strip rises from, so its edges fade during the entrance too. --- .../pages/player_page.history.dart | 4 ++ .../history/data/history_service.dart | 20 +++++- .../presentation/widgets/tv_showcase.dart | 70 ++++++++++--------- .../presentation/pages/sources_hub_page.dart | 11 ++- 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 2e286ef7..5c7ac698 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -84,6 +84,10 @@ extension _PlayerHistory on _PlayerPageState { } 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 diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index ab81688d..3528a633 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -80,6 +80,19 @@ class HistoryService { /// 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 @@ -95,7 +108,12 @@ class HistoryService { } await _box.put(item.storageKey, jsonEncode(item.toJson())); await _trimIfNeeded(); - if (notify) 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/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index 077fa87f..25e76afc 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -146,41 +146,47 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { ], stops: [0, 0.12, 0.88, 1], ).createShader(rect), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _Rise( - animation: _steps[2], - from: 22, - child: _ChannelStrip( - controller: _strips, - reverse: false, - speed: 1, + // 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[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, + const SizedBox(height: 9), + _Rise( + animation: _steps[4], + from: 22, + child: _ChannelStrip( + controller: _strips, + reverse: false, + speed: 0.91, + offset: 0.66, + ), ), - ), - ], + ], + ), ), ), ], diff --git a/lib/features/sources/presentation/pages/sources_hub_page.dart b/lib/features/sources/presentation/pages/sources_hub_page.dart index ee3034c2..1d741e73 100644 --- a/lib/features/sources/presentation/pages/sources_hub_page.dart +++ b/lib/features/sources/presentation/pages/sources_hub_page.dart @@ -309,6 +309,8 @@ class _SourcesHubPageState extends State /// 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( @@ -316,9 +318,12 @@ class _SourcesHubPageState extends State ), ); if (!mounted) return; - // "Use" in the catalogue can switch mode. Land on the tab of the source - // now in use so it is visible; _onTabMoved resets the scope. - final now = ContentMode.fromId(getIt().getContentMode()).index; + // "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; }