From 4ae371808e1eabf782377ff9fd14cd3c8f843a49 Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 10:45:14 +0500 Subject: [PATCH 1/9] fix(anilist): use the official logo artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces my reconstruction with the file from Wikimedia Commons. It carries its own colours — dark tile, blue mark, white A — so it is rendered untinted; the colour filter that suited a single-path mark would have flattened it. Corners are rounded at the call site rather than by editing the artwork. --- assets/icons/anilist.svg | 5 +- .../presentation/pages/connections_page.dart | 2 +- .../presentation/widgets/anilist_logo.dart | 51 +++++-------------- 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/assets/icons/anilist.svg b/assets/icons/anilist.svg index 24b5e0ac..7ca1e5f8 100644 --- a/assets/icons/anilist.svg +++ b/assets/icons/anilist.svg @@ -1,4 +1 @@ - - - - +AniList logoAnime and manga tracking website \ No newline at end of file diff --git a/lib/features/anilist/presentation/pages/connections_page.dart b/lib/features/anilist/presentation/pages/connections_page.dart index 5da03910..16f201e5 100644 --- a/lib/features/anilist/presentation/pages/connections_page.dart +++ b/lib/features/anilist/presentation/pages/connections_page.dart @@ -302,7 +302,7 @@ class _Avatar extends StatelessWidget { color: AppColors.textHint, ), ) - : const Center(child: AnilistLogo(size: 24, color: Colors.white)), + : const Center(child: AnilistLogo(size: 30)), ); } } diff --git a/lib/features/anilist/presentation/widgets/anilist_logo.dart b/lib/features/anilist/presentation/widgets/anilist_logo.dart index 4c22b383..30fc5c5e 100644 --- a/lib/features/anilist/presentation/widgets/anilist_logo.dart +++ b/lib/features/anilist/presentation/widgets/anilist_logo.dart @@ -1,30 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; - -/// The AniList mark. +/// The official AniList logo, from +/// https://commons.wikimedia.org/wiki/File:AniList_logo.svg /// -/// Drawn from `assets/icons/anilist.svg`, which is a reconstruction of the -/// official logo rather than the file from AniList's brand kit. Dropping the -/// real SVG in at that path replaces it everywhere with no code change. +/// Rendered in its own colours — the artwork includes its dark tile, so tinting +/// it to a single colour would erase the mark it exists to show. class AnilistLogo extends StatelessWidget { - const AnilistLogo({super.key, this.size = 20, this.color = kAnilistBlue}); + const AnilistLogo({super.key, this.size = 20, this.radius = 5}); final double size; - final Color color; + final double radius; @override - Widget build(BuildContext context) => SvgPicture.asset( - 'assets/icons/anilist.svg', - width: size, - height: size, - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SvgPicture.asset('assets/icons/anilist.svg', width: size, height: size), ); } -/// The mark on its brand-blue tile, for places that need a service badge — -/// a settings row, an empty state, an account card. +/// The logo at badge size, for a settings row or an empty state. class AnilistLogoBadge extends StatelessWidget { const AnilistLogoBadge({super.key, this.size = 44, this.radius = 12}); @@ -32,28 +27,6 @@ class AnilistLogoBadge extends StatelessWidget { final double radius; @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(radius), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [kAnilistBlue, kAnilistBlueDeep], - ), - boxShadow: [ - BoxShadow( - color: kAnilistBlue.withValues(alpha: 0.3), - blurRadius: size * 0.4, - spreadRadius: -size * 0.08, - ), - ], - ), - child: Center( - child: AnilistLogo(size: size * 0.52, color: Colors.white), - ), - ); - } + Widget build(BuildContext context) => + AnilistLogo(size: size, radius: radius); } From 76e6973f40cf57942460907cfe7e3e941a321fdc Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 11:35:58 +0500 Subject: [PATCH 2/9] feat(anilist): airing calendar, and mark linked titles in search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upcoming is deliberately narrow — only shows you already follow — so there was nowhere in the app to see what a new season brings. The calendar is the wide view: seven days from today, everything airing, with a filter back down to your own list. It works signed out, since the schedule is public; connecting only adds the marks and the filter. Days are fetched on demand and kept rather than pulling the week up front: a week of global airings runs to several hundred entries and six of those days are ones nobody opens. The query pages, because AniList caps perPage at 50 and asking for one page silently truncates the evening. Search results now carry the AniList mark when the title is already linked. Linking is what makes tracking work — a source title rarely matches AniList exactly — but the link was invisible outside the AniList screens, so there was no way to tell from a result whether watching it would count. Cross-search passes each hit's own provider; the single-source grid falls back to the active one. --- assets/translations/en.json | 9 +- assets/translations/ru.json | 9 +- assets/translations/uz.json | 9 +- lib/core/router/app_router.dart | 5 + lib/features/anilist/data/anilist_api.dart | 59 +++ .../domain/entities/anilist_entities.dart | 37 ++ .../airing_calendar_controller.dart | 118 +++++ .../pages/airing_calendar_page.dart | 411 ++++++++++++++++++ .../pages/anilist_library_page.dart | 8 + .../presentation/pages/connections_page.dart | 9 +- .../widgets/anilist_linked_badge.dart | 59 +++ .../presentation/pages/cross_search_page.dart | 11 + .../widgets/search_state_views.dart | 6 + 13 files changed, 746 insertions(+), 4 deletions(-) create mode 100644 lib/features/anilist/presentation/controllers/airing_calendar_controller.dart create mode 100644 lib/features/anilist/presentation/pages/airing_calendar_page.dart create mode 100644 lib/features/anilist/presentation/widgets/anilist_linked_badge.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 3ac0ff9c..44297468 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -934,7 +934,14 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Airing calendar", + "calendar_all": "All", + "calendar_mine": "My list", + "calendar_episode": "Episode {episode}", + "calendar_empty": "Nothing airs on this day.", + "calendar_empty_mine": "Nothing from your list airs on this day.", + "calendar_open": "Airing calendar" }, "tracker": { "title": "Tracking", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 3ab29ef1..328d89f4 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -934,7 +934,14 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Календарь выхода", + "calendar_all": "Все", + "calendar_mine": "Мой список", + "calendar_episode": "Эпизод {episode}", + "calendar_empty": "В этот день ничего не выходит.", + "calendar_empty_mine": "В этот день ничего из вашего списка не выходит.", + "calendar_open": "Календарь выхода" }, "tracker": { "title": "Tracking", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 46569e08..a62bd041 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -934,7 +934,14 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Efir kalendari", + "calendar_all": "Hammasi", + "calendar_mine": "Mening ro‘yxatim", + "calendar_episode": "{episode}-qism", + "calendar_empty": "Bu kuni hech narsa chiqmaydi.", + "calendar_empty_mine": "Bu kuni ro‘yxatingizdan hech narsa chiqmaydi.", + "calendar_open": "Efir kalendari" }, "tracker": { "title": "Tracking", diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 607b4ef3..23e60014 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -27,6 +27,7 @@ import 'package:soplay/features/manga/presentation/pages/reader_page.dart'; import 'package:soplay/features/main/presentation/pages/main_page.dart'; import 'package:soplay/features/network/presentation/pages/no_internet_page.dart'; import 'package:soplay/features/search/presentation/pages/cross_search_page.dart'; +import 'package:soplay/features/anilist/presentation/pages/airing_calendar_page.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_library_page.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_links_page.dart'; import 'package:soplay/features/anilist/presentation/pages/connections_page.dart'; @@ -191,6 +192,10 @@ class AppRouter { path: '/anilist', builder: (context, state) => const AnilistLibraryPage(), ), + GoRoute( + path: '/anilist/calendar', + builder: (context, state) => const AiringCalendarPage(), + ), GoRoute( path: '/anilist/links', builder: (context, state) => const AnilistLinksPage(), diff --git a/lib/features/anilist/data/anilist_api.dart b/lib/features/anilist/data/anilist_api.dart index d7db4377..b5b4239d 100644 --- a/lib/features/anilist/data/anilist_api.dart +++ b/lib/features/anilist/data/anilist_api.dart @@ -42,6 +42,7 @@ class AnilistApi { coverImage { large } bannerImage description(asHtml: false) + isAdult nextAiringEpisode { episode airingAt } '''; @@ -167,6 +168,64 @@ class AnilistApi { .toList(growable: false); } + /// Everything airing between [from] and [to]. + /// + /// Paged rather than a single large request: a day of global airings runs to + /// a few dozen entries and AniList caps perPage at 50, so asking for one page + /// silently truncates the evening. The cap on pages is a safety net against a + /// window someone widens later, not a real limit — a day never reaches it. + Future> airingSchedule({ + required DateTime from, + required DateTime to, + bool includeAdult = false, + }) async { + final gql = ''' + query (\$start: Int, \$end: Int, \$page: Int) { + Page(page: \$page, perPage: 50) { + pageInfo { hasNextPage } + airingSchedules( + airingAt_greater: \$start + airingAt_lesser: \$end + sort: TIME + ) { + episode + airingAt + media { $_mediaFields } + } + } + } + '''; + + final start = from.toUtc().millisecondsSinceEpoch ~/ 1000; + final end = to.toUtc().millisecondsSinceEpoch ~/ 1000; + final out = []; + + for (var page = 1; page <= 10; page++) { + final data = await _run( + gql, + variables: {'start': start, 'end': end, 'page': page}, + ); + final pageData = data['Page']; + if (pageData is! Map) break; + + final schedules = pageData['airingSchedules']; + if (schedules is List) { + for (final raw in schedules.whereType()) { + final airing = + AnilistScheduledAiring.fromJson(raw.cast()); + if (airing == null) continue; + if (!includeAdult && airing.media.isAdult) continue; + out.add(airing); + } + } + + final info = pageData['pageInfo']; + final hasNext = info is Map && info['hasNextPage'] == true; + if (!hasNext) break; + } + return out; + } + /// The viewer's own entry for one title, or null if it is not on their list. /// /// Read before every automatic write so progress is never moved BACKWARDS: diff --git a/lib/features/anilist/domain/entities/anilist_entities.dart b/lib/features/anilist/domain/entities/anilist_entities.dart index 0fb078df..78ca910b 100644 --- a/lib/features/anilist/domain/entities/anilist_entities.dart +++ b/lib/features/anilist/domain/entities/anilist_entities.dart @@ -62,6 +62,40 @@ class AnilistAiring { } /// One anime on AniList. +/// One episode of one show, at the minute it goes out. +/// +/// Distinct from [AnilistAiring], which hangs off a media object and only ever +/// describes that show's NEXT episode. The calendar asks the opposite question +/// — what airs in this window — so the airing is the subject and the media is +/// the detail. +class AnilistScheduledAiring { + const AnilistScheduledAiring({ + required this.media, + required this.episode, + required this.airingAt, + }); + + final AnilistMedia media; + final int episode; + final int airingAt; + + DateTime get airsAt => + DateTime.fromMillisecondsSinceEpoch(airingAt * 1000, isUtc: true).toLocal(); + + bool get hasAired => airsAt.isBefore(DateTime.now()); + + static AnilistScheduledAiring? fromJson(Map json) { + final rawMedia = json['media']; + final airingAt = (json['airingAt'] as num?)?.toInt(); + if (rawMedia is! Map || airingAt == null) return null; + return AnilistScheduledAiring( + media: AnilistMedia.fromJson(rawMedia.cast()), + episode: (json['episode'] as num?)?.toInt() ?? 0, + airingAt: airingAt, + ); + } +} + class AnilistMedia { const AnilistMedia({ required this.id, @@ -78,6 +112,7 @@ class AnilistMedia { this.status, this.siteUrl, this.nextAiring, + this.isAdult = false, }); final int id; @@ -94,6 +129,7 @@ class AnilistMedia { final String? status; final String? siteUrl; final AnilistAiring? nextAiring; + final bool isAdult; /// Episodes that exist to watch right now. /// @@ -147,6 +183,7 @@ class AnilistMedia { nextAiring: AnilistAiring.fromJson( (json['nextAiringEpisode'] as Map?)?.cast(), ), + isAdult: json['isAdult'] == true, ); } } diff --git a/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart new file mode 100644 index 00000000..388157e8 --- /dev/null +++ b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart @@ -0,0 +1,118 @@ +import 'package:flutter/foundation.dart'; + +import 'package:soplay/features/anilist/data/anilist_api.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; + +/// The airing week, one day at a time. +/// +/// Days are fetched on demand and kept, rather than pulling the whole week up +/// front: a week of global airings is several hundred entries and six of those +/// days are ones the user never looks at. Switching back to a day already seen +/// is then instant. +class AiringCalendarController extends ChangeNotifier { + AiringCalendarController({required AnilistService service}) + : _service = service; + + final AnilistService _service; + + /// Cache and in-flight state are keyed by the day's midnight, so a rebuild + /// with a fresh DateTime.now() still hits the same entry. + final Map> _byDay = {}; + final Map _errors = {}; + final Set _loading = {}; + + /// Media ids on the viewer's own list, used to mark and to filter. + Set _mine = {}; + + bool _mineOnly = false; + late DateTime _selected = startOfDay(DateTime.now()); + + DateTime get selected => _selected; + bool get mineOnly => _mineOnly; + bool get isConnected => _service.isConnected; + + bool get loading => _loading.contains(_selected); + String? get error => _errors[_selected]; + + /// The seven days on offer, from today. + /// + /// Anchored to today rather than to the calendar week: "what's on this week" + /// is only useful looking forward, and a Sunday would otherwise offer six + /// days that have already happened. + List get week { + final today = startOfDay(DateTime.now()); + return List.generate(7, (i) => today.add(Duration(days: i))); + } + + /// The selected day's airings, newest filter applied. + List get visible { + final all = _byDay[_selected] ?? const []; + if (!_mineOnly) return all; + return all.where((a) => _mine.contains(a.media.id)).toList(growable: false); + } + + /// Whether the day holds anything at all, regardless of the filter — so the + /// empty state can say "nothing on your list today" instead of "nothing at + /// all today", which would be wrong. + bool get dayHasAny => (_byDay[_selected] ?? const []).isNotEmpty; + + bool isMine(int mediaId) => _mine.contains(mediaId); + + int countFor(DateTime day) => (_byDay[startOfDay(day)] ?? const []).length; + + void select(DateTime day) { + final normalised = startOfDay(day); + if (normalised == _selected) return; + _selected = normalised; + notifyListeners(); + load(); + } + + void setMineOnly(bool value) { + if (_mineOnly == value) return; + _mineOnly = value; + notifyListeners(); + } + + /// Records which media the viewer follows. + /// + /// Passed in rather than fetched here: the library screens already hold this + /// list, and a second copy would drift from theirs after an edit. + void setLibrary(Iterable entries) { + final ids = entries.map((e) => e.media.id).toSet(); + if (setEquals(ids, _mine)) return; + _mine = ids; + notifyListeners(); + } + + Future load({bool force = false}) async { + final day = _selected; + if (_loading.contains(day)) return; + if (!force && _byDay.containsKey(day)) return; + + _loading.add(day); + _errors.remove(day); + notifyListeners(); + try { + final schedule = await _service.api.airingSchedule( + from: day, + to: day.add(const Duration(days: 1)), + ); + _byDay[day] = schedule; + } catch (e) { + _errors[day] = e is AnilistException + ? e.message + : 'Could not load the airing schedule'; + } finally { + _loading.remove(day); + // The day may no longer be selected — the user can switch while a fetch + // is in flight — but the result is cached either way, so notifying is + // still correct: it repaints whichever day they landed on. + notifyListeners(); + } + } + + static DateTime startOfDay(DateTime value) => + DateTime(value.year, value.month, value.day); +} diff --git a/lib/features/anilist/presentation/pages/airing_calendar_page.dart b/lib/features/anilist/presentation/pages/airing_calendar_page.dart new file mode 100644 index 00000000..7a3af83a --- /dev/null +++ b/lib/features/anilist/presentation/pages/airing_calendar_page.dart @@ -0,0 +1,411 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; +import 'package:soplay/features/anilist/presentation/controllers/airing_calendar_controller.dart'; +import 'package:soplay/features/anilist/presentation/controllers/anilist_library_controller.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; + +/// What airs this week, across all of AniList. +/// +/// The complement to Upcoming, which is deliberately narrow — only the shows +/// you already follow. This is the wide view, with a filter back down to yours, +/// so discovering a new season doesn't mean leaving the app. +/// +/// Works signed out: the schedule is public. Connecting only adds the "on your +/// list" marks and the filter. +class AiringCalendarPage extends StatefulWidget { + const AiringCalendarPage({super.key, this.showAppBar = true}); + + final bool showAppBar; + + @override + State createState() => _AiringCalendarPageState(); +} + +class _AiringCalendarPageState extends State { + final AnilistService _service = getIt(); + late final AiringCalendarController _calendar; + late final AnilistLibraryController _library; + + @override + void initState() { + super.initState(); + _calendar = AiringCalendarController(service: _service); + _library = AnilistLibraryController(service: _service); + _calendar.addListener(_onChange); + _library.addListener(_onLibraryChange); + _calendar.load(); + if (_service.isConnected) _library.load(); + } + + @override + void dispose() { + _calendar.removeListener(_onChange); + _library.removeListener(_onLibraryChange); + _calendar.dispose(); + _library.dispose(); + super.dispose(); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + void _onLibraryChange() { + _calendar.setLibrary(_library.entries); + _onChange(); + } + + @override + Widget build(BuildContext context) { + final body = Column( + children: [ + _WeekStrip(controller: _calendar), + if (_service.isConnected) _MineFilter(controller: _calendar), + Expanded(child: _buildDay(context)), + ], + ); + + if (!widget.showAppBar) return body; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Row( + children: [ + const AnilistLogo(size: 20), + const SizedBox(width: 9), + Text( + 'anilist.calendar_title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ], + ), + ), + body: body, + ); + } + + Widget _buildDay(BuildContext context) { + if (_calendar.loading && !_calendar.dayHasAny) { + return const Center( + child: CircularProgressIndicator(color: kAnilistBlue, strokeWidth: 2.5), + ); + } + + Future refresh() => _calendar.load(force: true); + final airings = _calendar.visible; + + if (airings.isEmpty) { + // Distinguishing the two cases matters: "nothing airs today" and "nothing + // of YOURS airs today" send the user to different places. + final message = _calendar.error ?? + (_calendar.mineOnly && _calendar.dayHasAny + ? 'anilist.calendar_empty_mine'.tr() + : 'anilist.calendar_empty'.tr()); + return RefreshIndicator( + color: kAnilistBlue, + backgroundColor: AppColors.surface, + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.sizeOf(context).height * 0.14), + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + _calendar.error != null + ? Icons.cloud_off_rounded + : Icons.event_busy_rounded, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), + const SizedBox(height: 14), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13.5, + height: 1.5, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + color: kAnilistBlue, + backgroundColor: AppColors.surface, + onRefresh: refresh, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + itemCount: airings.length, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (context, i) { + final airing = airings[i]; + return _AiringCard( + airing: airing, + mine: _calendar.isMine(airing.media.id), + ); + }, + ), + ); + } +} + +/// The seven-day selector. +class _WeekStrip extends StatelessWidget { + const _WeekStrip({required this.controller}); + + final AiringCalendarController controller; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + final today = AiringCalendarController.startOfDay(DateTime.now()); + + return SizedBox( + height: 74, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: controller.week.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final day = controller.week[i]; + final selected = day == controller.selected; + final isToday = day == today; + return GestureDetector( + onTap: () => controller.select(day), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 54, + decoration: BoxDecoration( + color: selected ? kAnilistBlue : AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isToday && !selected + ? kAnilistBlue.withValues(alpha: 0.55) + : Colors.transparent, + width: 1.4, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat.E(locale).format(day).toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: selected + ? Colors.white.withValues(alpha: 0.85) + : AppColors.textHint, + ), + ), + const SizedBox(height: 4), + Text( + '${day.day}', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: selected ? Colors.white : AppColors.textPrimary, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// All / my list toggle. Only offered while connected, since it needs a list. +class _MineFilter extends StatelessWidget { + const _MineFilter({required this.controller}); + + final AiringCalendarController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Row( + children: [ + _FilterPill( + label: 'anilist.calendar_all'.tr(), + selected: !controller.mineOnly, + onTap: () => controller.setMineOnly(false), + ), + const SizedBox(width: 8), + _FilterPill( + label: 'anilist.calendar_mine'.tr(), + selected: controller.mineOnly, + onTap: () => controller.setMineOnly(true), + ), + ], + ), + ); + } +} + +class _FilterPill extends StatelessWidget { + const _FilterPill({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: selected + ? kAnilistBlue.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? kAnilistBlue : Colors.transparent, + width: 1.2, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: selected ? kAnilistBlue : AppColors.textSecondary, + ), + ), + ), + ); + } +} + +class _AiringCard extends StatelessWidget { + const _AiringCard({required this.airing, required this.mine}); + + final AnilistScheduledAiring airing; + final bool mine; + + @override + Widget build(BuildContext context) { + final media = airing.media; + final title = + media.englishTitle ?? media.romajiTitle ?? media.nativeTitle ?? ''; + final time = DateFormat.Hm(context.locale.toString()).format(airing.airsAt); + final aired = airing.hasAired; + + return Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: mine ? kAnilistBlue.withValues(alpha: 0.4) : Colors.transparent, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Faded once it has gone out, so a glance down the day separates what + // is still to come from what already aired. + Opacity( + opacity: aired ? 0.55 : 1, + child: AnilistCover(url: media.coverImage, width: 44, radius: 8), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + height: 1.25, + fontWeight: FontWeight.w700, + color: aired + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + if (mine) ...[ + const SizedBox(width: 6), + const AnilistLogo(size: 15, radius: 4), + ], + ], + ), + const SizedBox(height: 5), + Text( + 'anilist.calendar_episode'.tr( + namedArgs: {'episode': '${airing.episode}'}, + ), + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: aired + ? AppColors.surfaceVariant + : kAnilistBlue.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + time, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: aired ? AppColors.textHint : kAnilistBlue, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/anilist/presentation/pages/anilist_library_page.dart b/lib/features/anilist/presentation/pages/anilist_library_page.dart index 0afa1ffd..c239b04e 100644 --- a/lib/features/anilist/presentation/pages/anilist_library_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_library_page.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -130,6 +131,13 @@ class _AnilistLibraryPageState extends State ], ), actions: [ + // Offered signed out too: the schedule is public, and it is the one + // part of AniList that is worth something without an account. + IconButton( + tooltip: 'anilist.calendar_title'.tr(), + onPressed: () => context.push('/anilist/calendar'), + icon: const Icon(Icons.calendar_month_rounded), + ), if (connected) IconButton( tooltip: 'anilist.refresh'.tr(), diff --git a/lib/features/anilist/presentation/pages/connections_page.dart b/lib/features/anilist/presentation/pages/connections_page.dart index 16f201e5..40f38f9a 100644 --- a/lib/features/anilist/presentation/pages/connections_page.dart +++ b/lib/features/anilist/presentation/pages/connections_page.dart @@ -237,8 +237,15 @@ class _ConnectionsPageState extends State { ], ), ), + // Outside the `connected` block on purpose: the schedule is public. + const SizedBox(height: 12), + _Row( + icon: Icons.calendar_month_rounded, + title: 'anilist.calendar_open'.tr(), + onTap: () => context.push('/anilist/calendar'), + ), if (connected) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), _Row( icon: Icons.auto_awesome_motion_rounded, title: 'anilist.open_library'.tr(), diff --git a/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart b/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart new file mode 100644 index 00000000..b59cb142 --- /dev/null +++ b/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/extractor/provider_manager.dart'; +import 'package:soplay/features/anilist/data/anilist_link_store.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; + +/// The AniList mark on a title that is already linked to an AniList entry. +/// +/// Linking is what makes tracking work at all — a source title rarely matches +/// AniList exactly — but until now the link was invisible outside the AniList +/// screens, so there was no way to tell from a result whether watching it would +/// count. This is that signal. +/// +/// Renders nothing when there is no link, so it costs a search grid nothing in +/// the common case. +class AnilistLinkedBadge extends StatelessWidget { + const AnilistLinkedBadge({ + super.key, + required this.contentUrl, + this.provider, + this.size = 15, + }); + + final String contentUrl; + + /// The source the result came from. Falls back to the active one, which is + /// correct for a single-source search and wrong for a cross-source grid — + /// hence cross search passes each hit's own provider. + final String? provider; + + final double size; + + @override + Widget build(BuildContext context) { + if (contentUrl.trim().isEmpty) return const SizedBox.shrink(); + if (!getIt.isRegistered()) return const SizedBox.shrink(); + + final source = provider ?? _activeProvider(); + if (source == null || source.isEmpty) return const SizedBox.shrink(); + + final mediaId = getIt().mediaIdFor(source, contentUrl); + if (mediaId == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(6), + ), + child: AnilistLogo(size: size, radius: 4), + ); + } + + static String? _activeProvider() { + if (!getIt.isRegistered()) return null; + return getIt().currentProviderId; + } +} diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index d774dde5..14b022cc 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -438,6 +439,16 @@ class _MovieCard extends StatelessWidget { fontWeight: FontWeight.w700)), ), ), + // Top-right, because the "N sources" pill owns the left and + // a hit can carry both. + Positioned( + top: 6, + right: 6, + child: AnilistLinkedBadge( + contentUrl: movie.url, + provider: provider, + ), + ), ], ), ), diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index 34453b13..fda01348 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/tv/tv.dart'; @@ -239,6 +240,11 @@ class _SearchMovieCard extends StatelessWidget { ), ), ), + Positioned( + top: 6, + left: 6, + child: AnilistLinkedBadge(contentUrl: movie.url), + ), if (movie.rating != null) Positioned( top: 6, From 1579f0f4d7206f5d65825440d0076de069f8406f Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 16:09:09 +0500 Subject: [PATCH 3/9] perf(js): stop serializing every source behind one shared Provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS runtime kept a single headless webview and a single globalThis.Provider, so every extension call took a global lock and a different provider meant re-evaluating that extension's whole script. A cross-search over six sources therefore ran six network waits back to back with six script parses between them — the pool's concurrency was decorative, and the app looked frozen. Extensions now register once into a per-name registry and calls resolve their provider by name, so nothing mutable is shared and the lock is only needed while registering. The calls overlap, which is the point. Provider stays lexically bound inside each extension's IIFE, so an extension referring to it by name still reaches its own object. The Mangayomi runtime has the same shape but a second reason for its lock — preferences are a page-global seeded per call — so only the compile cache lands there: switching back to a source seen this session is a pointer assignment rather than another new Function(code). invalidate() drops that cache, or it would hand back the stale build it exists to replace. --- assets/js/mangayomi_bridge.js | 16 ++++ lib/core/js/js_runtime_service.dart | 87 +++++++++++++------ .../extensions/data/mangayomi_runtime.dart | 32 +++++++ 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/assets/js/mangayomi_bridge.js b/assets/js/mangayomi_bridge.js index 6da4be1f..ab47bb79 100644 --- a/assets/js/mangayomi_bridge.js +++ b/assets/js/mangayomi_bridge.js @@ -505,7 +505,23 @@ } } + // Kept so switching back to a source already seen is a pointer assignment + // instead of another new Function(code) compile. A cross-search touching + // six sources used to recompile all six on every query. + const registry = globalThis.__sozoProviders || (globalThis.__sozoProviders = {}); + if (source && source.id != null) registry[String(source.id)] = instance; + + globalThis.__sozoProvider = instance; + return true; + }; + + /** Makes an already-loaded extension current. False when it is not loaded. */ + globalThis.__sozoActivateMangayomi = function (id) { + const registry = globalThis.__sozoProviders || {}; + const instance = registry[String(id)]; + if (!instance) return false; globalThis.__sozoProvider = instance; + globalThis.__sozoSource = instance.source || globalThis.__sozoSource; return true; }; diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index 387d3fa3..8d80b1d0 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -23,11 +23,21 @@ class JsRuntimeService { InAppWebViewController? _controller; Future? _ready; ExtractorManifest? _manifest; - String? _activeExtractor; - int? _activeVersion; - - // Serializes the extractor-swap + JS call critical section: every provider - // shares one webview and a single mutable globalThis.Provider. + /// Extractors already evaluated into the page, keyed `name@version`. + /// + /// Each is registered ONCE per session. The previous design kept a single + /// mutable `globalThis.Provider` and re-evaluated the whole extractor source + /// whenever a different provider was asked for — so a cross-search over six + /// providers re-parsed six scripts, in sequence, on every query. + final Set _registered = {}; + + /// Registrations in flight, so two legs racing for the same provider fetch + /// and evaluate it once rather than twice. + final Map> _registering = >{}; + + // Serializes registration only. Calls no longer need a gate: they resolve + // their provider from a registry rather than a shared mutable global, so a + // second leg can no longer swap it out mid-flight. Future _jsGate = Future.value(); static const String _runtimeName = '__runtime__'; @@ -159,23 +169,31 @@ class JsRuntimeService { // webview and one globalThis.Provider, so without this a second leg could // swap Provider between this leg's setup and its call — returning one // provider's results under another's name. - final result = await _locked(() async { - await _ensureExtractor(extractor.name, extractor.version); - return _controller!.callAsyncJavaScript( - functionBody: r''' - const __fn = (typeof Provider !== 'undefined') ? Provider[fnName] : null; + await _ensureExtractor(extractor.name, extractor.version); + // Unlocked on purpose. The provider is looked up by name, so several + // cross-search legs can be in flight at once and their network waits + // overlap instead of queueing — which is what made searching several + // sources feel frozen. + final result = await _controller!.callAsyncJavaScript( + functionBody: r''' + const __registry = (globalThis.__sozo || {}).providers || {}; + const __p = __registry[providerName]; + if (!__p) { + throw new Error('Provider "' + providerName + '" is not loaded'); + } + const __fn = __p[fnName]; if (typeof __fn !== 'function') { throw new Error('Provider.' + fnName + ' is not implemented'); } - const __r = await __fn.apply(Provider, fnArgs); + const __r = await __fn.apply(__p, fnArgs); return __r === undefined ? null : __r; ''', - arguments: { - 'fnName': fn, - 'fnArgs': args, - }, - ); - }); + arguments: { + 'providerName': extractor.name, + 'fnName': fn, + 'fnArgs': args, + }, + ); if (result == null) { JsLog.err(tag, '$fn returned null result'); @@ -324,11 +342,22 @@ class JsRuntimeService { await _controller!.evaluateJavascript(source: code); } - Future _ensureExtractor(String name, int wantedVersion) async { + /// Evaluates [name] into the page if it is not already there. + /// + /// Returns as soon as the extractor is registered; repeat calls are free. + Future _ensureExtractor(String name, int wantedVersion) { + final pending = _registering[name]; + if (pending != null) return pending; + final run = _registerExtractor(name, wantedVersion); + _registering[name] = run; + return run.whenComplete(() => _registering.remove(name)); + } + + Future _registerExtractor(String name, int wantedVersion) async { final manifest = _manifest ??= await remote.fetchManifest(); final entry = manifest.byName(name); final version = entry?.version ?? wantedVersion; - if (_activeExtractor == name && _activeVersion == version) return; + if (_registered.contains('$name@$version')) return; final cachedVersion = cache.readVersion(name); String? code; @@ -346,18 +375,24 @@ class JsRuntimeService { ); } if (code.isEmpty) throw StateError('Extractor "$name" JS is empty'); + // Each extractor keeps its own slot. `Provider` stays lexically scoped to + // this IIFE, so an extractor's own methods referring to it by name still + // resolve to their own object rather than to whoever registered last. + final slot = jsonEncode(name); final wrapped = ''' (function(){ - try { delete globalThis.Provider; } catch (e) {} + const __sozo = globalThis.__sozo || (globalThis.__sozo = {}); + const __providers = __sozo.providers || (__sozo.providers = {}); $code if (typeof Provider !== 'undefined') { - globalThis.Provider = Provider; + __providers[$slot] = Provider; } })(); '''; - await _controller!.evaluateJavascript(source: wrapped); - _activeExtractor = name; - _activeVersion = version; + // Evaluation mutates the shared page, so one at a time — but only the + // evaluation, which now happens once per extractor instead of once per call. + await _locked(() => _controller!.evaluateJavascript(source: wrapped)); + _registered.add('$name@$version'); } Future dispose() async { @@ -369,7 +404,7 @@ class JsRuntimeService { _webView = null; _controller = null; _ready = null; - _activeExtractor = null; - _activeVersion = null; + _registered.clear(); + _registering.clear(); } } diff --git a/lib/features/extensions/data/mangayomi_runtime.dart b/lib/features/extensions/data/mangayomi_runtime.dart index bb136b40..cfe0ab4e 100644 --- a/lib/features/extensions/data/mangayomi_runtime.dart +++ b/lib/features/extensions/data/mangayomi_runtime.dart @@ -37,6 +37,9 @@ class MangayomiRuntime { String? _activeId; String? _activeVersion; + /// Version of each extension compiled into the page this session, by id. + final Map _loaded = {}; + /// Serialises "swap the extension, then call it". Without this, two /// concurrent cross-search legs would race on the single `__sozoProvider` /// global and one source's results would be returned under another's name. @@ -153,6 +156,25 @@ class MangayomiRuntime { await _seedPrefs(source); return; } + + // Already compiled this session — swapping back is a pointer assignment. + // Reloading meant re-running new Function(code) on every source switch, + // which a cross-search does once per source per query. + if (_loaded[source.id] == source.version) { + await _seedPrefs(source); + final activated = await _controller!.callAsyncJavaScript( + functionBody: r'return __sozoActivateMangayomi(id);', + arguments: {'id': source.id}, + ); + if (activated?.value == true) { + _activeId = source.id; + _activeVersion = source.version; + return; + } + // The page was reloaded underneath us; fall through and compile again. + _loaded.remove(source.id); + } + final code = await store.code(source); await _seedPrefs(source); final result = await _controller!.callAsyncJavaScript( @@ -165,6 +187,7 @@ class MangayomiRuntime { } _activeId = source.id; _activeVersion = source.version; + _loaded[source.id] = source.version; } Future _seedPrefs(MangayomiSource source) async { @@ -265,6 +288,14 @@ class MangayomiRuntime { /// Drops the loaded extension so the next call re-reads its code. Used after /// an update or a preference change that alters the base url. void invalidate([String? sourceId]) { + // Must drop the compiled instance too, not just the active pointer: the + // whole point of this call is to make the next one re-read the code, and a + // cached instance would be handed straight back instead. + if (sourceId == null) { + _loaded.clear(); + } else { + _loaded.remove(sourceId); + } if (sourceId == null || sourceId == _activeId) { _activeId = null; _activeVersion = null; @@ -282,5 +313,6 @@ class MangayomiRuntime { _ready = null; _activeId = null; _activeVersion = null; + _loaded.clear(); } } From c0c14b3ff1733ef96d11a272e529b4cee9235bdc Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 16:09:30 +0500 Subject: [PATCH 4/9] feat(mobile): rework search, detail actions, profile, nav, calendar, player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search. Six mutually-exclusive states become one, and every awaiting handler carries a run token, so the type-clear-retype, provider-switch and genre-apply races stop painting a stale answer. Genres are fetched once per provider instead of on every clear, so a genres failure can no longer show the idle screen as 'no internet'. Results keep the previous grid behind a progress bar rather than blanking on each keystroke, and the empty and error states say what actually happened. One result card serves both grids. Genre no longer composes with a text query. The server's /contents/search reads only q, page and provider — a genre passed alongside a query was dropped, so the user got an unfiltered search back under a lit filter chip. Picking a genre browses that genre; typing clears it. The parameter is gone from the API rather than left as a promise nothing keeps. Detail. Seven circular buttons over the hero art become the primary pill, the list toggle, and an overflow sheet that shows each action's state — a menu cannot. The long-press-to-private gesture and the showcase survive. Profile. AniList moves out from under Watch History to near the top. The providers picker becomes its own routed page, which takes about a thousand lines out of a 3400-line file. Player. Server and quality are separate controls, grouped by host, and switching host keeps the resolution where that host has it — falling back to its highest, not its first, which is arbitrary. Before a source resolves the server row shows nothing rather than the literal 'Default'. Calendar. A fortnight rather than a week, DST-safe day arithmetic, per-day staleness, prefetch, a now marker, and same-minute airings collapsed into one row. AniList answers 429 under a fast day-strip, so the client backs off. addToList reads before it writes. SaveMediaListEntry is an upsert, so sending progress 0 and PLANNING would have reset a title the viewer was twelve episodes into — reachable whenever the library merely failed to load, since that also reads as 'not on your list'. Cross-search no longer pins itself in a permanent spinner on a one-character query, and Load more refuses a second tap instead of paying for the same page twice. My Lists tabs fill the bar again. The floating nav is frosted like the classic one. flutter analyze clean but for two pre-existing infos; debug APK builds. --- assets/translations/en.json | 50 +- assets/translations/ru.json | 50 +- assets/translations/uz.json | 50 +- lib/core/router/app_router.dart | 5 + lib/core/widgets/app_tab_bar.dart | 2 +- lib/features/anilist/data/anilist_api.dart | 119 +- .../airing_calendar_controller.dart | 263 +++- .../anilist_library_controller.dart | 11 + .../pages/airing_calendar_page.dart | 1018 +++++++++++--- .../detail/domain/video_option_groups.dart | 107 ++ .../presentation/pages/detail_page.dart | 161 +-- .../pages/player_page.controls.dart | 35 +- .../presentation/pages/player_page.dart | 1 + .../pages/player_page.panels.dart | 200 ++- .../widgets/detail_more_sheet.dart | 372 ++++++ .../main/presentation/pages/main_page.dart | 57 +- .../presentation/pages/profile_page.dart | 1174 +---------------- .../presentation/pages/providers_page.dart | 1111 ++++++++++++++++ .../data/datasources/search_data_source.dart | 15 +- .../repositories/search_repository_imp.dart | 6 +- .../search/data/search_recents_store.dart | 53 + .../domain/entities/cross_search_result.dart | 108 ++ .../repositories/search_repository.dart | 4 + .../domain/services/cross_search_engine.dart | 102 +- .../blocs/cross_search_controller.dart | 272 +++- .../presentation/blocs/search_bloc.dart | 306 +++-- .../presentation/blocs/search_event.dart | 33 +- .../blocs/search_query_policy.dart | 71 + .../presentation/blocs/search_state.dart | 169 ++- .../presentation/pages/cross_search_page.dart | 528 +++++--- .../presentation/pages/search_page.dart | 109 +- .../widgets/search_filter_sheet.dart | 4 +- .../presentation/widgets/search_header.dart | 319 +++-- .../widgets/search_result_card.dart | 193 +++ .../widgets/search_set_sheet.dart | 19 +- .../widgets/search_state_views.dart | 696 ++++++---- .../presentation/pages/following_page.dart | 37 +- .../presentation/pages/user_lists_page.dart | 12 +- 38 files changed, 5349 insertions(+), 2493 deletions(-) create mode 100644 lib/features/detail/domain/video_option_groups.dart create mode 100644 lib/features/detail/presentation/widgets/detail_more_sheet.dart create mode 100644 lib/features/profile/presentation/pages/providers_page.dart create mode 100644 lib/features/search/data/search_recents_store.dart create mode 100644 lib/features/search/presentation/blocs/search_query_policy.dart create mode 100644 lib/features/search/presentation/widgets/search_result_card.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 44297468..dc7228c7 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -178,7 +178,8 @@ "engine_sheet_subtitle": "Pick the engine for this video. You can change it any time in Settings → Player.", "engine_sheet_dont_ask": "Don't ask again", "engine_sheet_play": "Play", - "subtitle_preview_text": "The quick brown fox jumps over the lazy dog" + "subtitle_preview_text": "The quick brown fox jumps over the lazy dog", + "server": "Server" }, "profile": { "title": "Profile", @@ -315,7 +316,31 @@ "local_group": "Local", "search_in": "Search in", "sources_hint": "Search several sources at once. Leave empty to use your current provider.", - "searching": "Searching…" + "searching": "Searching…", + "all_source_search": "All-source search", + "cross_hint": "Search across your sources…", + "pick_sources": "Pick which sources to search across.", + "choose_sources": "Choose sources", + "no_sources_selected": "No sources selected — tap to choose", + "sources_selected": "{} selected — tap to change", + "type_to_search_n": "Type to search {} sources at once.", + "found_in": "Found in {} of {} sources", + "results_n": "{} results", + "waiting_for": "Searching {}…", + "no_results_any": "No results in any selected source.", + "offline_note": "Offline — only on-device sources can be searched.", + "group_by_source": "Group by source", + "merge_titles": "Merge titles", + "open_from": "Open from", + "load_more": "Load more", + "search_failed": "Search failed", + "source_failed": "This source is unavailable right now", + "try_all_sources": "Try all sources", + "search_sources": "Search sources", + "filter_providers": "Filter providers…", + "selected_n": "{} selected", + "apply_n": "Apply ({})", + "many_sources_warning": "Searching {} sources may be slow. The app stays responsive, but fewer is snappier." }, "movie": { "rating": "Rating", @@ -686,7 +711,13 @@ "no_cast": "No cast available", "no_recommendations": "No recommendations available", "untitled": "Untitled", - "read": "Read" + "read": "Read", + "watch_later": "Watch Later", + "watched": "Watched", + "follow_series": "Follow series", + "find_other_sources": "Find other sources", + "anilist_track": "Track on AniList", + "anilist_tracked": "Tracked on AniList" }, "shorts": { "refresh": "Refresh", @@ -941,7 +972,18 @@ "calendar_episode": "Episode {episode}", "calendar_empty": "Nothing airs on this day.", "calendar_empty_mine": "Nothing from your list airs on this day.", - "calendar_open": "Airing calendar" + "calendar_open": "Airing calendar", + "calendar_error": "Could not load the airing schedule.", + "calendar_rate_limited": "AniList is busy right now. Try again in a moment.", + "calendar_list_error": "Couldn't load your AniList list.", + "calendar_now": "now", + "calendar_count": "{count} airings", + "calendar_count_mine": "{mine} of {total} yours", + "calendar_episode_range": "Episodes {from}-{to}", + "calendar_add_planning": "Add to Planning", + "calendar_added_planning": "Added to your Planning list", + "calendar_add_failed": "Could not add this to your list", + "calendar_already_on_list": "Already on your list" }, "tracker": { "title": "Tracking", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 328d89f4..7a1a57a5 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -178,7 +178,8 @@ "engine_sheet_subtitle": "Выберите плеер для этого видео. Изменить можно в Настройках → Плеер.", "engine_sheet_dont_ask": "Больше не спрашивать", "engine_sheet_play": "Смотреть", - "subtitle_preview_text": "Съешь ещё этих мягких французских булок" + "subtitle_preview_text": "Съешь ещё этих мягких французских булок", + "server": "Сервер" }, "profile": { "title": "Профиль", @@ -315,7 +316,31 @@ "local_group": "Локальные", "search_in": "Искать в", "sources_hint": "Ищите сразу в нескольких источниках. Оставьте пустым, чтобы использовать текущего провайдера.", - "searching": "Поиск…" + "searching": "Поиск…", + "all_source_search": "Поиск по всем источникам", + "cross_hint": "Поиск по вашим источникам…", + "pick_sources": "Выберите источники для поиска.", + "choose_sources": "Выбрать источники", + "no_sources_selected": "Источники не выбраны — нажмите, чтобы выбрать", + "sources_selected": "Выбрано: {} — нажмите, чтобы изменить", + "type_to_search_n": "Введите запрос — поиск сразу по {} источникам.", + "found_in": "Найдено в {} из {} источников", + "results_n": "{} результатов", + "waiting_for": "Ищем в {}…", + "no_results_any": "Ни в одном выбранном источнике ничего не найдено.", + "offline_note": "Нет сети — доступны только источники на устройстве.", + "group_by_source": "Группировать по источникам", + "merge_titles": "Объединять одинаковые", + "open_from": "Открыть из", + "load_more": "Показать ещё", + "search_failed": "Не удалось выполнить поиск", + "source_failed": "Этот источник сейчас недоступен", + "try_all_sources": "Искать во всех источниках", + "search_sources": "Источники поиска", + "filter_providers": "Фильтр провайдеров…", + "selected_n": "Выбрано: {}", + "apply_n": "Применить ({})", + "many_sources_warning": "Поиск по {} источникам может быть медленным. Приложение не зависнет, но с меньшим числом источников быстрее." }, "movie": { "rating": "Рейтинг", @@ -686,7 +711,13 @@ "no_cast": "Нет информации об актёрах", "no_recommendations": "Нет рекомендаций", "untitled": "Без названия", - "read": "Читать" + "read": "Читать", + "watch_later": "Смотреть позже", + "watched": "Просмотрено", + "follow_series": "Следить за сериалом", + "find_other_sources": "Найти в других источниках", + "anilist_track": "Отслеживать в AniList", + "anilist_tracked": "Отслеживается в AniList" }, "shorts": { "refresh": "Обновить", @@ -941,7 +972,18 @@ "calendar_episode": "Эпизод {episode}", "calendar_empty": "В этот день ничего не выходит.", "calendar_empty_mine": "В этот день ничего из вашего списка не выходит.", - "calendar_open": "Календарь выхода" + "calendar_open": "Календарь выхода", + "calendar_error": "Не удалось загрузить расписание выхода.", + "calendar_rate_limited": "AniList сейчас перегружен. Попробуйте через минуту.", + "calendar_list_error": "Не удалось загрузить ваш список AniList.", + "calendar_now": "сейчас", + "calendar_count": "серий: {count}", + "calendar_count_mine": "{mine} из {total} ваших", + "calendar_episode_range": "Серии {from}-{to}", + "calendar_add_planning": "В «Запланировано»", + "calendar_added_planning": "Добавлено в «Запланировано»", + "calendar_add_failed": "Не удалось добавить в список", + "calendar_already_on_list": "Уже в вашем списке" }, "tracker": { "title": "Tracking", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a62bd041..5fdb373d 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -178,7 +178,8 @@ "engine_sheet_subtitle": "Shu video uchun pleyerni tanlang. Istalgan vaqt Sozlamalar → Pleyer bo‘limidan o‘zgartirasiz.", "engine_sheet_dont_ask": "Boshqa so‘ralmasin", "engine_sheet_play": "Ijro etish", - "subtitle_preview_text": "Tez qo’ng’ir tulki yalqov itning ustidan sakraydi" + "subtitle_preview_text": "Tez qo’ng’ir tulki yalqov itning ustidan sakraydi", + "server": "Server" }, "profile": { "title": "Profil", @@ -315,7 +316,31 @@ "local_group": "Lokal", "search_in": "Qidirish manbalari", "sources_hint": "Bir vaqtning o'zida bir nechta manbada qidiring. Joriy provayderdan foydalanish uchun bo'sh qoldiring.", - "searching": "Qidirilmoqda…" + "searching": "Qidirilmoqda…", + "all_source_search": "Barcha manbalarda qidiruv", + "cross_hint": "Manbalaringiz bo'ylab qidiring…", + "pick_sources": "Qidiriladigan manbalarni tanlang.", + "choose_sources": "Manbalarni tanlash", + "no_sources_selected": "Manba tanlanmagan — tanlash uchun bosing", + "sources_selected": "{} ta tanlandi — o'zgartirish uchun bosing", + "type_to_search_n": "So'rov kiriting — {} ta manbada birdan qidiriladi.", + "found_in": "{} / {} manbada topildi", + "results_n": "{} ta natija", + "waiting_for": "{} da qidirilmoqda…", + "no_results_any": "Tanlangan manbalarning hech birida natija yo'q.", + "offline_note": "Oflayn — faqat qurilmadagi manbalarda qidirish mumkin.", + "group_by_source": "Manba bo'yicha guruhlash", + "merge_titles": "Bir xil nomlarni birlashtirish", + "open_from": "Qayerdan ochish", + "load_more": "Yana yuklash", + "search_failed": "Qidiruv bajarilmadi", + "source_failed": "Bu manba hozir mavjud emas", + "try_all_sources": "Barcha manbalarda qidirish", + "search_sources": "Qidiruv manbalari", + "filter_providers": "Provayderlarni filtrlash…", + "selected_n": "{} ta tanlandi", + "apply_n": "Qo'llash ({})", + "many_sources_warning": "{} ta manbada qidiruv sekin bo'lishi mumkin. Ilova qotmaydi, lekin manbalar kamroq bo'lsa tezroq." }, "movie": { "rating": "Reyting", @@ -686,7 +711,13 @@ "no_cast": "Aktyorlar haqida ma'lumot yo'q", "no_recommendations": "Tavsiyalar mavjud emas", "untitled": "Nomsiz", - "read": "O‘qish" + "read": "O‘qish", + "watch_later": "Keyinroq ko'rish", + "watched": "Ko'rilgan", + "follow_series": "Seriyani kuzatish", + "find_other_sources": "Boshqa manbalardan izlash", + "anilist_track": "AniList'da kuzatish", + "anilist_tracked": "AniList'da kuzatilmoqda" }, "shorts": { "refresh": "Yangilash", @@ -941,7 +972,18 @@ "calendar_episode": "{episode}-qism", "calendar_empty": "Bu kuni hech narsa chiqmaydi.", "calendar_empty_mine": "Bu kuni ro‘yxatingizdan hech narsa chiqmaydi.", - "calendar_open": "Efir kalendari" + "calendar_open": "Efir kalendari", + "calendar_error": "Efir jadvalini yuklab bo'lmadi.", + "calendar_rate_limited": "AniList hozir band. Bir ozdan so'ng qayta urinib ko'ring.", + "calendar_list_error": "AniList ro'yxatingizni yuklab bo'lmadi.", + "calendar_now": "hozir", + "calendar_count": "{count} ta epizod", + "calendar_count_mine": "{total} tadan {mine} tasi sizniki", + "calendar_episode_range": "{from}-{to}-epizodlar", + "calendar_add_planning": "«Rejada»ga qo'shish", + "calendar_added_planning": "«Rejada» ro'yxatiga qo'shildi", + "calendar_add_failed": "Ro'yxatga qo'shib bo'lmadi", + "calendar_already_on_list": "Allaqachon ro'yxatingizda" }, "tracker": { "title": "Tracking", diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 23e60014..565c6aa8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -46,6 +46,7 @@ import 'package:soplay/features/trivia/presentation/trivia_args.dart'; import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; import 'package:soplay/features/profile/presentation/pages/player_settings_page.dart'; +import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; import 'package:soplay/features/notifications/presentation/pages/notifications_page.dart'; import 'package:soplay/features/private_list/presentation/pages/private_list_page.dart'; @@ -208,6 +209,10 @@ class AppRouter { path: '/player-settings', builder: (context, state) => const PlayerSettingsPage(), ), + GoRoute( + path: '/providers', + builder: (context, state) => const ProvidersPage(), + ), GoRoute( path: '/notifications', builder: (context, state) => const NotificationsPage(), diff --git a/lib/core/widgets/app_tab_bar.dart b/lib/core/widgets/app_tab_bar.dart index ade16f40..a11a3fd1 100644 --- a/lib/core/widgets/app_tab_bar.dart +++ b/lib/core/widgets/app_tab_bar.dart @@ -151,7 +151,7 @@ class _AppTabBarState extends State if (widget.showDivider) Container( height: AppTabBar._dividerHeight, - color: AppColors.divider, + color: AppColors.divider.withValues(alpha: 0.55), ), ], ), diff --git a/lib/features/anilist/data/anilist_api.dart b/lib/features/anilist/data/anilist_api.dart index b5b4239d..0122fc4d 100644 --- a/lib/features/anilist/data/anilist_api.dart +++ b/lib/features/anilist/data/anilist_api.dart @@ -25,6 +25,30 @@ class AnilistApi { final Dio _dio; + /// When the next request may be sent, after AniList answered 429. + /// + /// AniList's budget is small and enforced hard, and the calendar spends + /// several requests per day tapped — flicking across the strip trips it. + /// Failing fast until the window reopens beats firing a request that is + /// certain to be rejected. + DateTime? _throttledUntil; + + static const String _rateLimitMessage = 'AniList is rate limiting requests'; + + static Duration _retryAfter(Response? response) { + final headers = response?.headers; + final retryAfter = int.tryParse(headers?.value('retry-after') ?? ''); + if (retryAfter != null && retryAfter > 0) { + return Duration(seconds: retryAfter.clamp(1, 300)); + } + final reset = int.tryParse(headers?.value('x-ratelimit-reset') ?? ''); + if (reset != null) { + final left = reset - DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (left > 0) return Duration(seconds: left.clamp(1, 300)); + } + return const Duration(seconds: 60); + } + /// The media selection every query shares. /// /// One constant rather than a copy per query: the entity parses these fields @@ -46,6 +70,24 @@ class AnilistApi { nextAiringEpisode { episode airingAt } '''; + /// The lean selection the airing calendar uses. + /// + /// A global day runs to a hundred-odd airings over several pages, and the + /// heavy fields above — description most of all — cost a few hundred KB per + /// day tapped for values the calendar never renders. Named next to + /// [_mediaFields] rather than inlined so both selections stay visible + /// together when a field is added. + static const String _airingMediaFields = ''' + id + episodes + format + status + siteUrl + title { romaji english native } + coverImage { large } + isAdult + '''; + /// Runs [query]. [token] is optional: search and media lookups are public, /// only the viewer's own list and any write need it. Future> _run( @@ -53,13 +95,28 @@ class AnilistApi { Map variables = const {}, String? token, }) async { - final response = await _dio.post( - AnilistConstants.graphqlEndpoint, - data: {'query': query, 'variables': variables}, - options: Options( - headers: {if (token != null) 'Authorization': 'Bearer $token'}, - ), - ); + final until = _throttledUntil; + if (until != null && DateTime.now().isBefore(until)) { + throw const AnilistException(_rateLimitMessage, rateLimited: true); + } + + final Response response; + try { + response = await _dio.post( + AnilistConstants.graphqlEndpoint, + data: {'query': query, 'variables': variables}, + options: Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ), + ); + } on DioException catch (e) { + if (e.response?.statusCode == 429) { + _throttledUntil = DateTime.now().add(_retryAfter(e.response)); + throw const AnilistException(_rateLimitMessage, rateLimited: true); + } + rethrow; + } + _throttledUntil = null; final body = response.data; if (body is! Map) throw const AnilistException('Unexpected AniList reply'); @@ -190,13 +247,17 @@ class AnilistApi { ) { episode airingAt - media { $_mediaFields } + media { $_airingMediaFields } } } } '''; - final start = from.toUtc().millisecondsSinceEpoch ~/ 1000; + // `airingAt_greater` is strictly greater, so the one-second nudge is what + // keeps an episode airing exactly at midnight from falling between two + // days — excluded from this one for equalling its start, and from the + // previous one for that day's inclusive `airingAt_lesser`. + final start = from.toUtc().millisecondsSinceEpoch ~/ 1000 - 1; final end = to.toUtc().millisecondsSinceEpoch ~/ 1000; final out = []; @@ -255,6 +316,26 @@ class AnilistApi { ); } + /// Puts a title on the viewer's list, leaving an existing entry alone. + /// + /// SaveMediaListEntry is an UPSERT. Sending progress 0 and PLANNING for a + /// media the viewer is already twelve episodes into would reset both — on + /// their real account, with no undo. So this reads first and refuses to + /// write over an entry that exists, and never sends progress at all: the + /// caller's own "is it on the list" check can be stale or, worse, false + /// simply because the library failed to load. + /// + /// Returns null when the title was already there. + Future addToList({ + required String token, + required int mediaId, + AnilistStatus status = AnilistStatus.planning, + }) async { + final existing = await entryState(token: token, mediaId: mediaId); + if (existing != null) return null; + return saveProgress(token: token, mediaId: mediaId, status: status.value); + } + /// Writes progress back to AniList. /// /// [progress] is an episode COUNT, not an index — AniList means "episodes @@ -268,7 +349,10 @@ class AnilistApi { Future saveProgress({ required String token, required int mediaId, - required int progress, + // Optional so a status-only write leaves the viewer's position untouched. + // The mutation omits the argument entirely rather than sending null, which + // AniList would take as "set it to nothing". + int? progress, String? status, }) async { const mutation = ''' @@ -284,15 +368,17 @@ class AnilistApi { mutation, variables: { 'mediaId': mediaId, - 'progress': progress, + 'progress': ?progress, 'status': ?status, }, token: token, ); final saved = data['SaveMediaListEntry']; - if (saved is! Map) throw const AnilistException('AniList saqlamadi'); + if (saved is! Map) { + throw const AnilistException('AniList did not save the change'); + } return AnilistSaveResult( - progress: (saved['progress'] as num?)?.toInt() ?? progress, + progress: (saved['progress'] as num?)?.toInt() ?? progress ?? 0, status: saved['status'] as String? ?? status ?? AnilistStatus.current.value, ); } @@ -321,8 +407,13 @@ class AnilistEntryState { } class AnilistException implements Exception { - const AnilistException(this.message); + const AnilistException(this.message, {this.rateLimited = false}); final String message; + + /// AniList refused on its request budget, not because anything is wrong. + /// Callers can say "try again in a moment" rather than "it failed". + final bool rateLimited; + @override String toString() => message; } diff --git a/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart index 388157e8..d67973db 100644 --- a/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart +++ b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart @@ -1,65 +1,138 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/features/anilist/data/anilist_api.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; -/// The airing week, one day at a time. +/// One row of a day's list. /// -/// Days are fetched on demand and kept, rather than pulling the whole week up -/// front: a week of global airings is several hundred entries and six of those -/// days are ones the user never looks at. Switching back to a day already seen -/// is then instant. +/// Usually one airing. Two airings of the same show in the same minute are how +/// the schedule encodes a double-episode drop, not two events — rendering them +/// as two identical rows differing only in episode number reads as a bug. Two +/// airings at two DIFFERENT times stay two rows, because that is the truth. +class AiringDayRow { + const AiringDayRow({required this.airing, required this.lastEpisode}); + + final AnilistScheduledAiring airing; + final int lastEpisode; + + int get firstEpisode => airing.episode; + bool get isRange => lastEpisode > airing.episode; +} + +/// The airing window, one day at a time. +/// +/// Days are fetched on demand and kept, rather than pulling the window up +/// front: a day of global airings is a hundred-odd entries and most of the +/// window is days the user never looks at. Switching back to a day already +/// seen is then instant. class AiringCalendarController extends ChangeNotifier { AiringCalendarController({required AnilistService service}) - : _service = service; + : _service = service, + _mineOnly = _readMineOnly(); + + /// Yesterday earns its pill — "did last night's episode actually drop?" is a + /// real question. Two weeks forward is roughly as far ahead as AniList + /// announces. Backwards beyond yesterday is left to the library views. + static const int _daysBefore = 1; + static const int _daysAfter = 13; + + /// How long a fetched day is trusted before a re-select refetches it. Today + /// moves — delays and newly announced episodes land on it — so it is held + /// far more briefly than a day that is already fixed. + static const Duration _todayFreshness = Duration(minutes: 10); + static const Duration _dayFreshness = Duration(hours: 6); + + static const String _mineOnlyKey = 'anilist_calendar_mine_only'; final AnilistService _service; /// Cache and in-flight state are keyed by the day's midnight, so a rebuild /// with a fresh DateTime.now() still hits the same entry. final Map> _byDay = {}; + final Map _fetchedAt = {}; final Map _errors = {}; - final Set _loading = {}; + final Map> _inFlight = {}; /// Media ids on the viewer's own list, used to mark and to filter. Set _mine = {}; - bool _mineOnly = false; - late DateTime _selected = startOfDay(DateTime.now()); + bool _mineOnly; + + /// Today as the strip currently draws it. Held rather than recomputed per + /// build so the strip and the selection can never disagree about which day + /// is which; [syncToToday] is what moves it. + DateTime _anchor = startOfDay(DateTime.now()); + late DateTime _selected = _anchor; DateTime get selected => _selected; - bool get mineOnly => _mineOnly; + DateTime get today => _anchor; bool get isConnected => _service.isConnected; - bool get loading => _loading.contains(_selected); - String? get error => _errors[_selected]; + /// The filter only exists while connected, so a disconnect falls back to All + /// without discarding the preference a reconnect should restore. + bool get mineOnly => _mineOnly && _service.isConnected; - /// The seven days on offer, from today. - /// - /// Anchored to today rather than to the calendar week: "what's on this week" - /// is only useful looking forward, and a Sunday would otherwise offer six - /// days that have already happened. - List get week { - final today = startOfDay(DateTime.now()); - return List.generate(7, (i) => today.add(Duration(days: i))); + /// The days on offer, anchored to today rather than to the calendar week: + /// "what's on" is read forward, and a Sunday would otherwise offer six days + /// that have already happened. + List get days => List.generate( + _daysBefore + 1 + _daysAfter, + (i) => DateTime(_anchor.year, _anchor.month, _anchor.day - _daysBefore + i), + ); + + int get selectedIndex { + final index = days.indexOf(_selected); + return index < 0 ? _daysBefore : index; } - /// The selected day's airings, newest filter applied. - List get visible { - final all = _byDay[_selected] ?? const []; - if (!_mineOnly) return all; + bool isMine(int mediaId) => _mine.contains(mediaId); + + /// The day's airings with the filter applied. + List airingsFor(DateTime day) { + final all = _byDay[startOfDay(day)] ?? const []; + if (!mineOnly) return all; return all.where((a) => _mine.contains(a.media.id)).toList(growable: false); } + List rowsFor(DateTime day) => _collapse(airingsFor(day)); + + /// How many airings the day holds under the current filter, or null when the + /// day has never been fetched — the two are not the same answer, and a strip + /// marker that conflated them would confidently claim a dozen empty days. + int? countFor(DateTime day) { + final all = _byDay[startOfDay(day)]; + if (all == null) return null; + if (!mineOnly) return all.length; + return all.where((a) => _mine.contains(a.media.id)).length; + } + + int totalFor(DateTime day) => (_byDay[startOfDay(day)] ?? const []).length; + /// Whether the day holds anything at all, regardless of the filter — so the /// empty state can say "nothing on your list today" instead of "nothing at /// all today", which would be wrong. - bool get dayHasAny => (_byDay[_selected] ?? const []).isNotEmpty; + bool hasAnyFor(DateTime day) => totalFor(day) > 0; - bool isMine(int mediaId) => _mine.contains(mediaId); + bool loadingFor(DateTime day) => _inFlight.containsKey(startOfDay(day)); + String? errorFor(DateTime day) => _errors[startOfDay(day)]; + + /// The day has never resolved — in flight, or not asked for yet, which is + /// what the page next to the one being swiped away from looks like until + /// the swipe settles and selects it. + bool isPendingFor(DateTime day) { + final key = startOfDay(day); + return !_byDay.containsKey(key) && !_errors.containsKey(key); + } - int countFor(DateTime day) => (_byDay[startOfDay(day)] ?? const []).length; + /// A failed refresh over a day that still holds data. The list keeps showing + /// what it has, so the failure needs saying somewhere other than the empty + /// state. + bool isStaleFor(DateTime day) => + errorFor(day) != null && hasAnyFor(day) && !loadingFor(day); void select(DateTime day) { final normalised = startOfDay(day); @@ -72,40 +145,79 @@ class AiringCalendarController extends ChangeNotifier { void setMineOnly(bool value) { if (_mineOnly == value) return; _mineOnly = value; + _persistMineOnly(value); notifyListeners(); } - /// Records which media the viewer follows. + /// Records which media the viewer follows, and reports whether that actually + /// changed — a caller listening to the library as well can then skip the + /// rebuild this already sent. /// /// Passed in rather than fetched here: the library screens already hold this /// list, and a second copy would drift from theirs after an edit. - void setLibrary(Iterable entries) { + bool setLibrary(Iterable entries) { final ids = entries.map((e) => e.media.id).toSet(); - if (setEquals(ids, _mine)) return; + if (setEquals(ids, _mine)) return false; _mine = ids; notifyListeners(); + return true; } - Future load({bool force = false}) async { - final day = _selected; - if (_loading.contains(day)) return; - if (!force && _byDay.containsKey(day)) return; + /// Re-anchors the window when the clock has crossed midnight. + /// + /// Needed on resume above all: an app left backgrounded overnight comes back + /// with a selection that is no longer on the strip, showing yesterday's + /// schedule under a strip that starts at today. + void syncToToday() { + final now = startOfDay(DateTime.now()); + if (now == _anchor) return; - _loading.add(day); - _errors.remove(day); + final wasOnToday = _selected == _anchor; + _anchor = now; + final first = days.first; + _byDay.removeWhere((day, _) => day.isBefore(first)); + _fetchedAt.removeWhere((day, _) => day.isBefore(first)); + _errors.removeWhere((day, _) => day.isBefore(first)); + if (wasOnToday || _selected.isBefore(first)) _selected = now; + notifyListeners(); + load(); + } + + Future load({bool force = false}) => loadDay(_selected, force: force); + + Future loadDay(DateTime day, {bool force = false}) { + final key = startOfDay(day); + + // A forced refresh coalesces onto an in-flight fetch rather than being + // swallowed by it: pulling to refresh during the first load then holds the + // indicator until the data actually lands, instead of snapping shut. + final inFlight = _inFlight[key]; + if (inFlight != null) return inFlight; + if (!force && _byDay.containsKey(key) && !_isStale(key)) { + return Future.value(); + } + + final future = _fetch(key); + _inFlight[key] = future; notifyListeners(); + return future; + } + + Future _fetch(DateTime day) async { + _errors.remove(day); try { - final schedule = await _service.api.airingSchedule( + _byDay[day] = await _service.api.airingSchedule( from: day, - to: day.add(const Duration(days: 1)), + to: DateTime(day.year, day.month, day.day + 1), ); - _byDay[day] = schedule; + _fetchedAt[day] = DateTime.now(); + _prefetchNext(day); } catch (e) { _errors[day] = e is AnilistException - ? e.message - : 'Could not load the airing schedule'; + ? (e.rateLimited ? 'anilist.calendar_rate_limited'.tr() : e.message) + : 'anilist.calendar_error'.tr(); } finally { - _loading.remove(day); + _inFlight.remove(day); // The day may no longer be selected — the user can switch while a fetch // is in flight — but the result is cached either way, so notifying is // still correct: it repaints whichever day they landed on. @@ -113,6 +225,73 @@ class AiringCalendarController extends ChangeNotifier { } } + /// Warms tomorrow, and only tomorrow. + /// + /// Forward is the direction this list is read, so one day of lookahead takes + /// the skeleton out of the common swipe. The whole window would be a burst of + /// thirty-odd requests against a budget AniList enforces tightly, to warm + /// days most viewers never open. + void _prefetchNext(DateTime day) { + if (day != _selected) return; + final next = DateTime(day.year, day.month, day.day + 1); + if (next.isAfter(days.last)) return; + if (_byDay.containsKey(next) || _inFlight.containsKey(next)) return; + _inFlight[next] = _fetch(next); + } + + bool _isStale(DateTime day) { + final at = _fetchedAt[day]; + if (at == null) return true; + final age = DateTime.now().difference(at); + return age > (day == _anchor ? _todayFreshness : _dayFreshness); + } + + /// Collapses same-show, same-minute airings into one row. + static List _collapse(List airings) { + final out = []; + final at = {}; + for (final airing in airings) { + final key = '${airing.media.id}@${airing.airingAt ~/ 60}'; + final index = at[key]; + if (index == null) { + at[key] = out.length; + out.add(AiringDayRow(airing: airing, lastEpisode: airing.episode)); + continue; + } + final row = out[index]; + out[index] = AiringDayRow( + airing: airing.episode < row.airing.episode ? airing : row.airing, + lastEpisode: airing.episode > row.lastEpisode + ? airing.episode + : row.lastEpisode, + ); + } + return out; + } + + /// Built arithmetically rather than by adding a Duration: a day is not always + /// 24 hours, and on a DST transition `add(Duration(days: 1))` lands at 23:00 + /// or 01:00 — which would key the cache off a time that is not midnight and + /// hand the query a 23- or 25-hour window. static DateTime startOfDay(DateTime value) => DateTime(value.year, value.month, value.day); + + static bool _readMineOnly() { + try { + return Hive.box( + AppConstants.settingsBox, + ).get(_mineOnlyKey, defaultValue: false) == + true; + } catch (_) { + return false; + } + } + + static void _persistMineOnly(bool value) { + try { + Hive.box(AppConstants.settingsBox).put(_mineOnlyKey, value); + } catch (_) { + // Nothing on screen depends on this landing; the filter still works. + } + } } diff --git a/lib/features/anilist/presentation/controllers/anilist_library_controller.dart b/lib/features/anilist/presentation/controllers/anilist_library_controller.dart index ea194f9f..9f35cf1f 100644 --- a/lib/features/anilist/presentation/controllers/anilist_library_controller.dart +++ b/lib/features/anilist/presentation/controllers/anilist_library_controller.dart @@ -37,6 +37,17 @@ class AnilistLibraryController extends ChangeNotifier { return out; } + /// The viewer's entry for one title, or null when it is not on their list. + /// + /// Keyed by media rather than entry id because the airing calendar only ever + /// holds a media — it starts from the schedule, not from the library. + AnilistListEntry? entryForMedia(int mediaId) { + for (final e in _entries) { + if (e.media.id == mediaId) return e; + } + return null; + } + int countOf(AnilistStatus status) => _entries.where((e) => e.status == status.value).length; diff --git a/lib/features/anilist/presentation/pages/airing_calendar_page.dart b/lib/features/anilist/presentation/pages/airing_calendar_page.dart index 7a3af83a..ee815b6a 100644 --- a/lib/features/anilist/presentation/pages/airing_calendar_page.dart +++ b/lib/features/anilist/presentation/pages/airing_calendar_page.dart @@ -1,73 +1,194 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/data/anilist_api.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; import 'package:soplay/features/anilist/presentation/controllers/airing_calendar_controller.dart'; import 'package:soplay/features/anilist/presentation/controllers/anilist_library_controller.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_entry_sheet.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; -/// What airs this week, across all of AniList. +/// What airs over the next two weeks, across all of AniList. /// /// The complement to Upcoming, which is deliberately narrow — only the shows /// you already follow. This is the wide view, with a filter back down to yours, /// so discovering a new season doesn't mean leaving the app. /// /// Works signed out: the schedule is public. Connecting only adds the "on your -/// list" marks and the filter. +/// list" marks, the filter, and the per-entry actions. class AiringCalendarPage extends StatefulWidget { - const AiringCalendarPage({super.key, this.showAppBar = true}); + const AiringCalendarPage({ + super.key, + this.showAppBar = true, + this.controller, + }); final bool showAppBar; + /// The library, when a host already holds one. Lent rather than built here so + /// a "+1" made on another AniList screen is reflected by the marks on this + /// one, and so opening the calendar does not re-fetch the whole collection. + final AnilistLibraryController? controller; + @override State createState() => _AiringCalendarPageState(); } -class _AiringCalendarPageState extends State { +class _AiringCalendarPageState extends State + with WidgetsBindingObserver { final AnilistService _service = getIt(); late final AiringCalendarController _calendar; late final AnilistLibraryController _library; + late final bool _ownsLibrary = widget.controller == null; + + late final PageController _pages; + final ScrollController _strip = ScrollController(); + + /// Redraws what the clock decides: the aired fade, the time pill colour and + /// the "now" rule all move on their own, and nothing else on this page would + /// notice. Also the cheapest place to catch midnight passing. + Timer? _ticker; + + bool _libraryLoading = false; + String? _libraryError; @override void initState() { super.initState(); _calendar = AiringCalendarController(service: _service); - _library = AnilistLibraryController(service: _service); + _library = widget.controller ?? AnilistLibraryController(service: _service); + _pages = PageController(initialPage: _calendar.selectedIndex); + _calendar.addListener(_onChange); _library.addListener(_onLibraryChange); + _service.addListener(_onServiceChange); + WidgetsBinding.instance.addObserver(this); + + _calendar.setLibrary(_library.entries); _calendar.load(); if (_service.isConnected) _library.load(); + + _ticker = Timer.periodic(const Duration(seconds: 30), (_) { + if (!mounted) return; + _calendar.syncToToday(); + setState(() {}); + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _centreStrip(jump: true), + ); } @override void dispose() { + _ticker?.cancel(); + WidgetsBinding.instance.removeObserver(this); _calendar.removeListener(_onChange); _library.removeListener(_onLibraryChange); + _service.removeListener(_onServiceChange); + _pages.dispose(); + _strip.dispose(); _calendar.dispose(); - _library.dispose(); + if (_ownsLibrary) _library.dispose(); super.dispose(); } - void _onChange() { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + _calendar.syncToToday(); if (mounted) setState(() {}); } + void _onChange() { + if (!mounted) return; + setState(() {}); + _syncPager(); + } + void _onLibraryChange() { - _calendar.setLibrary(_library.entries); + // setLibrary notifies the calendar, which this page already listens to, so + // rebuilding again here would double every optimistic write the library + // makes — and the library notifies on each one. + if (_calendar.setLibrary(_library.entries)) return; + if (_library.loading == _libraryLoading && + _library.error == _libraryError) { + return; + } + _libraryLoading = _library.loading; + _libraryError = _library.error; _onChange(); } + void _onServiceChange() { + if (_service.isConnected) { + _library.load(); + } else { + _calendar.setLibrary(const []); + } + _onChange(); + } + + void _syncPager() { + if (!_pages.hasClients) return; + final index = _calendar.selectedIndex; + final current = (_pages.page ?? _pages.initialPage.toDouble()).round(); + if (current != index) { + _pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + _centreStrip(); + } + + /// Keeps the selected pill on screen. The strip runs a fortnight now, so the + /// day being shown can easily sit past the end of it. + void _centreStrip({bool jump = false}) { + if (!_strip.hasClients) return; + const extent = _DayStrip.pillWidth + _DayStrip.gap; + final viewport = _strip.position.viewportDimension; + final target = + (_calendar.selectedIndex * extent + + _DayStrip.padding + + _DayStrip.pillWidth / 2 - + viewport / 2) + .clamp(0.0, _strip.position.maxScrollExtent); + if ((target - _strip.offset).abs() < 1) return; + if (jump) { + _strip.jumpTo(target); + return; + } + _strip.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + @override Widget build(BuildContext context) { final body = Column( children: [ - _WeekStrip(controller: _calendar), + _DayStrip(controller: _calendar, scrollController: _strip), if (_service.isConnected) _MineFilter(controller: _calendar), - Expanded(child: _buildDay(context)), + _DayHeader(controller: _calendar), + Expanded( + child: PageView.builder( + controller: _pages, + itemCount: _calendar.days.length, + onPageChanged: (i) => _calendar.select(_calendar.days[i]), + itemBuilder: (context, i) => _buildDay(context, _calendar.days[i]), + ), + ), ], ); @@ -95,144 +216,277 @@ class _AiringCalendarPageState extends State { ); } - Widget _buildDay(BuildContext context) { - if (_calendar.loading && !_calendar.dayHasAny) { - return const Center( - child: CircularProgressIndicator(color: kAnilistBlue, strokeWidth: 2.5), - ); - } + Widget _buildDay(BuildContext context, DateTime day) { + if (_calendar.isPendingFor(day)) return const _DaySkeleton(); - Future refresh() => _calendar.load(force: true); - final airings = _calendar.visible; - - if (airings.isEmpty) { - // Distinguishing the two cases matters: "nothing airs today" and "nothing - // of YOURS airs today" send the user to different places. - final message = _calendar.error ?? - (_calendar.mineOnly && _calendar.dayHasAny - ? 'anilist.calendar_empty_mine'.tr() - : 'anilist.calendar_empty'.tr()); - return RefreshIndicator( - color: kAnilistBlue, - backgroundColor: AppColors.surface, - onRefresh: refresh, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox(height: MediaQuery.sizeOf(context).height * 0.14), - Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( + Future refresh() => _calendar.loadDay(day, force: true); + final rows = _calendar.rowsFor(day); + if (rows.isEmpty) return _buildEmpty(context, day, refresh); + + final nowAt = _nowMarker(day, rows); + + return RefreshIndicator( + color: kAnilistBlue, + backgroundColor: AppColors.surface, + onRefresh: refresh, + child: Column( + children: [ + // A failed refresh over a day that still holds entries has nowhere + // else to show: the list branch never reaches the empty state. + if (_calendar.isStaleFor(day)) + _StaleBanner( + message: _calendar.errorFor(day)!, + onRetry: () => _calendar.loadDay(day, force: true), + ), + Expanded( + child: ListView.separated( + key: PageStorageKey('anilist-day-${day.toIso8601String()}'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + itemCount: rows.length, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (context, i) { + final row = rows[i]; + final card = _AiringCard( + row: row, + mine: _calendar.isMine(row.airing.media.id), + onTap: () => _openRow(row), + ); + if (i != nowAt) return card; + return Column( children: [ - Icon( - _calendar.error != null - ? Icons.cloud_off_rounded - : Icons.event_busy_rounded, - size: 46, - color: AppColors.textHint.withValues(alpha: 0.6), - ), - const SizedBox(height: 14), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 13.5, - height: 1.5, - ), - ), + const _NowDivider(), + const SizedBox(height: 9), + card, ], - ), - ), + ); + }, ), - ], - ), - ); + ), + ], + ), + ); + } + + Widget _buildEmpty( + BuildContext context, + DateTime day, + Future Function() refresh, + ) { + final error = _calendar.errorFor(day); + // Three cases, not two: nothing airs, nothing OF YOURS airs, and "your list + // never loaded" — which the second would report as an empty week. + final listFailed = _calendar.mineOnly && _library.error != null; + final String message; + if (error != null) { + message = error; + } else if (listFailed) { + message = 'anilist.calendar_list_error'.tr(); + } else { + message = _calendar.mineOnly && _calendar.hasAnyFor(day) + ? 'anilist.calendar_empty_mine'.tr() + : 'anilist.calendar_empty'.tr(); } + final failed = error != null || listFailed; return RefreshIndicator( color: kAnilistBlue, backgroundColor: AppColors.surface, onRefresh: refresh, - child: ListView.separated( + child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), - itemCount: airings.length, - separatorBuilder: (_, _) => const SizedBox(height: 9), - itemBuilder: (context, i) { - final airing = airings[i]; - return _AiringCard( - airing: airing, - mine: _calendar.isMine(airing.media.id), - ); - }, + children: [ + SizedBox(height: MediaQuery.sizeOf(context).height * 0.12), + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + failed ? Icons.cloud_off_rounded : Icons.event_busy_rounded, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), + const SizedBox(height: 14), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13.5, + height: 1.5, + ), + ), + if (failed) ...[ + const SizedBox(height: 10), + TextButton( + onPressed: listFailed + ? () => _library.load(force: true) + : refresh, + style: TextButton.styleFrom( + foregroundColor: kAnilistBlue, + ), + child: Text('anilist.retry'.tr()), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + /// Where "now" falls in the day, or -1 when a rule would say nothing — any + /// other day, or a day entirely behind or entirely ahead of this moment. + int _nowMarker(DateTime day, List rows) { + if (day != _calendar.today) return -1; + final index = rows.indexWhere((r) => !r.airing.hasAired); + return index <= 0 ? -1 : index; + } + + void _openRow(AiringDayRow row) { + final media = row.airing.media; + final entry = _library.entryForMedia(media.id); + if (entry != null) { + AnilistEntrySheet.show(context, entryId: entry.id, controller: _library); + return; + } + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => _MediaActions( + media: media, + onAdd: _service.isConnected ? () => _addToPlanning(media) : null, ), ); } + + Future _addToPlanning(AnilistMedia media) async { + final token = _service.token; + if (token == null) return; + final messenger = ScaffoldMessenger.of(context); + String message; + try { + final saved = await _service.api.addToList(token: token, mediaId: media.id); + // Re-read rather than patch a synthetic entry in: the mark on this card + // is driven by the library, and AniList decides the entry's id. + await _library.load(force: true); + // null means the title was already there — which is what we see whenever + // the library failed to load, so say so instead of claiming an add. + message = saved == null + ? 'anilist.calendar_already_on_list'.tr() + : 'anilist.calendar_added_planning'.tr(); + } catch (e) { + message = e is AnilistException + ? e.message + : 'anilist.calendar_add_failed'.tr(); + } + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } } -/// The seven-day selector. -class _WeekStrip extends StatelessWidget { - const _WeekStrip({required this.controller}); +/// The day selector: yesterday, today, and a fortnight ahead. +class _DayStrip extends StatelessWidget { + const _DayStrip({required this.controller, required this.scrollController}); + + static const double pillWidth = 54; + static const double gap = 8; + static const double padding = 14; final AiringCalendarController controller; + final ScrollController scrollController; @override Widget build(BuildContext context) { final locale = context.locale.toString(); - final today = AiringCalendarController.startOfDay(DateTime.now()); + final days = controller.days; return SizedBox( - height: 74, + height: 76, child: ListView.separated( + controller: scrollController, scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: controller.week.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), + padding: const EdgeInsets.symmetric(horizontal: padding), + itemCount: days.length, + separatorBuilder: (_, _) => const SizedBox(width: gap), itemBuilder: (context, i) { - final day = controller.week[i]; + final day = days[i]; final selected = day == controller.selected; - final isToday = day == today; - return GestureDetector( - onTap: () => controller.select(day), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 54, - decoration: BoxDecoration( - color: selected ? kAnilistBlue : AppColors.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isToday && !selected - ? kAnilistBlue.withValues(alpha: 0.55) - : Colors.transparent, - width: 1.4, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - DateFormat.E(locale).format(day).toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - letterSpacing: 0.6, - color: selected - ? Colors.white.withValues(alpha: 0.85) - : AppColors.textHint, - ), + final isToday = day == controller.today; + // Null means "not fetched": a marker on every day would claim an + // empty schedule for a fortnight nobody has looked at yet. + final count = controller.countFor(day); + + return Semantics( + button: true, + selected: selected, + label: DateFormat.yMMMMEEEEd(locale).format(day), + child: ExcludeSemantics( + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: pillWidth, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: selected ? kAnilistBlue : AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isToday && !selected + ? kAnilistBlue.withValues(alpha: 0.55) + : Colors.transparent, + width: 1.4, ), - const SizedBox(height: 4), - Text( - '${day.day}', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w800, - color: selected ? Colors.white : AppColors.textPrimary, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => controller.select(day), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat.E(locale).format(day).toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: selected + ? Colors.white.withValues(alpha: 0.85) + : AppColors.textHint, + ), + ), + const SizedBox(height: 4), + Text( + '${day.day}', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: selected + ? Colors.white + : AppColors.textPrimary, + ), + ), + const SizedBox(height: 5), + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (count ?? 0) > 0 + ? (selected + ? Colors.white.withValues(alpha: 0.9) + : kAnilistBlue) + : Colors.transparent, + ), + ), + ], ), ), - ], + ), ), ), ); @@ -242,6 +496,48 @@ class _WeekStrip extends StatelessWidget { } } +/// The selected date in full, plus what the day actually holds. +/// +/// The strip only shows a weekday and a bare number, and the list below carries +/// times and nothing else, so past the first week there is otherwise nothing on +/// screen saying which day is being read. +class _DayHeader extends StatelessWidget { + const _DayHeader({required this.controller}); + + final AiringCalendarController controller; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + final day = controller.selected; + final date = DateFormat.MMMEd(locale).format(day); + final count = controller.countFor(day); + final total = controller.totalFor(day); + + final label = count == null + ? date + : controller.mineOnly + ? '$date · ${'anilist.calendar_count_mine'.tr(namedArgs: {'mine': '$count', 'total': '$total'})}' + : '$date · ${'anilist.calendar_count'.tr(namedArgs: {'count': '$count'})}'; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ), + ); + } +} + /// All / my list toggle. Only offered while connected, since it needs a list. class _MineFilter extends StatelessWidget { const _MineFilter({required this.controller}); @@ -251,7 +547,7 @@ class _MineFilter extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 0), child: Row( children: [ _FilterPill( @@ -284,27 +580,177 @@ class _FilterPill extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: selected - ? kAnilistBlue.withValues(alpha: 0.16) - : AppColors.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected ? kAnilistBlue : Colors.transparent, - width: 1.2, + return Material( + color: selected + ? kAnilistBlue.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? kAnilistBlue : Colors.transparent, + width: 1.2, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: selected ? kAnilistBlue : AppColors.textSecondary, + ), ), ), - child: Text( - label, - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w700, - color: selected ? kAnilistBlue : AppColors.textSecondary, + ), + ); + } +} + +/// The line between what has already aired and what is still to come. +class _NowDivider extends StatelessWidget { + const _NowDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: Row( + children: [ + Text( + 'anilist.calendar_now'.tr().toUpperCase(), + style: const TextStyle( + color: kAnilistBlue, + fontSize: 10, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 1, + color: kAnilistBlue.withValues(alpha: 0.35), + ), + ), + ], + ), + ); + } +} + +class _StaleBanner extends StatelessWidget { + const _StaleBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(14, 6, 14, 0), + padding: const EdgeInsets.fromLTRB(11, 8, 6, 8), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + children: [ + const Icon( + Icons.cloud_off_rounded, + size: 16, + color: AppColors.textHint, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + height: 1.35, + ), + ), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: kAnilistBlue, + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'anilist.retry'.tr(), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), ), + ], + ), + ); + } +} + +/// Placeholder rows in the shape of the real ones. +/// +/// A spinner in the middle of a blank page reads as a navigation, which is +/// wrong for something that happens on every swipe between days — and the row +/// geometry here is fixed, which is exactly when a skeleton beats a spinner. +class _DaySkeleton extends StatelessWidget { + const _DaySkeleton(); + + @override + Widget build(BuildContext context) { + Widget bar(double width, double height) => Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + ); + + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + itemCount: 6, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (context, i) => Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Container( + width: 44, + height: 66, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + bar(double.infinity, 11), + const SizedBox(height: 8), + bar(90, 9), + ], + ), + ), + const SizedBox(width: 10), + bar(42, 24), + ], ), ), ); @@ -312,100 +758,262 @@ class _FilterPill extends StatelessWidget { } class _AiringCard extends StatelessWidget { - const _AiringCard({required this.airing, required this.mine}); + const _AiringCard({ + required this.row, + required this.mine, + required this.onTap, + }); - final AnilistScheduledAiring airing; + final AiringDayRow row; final bool mine; + final VoidCallback onTap; @override Widget build(BuildContext context) { + final airing = row.airing; final media = airing.media; - final title = - media.englishTitle ?? media.romajiTitle ?? media.nativeTitle ?? ''; final time = DateFormat.Hm(context.locale.toString()).format(airing.airsAt); final aired = airing.hasAired; + final episode = row.isRange + ? 'anilist.calendar_episode_range'.tr( + namedArgs: {'from': '${row.firstEpisode}', 'to': '${row.lastEpisode}'}, + ) + : 'anilist.calendar_episode'.tr( + namedArgs: {'episode': '${airing.episode}'}, + ); - return Container( - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: mine ? kAnilistBlue.withValues(alpha: 0.4) : Colors.transparent, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Faded once it has gone out, so a glance down the day separates what - // is still to come from what already aired. - Opacity( - opacity: aired ? 0.55 : 1, - child: AnilistCover(url: media.coverImage, width: 44, radius: 8), + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: mine + ? kAnilistBlue.withValues(alpha: 0.4) + : Colors.transparent, + ), ), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + child: Row( + children: [ + // Faded once it has gone out, so a glance down the day separates + // what is still to come from what already aired. + Opacity( + opacity: aired ? 0.55 : 1, + child: AnilistCover(url: media.coverImage, width: 44, radius: 8), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.5, - height: 1.25, - fontWeight: FontWeight.w700, - color: aired - ? AppColors.textSecondary - : AppColors.textPrimary, + Row( + children: [ + Expanded( + child: Text( + media.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + height: 1.25, + fontWeight: FontWeight.w700, + color: aired + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), ), + if (mine) ...[ + const SizedBox(width: 6), + const AnilistLogo(size: 15, radius: 4), + ], + ], + ), + const SizedBox(height: 5), + Text( + episode, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + fontWeight: FontWeight.w600, ), ), - if (mine) ...[ - const SizedBox(width: 6), - const AnilistLogo(size: 15, radius: 4), - ], ], ), - const SizedBox(height: 5), - Text( - 'anilist.calendar_episode'.tr( - namedArgs: {'episode': '${airing.episode}'}, + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: aired + ? AppColors.surfaceVariant + : kAnilistBlue.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + time, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: aired ? AppColors.textHint : kAnilistBlue, ), - style: const TextStyle( - fontSize: 11.5, - color: AppColors.textHint, - fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// What can be done with a title that is not on the viewer's list. +/// +/// Deliberately short: the calendar's job is to hand a discovery over to the +/// rest of the app, and finding sources for it is the reason to be here at all. +class _MediaActions extends StatelessWidget { + const _MediaActions({required this.media, required this.onAdd}); + + final AnilistMedia media; + final VoidCallback? onAdd; + + @override + Widget build(BuildContext context) { + final siteUrl = media.siteUrl; + + return SafeArea( + top: false, + child: Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + padding: const EdgeInsets.fromLTRB(18, 10, 18, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 38, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnilistCover(url: media.coverImage, width: 54), + const SizedBox(width: 13), + Expanded( + child: Text( + media.displayTitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15.5, + fontWeight: FontWeight.w800, + height: 1.25, + ), ), ), ], ), - ), - const SizedBox(width: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), - decoration: BoxDecoration( - color: aired - ? AppColors.surfaceVariant - : kAnilistBlue.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(9), + const SizedBox(height: 12), + const Divider(color: AppColors.divider, height: 1), + _SheetAction( + icon: Icons.travel_explore_rounded, + label: 'anilist.find_in_sources'.tr(), + subtitle: 'anilist.find_in_sources_hint'.tr(), + onTap: () { + Navigator.of(context).pop(); + context.push('/cross-search', extra: media.displayTitle); + }, ), - child: Text( - time, - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w800, - color: aired ? AppColors.textHint : kAnilistBlue, + if (onAdd != null) + _SheetAction( + icon: Icons.bookmark_add_outlined, + label: 'anilist.calendar_add_planning'.tr(), + onTap: () { + Navigator.of(context).pop(); + onAdd!(); + }, ), + _SheetAction( + icon: Icons.open_in_new_rounded, + label: 'anilist.open_on_anilist'.tr(), + onTap: siteUrl == null + ? null + : () => launchUrl( + Uri.parse(siteUrl), + mode: LaunchMode.externalApplication, + ), ), - ), - ], + ], + ), ), ); } } + +class _SheetAction extends StatelessWidget { + const _SheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final String label; + final String? subtitle; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return ListTile( + contentPadding: EdgeInsets.zero, + enabled: enabled, + leading: Icon( + icon, + color: enabled ? kAnilistBlue : AppColors.textHint, + size: 22, + ), + title: Text( + label, + style: TextStyle( + color: enabled ? AppColors.textPrimary : AppColors.textHint, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: onTap, + ); + } +} diff --git a/lib/features/detail/domain/video_option_groups.dart b/lib/features/detail/domain/video_option_groups.dart new file mode 100644 index 00000000..df918e20 --- /dev/null +++ b/lib/features/detail/domain/video_option_groups.dart @@ -0,0 +1,107 @@ +/// Splits the flat source list into servers and the qualities each one offers. +/// +/// A provider returns one list mixing both — "SubsPlease · 1080p", +/// "Erai-raws · 720p" — and the player showed it raw, so picking a quality +/// could silently move you to another host. Servers and qualities are +/// independent choices and get one control each. +/// +/// Everything here works on the source labels alone, so it stays testable +/// without a player or a widget tree. +class VideoOptionGroups { + const VideoOptionGroups._(); + + static final RegExp _resolution = RegExp(r'\d{3,4}'); + static final RegExp _separator = RegExp(r'\s*[·•|]\s*'); + static final RegExp _spaces = RegExp(r'\s+'); + + /// Labels that name no host at all still need something to group under. + static const String _fallbackServer = 'Default'; + + static List servers(List labels) { + final out = []; + for (final label in labels) { + final name = serverOf(label); + if (!out.contains(name)) out.add(name); + } + return out; + } + + /// Positions in the ORIGINAL list, so the caller's selected index stays + /// valid. + static List indicesFor(List labels, String server) => [ + for (var i = 0; i < labels.length; i++) + if (serverOf(labels[i]) == server) i, + ]; + + /// The distinct qualities [server] offers, so a picker can say what choosing + /// it gets you. + static List qualitiesFor(List labels, String server) { + final out = []; + for (final i in indicesFor(labels, server)) { + final quality = qualityOf(labels[i]); + if (quality.isNotEmpty && !out.contains(quality)) out.add(quality); + } + return out; + } + + static String serverOf(String label) => _split(label).server; + + /// The label with the host stripped off — empty when the label carries no + /// quality of its own. + static String qualityOf(String label) => _split(label).quality; + + static int? resolutionOf(String label) => + int.tryParse(_resolution.firstMatch(qualityOf(label))?.group(0) ?? ''); + + /// The index to land on when switching to [server], keeping the current + /// resolution where that server has it. + /// + /// Falls back to the server's highest resolution rather than its first entry: + /// providers list sources in arbitrary order, and dropping someone from 1080p + /// to 360p because that entry happened to come first reads as a bug. + static int switchTo(List labels, int currentIndex, String server) { + final candidates = indicesFor(labels, server); + if (candidates.isEmpty) return currentIndex; + + final wanted = currentIndex >= 0 && currentIndex < labels.length + ? resolutionOf(labels[currentIndex]) + : null; + if (wanted != null) { + for (final i in candidates) { + if (resolutionOf(labels[i]) == wanted) return i; + } + } + var best = candidates.first; + for (final i in candidates) { + if ((resolutionOf(labels[i]) ?? 0) > (resolutionOf(labels[best]) ?? 0)) { + best = i; + } + } + return best; + } + + static ({String server, String quality}) _split(String label) { + final text = label.trim(); + if (text.isEmpty) return (server: _fallbackServer, quality: ''); + + final parts = [ + for (final part in text.split(_separator)) + if (part.trim().isNotEmpty) part.trim(), + ]; + if (parts.length > 1) { + return (server: parts.first, quality: parts.skip(1).join(' · ')); + } + + final words = text.split(_spaces); + final at = words.indexWhere(_resolution.hasMatch); + // Nothing resolution-shaped in it: the whole label names a server, the way + // "Server 1" or "SUB Mp4Upload" does. + if (at < 0) return (server: text, quality: ''); + + final host = [...words.take(at), ...words.skip(at + 1)].join(' '); + return ( + server: host.isEmpty ? _fallbackServer : host, + quality: words[at], + ); + } +} diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 7ea9271d..f990b8bf 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -33,20 +33,17 @@ import 'package:soplay/features/tracker/domain/entities/followed_title.dart'; import 'package:soplay/features/my_list/data/datasources/my_list_local_data_source.dart'; import 'package:soplay/features/my_list/data/private_list_service.dart'; import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; -import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; import 'package:soplay/features/private_list/presentation/private_unlock.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_cast_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_comments_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_hero.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_info.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_more_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_related.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_screenshots.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_skeleton.dart'; import 'package:showcaseview/showcaseview.dart'; -import 'package:soplay/features/anilist/data/anilist_service.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_track_button.dart'; class DetailPage extends StatelessWidget { const DetailPage({super.key, required this.args}); @@ -546,6 +543,24 @@ class _DetailViewState extends State<_DetailView> Share.share('${widget.detail.title}\n$link'); } + void _showMoreSheet() { + final detail = widget.detail; + showDetailMoreSheet( + context, + entity: FavoriteEntity( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + thumbnail: detail.thumbnail ?? '', + ), + showFollow: detail.isSerial, + isFollowing: _isFollowing, + onToggleFollow: _toggleFollow, + onFindSources: _onFindOtherSources, + onShare: _onShare, + ); + } + void _handlePlayback(PlaybackEntity playback) { // A reading source ALWAYS goes through the chapter list, even when it // reports a single chapter (one-shots, and Mangayomi novels that expose one @@ -903,15 +918,6 @@ class _DetailViewState extends State<_DetailView> showPill: showPill, title: detail.title, reader: detail.provider.opensReader, - // Identity for the Watch Later / Watched buttons. - // Those talk to UserListsRepository themselves, so - // they need the content, not another callback pair. - listEntity: FavoriteEntity( - provider: detail.provider, - contentUrl: detail.contentUrl, - title: detail.title, - thumbnail: detail.thumbnail ?? '', - ), isInList: isInList, inPrivate: inPrivate, isLoading: state is EpisodesLoading, @@ -924,11 +930,7 @@ class _DetailViewState extends State<_DetailView> onAddToList: _toggleMyList, onMoveToPrivate: _onMoveToPrivate, onPrivateActions: _showPrivateActions, - onFindSources: _onFindOtherSources, - showFollow: detail.isSerial, - isFollowing: _isFollowing, - onFollow: _toggleFollow, - onShare: _onShare, + onMore: _showMoreSheet, ), ), ), @@ -979,7 +981,6 @@ class _AnimatedTopBar extends StatelessWidget { required this.showPill, required this.title, required this.reader, - required this.listEntity, required this.isInList, required this.inPrivate, required this.isLoading, @@ -992,11 +993,7 @@ class _AnimatedTopBar extends StatelessWidget { required this.onAddToList, required this.onMoveToPrivate, required this.onPrivateActions, - required this.onFindSources, - required this.showFollow, - required this.isFollowing, - required this.onFollow, - required this.onShare, + required this.onMore, }); final double collapse; @@ -1005,9 +1002,6 @@ class _AnimatedTopBar extends StatelessWidget { /// Reading source — the pill says "Read" rather than "Play". final bool reader; - - /// Content identity for the Watch Later / Watched buttons. - final FavoriteEntity listEntity; final bool isInList; final bool inPrivate; final bool isLoading; @@ -1020,11 +1014,7 @@ class _AnimatedTopBar extends StatelessWidget { final VoidCallback onAddToList; final VoidCallback onMoveToPrivate; final VoidCallback onPrivateActions; - final VoidCallback onFindSources; - final bool showFollow; - final bool isFollowing; - final VoidCallback onFollow; - final VoidCallback onShare; + final VoidCallback onMore; @override Widget build(BuildContext context) { @@ -1130,47 +1120,9 @@ class _AnimatedTopBar extends StatelessWidget { ), const SizedBox(width: 8), ], - _UserListButton( - kind: UserListKind.watchLater, - entity: listEntity, - activeIcon: Icons.watch_later_rounded, - inactiveIcon: Icons.watch_later_outlined, - ), - const SizedBox(width: 8), - _UserListButton( - kind: UserListKind.watched, - entity: listEntity, - activeIcon: Icons.visibility_rounded, - inactiveIcon: Icons.visibility_outlined, - ), - const SizedBox(width: 8), - if (showFollow) ...[ - _CircleIconButton( - icon: isFollowing - ? Icons.notifications_active_rounded - : Icons.notifications_none_rounded, - iconColor: isFollowing ? AppColors.rating : Colors.white, - onTap: onFollow, - ), - const SizedBox(width: 8), - ], - AnilistTrackButton( - provider: listEntity.provider, - contentUrl: listEntity.contentUrl, - title: listEntity.title, - ), - // Zero-width when AniList is not connected, so the spacer would - // otherwise leave a visible gap for most users. - if (getIt().isConnected) - const SizedBox(width: 8), - _CircleIconButton( - icon: Icons.travel_explore_rounded, - onTap: onFindSources, - ), - const SizedBox(width: 8), _CircleIconButton( - icon: Icons.ios_share_rounded, - onTap: onShare, + icon: Icons.more_vert_rounded, + onTap: onMore, ), ], ), @@ -1181,71 +1133,6 @@ class _AnimatedTopBar extends StatelessWidget { } } -/// Toggle for one user-curated list (Watch Later / Watched). -/// -/// Owns its state and talks to [UserListsRepository] directly instead of adding -/// another callback pair to [_AnimatedTopBar]: the two buttons behave -/// identically, differ only by [kind], and a third list would be one more -/// instance rather than more plumbing. -/// -/// The repository writes its cache before the network, so the flip is immediate -/// and survives a failed request — no spinner, no rollback dance. -class _UserListButton extends StatefulWidget { - const _UserListButton({ - required this.kind, - required this.entity, - required this.activeIcon, - required this.inactiveIcon, - }); - - final UserListKind kind; - final FavoriteEntity entity; - final IconData activeIcon; - final IconData inactiveIcon; - - @override - State<_UserListButton> createState() => _UserListButtonState(); -} - -class _UserListButtonState extends State<_UserListButton> { - late bool _active = _read(); - - bool _read() => getIt() - .contains(widget.kind, widget.entity.contentUrl); - - @override - void didUpdateWidget(covariant _UserListButton oldWidget) { - super.didUpdateWidget(oldWidget); - // The same button is reused when the page rebinds to another title. - if (oldWidget.entity.contentUrl != widget.entity.contentUrl) { - _active = _read(); - } - } - - Future _toggle() async { - final repo = getIt(); - final next = !_active; - setState(() => _active = next); - if (next) { - await repo.add(widget.kind, widget.entity); - // Marking Watched evicts the title from Watch Later (server and cache - // both), so the sibling button must re-read rather than stay lit. - if (widget.kind == UserListKind.watched && mounted) setState(() {}); - } else { - await repo.remove(widget.kind, widget.entity.contentUrl); - } - } - - @override - Widget build(BuildContext context) { - return _CircleIconButton( - icon: _active ? widget.activeIcon : widget.inactiveIcon, - iconColor: _active ? AppColors.rating : Colors.white, - onTap: _toggle, - ); - } -} - class _CircleIconButton extends StatelessWidget { const _CircleIconButton({ required this.icon, diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 476e937b..5c6a04b4 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -683,7 +683,8 @@ extension _PlayerControls on _PlayerPageState { final c = _controller; final initialized = c != null && c.value.isInitialized; final hasEpisodes = widget.args.isSerial && widget.args.episodes.isNotEmpty; - final hasQualities = _videoSources.length > 1; + final hasServers = _sourceServers.length > 1; + final hasQualities = _currentServerSources.length > 1; final hasLangSwitcher = _availableLangsForCurrentEpisode().length > 1; // Same gate the settings-sheet entry uses (player_page.panels.dart): the // top bar only promotes the action, it does not widen who can download. @@ -772,6 +773,13 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.settings_outlined, onTap: _openSettingsSheet, ), + if (hasServers) ...[ + const SizedBox(width: 8), + _IconButton( + icon: Icons.dns_outlined, + onTap: _openServerSheet, + ), + ], if (hasEpisodes || hasQualities) ...[ const SizedBox(width: 8), _IconButton( @@ -856,7 +864,8 @@ extension _PlayerControls on _PlayerPageState { ), ), ), - if (initialized) _buildBottomBar(c, hasEpisodes, hasQualities), + if (initialized) + _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), ], ), ), @@ -911,6 +920,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildDesktopControlRow( PlayerController c, bool hasEpisodes, + bool hasServers, bool hasQualities, bool hasPrev, bool hasNext, @@ -1006,6 +1016,13 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.settings_outlined, onTap: _openSettingsSheet, ), + if (hasServers) ...[ + const SizedBox(width: 4), + _IconButton( + icon: Icons.dns_outlined, + onTap: _openServerSheet, + ), + ], if (hasQualities) ...[ const SizedBox(width: 4), _IconButton( @@ -1040,6 +1057,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildBottomBar( PlayerController c, bool hasEpisodes, + bool hasServers, bool hasQualities, ) { final hasNext = @@ -1215,7 +1233,7 @@ extension _PlayerControls on _PlayerPageState { const SizedBox(height: 4), if (isDesktopPlatform) _buildDesktopControlRow( - c, hasEpisodes, hasQualities, hasPrev, hasNext) + c, hasEpisodes, hasServers, hasQualities, hasPrev, hasNext) else SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -1242,10 +1260,19 @@ extension _PlayerControls on _PlayerPageState { enabled: true, onTap: _openSpeedSheet, ), + if (hasServers) + _BottomTextButton( + icon: Icons.dns_outlined, + label: _currentServer ?? '—', + enabled: true, + onTap: _openServerSheet, + ), if (hasQualities) _BottomTextButton( icon: Icons.high_quality_rounded, - label: _currentQuality ?? 'player.quality'.tr(), + label: _currentQuality == null + ? 'player.quality'.tr() + : _qualityLabel(_currentQuality!), enabled: true, onTap: () => _openPanel(_SidePanel.quality), ), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 5e7a60ab..3b36b1bd 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -34,6 +34,7 @@ import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; import 'package:soplay/features/detail/domain/entities/thumbnails_entity.dart'; import 'package:soplay/core/preview/frame_preview_service.dart'; import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 2145c52b..246e2b22 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -69,8 +69,116 @@ extension _PlayerPanels on _PlayerPageState { ); } + List get _sourceLabels => [for (final s in _videoSources) s.quality]; + + List get _sourceServers => VideoOptionGroups.servers(_sourceLabels); + + /// The host currently playing, or null before the first source resolves. + /// + /// Nullable on purpose: serverOf('') answers with the "Default" placeholder, + /// which the bar and the settings sheet were painting as if it were a real + /// host name. + String? get _currentServer { + final quality = _currentQuality; + if (quality == null) return null; + return VideoOptionGroups.serverOf(quality); + } + + /// Positions in [_videoSources] the current server offers. + List get _currentServerSources { + final labels = _sourceLabels; + final indices = VideoOptionGroups.indicesFor(labels, _currentServer ?? ''); + // Until the first source resolves there is no current server; offering + // everything beats offering nothing. + return indices.isEmpty + ? [for (var i = 0; i < labels.length; i++) i] + : indices; + } + + String _qualityLabel(String label) { + final quality = VideoOptionGroups.qualityOf(label); + return quality.isEmpty ? label : quality; + } + + /// [_QualityRow] paints the label verbatim, and every label in a one-server + /// list starts with that server's name — so hand it a display-only copy. + VideoSourceEntity _resolutionOnly(VideoSourceEntity source) => + VideoSourceEntity( + quality: _qualityLabel(source.quality), + videoUrl: source.videoUrl, + isDefault: source.isDefault, + accessible: source.accessible, + ); + + void _openServerSheet() { + final labels = _sourceLabels; + final servers = _sourceServers; + if (servers.length < 2) return; + showAdaptiveModal( + context: context, + backgroundColor: const Color(0xFF111111), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + const Icon( + Icons.dns_outlined, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 10), + Text( + 'player.server'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + for (final server in servers) + _ServerTile( + label: server, + qualities: VideoOptionGroups.qualitiesFor(labels, server) + .join(' · '), + selected: server == _currentServer, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchServer(server); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Future _switchServer(String server) async { + final labels = _sourceLabels; + final current = + _currentQuality == null ? -1 : labels.indexOf(_currentQuality!); + final target = VideoOptionGroups.switchTo(labels, current, server); + if (target < 0 || target >= _videoSources.length) return; + await _switchQuality(_videoSources[target]); + } + void _openSettingsSheet() { - final hasQualities = _videoSources.length > 1; + final hasServers = _sourceServers.length > 1; + final hasQualities = _currentServerSources.length > 1; final langs = _availableLangsForCurrentEpisode(); final hasLangs = langs.length > 1; showAdaptiveModal( @@ -126,11 +234,23 @@ extension _PlayerPanels on _PlayerPageState { _openFitSheet(); }, ), + if (hasServers) + _SettingsTile( + icon: Icons.dns_outlined, + label: 'player.server'.tr(), + value: _currentServer ?? '—', + onTap: () { + Navigator.of(sheetContext).pop(); + _openServerSheet(); + }, + ), if (hasQualities) _SettingsTile( icon: Icons.high_quality_rounded, label: 'player.quality'.tr(), - value: _currentQuality ?? '—', + value: _currentQuality == null + ? '—' + : _qualityLabel(_currentQuality!), onTap: () { Navigator.of(sheetContext).pop(); _openPanel(_SidePanel.quality); @@ -485,6 +605,7 @@ extension _PlayerPanels on _PlayerPageState { Widget _buildSidePanel() { final isQuality = _panel == _SidePanel.quality; + final sources = _currentServerSources; return Positioned( top: 0, bottom: 0, @@ -529,15 +650,15 @@ extension _PlayerPanels on _PlayerPageState { child: isQuality ? ListView.separated( controller: _tvPanelScroll, - itemCount: _videoSources.length, + itemCount: sources.length, separatorBuilder: (_, _) => Divider( color: Colors.white.withValues(alpha: 0.06), height: 1, ), itemBuilder: (_, i) { - final src = _videoSources[i]; + final src = _videoSources[sources[i]]; return _QualityRow( - source: src, + source: _resolutionOnly(src), isActive: src.quality == _currentQuality, onTap: () => _switchQuality(src), ); @@ -567,3 +688,72 @@ extension _PlayerPanels on _PlayerPageState { ); } } + +class _ServerTile extends StatelessWidget { + const _ServerTile({ + required this.label, + required this.qualities, + required this.selected, + required this.onTap, + }); + + final String label; + final String qualities; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + autofocus: isTvPlatform && selected, + focusColor: _kTvFocusFill, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: selected ? AppColors.primary : Colors.white54, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? Colors.white : Colors.white70, + fontSize: 14, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + if (qualities.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + qualities, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/detail/presentation/widgets/detail_more_sheet.dart b/lib/features/detail/presentation/widgets/detail_more_sheet.dart new file mode 100644 index 00000000..25028e48 --- /dev/null +++ b/lib/features/detail/presentation/widgets/detail_more_sheet.dart @@ -0,0 +1,372 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/data/anilist_link_store.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_link_sheet.dart'; +import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; +import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; +import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; + +/// The overflow menu behind the title screen's three-dot button. +/// +/// A sheet rather than a [PopupMenuButton] because half of these entries are +/// toggles: the row has to say whether the title is already in Watch Later or +/// tracked on AniList, which a bare menu of labels cannot. +Future showDetailMoreSheet( + BuildContext context, { + required FavoriteEntity entity, + required bool showFollow, + required bool isFollowing, + required VoidCallback onToggleFollow, + required VoidCallback onFindSources, + required VoidCallback onShare, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _DetailMoreSheet( + entity: entity, + showFollow: showFollow, + isFollowing: isFollowing, + onToggleFollow: onToggleFollow, + onFindSources: onFindSources, + onShare: onShare, + ), + ); +} + +class _DetailMoreSheet extends StatefulWidget { + const _DetailMoreSheet({ + required this.entity, + required this.showFollow, + required this.isFollowing, + required this.onToggleFollow, + required this.onFindSources, + required this.onShare, + }); + + final FavoriteEntity entity; + final bool showFollow; + final bool isFollowing; + final VoidCallback onToggleFollow; + final VoidCallback onFindSources; + final VoidCallback onShare; + + @override + State<_DetailMoreSheet> createState() => _DetailMoreSheetState(); +} + +class _DetailMoreSheetState extends State<_DetailMoreSheet> { + final UserListSync _listSync = UserListSync(); + late bool _following = widget.isFollowing; + + @override + void dispose() { + _listSync.dispose(); + super.dispose(); + } + + void _toggleFollow() { + setState(() => _following = !_following); + widget.onToggleFollow(); + } + + void _run(VoidCallback action) { + Navigator.of(context).pop(); + action(); + } + + @override + Widget build(BuildContext context) { + final anilistReady = getIt().isConnected && + widget.entity.contentUrl.isNotEmpty; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.textHint, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 10), + child: Text( + widget.entity.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + UserListToggle( + kind: UserListKind.watchLater, + entity: widget.entity, + sync: _listSync, + builder: (_, active, toggle) => _SheetRow( + icon: active + ? Icons.watch_later_rounded + : Icons.watch_later_outlined, + label: 'detail.watch_later'.tr(), + active: active, + onTap: toggle, + ), + ), + UserListToggle( + kind: UserListKind.watched, + entity: widget.entity, + sync: _listSync, + builder: (_, active, toggle) => _SheetRow( + icon: active ? Icons.visibility_rounded : Icons.visibility_outlined, + label: 'detail.watched'.tr(), + active: active, + onTap: toggle, + ), + ), + if (widget.showFollow) + _SheetRow( + icon: _following + ? Icons.notifications_active_rounded + : Icons.notifications_none_rounded, + label: 'detail.follow_series'.tr(), + active: _following, + onTap: _toggleFollow, + ), + if (anilistReady) _AnilistRow(entity: widget.entity), + const Divider( + color: AppColors.divider, + height: 17, + indent: 18, + endIndent: 18, + ), + _SheetRow( + icon: Icons.travel_explore_rounded, + label: 'detail.find_other_sources'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: () => _run(widget.onFindSources), + ), + _SheetRow( + icon: Icons.ios_share_rounded, + label: 'movie.share'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: () => _run(widget.onShare), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +/// Fires after any [UserListToggle] writes, so sibling toggles re-read the +/// cache: marking a title Watched evicts it from Watch Later. +class UserListSync extends ChangeNotifier { + void ping() => notifyListeners(); +} + +/// Holds the membership state of one user-curated list and hands it to +/// [builder], so a circle button and a sheet row share the same logic. +/// +/// The repository writes its cache before the network, so the flip is immediate +/// and survives a failed request — no spinner, no rollback dance. +class UserListToggle extends StatefulWidget { + const UserListToggle({ + super.key, + required this.kind, + required this.entity, + required this.builder, + this.sync, + }); + + final UserListKind kind; + final FavoriteEntity entity; + final UserListSync? sync; + final Widget Function(BuildContext context, bool active, VoidCallback toggle) + builder; + + @override + State createState() => _UserListToggleState(); +} + +class _UserListToggleState extends State { + late bool _active = _read(); + + bool _read() => getIt() + .contains(widget.kind, widget.entity.contentUrl); + + @override + void initState() { + super.initState(); + widget.sync?.addListener(_reread); + } + + @override + void didUpdateWidget(covariant UserListToggle oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sync != widget.sync) { + oldWidget.sync?.removeListener(_reread); + widget.sync?.addListener(_reread); + } + // The same toggle is reused when the page rebinds to another title. + if (oldWidget.entity.contentUrl != widget.entity.contentUrl) { + _active = _read(); + } + } + + @override + void dispose() { + widget.sync?.removeListener(_reread); + super.dispose(); + } + + void _reread() { + final active = _read(); + if (mounted && active != _active) setState(() => _active = active); + } + + Future _toggle() async { + final repo = getIt(); + final next = !_active; + setState(() => _active = next); + if (next) { + await repo.add(widget.kind, widget.entity); + } else { + await repo.remove(widget.kind, widget.entity.contentUrl); + } + if (mounted) widget.sync?.ping(); + } + + @override + Widget build(BuildContext context) => + widget.builder(context, _active, _toggle); +} + +class _AnilistRow extends StatefulWidget { + const _AnilistRow({required this.entity}); + + final FavoriteEntity entity; + + @override + State<_AnilistRow> createState() => _AnilistRowState(); +} + +class _AnilistRowState extends State<_AnilistRow> { + late AnilistLink? _link = getIt() + .get(widget.entity.provider, widget.entity.contentUrl); + + Future _open() async { + await AnilistLinkSheet.show( + context, + provider: widget.entity.provider, + contentUrl: widget.entity.contentUrl, + title: widget.entity.title, + ); + if (!mounted) return; + setState(() => _link = getIt() + .get(widget.entity.provider, widget.entity.contentUrl)); + } + + @override + Widget build(BuildContext context) { + final linked = _link != null; + return _SheetRow( + icon: linked ? Icons.bookmark_added_rounded : Icons.bookmark_add_outlined, + label: linked + ? 'detail.anilist_tracked'.tr() + : 'detail.anilist_track'.tr(), + active: linked, + accent: kAnilistBlue, + onTap: _open, + ); + } +} + +class _SheetRow extends StatelessWidget { + const _SheetRow({ + required this.icon, + required this.label, + required this.onTap, + this.active = false, + this.accent = AppColors.rating, + this.trailing, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + final Color accent; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: active + ? accent.withValues(alpha: 0.16) + : Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + icon, + size: 19, + color: active ? accent : AppColors.textPrimary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + ), + ), + trailing ?? + (active + ? Icon(Icons.check_rounded, size: 20, color: accent) + : const SizedBox.shrink()), + ], + ), + ), + ); + } +} diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index fc836eb5..15602836 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -769,34 +769,49 @@ class _SoplayFloatingNav extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(8), + const radius = BorderRadius.all(Radius.circular(9999)); + + return DecoratedBox( decoration: BoxDecoration( - color: AppColors.navBackground, - borderRadius: BorderRadius.circular(9999), + borderRadius: radius, boxShadow: [ + // `outer` keeps the lift without darkening the content that now + // shows through the translucent fill. BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 55, - offset: const Offset(0, 20), - ), - BoxShadow( - color: Colors.black.withValues(alpha: 0.17), - blurRadius: 13, - offset: const Offset(0, 12), + color: Colors.black.withValues(alpha: 0.32), + blurRadius: 28, + offset: const Offset(0, 10), + blurStyle: BlurStyle.outer, ), ], ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < items.length; i++) - _NavCircle( - item: items[i], - selected: index == i, - onTap: () => onTap(i), + child: ClipRRect( + borderRadius: radius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF262626).withValues(alpha: 0.72), + borderRadius: radius, + border: Border.all( + color: Colors.white.withValues(alpha: 0.10), + width: 0.5, + ), ), - ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < items.length; i++) + _NavCircle( + item: items[i], + selected: index == i, + onTap: () => onTap(i), + ), + ], + ), + ), + ), ), ); } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 957a2b8d..099ed265 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -10,7 +10,6 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/features/link_tv/presentation/pages/link_tv_page.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/features/profile/presentation/widgets/tab_customizer_sheet.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; @@ -25,7 +24,6 @@ import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/desktop_window.dart'; import 'package:soplay/core/system/nav_prefs.dart'; import 'package:soplay/core/system/responsive.dart'; -import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; import 'package:soplay/features/manga/presentation/pages/manga_sources_page.dart'; import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sources_page.dart'; import 'package:soplay/features/app_lock/domain/repositories/app_lock_repository.dart'; @@ -36,11 +34,11 @@ import 'package:soplay/features/auth/presentation/bloc/auth_event.dart'; import 'package:soplay/features/auth/presentation/bloc/auth_state.dart'; import 'package:soplay/features/download/data/download_service.dart'; import 'package:soplay/features/history/data/history_service.dart'; -import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/streak/presentation/widgets/streak_card.dart'; 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/profile/presentation/pages/providers_page.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; @@ -146,7 +144,11 @@ class _ProfileViewState extends State<_ProfileView> { ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 2, child: _ProvidersSection()), + child: _Reveal(order: 2, child: _ConnectionsSection()), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + const SliverToBoxAdapter( + child: _Reveal(order: 3, child: _ProvidersSection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), if (BridgeControl.canHost && CloudStreamChannel.isSupported) ...[ @@ -320,23 +322,23 @@ class _ProfileViewState extends State<_ProfileView> { ), ), const SliverToBoxAdapter( - child: _Reveal(order: 3, child: _WatchHistorySection()), + child: _Reveal(order: 4, child: _WatchHistorySection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 4, child: _SecuritySection()), + child: _Reveal(order: 5, child: _SecuritySection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 5, child: _AppearanceEntry()), + child: _Reveal(order: 6, child: _AppearanceEntry()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 6, child: _PlayerEntry()), + child: _Reveal(order: 7, child: _PlayerEntry()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 7, child: _AboutSection()), + child: _Reveal(order: 8, child: _AboutSection()), ), SliverToBoxAdapter( child: SizedBox( @@ -488,6 +490,8 @@ class _ProfileViewState extends State<_ProfileView> { ), const SizedBox(height: 8), const StreakCard(), + const SizedBox(height: 16), + const _ConnectionsSection(), ], ); case 1: @@ -904,6 +908,7 @@ class _LogoutButton extends StatelessWidget { } } +/// Compact summary of the active provider; the picker itself is [ProvidersPage]. class _ProvidersSection extends StatelessWidget { const _ProvidersSection(); @@ -918,12 +923,11 @@ class _ProvidersSection extends StatelessWidget { const SizedBox(height: 8), BlocBuilder( builder: (context, state) { - final currentName = state is ProviderLoaded - ? state.currentProvider?.name ?? state.currentProviderId - : '—'; - final currentProvider = state is ProviderLoaded - ? state.currentProvider - : null; + final loaded = state is ProviderLoaded ? state : null; + final currentProvider = loaded?.currentProvider; + final currentName = + currentProvider?.name ?? loaded?.currentProviderId ?? '—'; + final total = loaded?.providers.length ?? 0; return _SectionCard( children: [ @@ -949,13 +953,28 @@ class _ProvidersSection extends StatelessWidget { ), ), ), - Text( - currentName, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 14, + Flexible( + child: Text( + currentName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), ), ), + if (total > 0) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + '· $total', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + ), + ), + ), const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, @@ -964,9 +983,7 @@ class _ProvidersSection extends StatelessWidget { ), ], ), - onTap: () { - _ProvidersPage.open(context, context.read()); - }, + onTap: () => context.push('/providers'), ), ], ); @@ -978,1045 +995,8 @@ class _ProvidersSection extends StatelessWidget { } } -String providerGroup(ProviderEntity p) { - if (p.category == 'cloudstream') return 'cloudstream'; - if (p.category == 'aniyomi') return 'aniyomi'; - if (p.category == 'manga') return 'manga'; - if (p.category == 'mangayomi') return 'mangayomi'; - return switch (p.mode) { - 'hybrid' => 'hybrid', - 'client' => 'local', - _ => 'cloud', - }; -} - -String _providerSheetFilter = 'all'; - void openProviderPicker(BuildContext context, ProviderBloc bloc) { - _ProvidersPage.open(context, bloc); -} - -class _ProvidersPage extends StatefulWidget { - const _ProvidersPage(); - - static void open(BuildContext context, ProviderBloc bloc) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: bloc, - child: const _ProvidersPage(), - ), - ), - ); - } - - @override - State<_ProvidersPage> createState() => _ProvidersPageState(); -} - -class _ProvidersPageState extends State<_ProvidersPage> { - late String _selectedCategory; - final _searchController = TextEditingController(); - String _query = ''; - Timer? _searchDebounce; - - @override - void initState() { - super.initState(); - final hasFavorites = - getIt().getFavoriteProviders().isNotEmpty; - var initial = _providerSheetFilter; - if (initial == 'favorites' && !hasFavorites) initial = 'all'; - if (initial == 'all' && hasFavorites) initial = 'favorites'; - _selectedCategory = initial; - _providerSheetFilter = initial; - } - - @override - void dispose() { - _searchDebounce?.cancel(); - _searchController.dispose(); - super.dispose(); - } - - Future _toggleFavorite(String id) async { - await getIt().toggleFavoriteProvider(id); - if (!mounted) return; - if (_selectedCategory == 'favorites' && - getIt().getFavoriteProviders().isEmpty) { - _selectedCategory = 'all'; - _providerSheetFilter = 'all'; - } - setState(() {}); - } - - @override - Widget build(BuildContext context) { - final bottomPad = MediaQuery.paddingOf(context).bottom; - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - elevation: 0, - title: Text('profile.choose_provider'.tr()), - actions: [ - BlocBuilder( - builder: (context, state) => state is ProviderLoaded - ? Padding( - padding: const EdgeInsets.only(right: 6), - child: _CategoryFilterButton( - providers: state.providers, - selected: _selectedCategory, - onSelected: (cat) => setState(() { - _selectedCategory = cat; - _providerSheetFilter = cat; - }), - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - final filtered = state is ProviderLoaded - ? _filteredProviders(state.providers) - : const []; - final favorites = - getIt().getFavoriteProviders().toSet(); - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), - child: TextField( - controller: _searchController, - onChanged: (v) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 200), - () { - if (mounted) setState(() => _query = v.trim()); - }, - ); - }, - style: const TextStyle(color: Colors.white, fontSize: 14), - textInputAction: TextInputAction.search, - decoration: InputDecoration( - isDense: true, - hintText: 'profile.search_providers_hint'.tr(), - hintStyle: const TextStyle(color: AppColors.textHint), - prefixIcon: const Icon(Icons.search, - color: AppColors.textHint, size: 20), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear, - color: AppColors.textHint, size: 20), - onPressed: () => setState(() { - _searchController.clear(); - _query = ''; - }), - ), - filled: true, - fillColor: AppColors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ), - if (state is ProviderLoaded && state.offline) - _ProvidersOfflineBanner( - usableCount: state.usableProviders.length, - cachedAt: state.cachedAt, - onRetry: () => - context.read().add(const ProviderLoad()), - ), - if (state is ProviderLoaded) - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 16, 6), - child: Text( - _query.isEmpty - ? 'profile.count_of_total_shown'.tr(args: [ - '${filtered.length}', - '${state.providers.length}' - ]) - // A query ignores the category chip, so say so — - // otherwise a hit from a hidden group looks like a bug. - : '${filtered.length} / ${state.providers.length} · ' - '${'profile.searching_all_sources'.tr()}', - style: const TextStyle( - color: AppColors.textHint, fontSize: 12), - ), - ), - ), - Expanded( - child: switch (state) { - ProviderLoaded() => filtered.isEmpty - ? const _ProvidersEmpty() - : _ProvidersList( - providers: filtered, - currentProviderId: state.currentProviderId, - bottomPad: bottomPad, - favorites: favorites, - onToggleFavorite: _toggleFavorite, - unavailableIds: { - for (final p in state.providers) - if (!state.isUsable(p)) p.id, - }, - ), - ProviderError() => _ProvidersError( - onRetry: () => - context.read().add(const ProviderLoad()), - ), - _ => const _ProvidersLoading(), - }, - ), - ], - ); - }, - ), - ); - } - - /// Providers matching the current category + query. - /// - /// **A query searches every provider, not just the active category.** The - /// category chips are a browsing aid; "All" deliberately hides the 260+ - /// CloudStream/Aniyomi/Manga extension sources so the default list stays - /// short. Scoping the search box to that same subset meant typing an - /// installed extension's name in the default view found nothing at all — - /// the one place a user with hundreds of sources actually needs search. - List _filteredProviders(List all) { - final q = _query.trim().toLowerCase(); - if (q.isNotEmpty) { - return all - .where((p) => - p.name.toLowerCase().contains(q) || p.id.toLowerCase().contains(q)) - .toList(); - } - - Iterable list; - if (_selectedCategory == 'favorites') { - final favs = getIt().getFavoriteProviders().toSet(); - list = all.where((p) => favs.contains(p.id)); - } else if (_selectedCategory == 'all') { - list = all.where((p) => - providerGroup(p) != 'cloudstream' && - providerGroup(p) != 'aniyomi' && - providerGroup(p) != 'manga' && - providerGroup(p) != 'mangayomi'); - } else if (_selectedCategory.startsWith('repo:')) { - final repo = _selectedCategory.substring(5); - list = all.where( - (p) => providerGroup(p) == 'cloudstream' && p.description == repo); - } else { - list = all.where((p) => providerGroup(p) == _selectedCategory); - } - return list.toList(); - } -} - -class _CategoryFilterButton extends StatelessWidget { - const _CategoryFilterButton({ - required this.providers, - required this.selected, - required this.onSelected, - }); - - final List providers; - final String selected; - final ValueChanged onSelected; - - static const _canonicalOrder = [ - 'favorites', - 'cloud', - 'hybrid', - 'local', - 'cloudstream', - 'aniyomi', - 'manga', - 'mangayomi', - ]; - - static const _meta = { - 'all': ('All', Icons.apps_rounded), - 'favorites': ('Favorites', Icons.star), - 'cloud': ('Cloud', Icons.cloud_outlined), - 'hybrid': ('Hybrid', Icons.sync_rounded), - 'local': ('Local', Icons.smartphone_outlined), - 'cloudstream':('CloudStream', Icons.extension_outlined), - 'aniyomi': ('Aniyomi', Icons.play_circle_outline), - 'manga': ('Manga', Icons.menu_book_outlined), - 'mangayomi': ('Mangayomi', Icons.javascript_outlined), - }; - - String _label(String key) => - key == 'favorites' ? 'profile.favorites'.tr() : (_meta[key]?.$1 ?? key); - - String _repoShort(String repo) { - final seg = repo.contains('/') ? repo.split('/').last : repo; - return seg.length > 18 ? '${seg.substring(0, 17)}…' : seg; - } - - @override - Widget build(BuildContext context) { - final counts = {}; - for (final p in providers) { - final g = providerGroup(p); - counts[g] = (counts[g] ?? 0) + 1; - } - final favIds = getIt().getFavoriteProviders().toSet(); - final favoriteCount = providers.where((p) => favIds.contains(p.id)).length; - if (favoriteCount > 0) counts['favorites'] = favoriteCount; - final repoCounts = {}; - for (final p in providers) { - if (providerGroup(p) != 'cloudstream') continue; - final r = p.description; - if (r.isEmpty || r == 'CloudStream') continue; - repoCounts[r] = (repoCounts[r] ?? 0) + 1; - } - final available = _canonicalOrder.where(counts.containsKey).toList(); - final repos = repoCounts.keys.toList()..sort(); - if (available.length < 2 && repos.isEmpty) return const SizedBox.shrink(); - - final (String, IconData) selectedMeta = selected.startsWith('repo:') - ? (_repoShort(selected.substring(5)), Icons.folder_outlined) - : (_label(selected), (_meta[selected] ?? _meta['all']!).$2); - final selectedCount = selected == 'all' - ? providers.length - : selected.startsWith('repo:') - ? (repoCounts[selected.substring(5)] ?? 0) - : (counts[selected] ?? 0); - - final entries = <(String, String, IconData, int)>[ - ('all', _meta['all']!.$1, _meta['all']!.$2, providers.length), - ...available.map((cat) { - final meta = _meta[cat] ?? (cat, Icons.label_outline); - return (cat, _label(cat), meta.$2, counts[cat] ?? 0); - }), - ...repos.map((r) => - ('repo:$r', _repoShort(r), Icons.folder_outlined, repoCounts[r] ?? 0)), - ]; - - return PopupMenuButton( - tooltip: 'search.filter'.tr(), - offset: const Offset(0, 44), - color: AppColors.surfaceVariant, - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), - ), - onSelected: onSelected, - itemBuilder: (_) => [ - for (final (id, label, icon, count) in entries) - PopupMenuItem( - value: id, - height: 42, - child: Row( - children: [ - Icon( - icon, - size: 16, - color: selected == id - ? AppColors.primary - : AppColors.textSecondary, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - color: selected == id - ? AppColors.primary - : AppColors.textPrimary, - fontSize: 13.5, - fontWeight: selected == id - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - Text( - '$count', - style: const TextStyle( - color: AppColors.textHint, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - child: Container( - padding: const EdgeInsets.fromLTRB(10, 7, 8, 7), - decoration: BoxDecoration( - color: AppColors.surfaceVariant.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(selectedMeta.$2, size: 14, color: AppColors.textSecondary), - const SizedBox(width: 6), - Text( - selectedMeta.$1, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12.5, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 5), - Text( - '$selectedCount', - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.keyboard_arrow_down_rounded, - size: 16, - color: AppColors.textHint, - ), - ], - ), - ), - ); - } -} - -class _ProvidersList extends StatefulWidget { - const _ProvidersList({ - required this.providers, - required this.currentProviderId, - required this.bottomPad, - required this.favorites, - required this.onToggleFavorite, - this.unavailableIds = const {}, - }); - - final List providers; - final String currentProviderId; - final double bottomPad; - final Set favorites; - final ValueChanged onToggleFavorite; - - /// Providers that exist in the list but cannot serve content right now - /// (server-backed entries while the backend is unreachable). - final Set unavailableIds; - - @override - State<_ProvidersList> createState() => _ProvidersListState(); -} - -class _ProvidersListState extends State<_ProvidersList> { - static const double _estItemExtent = 72.0; - late final ScrollController _controller; - - @override - void initState() { - super.initState(); - final i = widget.providers.indexWhere((p) => p.id == widget.currentProviderId); - final offset = i > 2 ? (i * _estItemExtent - 80).clamp(0.0, double.infinity) : 0.0; - _controller = ScrollController(initialScrollOffset: offset); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListView.builder( - controller: _controller, - padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPad + 16), - addAutomaticKeepAlives: false, - itemExtent: _estItemExtent, - itemCount: widget.providers.length, - itemBuilder: (context, i) { - final provider = widget.providers[i]; - final selected = provider.id == widget.currentProviderId; - final unavailable = widget.unavailableIds.contains(provider.id); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _ProviderListTile( - provider: provider, - selected: selected, - isFavorite: widget.favorites.contains(provider.id), - unavailable: unavailable, - onToggleFavorite: () => widget.onToggleFavorite(provider.id), - onTap: () { - // Refuse the selection outright rather than letting it fail - // three screens later inside a home or detail request. - if (unavailable) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.provider_needs_server'.tr()), - backgroundColor: AppColors.surface, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 3), - ), - ); - return; - } - context.read().add(ProviderSelect(provider.id)); - Navigator.of(context).pop(); - }, - ), - ); - }, - ); - } -} - -class _ProvidersEmpty extends StatelessWidget { - const _ProvidersEmpty(); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_off_rounded, - size: 44, - color: AppColors.textHint.withValues(alpha: 0.7), - ), - const SizedBox(height: 12), - Text( - 'profile.no_providers_in_category'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - 'profile.try_select_all'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.textHint.withValues(alpha: 0.85), - fontSize: 12, - ), - ), - ], - ), - ), - ); - } -} - -class _ProviderListTile extends StatelessWidget { - const _ProviderListTile({ - required this.provider, - required this.selected, - required this.isFavorite, - required this.onToggleFavorite, - required this.onTap, - this.unavailable = false, - }); - - final ProviderEntity provider; - final bool selected; - final bool isFavorite; - final bool unavailable; - final VoidCallback onToggleFavorite; - final VoidCallback onTap; - - bool get _canSolveCloudflare => - provider.id.startsWith('an:') || - provider.id.startsWith('mn:') || - provider.id.startsWith('cs:'); - - Future _solveCloudflare(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final ok = await requestCloudflareSolve(context, provider.id); - messenger.showSnackBar( - SnackBar( - content: Text(ok ? '${'general.done'.tr()} ✓' : 'general.cancel'.tr()), - backgroundColor: AppColors.surface, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: unavailable ? 0.45 : 1, - child: _tile(context), - ); - } - - Widget _tile(BuildContext context) { - return Material( - color: selected - ? AppColors.primary.withValues(alpha: 0.10) - : AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: selected - ? Border.all(color: AppColors.primary, width: 1.2) - : null, - ), - child: InkWell( - onTap: onTap, - onLongPress: - _canSolveCloudflare ? () => _solveCloudflare(context) : null, - onSecondaryTap: - _canSolveCloudflare ? () => _solveCloudflare(context) : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - _ProviderLogo(provider: provider, size: 40), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Flexible( - child: Text( - provider.name, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textSecondary, - fontSize: 14, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - if (unavailable) - const _ServerDownBadge() - else - _ProviderModeBadge(mode: provider.mode), - if (provider.requiresCfBypass) ...[ - const SizedBox(width: 4), - const _CfBypassBadge(), - ], - if (provider.nsfw) ...[ - const SizedBox(width: 4), - const _NsfwBadge(), - ], - ], - ), - if (provider.description.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - provider.description, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - height: 1.25, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: onToggleFavorite, - icon: Icon( - isFavorite ? Icons.star : Icons.star_border, - color: isFavorite ? Colors.amber : AppColors.textHint, - size: 20, - ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 34, - minHeight: 34, - ), - tooltip: 'profile.add_favorite'.tr(), - ), - const SizedBox(width: 2), - if (selected) - Container( - width: 20, - height: 20, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check_rounded, - color: Colors.white, - size: 12, - ), - ) - else - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textHint, - size: 20, - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Replaces the mode badge on a server-backed provider while the API is down, -/// so the reason it is greyed out is readable at a glance. -class _ServerDownBadge extends StatelessWidget { - const _ServerDownBadge(); - - @override - Widget build(BuildContext context) { - const color = AppColors.error; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off_rounded, size: 9, color: color), - const SizedBox(width: 3), - Text( - 'profile.offline_badge'.tr(), - style: const TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ], - ), - ); - } -} - -/// Explains the outage in place, above a list that is still partly usable. -class _ProvidersOfflineBanner extends StatelessWidget { - const _ProvidersOfflineBanner({ - required this.usableCount, - required this.cachedAt, - required this.onRetry, - }); - - final int usableCount; - final DateTime? cachedAt; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 2, 16, 10), - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.border, width: 0.6), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.cloud_off_rounded, - color: AppColors.textSecondary, size: 20), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'profile.offline_title'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - usableCount > 0 - ? 'profile.offline_local_available' - .tr(args: ['$usableCount']) - : 'profile.offline_no_local'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12, - height: 1.4, - ), - ), - if (cachedAt != null) ...[ - const SizedBox(height: 2), - Text( - 'profile.offline_cached_at'.tr(args: [_stamp(cachedAt!)]), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: onRetry, - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - child: Text('general.retry'.tr()), - ), - ], - ), - ); - } - - static String _stamp(DateTime at) { - final l = at.toLocal(); - String two(int v) => v.toString().padLeft(2, '0'); - return '${two(l.day)}.${two(l.month)} ${two(l.hour)}:${two(l.minute)}'; - } -} - -class _ProviderModeBadge extends StatelessWidget { - const _ProviderModeBadge({required this.mode}); - final String mode; - - @override - Widget build(BuildContext context) { - final normalized = mode.toLowerCase(); - final (label, color) = switch (normalized) { - 'client' => ('Local', const Color(0xFF34A853)), - 'hybrid' => ('Hybrid', const Color(0xFFF59E0B)), - 'server' => ('Cloud', const Color(0xFF6B7280)), - _ => (mode.isEmpty ? 'Cloud' : mode, const Color(0xFF6B7280)), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ); - } -} - -class _CfBypassBadge extends StatelessWidget { - const _CfBypassBadge(); - - @override - Widget build(BuildContext context) { - const color = Color(0xFFF38020); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 9, color: color), - SizedBox(width: 3), - Text( - 'CF', - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ], - ), - ); - } -} - -class _NsfwBadge extends StatelessWidget { - const _NsfwBadge(); - - @override - Widget build(BuildContext context) { - const color = Color(0xFFE53935); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: const Text( - '18+', - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ); - } -} - -class _ProvidersLoading extends StatelessWidget { - const _ProvidersLoading(); - - @override - Widget build(BuildContext context) { - return const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textHint, - ), - ); - } -} - -/// Reached only when the backend is unreachable, there is no cached list *and* -/// no plugin is installed — i.e. there is genuinely no working path left. The -/// copy therefore points at installing plugins, which needs no server. -class _ProvidersError extends StatelessWidget { - const _ProvidersError({required this.onRetry}); - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.09)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.06), - ), - child: const Icon( - Icons.cloud_off_rounded, - color: AppColors.textSecondary, - size: 28, - ), - ), - const SizedBox(height: 18), - Text( - 'profile.offline_title'.tr(), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w800, - height: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - 'profile.offline_no_local'.tr(), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - height: 1.5, - ), - ), - const SizedBox(height: 22), - SizedBox( - height: 46, - width: double.infinity, - child: ElevatedButton( - onPressed: onRetry, - child: Text('general.retry'.tr()), - ), - ), - if (CloudStreamChannel.isSupported) ...[ - const SizedBox(height: 10), - SizedBox( - height: 46, - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CloudStreamSourcesPage(), - ), - ), - child: Text('profile.offline_install_plugins'.tr()), - ), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } + ProvidersPage.open(context, bloc); } class _WatchHistorySection extends StatefulWidget { @@ -2127,8 +1107,6 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { onTap: () => context.push('/following'), ), const Divider(color: AppColors.divider, height: 1), - _ConnectionsTile(), - const Divider(color: AppColors.divider, height: 1), _Tile( icon: Icons.devices_rounded, title: BridgeControl.canHost @@ -2151,6 +1129,20 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { } } +/// Sits directly under the streak card: the offer to connect a tracker only +/// works if it is seen, and it was previously buried in the Activity list. +class _ConnectionsSection extends StatelessWidget { + const _ConnectionsSection(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _SectionCard(children: [_ConnectionsTile()]), + ); + } +} + /// The Connections row, which shows AniList's state inline. /// /// A tile of its own rather than a plain link: whether a tracker is connected @@ -2158,6 +1150,8 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { /// navigate to find out defeats the point. Listens to the service so connecting /// elsewhere updates it without a manual refresh. class _ConnectionsTile extends StatefulWidget { + const _ConnectionsTile(); + @override State<_ConnectionsTile> createState() => _ConnectionsTileState(); } @@ -3059,60 +2053,6 @@ class _Initials extends StatelessWidget { } } -class _ProviderLogo extends StatelessWidget { - const _ProviderLogo({required this.provider, this.size = 42}); - final ProviderEntity provider; - final double size; - - @override - Widget build(BuildContext context) { - final cache = (size * MediaQuery.devicePixelRatioOf(context)).round(); - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: provider.image.isEmpty - ? _ProviderFallback(name: provider.name, size: size) - : CachedNetworkImage( - imageUrl: provider.image, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cache, - memCacheHeight: cache, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => - _ProviderFallback(name: provider.name, size: size), - errorWidget: (_, _, _) => - _ProviderFallback(name: provider.name, size: size), - ), - ); - } -} - -class _ProviderFallback extends StatelessWidget { - const _ProviderFallback({required this.name, required this.size}); - final String name; - final double size; - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - color: AppColors.surfaceVariant, - alignment: Alignment.center, - child: Text( - name.isEmpty ? '?' : name[0].toUpperCase(), - style: TextStyle( - color: AppColors.textSecondary, - fontWeight: FontWeight.w800, - fontSize: size * 0.38, - ), - ), - ); - } -} - - class _ServerCountdownTile extends StatefulWidget { const _ServerCountdownTile(); diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart new file mode 100644 index 00000000..6c9b282a --- /dev/null +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -0,0 +1,1111 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; +import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sources_page.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +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'; + +String providerGroup(ProviderEntity p) { + if (p.category == 'cloudstream') return 'cloudstream'; + if (p.category == 'aniyomi') return 'aniyomi'; + if (p.category == 'manga') return 'manga'; + if (p.category == 'mangayomi') return 'mangayomi'; + return switch (p.mode) { + 'hybrid' => 'hybrid', + 'client' => 'local', + _ => 'cloud', + }; +} + +String _providerSheetFilter = 'all'; + +/// Full-screen provider picker, routed at `/providers` and also pushed +/// imperatively from the home top bar and the outage banner. +class ProvidersPage extends StatefulWidget { + const ProvidersPage({super.key}); + + /// Push carrying an explicit [ProviderBloc], for callers that cannot rely on + /// an ambient one. + static void open(BuildContext context, ProviderBloc bloc) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: bloc, + child: const ProvidersPage(), + ), + ), + ); + } + + @override + State createState() => _ProvidersPageState(); +} + +class _ProvidersPageState extends State { + late String _selectedCategory; + final _searchController = TextEditingController(); + String _query = ''; + Timer? _searchDebounce; + + @override + void initState() { + super.initState(); + final hasFavorites = + getIt().getFavoriteProviders().isNotEmpty; + var initial = _providerSheetFilter; + if (initial == 'favorites' && !hasFavorites) initial = 'all'; + if (initial == 'all' && hasFavorites) initial = 'favorites'; + _selectedCategory = initial; + _providerSheetFilter = initial; + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _toggleFavorite(String id) async { + await getIt().toggleFavoriteProvider(id); + if (!mounted) return; + if (_selectedCategory == 'favorites' && + getIt().getFavoriteProviders().isEmpty) { + _selectedCategory = 'all'; + _providerSheetFilter = 'all'; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final bottomPad = MediaQuery.paddingOf(context).bottom; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('profile.choose_provider'.tr()), + actions: [ + BlocBuilder( + builder: (context, state) => state is ProviderLoaded + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: _CategoryFilterButton( + providers: state.providers, + selected: _selectedCategory, + onSelected: (cat) => setState(() { + _selectedCategory = cat; + _providerSheetFilter = cat; + }), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + final filtered = state is ProviderLoaded + ? _filteredProviders(state.providers) + : const []; + final favorites = + getIt().getFavoriteProviders().toSet(); + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), + child: TextField( + controller: _searchController, + onChanged: (v) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 200), + () { + if (mounted) setState(() => _query = v.trim()); + }, + ); + }, + style: const TextStyle(color: Colors.white, fontSize: 14), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + hintText: 'profile.search_providers_hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: const Icon(Icons.search, + color: AppColors.textHint, size: 20), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear, + color: AppColors.textHint, size: 20), + onPressed: () => setState(() { + _searchController.clear(); + _query = ''; + }), + ), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (state is ProviderLoaded && state.offline) + _ProvidersOfflineBanner( + usableCount: state.usableProviders.length, + cachedAt: state.cachedAt, + onRetry: () => + context.read().add(const ProviderLoad()), + ), + if (state is ProviderLoaded) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 16, 6), + child: Text( + _query.isEmpty + ? 'profile.count_of_total_shown'.tr(args: [ + '${filtered.length}', + '${state.providers.length}' + ]) + // A query ignores the category chip, so say so — + // otherwise a hit from a hidden group looks like a bug. + : '${filtered.length} / ${state.providers.length} · ' + '${'profile.searching_all_sources'.tr()}', + style: const TextStyle( + color: AppColors.textHint, fontSize: 12), + ), + ), + ), + Expanded( + child: switch (state) { + ProviderLoaded() => filtered.isEmpty + ? const _ProvidersEmpty() + : _ProvidersList( + providers: filtered, + currentProviderId: state.currentProviderId, + bottomPad: bottomPad, + favorites: favorites, + onToggleFavorite: _toggleFavorite, + unavailableIds: { + for (final p in state.providers) + if (!state.isUsable(p)) p.id, + }, + ), + ProviderError() => _ProvidersError( + onRetry: () => + context.read().add(const ProviderLoad()), + ), + _ => const _ProvidersLoading(), + }, + ), + ], + ); + }, + ), + ); + } + + /// Providers matching the current category + query. + /// + /// **A query searches every provider, not just the active category.** The + /// category chips are a browsing aid; "All" deliberately hides the 260+ + /// CloudStream/Aniyomi/Manga extension sources so the default list stays + /// short. Scoping the search box to that same subset meant typing an + /// installed extension's name in the default view found nothing at all — + /// the one place a user with hundreds of sources actually needs search. + List _filteredProviders(List all) { + final q = _query.trim().toLowerCase(); + if (q.isNotEmpty) { + return all + .where((p) => + p.name.toLowerCase().contains(q) || p.id.toLowerCase().contains(q)) + .toList(); + } + + Iterable list; + if (_selectedCategory == 'favorites') { + final favs = getIt().getFavoriteProviders().toSet(); + list = all.where((p) => favs.contains(p.id)); + } else if (_selectedCategory == 'all') { + list = all.where((p) => + providerGroup(p) != 'cloudstream' && + providerGroup(p) != 'aniyomi' && + providerGroup(p) != 'manga' && + providerGroup(p) != 'mangayomi'); + } else if (_selectedCategory.startsWith('repo:')) { + final repo = _selectedCategory.substring(5); + list = all.where( + (p) => providerGroup(p) == 'cloudstream' && p.description == repo); + } else { + list = all.where((p) => providerGroup(p) == _selectedCategory); + } + return list.toList(); + } +} + +class _CategoryFilterButton extends StatelessWidget { + const _CategoryFilterButton({ + required this.providers, + required this.selected, + required this.onSelected, + }); + + final List providers; + final String selected; + final ValueChanged onSelected; + + static const _canonicalOrder = [ + 'favorites', + 'cloud', + 'hybrid', + 'local', + 'cloudstream', + 'aniyomi', + 'manga', + 'mangayomi', + ]; + + static const _meta = { + 'all': ('All', Icons.apps_rounded), + 'favorites': ('Favorites', Icons.star), + 'cloud': ('Cloud', Icons.cloud_outlined), + 'hybrid': ('Hybrid', Icons.sync_rounded), + 'local': ('Local', Icons.smartphone_outlined), + 'cloudstream':('CloudStream', Icons.extension_outlined), + 'aniyomi': ('Aniyomi', Icons.play_circle_outline), + 'manga': ('Manga', Icons.menu_book_outlined), + 'mangayomi': ('Mangayomi', Icons.javascript_outlined), + }; + + String _label(String key) => + key == 'favorites' ? 'profile.favorites'.tr() : (_meta[key]?.$1 ?? key); + + String _repoShort(String repo) { + final seg = repo.contains('/') ? repo.split('/').last : repo; + return seg.length > 18 ? '${seg.substring(0, 17)}…' : seg; + } + + @override + Widget build(BuildContext context) { + final counts = {}; + for (final p in providers) { + final g = providerGroup(p); + counts[g] = (counts[g] ?? 0) + 1; + } + final favIds = getIt().getFavoriteProviders().toSet(); + final favoriteCount = providers.where((p) => favIds.contains(p.id)).length; + if (favoriteCount > 0) counts['favorites'] = favoriteCount; + final repoCounts = {}; + for (final p in providers) { + if (providerGroup(p) != 'cloudstream') continue; + final r = p.description; + if (r.isEmpty || r == 'CloudStream') continue; + repoCounts[r] = (repoCounts[r] ?? 0) + 1; + } + final available = _canonicalOrder.where(counts.containsKey).toList(); + final repos = repoCounts.keys.toList()..sort(); + if (available.length < 2 && repos.isEmpty) return const SizedBox.shrink(); + + final (String, IconData) selectedMeta = selected.startsWith('repo:') + ? (_repoShort(selected.substring(5)), Icons.folder_outlined) + : (_label(selected), (_meta[selected] ?? _meta['all']!).$2); + final selectedCount = selected == 'all' + ? providers.length + : selected.startsWith('repo:') + ? (repoCounts[selected.substring(5)] ?? 0) + : (counts[selected] ?? 0); + + final entries = <(String, String, IconData, int)>[ + ('all', _meta['all']!.$1, _meta['all']!.$2, providers.length), + ...available.map((cat) { + final meta = _meta[cat] ?? (cat, Icons.label_outline); + return (cat, _label(cat), meta.$2, counts[cat] ?? 0); + }), + ...repos.map((r) => + ('repo:$r', _repoShort(r), Icons.folder_outlined, repoCounts[r] ?? 0)), + ]; + + return PopupMenuButton( + tooltip: 'search.filter'.tr(), + offset: const Offset(0, 44), + color: AppColors.surfaceVariant, + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + onSelected: onSelected, + itemBuilder: (_) => [ + for (final (id, label, icon, count) in entries) + PopupMenuItem( + value: id, + height: 42, + child: Row( + children: [ + Icon( + icon, + size: 16, + color: selected == id + ? AppColors.primary + : AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: selected == id + ? AppColors.primary + : AppColors.textPrimary, + fontSize: 13.5, + fontWeight: selected == id + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Text( + '$count', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.fromLTRB(10, 7, 8, 7), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(selectedMeta.$2, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + selectedMeta.$1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 5), + Text( + '$selectedCount', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), + ); + } +} + +class _ProvidersList extends StatefulWidget { + const _ProvidersList({ + required this.providers, + required this.currentProviderId, + required this.bottomPad, + required this.favorites, + required this.onToggleFavorite, + this.unavailableIds = const {}, + }); + + final List providers; + final String currentProviderId; + final double bottomPad; + final Set favorites; + final ValueChanged onToggleFavorite; + + /// Providers that exist in the list but cannot serve content right now + /// (server-backed entries while the backend is unreachable). + final Set unavailableIds; + + @override + State<_ProvidersList> createState() => _ProvidersListState(); +} + +class _ProvidersListState extends State<_ProvidersList> { + static const double _estItemExtent = 72.0; + late final ScrollController _controller; + + @override + void initState() { + super.initState(); + final i = widget.providers.indexWhere((p) => p.id == widget.currentProviderId); + final offset = i > 2 ? (i * _estItemExtent - 80).clamp(0.0, double.infinity) : 0.0; + _controller = ScrollController(initialScrollOffset: offset); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: _controller, + padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPad + 16), + addAutomaticKeepAlives: false, + itemExtent: _estItemExtent, + itemCount: widget.providers.length, + itemBuilder: (context, i) { + final provider = widget.providers[i]; + final selected = provider.id == widget.currentProviderId; + final unavailable = widget.unavailableIds.contains(provider.id); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ProviderListTile( + provider: provider, + selected: selected, + isFavorite: widget.favorites.contains(provider.id), + unavailable: unavailable, + onToggleFavorite: () => widget.onToggleFavorite(provider.id), + onTap: () { + // Refuse the selection outright rather than letting it fail + // three screens later inside a home or detail request. + if (unavailable) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('profile.provider_needs_server'.tr()), + backgroundColor: AppColors.surface, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + ), + ); + return; + } + context.read().add(ProviderSelect(provider.id)); + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } +} + +class _ProvidersEmpty extends StatelessWidget { + const _ProvidersEmpty(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off_rounded, + size: 44, + color: AppColors.textHint.withValues(alpha: 0.7), + ), + const SizedBox(height: 12), + Text( + 'profile.no_providers_in_category'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'profile.try_select_all'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textHint.withValues(alpha: 0.85), + fontSize: 12, + ), + ), + ], + ), + ), + ); + } +} + +class _ProviderListTile extends StatelessWidget { + const _ProviderListTile({ + required this.provider, + required this.selected, + required this.isFavorite, + required this.onToggleFavorite, + required this.onTap, + this.unavailable = false, + }); + + final ProviderEntity provider; + final bool selected; + final bool isFavorite; + final bool unavailable; + final VoidCallback onToggleFavorite; + final VoidCallback onTap; + + bool get _canSolveCloudflare => + provider.id.startsWith('an:') || + provider.id.startsWith('mn:') || + provider.id.startsWith('cs:'); + + Future _solveCloudflare(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final ok = await requestCloudflareSolve(context, provider.id); + messenger.showSnackBar( + SnackBar( + content: Text(ok ? '${'general.done'.tr()} ✓' : 'general.cancel'.tr()), + backgroundColor: AppColors.surface, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: unavailable ? 0.45 : 1, + child: _tile(context), + ); + } + + Widget _tile(BuildContext context) { + return Material( + color: selected + ? AppColors.primary.withValues(alpha: 0.10) + : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: selected + ? Border.all(color: AppColors.primary, width: 1.2) + : null, + ), + child: InkWell( + onTap: onTap, + onLongPress: + _canSolveCloudflare ? () => _solveCloudflare(context) : null, + onSecondaryTap: + _canSolveCloudflare ? () => _solveCloudflare(context) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + _ProviderLogo(provider: provider, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + provider.name, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 14, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + if (unavailable) + const _ServerDownBadge() + else + _ProviderModeBadge(mode: provider.mode), + if (provider.requiresCfBypass) ...[ + const SizedBox(width: 4), + const _CfBypassBadge(), + ], + if (provider.nsfw) ...[ + const SizedBox(width: 4), + const _NsfwBadge(), + ], + ], + ), + if (provider.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + provider.description, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + height: 1.25, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: onToggleFavorite, + icon: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: isFavorite ? Colors.amber : AppColors.textHint, + size: 20, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 34, + minHeight: 34, + ), + tooltip: 'profile.add_favorite'.tr(), + ), + const SizedBox(width: 2), + if (selected) + Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + color: Colors.white, + size: 12, + ), + ) + else + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Replaces the mode badge on a server-backed provider while the API is down, +/// so the reason it is greyed out is readable at a glance. +class _ServerDownBadge extends StatelessWidget { + const _ServerDownBadge(); + + @override + Widget build(BuildContext context) { + const color = AppColors.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_rounded, size: 9, color: color), + const SizedBox(width: 3), + Text( + 'profile.offline_badge'.tr(), + style: const TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +/// Explains the outage in place, above a list that is still partly usable. +class _ProvidersOfflineBanner extends StatelessWidget { + const _ProvidersOfflineBanner({ + required this.usableCount, + required this.cachedAt, + required this.onRetry, + }); + + final int usableCount; + final DateTime? cachedAt; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 2, 16, 10), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.cloud_off_rounded, + color: AppColors.textSecondary, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'profile.offline_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + usableCount > 0 + ? 'profile.offline_local_available' + .tr(args: ['$usableCount']) + : 'profile.offline_no_local'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + height: 1.4, + ), + ), + if (cachedAt != null) ...[ + const SizedBox(height: 2), + Text( + 'profile.offline_cached_at'.tr(args: [_stamp(cachedAt!)]), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ); + } + + static String _stamp(DateTime at) { + final l = at.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(l.day)}.${two(l.month)} ${two(l.hour)}:${two(l.minute)}'; + } +} + +class _ProviderModeBadge extends StatelessWidget { + const _ProviderModeBadge({required this.mode}); + final String mode; + + @override + Widget build(BuildContext context) { + final normalized = mode.toLowerCase(); + final (label, color) = switch (normalized) { + 'client' => ('Local', const Color(0xFF34A853)), + 'hybrid' => ('Hybrid', const Color(0xFFF59E0B)), + 'server' => ('Cloud', const Color(0xFF6B7280)), + _ => (mode.isEmpty ? 'Cloud' : mode, const Color(0xFF6B7280)), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ); + } +} + +class _CfBypassBadge extends StatelessWidget { + const _CfBypassBadge(); + + @override + Widget build(BuildContext context) { + const color = Color(0xFFF38020); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 9, color: color), + SizedBox(width: 3), + Text( + 'CF', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +class _NsfwBadge extends StatelessWidget { + const _NsfwBadge(); + + @override + Widget build(BuildContext context) { + const color = Color(0xFFE53935); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: const Text( + '18+', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ); + } +} + +class _ProvidersLoading extends StatelessWidget { + const _ProvidersLoading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ); + } +} + +/// Reached only when the backend is unreachable, there is no cached list *and* +/// no plugin is installed — i.e. there is genuinely no working path left. The +/// copy therefore points at installing plugins, which needs no server. +class _ProvidersError extends StatelessWidget { + const _ProvidersError({required this.onRetry}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.09)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.06), + ), + child: const Icon( + Icons.cloud_off_rounded, + color: AppColors.textSecondary, + size: 28, + ), + ), + const SizedBox(height: 18), + Text( + 'profile.offline_title'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 8), + Text( + 'profile.offline_no_local'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: 22), + SizedBox( + height: 46, + width: double.infinity, + child: ElevatedButton( + onPressed: onRetry, + child: Text('general.retry'.tr()), + ), + ), + if (CloudStreamChannel.isSupported) ...[ + const SizedBox(height: 10), + SizedBox( + height: 46, + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CloudStreamSourcesPage(), + ), + ), + child: Text('profile.offline_install_plugins'.tr()), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ProviderLogo extends StatelessWidget { + const _ProviderLogo({required this.provider, this.size = 42}); + final ProviderEntity provider; + final double size; + + @override + Widget build(BuildContext context) { + final cache = (size * MediaQuery.devicePixelRatioOf(context)).round(); + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: provider.image.isEmpty + ? _ProviderFallback(name: provider.name, size: size) + : CachedNetworkImage( + imageUrl: provider.image, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cache, + memCacheHeight: cache, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => + _ProviderFallback(name: provider.name, size: size), + errorWidget: (_, _, _) => + _ProviderFallback(name: provider.name, size: size), + ), + ); + } +} + +class _ProviderFallback extends StatelessWidget { + const _ProviderFallback({required this.name, required this.size}); + final String name; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + color: AppColors.surfaceVariant, + alignment: Alignment.center, + child: Text( + name.isEmpty ? '?' : name[0].toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontWeight: FontWeight.w800, + fontSize: size * 0.38, + ), + ), + ); + } +} diff --git a/lib/features/search/data/datasources/search_data_source.dart b/lib/features/search/data/datasources/search_data_source.dart index a78f30e4..455abd76 100644 --- a/lib/features/search/data/datasources/search_data_source.dart +++ b/lib/features/search/data/datasources/search_data_source.dart @@ -31,10 +31,21 @@ class SearchDataSource { return SearchModel.fromJson(response.data); } - Future searchMovies(String query, {int page = 1}) async { + /// [provider] overrides the interceptor's "current provider", which is what + /// lets cross-search treat each selected server provider as its own leg + /// instead of collapsing them into one. + Future searchMovies( + String query, { + int page = 1, + String? provider, + }) async { var response = await dio.get( '/contents/search', - queryParameters: {'q': query, "page": page}, + queryParameters: { + 'q': query, + 'page': page, + if (provider != null && provider.isNotEmpty) 'provider': provider, + }, ); return SearchModel.fromJson(response.data); } diff --git a/lib/features/search/data/repositories/search_repository_imp.dart b/lib/features/search/data/repositories/search_repository_imp.dart index d9d0999a..e0c97e45 100644 --- a/lib/features/search/data/repositories/search_repository_imp.dart +++ b/lib/features/search/data/repositories/search_repository_imp.dart @@ -84,7 +84,11 @@ class SearchRepositoryImp extends SearchRepository { } @override - Future> searchMovies(String query, {int page = 1}) async { + Future> searchMovies( + String query, { + int page = 1, + String? genre, + }) async { final js = jsRuntime; final provider = _currentProvider; if (provider != null && provider.startsWith('cs:')) { diff --git a/lib/features/search/data/search_recents_store.dart b/lib/features/search/data/search_recents_store.dart new file mode 100644 index 00000000..f55b7e8f --- /dev/null +++ b/lib/features/search/data/search_recents_store.dart @@ -0,0 +1,53 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; + +/// The last queries the user actually ran, so the idle search screen has +/// something on it. Every operation is best-effort: search must keep working +/// even if the settings box is unavailable (tests, first run). +class SearchRecentsStore { + static const String _key = 'search_recent_queries'; + static const int maxEntries = 8; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + List load() { + final raw = _box?.get(_key); + if (raw is! List) return const []; + return raw.map((e) => e.toString()).where((e) => e.isNotEmpty).toList(); + } + + Future> add(String query) async { + final q = query.trim(); + if (q.isEmpty) return load(); + final list = load() + ..removeWhere((e) => e.toLowerCase() == q.toLowerCase()) + ..insert(0, q); + final trimmed = list.take(maxEntries).toList(); + await _write(trimmed); + return trimmed; + } + + Future> remove(String query) async { + final list = load() + ..removeWhere((e) => e.toLowerCase() == query.trim().toLowerCase()); + await _write(list); + return list; + } + + Future> clear() async { + await _write(const []); + return const []; + } + + Future _write(List list) async { + try { + await _box?.put(_key, list); + } catch (_) {} + } +} diff --git a/lib/features/search/domain/entities/cross_search_result.dart b/lib/features/search/domain/entities/cross_search_result.dart index 88375da8..9cab46c1 100644 --- a/lib/features/search/domain/entities/cross_search_result.dart +++ b/lib/features/search/domain/entities/cross_search_result.dart @@ -57,11 +57,119 @@ class ProviderSearchResult { required this.provider, required this.items, required this.status, + this.page = 1, + this.totalPages = 1, + this.message = '', }); final ProviderRef provider; final List items; final ProviderSearchStatus status; + final int page; + final int totalPages; + + /// Why this leg failed, when it did. Shown next to the source's name instead + /// of being reduced to a count of anonymous failures. + final String message; bool get hasItems => items.isNotEmpty; + bool get hasMore => page < totalPages; + + ProviderSearchResult copyWith({ + List? items, + ProviderSearchStatus? status, + int? page, + int? totalPages, + String? message, + }) => + ProviderSearchResult( + provider: provider, + items: items ?? this.items, + status: status ?? this.status, + page: page ?? this.page, + totalPages: totalPages ?? this.totalPages, + message: message ?? this.message, + ); +} + +/// One source's copy of a title. +class TitleHit { + const TitleHit({required this.provider, required this.item}); + + final ProviderRef provider; + final MovieEntity item; +} + +/// The same title as carried by one or more sources. +/// +/// This is the whole point of cross-search: without it the user has to scroll +/// N per-source rails to discover that one title is on N sources. +class MergedSearchTitle { + MergedSearchTitle({required this.key, required this.hits, this.year}); + + final String key; + final List hits; + final int? year; + + MovieEntity get primary => hits.first.item; + ProviderRef get primaryProvider => hits.first.provider; + int get sourceCount => hits.map((h) => h.provider.id).toSet().length; +} + +/// Title key used to merge hits across sources: lowercased, punctuation and a +/// trailing "(year)" removed, leading article dropped, whitespace collapsed. +String normalizedTitleKey(String title) { + var t = title.toLowerCase().trim(); + t = t.replaceAll(RegExp(r'[\(\[]\s*(19|20)\d{2}\s*[\)\]]'), ' '); + t = t.replaceAll(RegExp(r"[^a-z0-9\u0400-\u04ff\u0600-\u06ff ]+"), ' '); + t = t.replaceAll(RegExp(r'\s+'), ' ').trim(); + for (final article in const ['the ', 'a ', 'an ']) { + if (t.startsWith(article)) { + t = t.substring(article.length); + break; + } + } + return t; +} + +/// Groups every leg's items into one list of titles. +/// +/// Two hits merge when their [normalizedTitleKey] matches and their years are +/// compatible — equal, or missing on at least one side, because extension hosts +/// almost never populate a year. +List mergeSearchResults(List legs) { + final groups = >{}; + final ordered = []; + + for (final leg in legs) { + for (final item in leg.items) { + final key = normalizedTitleKey(item.title); + if (key.isEmpty) continue; + final bucket = groups.putIfAbsent(key, () => []); + final match = bucket + .where((g) => g.year == null || item.year == null || g.year == item.year) + .firstOrNull; + if (match == null) { + final group = MergedSearchTitle( + key: key, + year: item.year, + hits: [TitleHit(provider: leg.provider, item: item)], + ); + bucket.add(group); + ordered.add(group); + } else { + match.hits.add(TitleHit(provider: leg.provider, item: item)); + } + } + } + + // Ties keep arrival order: a rail that reorders under the user's finger is + // how a tap lands on a different title than the one that was under it. + final indexed = [ + for (var i = 0; i < ordered.length; i++) (index: i, group: ordered[i]), + ]..sort((a, b) { + final byCount = b.group.sourceCount.compareTo(a.group.sourceCount); + return byCount != 0 ? byCount : a.index.compareTo(b.index); + }); + return [for (final e in indexed) e.group]; } diff --git a/lib/features/search/domain/repositories/search_repository.dart b/lib/features/search/domain/repositories/search_repository.dart index 7f9ff3b7..b1c818b2 100644 --- a/lib/features/search/domain/repositories/search_repository.dart +++ b/lib/features/search/domain/repositories/search_repository.dart @@ -7,5 +7,9 @@ abstract class SearchRepository { Future> getMoviesByGenre(String genre, {int page = 1}); + /// Text search. Genre is NOT a parameter: the server's /contents/search + /// reads only q, page and provider, so a genre passed here was silently + /// dropped and the caller got an unfiltered search back. Browsing a genre + /// goes through [getMoviesByGenre]. Future> searchMovies(String query, {int page = 1}); } diff --git a/lib/features/search/domain/services/cross_search_engine.dart b/lib/features/search/domain/services/cross_search_engine.dart index 25d0053e..ca62d110 100644 --- a/lib/features/search/domain/services/cross_search_engine.dart +++ b/lib/features/search/domain/services/cross_search_engine.dart @@ -10,6 +10,8 @@ import 'package:soplay/features/search/data/datasources/search_data_source.dart' import 'package:soplay/features/search/data/model/search_model.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; +typedef _Leg = ({List items, int page, int totalPages}); + /// Fans a query out across a set of providers with **bounded concurrency**, a /// **per-provider timeout**, and **incremental** emission — the core reason the /// feature never freezes even with a large provider set: @@ -48,34 +50,18 @@ class CrossSearchEngine { /// this ceiling is only ever paid once per source. static const Duration channelTimeout = Duration(seconds: 45); - /// A synthetic id used for the single collapsed backend ("Sozo") search leg. - static const String serverId = '__server__'; - + /// Every selected provider is its own leg, server providers included: the + /// backend takes an explicit `provider`, so collapsing them into one call was + /// both a lie in the summary ("1 of 1 sources") and a silent no-op for every + /// server provider the user picked beyond the first. Stream search({ required List set, required String query, + int page = 1, int concurrency = defaultConcurrency, Duration perProviderTimeout = defaultTimeout, }) { - // Collapse every server provider into a single backend call — the backend - // search endpoint is provider-agnostic, so N server providers = 1 leg. - final tasks = []; - var hasServer = false; - for (final p in set) { - if (p.kind == ProviderKind.server) { - hasServer = true; - } else { - tasks.add(p); - } - } - if (hasServer) { - tasks.add(const ProviderRef( - id: serverId, - name: 'Sozo', - kind: ProviderKind.server, - )); - } - + final tasks = List.of(set); final controller = StreamController(); var cancelled = false; controller.onCancel = () => cancelled = true; @@ -87,7 +73,12 @@ class CrossSearchEngine { while (!cancelled) { final i = next++; if (i >= tasks.length) return; - final result = await _searchOne(tasks[i], query, perProviderTimeout); + final result = await searchProvider( + tasks[i], + query, + page: page, + timeout: perProviderTimeout, + ); if (cancelled || controller.isClosed) return; controller.add(result); } @@ -97,28 +88,33 @@ class CrossSearchEngine { if (!controller.isClosed) await controller.close(); } - // Fire-and-forget; every error is captured inside [_searchOne]. + // Fire-and-forget; every error is captured inside [searchProvider]. unawaited(drain()); return controller.stream; } - Future _searchOne( + /// One leg on its own — used to retry a single failed source and to page it. + Future searchProvider( ProviderRef ref, - String query, - Duration timeout, - ) async { + String query, { + int page = 1, + Duration timeout = defaultTimeout, + }) async { // Extension hosts get the longer budget — see [channelTimeout]. final effective = ref.kind == ProviderKind.channel && timeout < channelTimeout ? channelTimeout : timeout; try { - final items = await _dispatch(ref, query).timeout(effective); + final leg = await _dispatch(ref, query, page).timeout(effective); return ProviderSearchResult( provider: ref, - items: items, - status: - items.isEmpty ? ProviderSearchStatus.empty : ProviderSearchStatus.ok, + items: leg.items, + page: leg.page, + totalPages: leg.totalPages, + status: leg.items.isEmpty + ? ProviderSearchStatus.empty + : ProviderSearchStatus.ok, ); } on TimeoutException { return ProviderSearchResult( @@ -126,11 +122,12 @@ class CrossSearchEngine { items: const [], status: ProviderSearchStatus.timeout, ); - } catch (_) { + } catch (e) { return ProviderSearchResult( provider: ref, items: const [], status: ProviderSearchStatus.error, + message: e.toString().replaceFirst('Exception: ', ''), ); } } @@ -141,40 +138,55 @@ class CrossSearchEngine { /// or an `error` field from the host) so the leg is reported as `error` /// rather than `empty` — "this source is down" and "no match here" look /// identical to the user otherwise, and only one of them is worth retrying. - List _unwrap(Map map, String label) { + _Leg _unwrap(Map map, String label) { if (map.isEmpty) throw Exception('$label: source unavailable'); final model = SearchModel.fromJson(map); final error = (map['error'] as String?)?.trim(); if (model.items.isEmpty && error != null && error.isNotEmpty) { throw Exception('$label: $error'); } - return model.items; + return (items: model.items, page: model.page, totalPages: model.totalPages); } - Future> _dispatch(ProviderRef ref, String query) async { + Future<_Leg> _dispatch(ProviderRef ref, String query, int page) async { final id = ref.id; if (id.startsWith('cs:')) { return _unwrap( - await CloudStreamChannel.search(id.substring(3), query), ref.name); + await CloudStreamChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('an:')) { return _unwrap( - await AniyomiChannel.search(id.substring(3), query), ref.name); + await AniyomiChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('mn:')) { return _unwrap( - await MangaChannel.search(id.substring(3), query), ref.name); + await MangaChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('my:')) { return _unwrap( - await mangayomi.search(id.substring(3), query), ref.name); + await mangayomi.search(id.substring(3), query, page: page), + ref.name, + ); } if (ref.kind == ProviderKind.js) { - final map = await jsRuntime.trySearch(id, query, 1); - return map == null ? const [] : SearchModel.fromJson(map).items; + final map = await jsRuntime.trySearch(id, query, page); + // A null response means the extractor is missing or failed to load. That + // is a broken source, not "no match here" — reporting it as empty is the + // one place this engine used to invert its own distinction. + if (map == null) throw Exception('${ref.name}: source unavailable'); + return _unwrap(map, ref.name); } - // server — provider-agnostic backend search. - final model = await dataSource.searchMovies(query); - return model.items; + final model = await dataSource.searchMovies( + query, + page: page, + provider: ref.id, + ); + return (items: model.items, page: model.page, totalPages: model.totalPages); } } diff --git a/lib/features/search/presentation/blocs/cross_search_controller.dart b/lib/features/search/presentation/blocs/cross_search_controller.dart index cf608920..d135d57c 100644 --- a/lib/features/search/presentation/blocs/cross_search_controller.dart +++ b/lib/features/search/presentation/blocs/cross_search_controller.dart @@ -1,10 +1,18 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; +import 'package:soplay/features/search/presentation/blocs/search_query_policy.dart'; -/// Drives one cross-search page: debounces input, runs the engine, collects +/// Where a cross-search run is, so the UI can never claim a finished search it +/// has not started: [pending] is "the query changed, the fan-out has not begun", +/// which is exactly the window in which the old query's counts used to be +/// presented as the new query's answer. +enum CrossSearchPhase { idle, pending, running, done } + +/// Drives one cross-search surface: debounces input, runs the engine, collects /// results incrementally, and cancels the previous run on every new query. class CrossSearchController extends ChangeNotifier { CrossSearchController({required this.engine, required List set}) @@ -13,89 +21,191 @@ class CrossSearchController extends ChangeNotifier { final CrossSearchEngine engine; List _set; - Timer? _debounce; + final QueryDebouncer _debouncer = QueryDebouncer(); StreamSubscription? _sub; int _token = 0; String _query = ''; - bool _searching = false; + CrossSearchPhase _phase = CrossSearchPhase.idle; + + /// Keyed by provider id, but read back in [providerSet] order so a leg that + /// lands late cannot make the sections jump around under the user's finger. final Map _results = {}; + final Set _retrying = {}; + List _merged = const []; + + static const int _cacheEntries = 6; + static const Duration _cacheTtl = Duration(minutes: 5); + final LinkedHashMap _cache = LinkedHashMap(); String get query => _query; - bool get searching => _searching; + CrossSearchPhase get phase => _phase; + bool get searching => + _phase == CrossSearchPhase.pending || _phase == CrossSearchPhase.running; List get providerSet => _set; - /// Number of search legs (server providers collapse into one). - int get expectedLegs { - var legs = 0; - var hasServer = false; - for (final p in _set) { - if (p.kind == ProviderKind.server) { - hasServer = true; - } else { - legs++; - } - } - return legs + (hasServer ? 1 : 0); - } - + int get expectedLegs => _set.length; int get completedLegs => _results.length; int get totalItems => _results.values.fold(0, (s, r) => s + r.items.length); - int get sourcesWithResults => - _results.values.where((r) => r.hasItems).length; - - /// Results ordered so sources with hits come first, then empty/timeout/error. - List get results { - final out = _results.values.toList(); - int rank(ProviderSearchResult r) => switch (r.status) { - ProviderSearchStatus.ok => 0, - ProviderSearchStatus.empty => 1, - ProviderSearchStatus.timeout => 2, - ProviderSearchStatus.error => 3, - }; - out.sort((a, b) => rank(a).compareTo(rank(b))); - return out; - } + int get sourcesWithResults => _results.values.where((r) => r.hasItems).length; + + /// Every leg in the selected order — answered or not. + List get results => [ + for (final ref in _set) + if (_results[ref.id] != null) _results[ref.id]!, + ]; + + List get legsWithItems => + results.where((r) => r.hasItems).toList(); + + List get failedLegs => results + .where((r) => + r.status == ProviderSearchStatus.timeout || + r.status == ProviderSearchStatus.error) + .toList(); - /// How many distinct providers returned a title (normalized title + year). - /// Only exact normalized-key matches count — no fuzzy merging. - Map _crossSourceCounts = const {}; - int crossSourceCount(String title, int? year) => - _crossSourceCounts[_key(title, year)] ?? 0; + /// Sources that have not answered yet, by name — a progress line that names + /// what it is waiting for instead of counting anonymous legs. + List get pendingSources => [ + for (final ref in _set) + if (!_results.containsKey(ref.id)) ref.name, + ]; - static String _key(String title, int? year) => - '${title.trim().toLowerCase()}|${year ?? ''}'; + bool isRetrying(String providerId) => _retrying.contains(providerId); - void onQueryChanged(String q) { - _debounce?.cancel(); - final trimmed = q.trim(); + /// One card per title, contributed by one or more sources. + List get merged => _merged; + + bool get hasMoreAnywhere => results.any((r) => r.hasMore); + + void onQueryChanged(String raw) { + final trimmed = SearchQueryPolicy.normalize(raw); + if (trimmed == _query) return; _query = trimmed; + + _cancel(); + _results.clear(); + _retrying.clear(); + _merged = const []; + if (trimmed.isEmpty) { - _cancel(); - _results.clear(); - _crossSourceCounts = const {}; - _searching = false; + _phase = CrossSearchPhase.idle; notifyListeners(); return; } - _debounce = Timer(const Duration(milliseconds: 450), () => _run(trimmed)); + + if (_restoreFromCache(trimmed)) return; + + _debouncer.reset(); + // A query below the minimum length arms nothing, so claiming "pending" + // would leave the page spinning against a request that never happens. + _phase = _debouncer.schedule(trimmed, _run) + ? CrossSearchPhase.pending + : CrossSearchPhase.idle; + notifyListeners(); + } + + /// The keyboard's Search key: no debounce, no minimum length. + void submit(String raw) { + final trimmed = SearchQueryPolicy.normalize(raw); + if (trimmed.isEmpty) return; + _query = trimmed; + _debouncer.reset(); + _run(trimmed); } void setProviderSet(List set) { _set = set; - if (_query.isNotEmpty) _run(_query); + _cache.clear(); + if (_query.isEmpty) { + notifyListeners(); + return; + } + _run(_query); } - void retry() { - if (_query.isNotEmpty) _run(_query); + /// Re-runs a single source. A first-ever extension search has to download and + /// dex-load an APK, so a timeout here is expected and needs to be actionable + /// per source rather than as one anonymous "2 timed out". + Future retryProvider(String providerId) async { + final ref = _set.where((p) => p.id == providerId).firstOrNull; + if (ref == null || _query.isEmpty || _retrying.contains(providerId)) return; + + final token = _token; + _retrying.add(providerId); + notifyListeners(); + + final result = await engine.searchProvider(ref, _query); + _retrying.remove(providerId); + if (token != _token) return; + + _results[ref.id] = result; + _remerge(); + notifyListeners(); + } + + /// Whether a [loadMore] is in flight, so the button can say so and refuse a + /// second tap. Without it two taps both page from the same snapshot: the + /// same page is fetched twice and the second write clobbers the first. + bool get loadingMore => _loadingMore; + bool _loadingMore = false; + + /// Next page from every source that reported one. + Future loadMore() async { + if (_loadingMore) return; + final token = _token; + final pending = results.where((r) => r.hasMore).toList(); + if (pending.isEmpty || _query.isEmpty) return; + + _loadingMore = true; + notifyListeners(); + try { + await _loadMoreLegs(pending, token); + } finally { + // A superseded run must not clear the flag for the one that replaced it. + if (token == _token) { + _loadingMore = false; + notifyListeners(); + } + } + } + + Future _loadMoreLegs( + List pending, + int token, + ) async { + for (final leg in pending) { + final next = await engine.searchProvider( + leg.provider, + _query, + page: leg.page + 1, + ); + if (token != _token) return; + if (next.status != ProviderSearchStatus.ok) continue; + final seen = {for (final m in leg.items) '${m.provider}::${m.url}'}; + _results[leg.provider.id] = leg.copyWith( + items: [ + ...leg.items, + for (final m in next.items) + if (seen.add('${m.provider}::${m.url}')) m, + ], + page: next.page, + totalPages: next.totalPages, + ); + _remerge(); + notifyListeners(); + } } void _run(String q) { _cancel(); final token = ++_token; + _loadingMore = false; _results.clear(); - _crossSourceCounts = const {}; - _searching = _set.isNotEmpty; + _retrying.clear(); + _merged = const []; + _phase = + _set.isEmpty ? CrossSearchPhase.done : CrossSearchPhase.running; notifyListeners(); if (_set.isEmpty) return; @@ -103,40 +213,68 @@ class CrossSearchController extends ChangeNotifier { (result) { if (token != _token) return; _results[result.provider.id] = result; - _recomputeCrossCounts(); + _remerge(); notifyListeners(); }, onDone: () { if (token != _token) return; - _searching = false; + _phase = CrossSearchPhase.done; + _store(q); notifyListeners(); }, ); } - void _recomputeCrossCounts() { - final counts = >{}; - for (final r in _results.values) { - for (final m in r.items) { - counts - .putIfAbsent(_key(m.title, m.year), () => {}) - .add(r.provider.id); - } + void _remerge() => _merged = mergeSearchResults(results); + + void _store(String q) { + _cache.remove(_cacheKey(q)); + _cache[_cacheKey(q)] = _CachedRun( + at: DateTime.now(), + results: List.of(results), + ); + while (_cache.length > _cacheEntries) { + _cache.remove(_cache.keys.first); } - _crossSourceCounts = { - for (final e in counts.entries) e.key: e.value.length, - }; } + bool _restoreFromCache(String q) { + final hit = _cache[_cacheKey(q)]; + if (hit == null) return false; + if (DateTime.now().difference(hit.at) > _cacheTtl) { + _cache.remove(_cacheKey(q)); + return false; + } + ++_token; + for (final r in hit.results) { + _results[r.provider.id] = r; + } + _remerge(); + _phase = CrossSearchPhase.done; + notifyListeners(); + return true; + } + + String _cacheKey(String q) => + '${_set.map((p) => p.id).join(',')}|${q.toLowerCase()}'; + void _cancel() { + _debouncer.cancel(); _sub?.cancel(); _sub = null; } @override void dispose() { - _debounce?.cancel(); + _debouncer.dispose(); _cancel(); super.dispose(); } } + +class _CachedRun { + const _CachedRun({required this.at, required this.results}); + + final DateTime at; + final List results; +} diff --git a/lib/features/search/presentation/blocs/search_bloc.dart b/lib/features/search/presentation/blocs/search_bloc.dart index 8f722805..793dccd7 100644 --- a/lib/features/search/presentation/blocs/search_bloc.dart +++ b/lib/features/search/presentation/blocs/search_bloc.dart @@ -4,131 +4,281 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/search/data/search_recents_store.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; +import 'package:soplay/features/search/domain/entities/search_entity.dart'; import 'package:soplay/features/search/domain/usecases/genre_usecase.dart'; import 'package:soplay/features/search/domain/usecases/search_usecase.dart'; +import 'package:soplay/features/search/presentation/blocs/search_query_policy.dart'; part 'search_event.dart'; part 'search_state.dart'; +/// Single-source search. +/// +/// Every handler that awaits carries the run token: a response that lands after +/// a newer run has started is dropped instead of overwriting it. Genres are a +/// field on the state, not a state of their own, so a failed genres fetch can +/// never repaint the results as a network error. class SearchBloc extends Bloc { final SearchUseCase _searchUseCase; final GenreUseCase _genreUseCase; + final SearchRecentsStore _recents; - Timer? _debounce; + final QueryDebouncer _debouncer = QueryDebouncer(); - int _execToken = 0; + int _runToken = 0; + int _genreToken = 0; + + /// What is in the box right now. Kept off the state on purpose: a keystroke + /// must not rebuild the results grid. + String _pendingText = ''; SearchBloc({ required SearchUseCase searchUseCase, required GenreUseCase genreUseCase, + SearchRecentsStore? recentsStore, }) : _searchUseCase = searchUseCase, _genreUseCase = genreUseCase, - super(const SearchInitial()) { + _recents = recentsStore ?? SearchRecentsStore(), + super(const SearchState()) { on(_onLoad); on(_onQueryChanged); - on<_SearchExecute>(_onExecute); + on(_onSubmitted); + on(_onGenreSelected); + on<_SearchRun>(_onRun); on(_onLoadMore); - on(_onByGenre); + on(_onRetry); + on(_onRecentRemoved); + on(_onRecentsCleared); + + add(const SearchLoad()); } Future _onLoad(SearchLoad event, Emitter emit) async { - ++_execToken; // invalidate any in-flight text search (box cleared → genres) - emit(const SearchGenresLoading()); + final genreToken = ++_genreToken; + ++_runToken; + _debouncer.reset(); + + // Genres belong to the provider that is being left behind, and so do the + // results; the text in the box does not. + final criteria = SearchCriteria(text: state.criteria.text); + emit(state.copyWith( + criteria: criteria, + status: criteria.isEmpty ? SearchStatus.idle : SearchStatus.loading, + items: const [], + page: 1, + totalPages: 1, + isLoadingMore: false, + genres: const [], + genresLoading: true, + genresFailed: false, + recent: _recents.load(), + clearError: true, + )); + + if (criteria.isNotEmpty) add(_SearchRun(criteria)); + final result = await _genreUseCase(); - if (result.isSuccess) { - emit(SearchGenresLoaded(result.getOrNull()!)); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); - } + if (genreToken != _genreToken || isClosed) return; + emit(state.copyWith( + genres: result.isSuccess ? result.getOrNull()! : const [], + genresLoading: false, + genresFailed: result.isError, + )); } void _onQueryChanged(SearchQueryChanged event, Emitter emit) { - _debounce?.cancel(); - final q = event.query.trim(); + final q = SearchQueryPolicy.normalize(event.query); + _pendingText = q; + if (q.isEmpty) { - add(const SearchLoad()); + _debouncer.reset(); + ++_runToken; + final criteria = state.criteria.copyWith(text: ''); + if (criteria.isEmpty) { + emit(state.copyWith( + criteria: criteria, + status: SearchStatus.idle, + items: const [], + page: 1, + totalPages: 1, + isLoadingMore: false, + recent: _recents.load(), + clearError: true, + )); + } else { + add(_SearchRun(criteria)); + } return; } - _debounce = Timer(const Duration(milliseconds: 450), () { - if (!isClosed) add(_SearchExecute(q)); + + _debouncer.schedule(q, (value) { + if (isClosed) return; + // Same reason as _onGenreSelected: the two cannot be combined, so the + // one the user just acted on wins. + add(_SearchRun(SearchCriteria(text: value, genre: ''))); }); } - Future _onExecute(_SearchExecute event, Emitter emit) async { - final token = ++_execToken; - emit(const SearchLoading()); - final result = await _searchUseCase(event.query); - if (token != _execToken) return; - if (result.isSuccess) { - final data = result.getOrNull()!; - emit(SearchLoaded( - query: event.query, - items: data.items, - page: data.page, - totalPages: data.totalPages, - )); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); - } + void _onSubmitted(SearchSubmitted event, Emitter emit) { + _pendingText = SearchQueryPolicy.normalize(event.query); + _debouncer.runNow(_pendingText, (value) { + if (isClosed) return; + add(_SearchRun(state.criteria.copyWith(text: value))); + }); } - Future _onLoadMore(SearchLoadMore event, Emitter emit) async { - final current = state; - if (current is! SearchLoaded || current.isLoadingMore || !current.hasMore) return; - - final loading = current.copyWith(isLoadingMore: true); - emit(loading); - final nextPage = current.page + 1; - - final Result result = current.query.isNotEmpty - ? await _searchUseCase(current.query, page: nextPage) - : await _genreUseCase.callByGenre(current.genre, page: nextPage); - - // A newer search/genre may have replaced the state while paging was in - // flight — discard this stale page instead of clobbering current results. - if (state != loading) return; - - if (result.isSuccess) { - final data = result.getOrNull()!; - final seen = { - for (final m in current.items) '${m.provider}::${m.url}', - }; - final fresh = []; - for (final m in data.items) { - if (seen.add('${m.provider}::${m.url}')) fresh.add(m); - } - emit(current.copyWith( - items: [...current.items, ...fresh], - page: data.page, - totalPages: data.totalPages, + void _onGenreSelected(SearchGenreSelected event, Emitter emit) { + _debouncer.reset(); + // Genre and text do not compose: the server's /contents/search takes only + // q, page and provider, so a genre sent alongside a query is dropped on the + // floor and the user gets a plain text search under an active filter chip. + // Picking a genre therefore browses that genre instead. + final genre = event.genre.trim(); + if (genre.isNotEmpty) _pendingText = ''; + final text = genre.isNotEmpty + ? '' + : (SearchQueryPolicy.runnable(_pendingText) ? _pendingText : ''); + final criteria = SearchCriteria(text: text, genre: genre); + if (criteria.isEmpty) { + ++_runToken; + emit(state.copyWith( + criteria: criteria, + status: SearchStatus.idle, + items: const [], + page: 1, + totalPages: 1, isLoadingMore: false, + recent: _recents.load(), + clearError: true, )); - } else { - emit(current.copyWith(isLoadingMore: false)); + return; } + add(_SearchRun(criteria)); } - Future _onByGenre(SearchByGenre event, Emitter emit) async { - ++_execToken; // invalidate any in-flight text search (genre filter applied) - emit(const SearchLoading()); - final result = await _genreUseCase.callByGenre(event.genre); - if (result.isSuccess) { - final data = result.getOrNull()!; - emit(SearchLoaded( - genre: event.genre, - items: data.items, - page: data.page, - totalPages: data.totalPages, + Future _onRun(_SearchRun event, Emitter emit) async { + final token = ++_runToken; + final criteria = event.criteria; + + // Something is already on screen: keep it and show progress on top of it + // rather than blanking the grid on every keystroke. + final keepItems = state.items.isNotEmpty; + emit(state.copyWith( + criteria: criteria, + status: keepItems ? SearchStatus.refreshing : SearchStatus.loading, + isLoadingMore: false, + clearError: true, + )); + + final result = await _fetch(criteria, 1); + if (token != _runToken || isClosed) return; + + if (result.isError) { + final raw = result.getErrorOrNull()!.toString(); + _debouncer.forget(); + emit(state.copyWith( + status: SearchStatus.error, + items: const [], + page: 1, + totalPages: 1, + errorMessage: cleanFailureMessage(raw), + errorKind: classifySearchFailure(raw), )); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); + return; + } + + final data = result.getOrNull()!; + final recent = criteria.text.isEmpty + ? state.recent + : await _recents.add(criteria.text); + if (token != _runToken || isClosed) return; + + emit(state.copyWith( + items: data.items, + page: data.page, + totalPages: data.totalPages, + status: data.items.isEmpty ? SearchStatus.empty : SearchStatus.loaded, + recent: recent, + clearError: true, + )); + } + + Future _onLoadMore( + SearchLoadMore event, + Emitter emit, + ) async { + if (state.status != SearchStatus.loaded || + state.isLoadingMore || + !state.hasMore) { + return; + } + + final token = ++_runToken; + final criteria = state.criteria; + final nextPage = state.page + 1; + emit(state.copyWith(isLoadingMore: true)); + + final result = await _fetch(criteria, nextPage); + if (token != _runToken || isClosed) return; + + if (result.isError) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + + final data = result.getOrNull()!; + final seen = {for (final m in state.items) '${m.provider}::${m.url}'}; + final fresh = [ + for (final m in data.items) + if (seen.add('${m.provider}::${m.url}')) m, + ]; + emit(state.copyWith( + items: [...state.items, ...fresh], + page: data.page, + totalPages: data.totalPages, + isLoadingMore: false, + )); + } + + void _onRetry(SearchRetry event, Emitter emit) { + _debouncer.reset(); + if (state.criteria.isEmpty) { + add(const SearchLoad()); + return; + } + add(_SearchRun(state.criteria)); + } + + Future _onRecentRemoved( + SearchRecentRemoved event, + Emitter emit, + ) async { + final recent = await _recents.remove(event.query); + if (isClosed) return; + emit(state.copyWith(recent: recent)); + } + + Future _onRecentsCleared( + SearchRecentsCleared event, + Emitter emit, + ) async { + final recent = await _recents.clear(); + if (isClosed) return; + emit(state.copyWith(recent: recent)); + } + + Future> _fetch(SearchCriteria criteria, int page) { + if (criteria.text.isNotEmpty) { + return _searchUseCase(criteria.text, page: page); } + return _genreUseCase.callByGenre(criteria.genre, page: page); } @override Future close() { - _debounce?.cancel(); + _debouncer.dispose(); return super.close(); } } diff --git a/lib/features/search/presentation/blocs/search_event.dart b/lib/features/search/presentation/blocs/search_event.dart index 3866a1c4..e4868738 100644 --- a/lib/features/search/presentation/blocs/search_event.dart +++ b/lib/features/search/presentation/blocs/search_event.dart @@ -4,6 +4,8 @@ abstract class SearchEvent { const SearchEvent(); } +/// First mount and every provider switch: genres belong to the provider, so +/// they are dropped and refetched, and whatever is in the box is re-run. class SearchLoad extends SearchEvent { const SearchLoad(); } @@ -13,16 +15,37 @@ class SearchQueryChanged extends SearchEvent { final String query; } -class _SearchExecute extends SearchEvent { - const _SearchExecute(this.query); +/// The keyboard's Search key: run now, no debounce, no dedupe. +class SearchSubmitted extends SearchEvent { + const SearchSubmitted(this.query); final String query; } +/// Applies or clears the genre filter. Combines with whatever text is present. +class SearchGenreSelected extends SearchEvent { + const SearchGenreSelected(this.genre); + final String genre; +} + class SearchLoadMore extends SearchEvent { const SearchLoadMore(); } -class SearchByGenre extends SearchEvent { - const SearchByGenre(this.genre); - final String genre; +/// Re-runs the operation that actually failed, not the genres call. +class SearchRetry extends SearchEvent { + const SearchRetry(); +} + +class SearchRecentRemoved extends SearchEvent { + const SearchRecentRemoved(this.query); + final String query; +} + +class SearchRecentsCleared extends SearchEvent { + const SearchRecentsCleared(); +} + +class _SearchRun extends SearchEvent { + const _SearchRun(this.criteria); + final SearchCriteria criteria; } diff --git a/lib/features/search/presentation/blocs/search_query_policy.dart b/lib/features/search/presentation/blocs/search_query_policy.dart new file mode 100644 index 00000000..a9fce22f --- /dev/null +++ b/lib/features/search/presentation/blocs/search_query_policy.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +/// The single debounce + run policy for every search surface. +/// +/// Both the single-source bloc and the cross-source controller used to carry +/// their own copy of this timer, which is how they drifted apart: same delay, +/// different behaviour on a re-typed query and on a run triggered from outside +/// the field (retry, provider-set change) while a debounce was still pending. +class SearchQueryPolicy { + const SearchQueryPolicy._(); + + static const Duration debounce = Duration(milliseconds: 450); + + /// A one-character query fans out to every selected source for almost no + /// signal, so it never auto-runs. Submitting from the keyboard still does. + static const int minLength = 2; + + static String normalize(String raw) => raw.trim(); + + static bool runnable(String query) => normalize(query).length >= minLength; +} + +class QueryDebouncer { + Timer? _timer; + String? _lastDispatched; + + bool get isPending => _timer?.isActive ?? false; + + /// Schedules [run] unless the query is too short or identical to the last one + /// dispatched. Always cancels a pending run first. + /// + /// Returns whether a run was actually armed. Callers that show a spinner have + /// to know: setting one before calling this left the UI searching forever on + /// a one-character query, because nothing was ever dispatched to end it. + bool schedule(String query, void Function(String query) run) { + _timer?.cancel(); + final q = SearchQueryPolicy.normalize(query); + if (!SearchQueryPolicy.runnable(q)) return false; + if (q == _lastDispatched) return false; + _timer = Timer(SearchQueryPolicy.debounce, () { + _lastDispatched = q; + run(q); + }); + return true; + } + + /// Runs [query] now, dropping any pending debounce. Used by the keyboard's + /// Search key, which must not be swallowed by the dedupe. + void runNow(String query, void Function(String query) run) { + _timer?.cancel(); + final q = SearchQueryPolicy.normalize(query); + if (q.isEmpty) return; + _lastDispatched = q; + run(q); + } + + void cancel() => _timer?.cancel(); + + /// Drops the dedupe key but leaves a pending run alone — used when a run + /// failed, so retyping the same query is not swallowed as a duplicate. + void forget() => _lastDispatched = null; + + /// Forgets the dedupe key so the same query runs again — a retry, a provider + /// switch or a filter change all mean "the previous answer is void". + void reset() { + _timer?.cancel(); + _lastDispatched = null; + } + + void dispose() => _timer?.cancel(); +} diff --git a/lib/features/search/presentation/blocs/search_state.dart b/lib/features/search/presentation/blocs/search_state.dart index 11db3d89..cefd8956 100644 --- a/lib/features/search/presentation/blocs/search_state.dart +++ b/lib/features/search/presentation/blocs/search_state.dart @@ -1,73 +1,164 @@ part of 'search_bloc.dart'; -abstract class SearchState extends Equatable { - const SearchState(); - @override - List get props => []; -} +enum SearchStatus { + /// Nothing asked for yet — recents, genres and the hint live here. + idle, -class SearchInitial extends SearchState { - const SearchInitial(); -} + /// First results for the current criteria; nothing to keep on screen. + loading, -class SearchGenresLoading extends SearchState { - const SearchGenresLoading(); + /// New results for criteria that already have something on screen. + refreshing, + loaded, + empty, + error, } -class SearchGenresLoaded extends SearchState { - const SearchGenresLoaded(this.genres); - final List genres; - @override - List get props => [genres]; -} +/// Why a search failed, so the view can stop blaming the user's wifi for a +/// broken extension source. +enum SearchFailureKind { network, source, unknown } + +/// What the user is asking for. Text and genre are composable: either one, both +/// or neither, and every change re-runs through the same path. +class SearchCriteria extends Equatable { + const SearchCriteria({this.text = '', this.genre = ''}); + + final String text; + final String genre; + + bool get isEmpty => text.isEmpty && genre.isEmpty; + bool get isNotEmpty => !isEmpty; + + String get label => text.isNotEmpty ? text : genre; + + SearchCriteria copyWith({String? text, String? genre}) => SearchCriteria( + text: text ?? this.text, + genre: genre ?? this.genre, + ); -class SearchLoading extends SearchState { - const SearchLoading(); + @override + List get props => [text, genre]; } -class SearchLoaded extends SearchState { - const SearchLoaded({ - required this.items, - required this.page, - required this.totalPages, - this.query = '', - this.genre = '', +class SearchState extends Equatable { + const SearchState({ + this.criteria = const SearchCriteria(), + this.status = SearchStatus.idle, + this.items = const [], + this.page = 1, + this.totalPages = 1, this.isLoadingMore = false, + this.errorMessage = '', + this.errorKind = SearchFailureKind.unknown, + this.genres = const [], + this.genresLoading = false, + this.genresFailed = false, + this.recent = const [], }); + final SearchCriteria criteria; + final SearchStatus status; final List items; final int page; final int totalPages; - final String query; - final String genre; final bool isLoadingMore; + final String errorMessage; + final SearchFailureKind errorKind; + + /// Genres are a *field*, not a state: a failed genre fetch must never be able + /// to paint the search screen as broken, and clearing the box must never be + /// able to wipe results. + final List genres; + final bool genresLoading; + final bool genresFailed; + + final List recent; + bool get hasMore => page < totalPages; + bool get hasGenres => genres.isNotEmpty; + bool get isBusy => + status == SearchStatus.loading || status == SearchStatus.refreshing; - SearchLoaded copyWith({ + SearchState copyWith({ + SearchCriteria? criteria, + SearchStatus? status, List? items, int? page, int? totalPages, - String? query, - String? genre, bool? isLoadingMore, + String? errorMessage, + SearchFailureKind? errorKind, + List? genres, + bool? genresLoading, + bool? genresFailed, + List? recent, + bool clearError = false, }) => - SearchLoaded( + SearchState( + criteria: criteria ?? this.criteria, + status: status ?? this.status, items: items ?? this.items, page: page ?? this.page, totalPages: totalPages ?? this.totalPages, - query: query ?? this.query, - genre: genre ?? this.genre, isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: clearError ? '' : (errorMessage ?? this.errorMessage), + errorKind: clearError + ? SearchFailureKind.unknown + : (errorKind ?? this.errorKind), + genres: genres ?? this.genres, + genresLoading: genresLoading ?? this.genresLoading, + genresFailed: genresFailed ?? this.genresFailed, + recent: recent ?? this.recent, ); @override - List get props => [items, page, totalPages, query, genre, isLoadingMore]; + List get props => [ + criteria, + status, + items, + page, + totalPages, + isLoadingMore, + errorMessage, + errorKind, + genres, + genresLoading, + genresFailed, + recent, + ]; } -class SearchError extends SearchState { - const SearchError(this.message); - final String message; - @override - List get props => [message]; +/// Best-effort classification of a repository failure. +/// +/// The repository and the extension hosts already distinguish "source +/// unavailable" from "no match"; this keeps that distinction alive up to the +/// view instead of collapsing everything into "check your connection". +SearchFailureKind classifySearchFailure(String raw) { + final m = raw.toLowerCase(); + if (m.contains('socketexception') || + m.contains('failed host lookup') || + m.contains('connection error') || + m.contains('connection refused') || + m.contains('connection closed') || + m.contains('network is unreachable') || + m.contains('timeout') || + m.contains('timed out')) { + return SearchFailureKind.network; + } + if (m.contains('source unavailable') || + m.contains('platformexception') || + m.contains('extension') || + m.contains('extractor')) { + return SearchFailureKind.source; + } + return SearchFailureKind.unknown; +} + +String cleanFailureMessage(String raw) { + var m = raw.trim(); + while (m.startsWith('Exception:')) { + m = m.substring('Exception:'.length).trim(); + } + return m; } diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index 14b022cc..56bc0fbd 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -1,25 +1,24 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; -import 'package:soplay/features/home/domain/entities/movie.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; import 'package:soplay/features/search/presentation/blocs/cross_search_controller.dart'; +import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; import 'package:soplay/features/search/presentation/widgets/search_set_sheet.dart'; -/// Search a curated set of providers at once. Results stream in, grouped by -/// provider — freeze-proof (bounded concurrency + per-provider timeout in the -/// engine), so a large or partly-broken set never blocks the UI. +/// Search a curated set of providers at once. Results stream in and are merged +/// into one title per card — freeze-proof (bounded concurrency + per-provider +/// timeout in the engine), so a large or partly-broken set never blocks the UI. class CrossSearchPage extends StatefulWidget { const CrossSearchPage({super.key, this.initialQuery}); @@ -34,21 +33,25 @@ class CrossSearchPage extends StatefulWidget { class _CrossSearchPageState extends State { final _textController = TextEditingController(); late final CrossSearchController _controller; + Set _selectedIds = {}; + List _providers = const []; + bool _offline = false; + bool _grouped = false; @override void initState() { super.initState(); - final providers = _allProviders(); - _selectedIds = _initialSelection(providers); + _providers = _providersOf(context.read().state); + _selectedIds = _initialSelection(_providers); _controller = CrossSearchController( engine: getIt(), - set: _buildRefs(providers, _selectedIds), + set: _buildRefs(_providers, _selectedIds), ); final q = widget.initialQuery?.trim() ?? ''; if (q.isNotEmpty) { _textController.text = q; - _controller.onQueryChanged(q); + _controller.submit(q); } } @@ -61,9 +64,31 @@ class _CrossSearchPageState extends State { /// Offline this yields only the on-device plugins: fanning out to server /// providers with the API down would just add a row of failed legs. - List _allProviders() { - final state = context.read().state; - return state is ProviderLoaded ? state.usableProviders : const []; + List _providersOf(ProviderState state) => + state is ProviderLoaded ? state.usableProviders : const []; + + /// The page used to snapshot ProviderBloc once in initState, so opening it + /// before the providers had loaded left it permanently empty. + void _syncProviders(ProviderState state) { + final providers = _providersOf(state); + final offline = state is ProviderLoaded && state.offline; + if (providers.length == _providers.length && + offline == _offline && + providers.every((p) => _providers.any((e) => e.id == p.id))) { + return; + } + final wasEmpty = _providers.isEmpty; + _providers = providers; + _offline = offline; + if (wasEmpty || _selectedIds.isEmpty) { + _selectedIds = _initialSelection(providers); + } else { + _selectedIds = _selectedIds + .where((id) => providers.any((p) => p.id == id)) + .toSet(); + } + setState(() {}); + _controller.setProviderSet(_buildRefs(providers, _selectedIds)); } Set _initialSelection(List providers) { @@ -88,44 +113,130 @@ class _CrossSearchPageState extends State { } Future _openSetSheet() async { - final providers = _allProviders(); final result = await SearchSetSheet.show( context, - providers: providers, + providers: _providers, initialSelected: _selectedIds, ); if (result == null || !mounted) return; _selectedIds = result; await getIt().setCrossSearchProviders(result.toList()); - _controller.setProviderSet(_buildRefs(providers, result)); + _controller.setProviderSet(_buildRefs(_providers, result)); setState(() {}); } + void _openDetail(MergedSearchTitle title) { + if (title.sourceCount <= 1) { + _push(title.hits.first); + return; + } + showModalBottomSheet( + context: context, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 6), + child: Text( + 'search.open_from'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + for (final hit in title.hits) + ListTile( + dense: true, + leading: const Icon(Icons.play_circle_outline, + color: AppColors.primary, size: 20), + title: Text(hit.provider.name, + style: const TextStyle(color: Colors.white, fontSize: 14)), + subtitle: Text(hit.item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, fontSize: 12)), + onTap: () { + Navigator.of(context).pop(); + _push(hit); + }, + ), + ], + ), + ), + ); + } + + void _push(TitleHit hit) { + if (hit.item.url.isEmpty) return; + context.push( + '/detail', + extra: DetailArgs( + contentUrl: hit.item.url, + preview: hit.item, + provider: _providerIdOf(hit), + ), + ); + } + + /// The source this hit came from, never the app's "current" provider. + String? _providerIdOf(TitleHit hit) { + if (hit.provider.kind == ProviderKind.server && + hit.item.provider.isNotEmpty) { + return hit.item.provider; + } + return hit.provider.id; + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( + return BlocListener( + listener: (_, state) => _syncProviders(state), + child: Scaffold( backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - elevation: 0, - title: const Text('All-source search', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _searchField(), - _sourcesBar(), - const Divider(height: 1, color: Colors.white10), - Expanded( - child: AnimatedBuilder( - animation: _controller, - builder: (_, _) => _body(), + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('search.all_source_search'.tr(), + style: + const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + actions: [ + IconButton( + tooltip: _grouped + ? 'search.merge_titles'.tr() + : 'search.group_by_source'.tr(), + onPressed: () => setState(() => _grouped = !_grouped), + icon: Icon( + _grouped ? Icons.grid_view_rounded : Icons.view_agenda_outlined, + size: 20, + ), ), - ), - ], + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _searchField(), + _sourcesBar(), + const Divider(height: 1, color: Colors.white10), + Expanded( + child: AnimatedBuilder( + animation: _controller, + builder: (_, _) => _body(), + ), + ), + ], + ), ), ); } @@ -139,22 +250,30 @@ class _CrossSearchPageState extends State { style: const TextStyle(color: Colors.white), textInputAction: TextInputAction.search, onChanged: _controller.onQueryChanged, + onSubmitted: (value) { + FocusScope.of(context).unfocus(); + _controller.submit(value); + }, decoration: InputDecoration( isDense: true, - hintText: 'Search across your sources…', + hintText: 'search.cross_hint'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint, size: 20), - suffixIcon: _textController.text.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.close, color: AppColors.textHint), - onPressed: () { - _textController.clear(); - _controller.onQueryChanged(''); - setState(() {}); - }, - ), + // Driven by the controller: nothing else subscribes to it, so the + // clear button used to appear only on an unrelated rebuild. + suffixIcon: ValueListenableBuilder( + valueListenable: _textController, + builder: (_, value, _) => value.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.close, color: AppColors.textHint), + onPressed: () { + _textController.clear(); + _controller.onQueryChanged(''); + }, + ), + ), filled: true, fillColor: AppColors.surface, border: OutlineInputBorder( @@ -168,8 +287,8 @@ class _CrossSearchPageState extends State { Widget _sourcesBar() { final label = _selectedIds.isEmpty - ? 'No sources selected — tap to choose' - : '${_selectedIds.length} source(s) selected — tap to change'; + ? 'search.no_sources_selected'.tr() + : 'search.sources_selected'.tr(args: ['${_selectedIds.length}']); return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), child: Material( @@ -208,48 +327,114 @@ class _CrossSearchPageState extends State { if (_selectedIds.isEmpty) { return _centered( icon: Icons.tune, - text: 'Pick which sources to search across.', + text: 'search.pick_sources'.tr(), action: FilledButton( onPressed: _openSetSheet, - child: const Text('Choose sources'), + child: Text('search.choose_sources'.tr()), ), ); } if (_controller.query.isEmpty) { return _centered( icon: Icons.travel_explore, - text: 'Type to search all ${_selectedIds.length} selected sources ' - 'at once.', + text: 'search.type_to_search_n' + .tr(args: ['${_controller.expectedLegs}']), ); } - final results = _controller.results; - final withItems = results.where((r) => r.hasItems).toList(); - final withoutItems = results.where((r) => !r.hasItems).toList(); - final pending = _controller.expectedLegs - _controller.completedLegs; + final merged = _controller.merged; + final done = _controller.phase == CrossSearchPhase.done; - return ListView( - padding: const EdgeInsets.only(bottom: 24), - children: [ - _summary(pending), - for (final r in withItems) _providerSection(r), - if (withoutItems.isNotEmpty) _noResultFooter(withoutItems), - if (withItems.isEmpty && pending <= 0) - _centered( - icon: Icons.search_off, - text: 'No results in any selected source.', - padded: false, + return CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter(child: _summary()), + if (_offline) + SliverToBoxAdapter(child: _note('search.offline_note'.tr())), + if (merged.isEmpty && done) + SliverToBoxAdapter( + child: _centered( + icon: Icons.search_off, + text: 'search.no_results_any'.tr(), + padded: false, + ), + ) + else if (_grouped) + SliverList( + delegate: SliverChildBuilderDelegate( + (_, i) => _providerSection(_controller.legsWithItems[i]), + childCount: _controller.legsWithItems.length, + ), + ) + else + _mergedGrid(merged), + if (_controller.hasMoreAnywhere && done) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: OutlinedButton( + onPressed: + _controller.loadingMore ? null : _controller.loadMore, + child: _controller.loadingMore + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text('search.load_more'.tr()), + ), + ), + ), ), + SliverToBoxAdapter(child: _sourceStatusList()), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); } - Widget _summary(int pending) { + Widget _mergedGrid(List merged) { + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (_, i) { + final title = merged[i]; + return SearchResultCard( + key: ValueKey('${title.key}|${title.year ?? ''}'), + movie: title.primary, + provider: _providerIdOf(title.hits.first), + sourceLabel: title.sourceCount > 1 + ? 'search.sources_n'.tr(args: ['${title.sourceCount}']) + : title.primaryProvider.name, + sourceCount: title.sourceCount, + onTap: () => _openDetail(title), + ); + }, + childCount: merged.length, + ), + gridDelegate: searchGridDelegate(context), + ), + ); + } + + Widget _summary() { + final pending = _controller.pendingSources; + final searching = _controller.searching; + final text = searching + ? (pending.isEmpty + ? 'search.searching'.tr() + : 'search.waiting_for'.tr(args: [pending.take(2).join(', ')])) + : 'search.found_in'.tr(args: [ + '${_controller.sourcesWithResults}', + '${_controller.expectedLegs}', + ]); + return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ - if (_controller.searching && pending > 0) ...[ + if (searching) ...[ const SizedBox( width: 14, height: 14, @@ -259,10 +444,7 @@ class _CrossSearchPageState extends State { ], Expanded( child: Text( - 'Found in ${_controller.sourcesWithResults} of ' - '${_controller.expectedLegs} sources · ' - '${_controller.totalItems} results' - '${pending > 0 ? ' · searching…' : ''}', + '$text · ${'search.results_n'.tr(args: ['${_controller.totalItems}'])}', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -276,6 +458,7 @@ class _CrossSearchPageState extends State { Widget _providerSection(ProviderSearchResult r) { return Column( + key: ValueKey('section-${r.provider.id}'), crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( @@ -309,25 +492,22 @@ class _CrossSearchPageState extends State { ), ), SizedBox( - height: 188, + height: searchCardHeight(116), child: ListView.separated( + key: PageStorageKey('cross-rail-${r.provider.id}'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: r.items.length, separatorBuilder: (_, _) => const SizedBox(width: 10), itemBuilder: (_, i) { final m = r.items[i]; - // Open the detail with the SOURCE this result came from — not the - // app's "current" provider. Server legs are collapsed under the - // synthetic __server__ ref, so use the item's own backend provider - // there; channel/js legs carry the real id on the section ref. - final prov = r.provider.kind == ProviderKind.server - ? (m.provider.isNotEmpty ? m.provider : null) - : r.provider.id; - return _MovieCard( + final hit = TitleHit(provider: r.provider, item: m); + return SearchResultCard( + width: 116, movie: m, - alsoOn: _controller.crossSourceCount(m.title, m.year), - provider: prov, + provider: _providerIdOf(hit), + sourceLabel: m.year != null ? '${m.year}' : '', + onTap: () => _push(hit), ); }, ), @@ -336,23 +516,75 @@ class _CrossSearchPageState extends State { ); } - Widget _noResultFooter(List list) { - final noResults = list.where((r) => r.status == ProviderSearchStatus.empty).length; - final timeouts = list.where((r) => r.status == ProviderSearchStatus.timeout).length; - final errors = list.where((r) => r.status == ProviderSearchStatus.error).length; - final parts = [ - if (noResults > 0) '$noResults with no results', - if (timeouts > 0) '$timeouts timed out', - if (errors > 0) '$errors failed', - ]; - if (parts.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 4), - child: Text(parts.join(' · '), - style: const TextStyle(color: AppColors.textHint, fontSize: 12)), + /// Every leg by name with what it did, and a Retry for the ones that failed. + Widget _sourceStatusList() { + final legs = _controller.results; + if (legs.isEmpty) return const SizedBox.shrink(); + return Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: _controller.failedLegs.isNotEmpty, + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + 'search.sources'.tr(), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + children: [ + for (final leg in legs) + ListTile( + dense: true, + contentPadding: const EdgeInsets.only(left: 16, right: 8), + title: Text(leg.provider.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13)), + subtitle: Text( + _statusLabel(leg), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 11.5), + ), + trailing: leg.status == ProviderSearchStatus.ok + ? null + : _controller.isRetrying(leg.provider.id) + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: () => + _controller.retryProvider(leg.provider.id), + child: Text('general.retry'.tr()), + ), + ), + ], + ), ); } + String _statusLabel(ProviderSearchResult leg) => switch (leg.status) { + ProviderSearchStatus.ok => + 'search.results_n'.tr(args: ['${leg.items.length}']), + ProviderSearchStatus.empty => 'search.no_results'.tr(), + ProviderSearchStatus.timeout => 'errors.timeout'.tr(), + ProviderSearchStatus.error => leg.message.isEmpty + ? 'search.source_failed'.tr() + : leg.message, + }; + + Widget _note(String text) => Container( + margin: const EdgeInsets.fromLTRB(16, 6, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, + style: const TextStyle( + color: Colors.orange, fontSize: 11.5, height: 1.3)), + ); + Widget _centered({ required IconData icon, required String text, @@ -372,103 +604,11 @@ class _CrossSearchPageState extends State { ); if (!padded) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 60), child: Center(child: content)); + padding: const EdgeInsets.symmetric(vertical: 60), + child: Center(child: content)); } return Center( child: Padding(padding: const EdgeInsets.all(32), child: content), ); } } - -class _MovieCard extends StatelessWidget { - const _MovieCard({required this.movie, required this.alsoOn, this.provider}); - - final MovieEntity movie; - final int alsoOn; - final String? provider; - - @override - Widget build(BuildContext context) { - // Android TV: these cards are every result of a cross-provider search, so a - // bare GestureDetector made the whole results grid unreachable by the D-pad. - // Off TV TvFocusable collapses to exactly the GestureDetector that was here - // — same onTap, same explicit HitTestBehavior.opaque, nothing else added. - return TvFocusable( - behavior: HitTestBehavior.opaque, - onPressed: () { - if (movie.url.isEmpty) return; - context.push('/detail', - extra: DetailArgs( - contentUrl: movie.url, preview: movie, provider: provider)); - }, - child: SizedBox( - width: 116, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - SizedBox( - width: 116, - height: 150, - child: movie.thumbnail != null - ? Image.network( - movie.thumbnail!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _placeholder(), - ) - : _placeholder(), - ), - if (alsoOn > 1) - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(6), - ), - child: Text('$alsoOn sources', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.w700)), - ), - ), - // Top-right, because the "N sources" pill owns the left and - // a hit can carry both. - Positioned( - top: 6, - right: 6, - child: AnilistLinkedBadge( - contentUrl: movie.url, - provider: provider, - ), - ), - ], - ), - ), - const SizedBox(height: 6), - Text( - movie.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, fontSize: 11.5, height: 1.2), - ), - ], - ), - ), - ); - } - - Widget _placeholder() => Container( - color: AppColors.surfaceVariant, - child: const Icon(Icons.movie_rounded, - color: AppColors.textHint, size: 30), - ); -} diff --git a/lib/features/search/presentation/pages/search_page.dart b/lib/features/search/presentation/pages/search_page.dart index 0622e5f7..fafb2029 100644 --- a/lib/features/search/presentation/pages/search_page.dart +++ b/lib/features/search/presentation/pages/search_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'package:soplay/features/search/presentation/widgets/search_filter_sheet.dart'; import 'package:soplay/features/search/presentation/widgets/search_header.dart'; @@ -29,19 +28,14 @@ class _SearchViewState extends State<_SearchView> { final _scrollController = ScrollController(); final _blurProgress = ValueNotifier(0); - List _cachedGenres = []; - SearchFilterSelection _filter = const SearchFilterSelection(); - - bool get _hasActiveFilter => _filter.hasActiveFilter; - @override void initState() { super.initState(); _scrollController.addListener(_onScroll); - final bloc = context.read(); - if (bloc.state is SearchInitial) { - bloc.add(const SearchLoad()); - } + // Coming back to the tab: the bloc still holds the last query, so the box + // must show it instead of looking empty over a full grid of results. The + // bloc loads itself, so re-entering the tab never clears the results. + _controller.text = context.read().state.criteria.text; } @override @@ -67,7 +61,11 @@ class _SearchViewState extends State<_SearchView> { void _maybeAutoFill(SearchState state) { if (!isDesktopPlatform) return; - if (state is! SearchLoaded || !state.hasMore || state.isLoadingMore) return; + if (state.status != SearchStatus.loaded || + !state.hasMore || + state.isLoadingMore) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; if (_scrollController.position.maxScrollExtent <= 0) { @@ -81,6 +79,17 @@ class _SearchViewState extends State<_SearchView> { context.read().add(const SearchQueryChanged('')); } + void _runQuery(String query) { + _controller.text = query; + _controller.selection = TextSelection.collapsed(offset: query.length); + context.read().add(SearchSubmitted(query)); + } + + void _openCrossSearch([String? query]) { + final q = (query ?? _controller.text).trim(); + context.push('/cross-search', extra: q.isEmpty ? null : q); + } + void _openFilter() { final bloc = context.read(); showAdaptiveModal( @@ -88,19 +97,11 @@ class _SearchViewState extends State<_SearchView> { backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => SearchFilterSheet( - initialSelection: _filter, - genres: _cachedGenres, - onApply: (selection) { - if (!mounted) return; - setState(() => _filter = selection); - - final query = _controller.text.trim(); - if (selection.genre.isNotEmpty && query.isEmpty) { - bloc.add(SearchByGenre(selection.genre)); - } else if (!selection.hasActiveFilter && query.isEmpty) { - bloc.add(const SearchLoad()); - } - }, + initialSelection: SearchFilterSelection(genre: bloc.state.criteria.genre), + genres: bloc.state.genres, + // Always dispatch: a genre picked or cleared while text is in the box + // used to change nothing but the button's active dot. + onApply: (selection) => bloc.add(SearchGenreSelected(selection.genre)), ), ); } @@ -113,39 +114,45 @@ class _SearchViewState extends State<_SearchView> { return Scaffold( backgroundColor: AppColors.background, - body: Stack( - children: [ - BlocConsumer( - listener: (context, state) { - if (state is SearchGenresLoaded) { - _cachedGenres = state.genres; - } - _maybeAutoFill(state); - }, - builder: (context, state) => SearchContentView( + body: BlocConsumer( + listener: (context, state) => _maybeAutoFill(state), + builder: (context, state) => Stack( + children: [ + SearchContentView( state: state, scrollController: _scrollController, topPad: headerHeight, bottomPad: bottomPad, - onRetry: () => context.read().add(const SearchLoad()), + onRetry: () => context.read().add(const SearchRetry()), + onSuggestion: _runQuery, + onGenre: (genre) => + context.read().add(SearchGenreSelected(genre)), + onRemoveRecent: (query) => + context.read().add(SearchRecentRemoved(query)), + onClearRecents: () => + context.read().add(const SearchRecentsCleared()), + onTryAllSources: () => _openCrossSearch(state.criteria.text), ), - ), - ValueListenableBuilder( - valueListenable: _blurProgress, - builder: (context, progress, _) => SearchStickyHeader( - progress: progress, - topPad: topPad, - controller: _controller, - focus: _focus, - hasActiveFilter: _hasActiveFilter, - onFilterTap: _openFilter, - onMultiSearchTap: () => context.push('/cross-search'), - onQueryChanged: (q) => - context.read().add(SearchQueryChanged(q)), - onClear: _clearSearch, + ValueListenableBuilder( + valueListenable: _blurProgress, + builder: (context, progress, _) => SearchStickyHeader( + progress: progress, + topPad: topPad, + controller: _controller, + focus: _focus, + hasActiveFilter: state.criteria.genre.isNotEmpty, + showFilter: state.hasGenres, + onFilterTap: _openFilter, + onMultiSearchTap: _openCrossSearch, + onQueryChanged: (q) => + context.read().add(SearchQueryChanged(q)), + onSubmitted: (q) => + context.read().add(SearchSubmitted(q)), + onClear: _clearSearch, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/search/presentation/widgets/search_filter_sheet.dart b/lib/features/search/presentation/widgets/search_filter_sheet.dart index ffc6f1c4..1f728de0 100644 --- a/lib/features/search/presentation/widgets/search_filter_sheet.dart +++ b/lib/features/search/presentation/widgets/search_filter_sheet.dart @@ -91,7 +91,9 @@ class _SearchFilterSheetState extends State { options: widget.genres .map( (genre) => SearchFilterOption( - label: genre.slug, + label: genre.name.isNotEmpty + ? genre.name + : genre.slug, value: genre.slug, ), ) diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 1e3d2d9c..d4a3e2b7 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -13,9 +13,11 @@ class SearchStickyHeader extends StatelessWidget { required this.controller, required this.focus, required this.hasActiveFilter, + required this.showFilter, required this.onFilterTap, required this.onMultiSearchTap, required this.onQueryChanged, + required this.onSubmitted, required this.onClear, }); @@ -24,9 +26,14 @@ class SearchStickyHeader extends StatelessWidget { final TextEditingController controller; final FocusNode focus; final bool hasActiveFilter; + + /// Hidden when the current provider exposes no genres — an empty filter + /// sheet is worse than no button. + final bool showFilter; final VoidCallback onFilterTap; final VoidCallback onMultiSearchTap; final ValueChanged onQueryChanged; + final ValueChanged onSubmitted; final VoidCallback onClear; @override @@ -38,9 +45,10 @@ class SearchStickyHeader extends StatelessWidget { final titleHeight = lerpDouble(28, 0, compactProgress)!; final titleGap = lerpDouble(14, 8, compactProgress)!; final bottomGap = lerpDouble(16, 10, compactProgress)!; - final backgroundColor = progress < 0.01 - ? AppColors.background - : const Color(0xFF181818).withValues(alpha: 0.82); + final blurred = progress > 0.01; + final backgroundColor = blurred + ? const Color(0xFF181818).withValues(alpha: 0.82) + : AppColors.background; final inner = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -80,6 +88,7 @@ class SearchStickyHeader extends StatelessWidget { controller: controller, focus: focus, onChanged: onQueryChanged, + onSubmitted: onSubmitted, onClear: onClear, ), ), @@ -87,148 +96,140 @@ class SearchStickyHeader extends StatelessWidget { _HeaderIconButton( icon: Icons.travel_explore_rounded, onTap: onMultiSearchTap, - tooltip: 'Search all sources', + tooltip: 'search.all_source_search'.tr(), ), - const SizedBox(width: 10), - _FilterButton(active: hasActiveFilter, onTap: onFilterTap), + if (showFilter) ...[ + const SizedBox(width: 10), + _FilterButton(active: hasActiveFilter, onTap: onFilterTap), + ], ], ), ), ], ); + final surface = Container( + decoration: BoxDecoration( + color: backgroundColor, + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.06 * progress), + ), + ), + ), + child: inner, + ); + + // An unscrolled page needs no save layer: a BackdropFilter at sigma 0 still + // allocates one, on the same frames as the debounce and the poster decode. + if (!blurred) return surface; + return ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), - child: Container( - decoration: BoxDecoration( - color: backgroundColor, - border: Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.06 * progress), - ), - ), - ), - child: inner, - ), + child: surface, ), ); } } -class _SearchField extends StatefulWidget { +/// Only the pieces that actually change rebuild on a keystroke: the clear icon +/// listens to the controller, the border listens to the focus node, and the +/// [TextField] itself is passed through untouched. +class _SearchField extends StatelessWidget { const _SearchField({ required this.controller, required this.focus, required this.onChanged, + required this.onSubmitted, required this.onClear, }); final TextEditingController controller; final FocusNode focus; final ValueChanged onChanged; + final ValueChanged onSubmitted; final VoidCallback onClear; - @override - State<_SearchField> createState() => _SearchFieldState(); -} - -class _SearchFieldState extends State<_SearchField> { - @override - void initState() { - super.initState(); - widget.controller.addListener(_rebuild); - widget.focus.addListener(_rebuild); - } - - void _rebuild() => setState(() {}); - - @override - void dispose() { - widget.controller.removeListener(_rebuild); - widget.focus.removeListener(_rebuild); - super.dispose(); - } - @override Widget build(BuildContext context) { - final focused = widget.focus.hasFocus; + final field = TextField( + controller: controller, + focusNode: focus, + cursorRadius: const Radius.circular(14), + textInputAction: TextInputAction.search, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + height: 1, + ), + decoration: InputDecoration( + hintText: 'search.hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 15), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.textHint, + size: 20, + ), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + const icon = Icon( + Icons.close_rounded, + color: AppColors.textHint, + size: 18, + ); + // Android TV: the clear (X) is the only way to drop a query + // without a hardware keyboard, so it needs to be a focus stop. + if (isTvPlatform) { + return TvFocusable( + onPressed: onClear, + borderRadius: 9, + child: icon, + ); + } + return GestureDetector(onTap: onClear, child: icon); + }, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: onChanged, + onSubmitted: (value) { + focus.unfocus(); + onSubmitted(value); + }, + onTapOutside: (_) => focus.unfocus(), + ); return ClipRRect( borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - height: 46, - decoration: BoxDecoration( - color: focused - ? AppColors.surfaceVariant.withValues(alpha: 0.96) - : AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( + child: ListenableBuilder( + listenable: focus, + child: field, + builder: (context, child) { + final focused = focus.hasFocus; + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + height: 46, + decoration: BoxDecoration( color: focused - ? AppColors.primary.withValues(alpha: 0.58) - : Colors.white.withValues(alpha: 0.08), - width: focused ? 1.2 : 1, - ), - ), - child: TextField( - controller: widget.controller, - focusNode: widget.focus, - cursorRadius: const Radius.circular(14), - textInputAction: TextInputAction.search, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - height: 1, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(14)), + ? AppColors.surfaceVariant.withValues(alpha: 0.96) + : AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: focused + ? AppColors.primary.withValues(alpha: 0.58) + : Colors.white.withValues(alpha: 0.08), + width: focused ? 1.2 : 1, ), - hintText: 'search.hint'.tr(), - hintStyle: const TextStyle( - color: AppColors.textHint, - fontSize: 15, - ), - prefixIcon: const Icon( - Icons.search_rounded, - color: AppColors.textHint, - size: 20, - ), - // Android TV: the clear (X) is the only way to drop a query - // without a hardware keyboard, so it needs to be a focus stop. - // Off TV both branches below are the original GestureDetector. - suffixIcon: widget.controller.text.isEmpty - ? null - : (isTvPlatform - ? TvFocusable( - onPressed: widget.onClear, - borderRadius: 9, - child: const Icon( - Icons.close_rounded, - color: AppColors.textHint, - size: 18, - ), - ) - : GestureDetector( - onTap: widget.onClear, - child: const Icon( - Icons.close_rounded, - color: AppColors.textHint, - size: 18, - ), - )), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, ), - onChanged: widget.onChanged, - onTapOutside: (_) => widget.focus.unfocus(), - ), - ), + child: child, + ); + }, ), ); } @@ -247,23 +248,15 @@ class _HeaderIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ClipRRect( - borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.white.withValues(alpha: 0.08), - ), - ), - child: Icon(icon, size: 20, color: AppColors.textSecondary), - ), + final content = Container( + height: 46, + width: 46, + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), + child: Icon(icon, size: 20, color: AppColors.textSecondary), ); // Android TV: the search header's action buttons were unreachable by the // D-pad. Off TV this is the GestureDetector that was always here. @@ -282,48 +275,42 @@ class _FilterButton extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ClipRRect( - borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: active - ? AppColors.primary.withValues(alpha: 0.18) - : AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active - ? AppColors.primary.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.08), - ), + final content = Container( + height: 46, + width: 46, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.18) + : AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active + ? AppColors.primary.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.tune_rounded, + size: 20, + color: active ? AppColors.primary : AppColors.textSecondary, ), - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - Icons.tune_rounded, - size: 20, - color: active ? AppColors.primary : AppColors.textSecondary, - ), - if (active) - Positioned( - top: 9, - right: 9, - child: Container( - width: 7, - height: 7, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - ), + if (active) + Positioned( + top: 9, + right: 9, + child: Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, ), - ], - ), - ), + ), + ), + ], ), ); diff --git a/lib/features/search/presentation/widgets/search_result_card.dart b/lib/features/search/presentation/widgets/search_result_card.dart new file mode 100644 index 00000000..8dc22b11 --- /dev/null +++ b/lib/features/search/presentation/widgets/search_result_card.dart @@ -0,0 +1,193 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; +import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; + +const double _posterRatio = 2 / 3; +const double _captionHeight = 48; + +/// Poster grid shared by single-source and cross-source search. +/// +/// Column count comes from the actual width, not the platform: an Android +/// tablet, an iPad and a TV are all "mobile" to [isDesktopPlatform] and used to +/// get three enormous columns. The extent is computed rather than expressed as +/// an aspect ratio so the poster keeps a true 2:3 and the caption strip is the +/// same height everywhere. +SliverGridDelegate searchGridDelegate( + BuildContext context, { + double horizontalPadding = 32, + double spacing = 10, +}) { + final width = MediaQuery.sizeOf(context).width; + final columns = searchGridColumns(width); + final tile = (width - horizontalPadding - spacing * (columns - 1)) / columns; + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: spacing, + mainAxisSpacing: 16, + mainAxisExtent: tile / _posterRatio + _captionHeight, + ); +} + +int searchGridColumns(double width) { + if (width < 420) return 3; + if (width < 620) return 4; + if (width < 900) return 5; + if (width < 1200) return 6; + return 7; +} + +double searchCardHeight(double tileWidth) => + tileWidth / _posterRatio + _captionHeight; + +class SearchResultCard extends StatelessWidget { + const SearchResultCard({ + super.key, + required this.movie, + required this.onTap, + this.provider, + this.sourceLabel, + this.sourceCount = 1, + this.width, + }); + + final MovieEntity movie; + final VoidCallback onTap; + + /// The source this hit came from — passed on to the detail page so a result + /// never opens against the app's "current" provider by accident. + final String? provider; + + /// Human-readable source name, shown when several sources are on screen. + final String? sourceLabel; + + /// How many sources carry this title. > 1 draws the merge pill. + final int sourceCount; + + final double? width; + + @override + Widget build(BuildContext context) { + final subtitle = sourceLabel ?? (movie.year != null ? '${movie.year}' : ''); + + final card = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + HomeNetworkImage( + url: movie.thumbnail, + borderRadius: BorderRadius.circular(10), + placeholderIcon: Icons.movie_rounded, + ), + Positioned( + top: 6, + left: 6, + child: AnilistLinkedBadge( + contentUrl: movie.url, + provider: provider, + ), + ), + if (movie.rating != null) + Positioned( + top: 6, + right: 6, + child: _Pill( + color: Colors.black.withValues(alpha: 0.72), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star_rounded, + color: AppColors.rating, + size: 10, + ), + const SizedBox(width: 2), + Text( + '${movie.rating}', + style: const TextStyle( + color: Colors.white, + fontSize: 9.5, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + if (sourceCount > 1) + Positioned( + left: 6, + bottom: 6, + child: _Pill( + color: AppColors.primary.withValues(alpha: 0.92), + child: Text( + 'search.sources_n'.tr(args: ['$sourceCount']), + style: const TextStyle( + color: Colors.white, + fontSize: 9.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + movie.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + height: 1.15, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + height: 1.2, + ), + ), + ], + ); + + final tappable = HoverTap( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: card, + ); + + return width == null ? tappable : SizedBox(width: width, child: tappable); + } +} + +class _Pill extends StatelessWidget { + const _Pill({required this.color, required this.child}); + + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(5), + ), + child: child, + ); +} diff --git a/lib/features/search/presentation/widgets/search_set_sheet.dart b/lib/features/search/presentation/widgets/search_set_sheet.dart index d6a86810..bc71eb12 100644 --- a/lib/features/search/presentation/widgets/search_set_sheet.dart +++ b/lib/features/search/presentation/widgets/search_set_sheet.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -98,12 +99,13 @@ class _SearchSetSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Search sources', - style: TextStyle( + Text('search.search_sources'.tr(), + style: const TextStyle( color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w800)), - Text('${_selected.length} selected', + Text('search.selected_n' + .tr(args: ['${_selected.length}']), style: const TextStyle( color: AppColors.textHint, fontSize: 12)), ], @@ -113,7 +115,7 @@ class _SearchSetSheetState extends State { onPressed: _selected.isEmpty ? null : () => setState(_selected.clear), - child: const Text('Clear'), + child: Text('search.clear_filter'.tr()), ), ], ), @@ -125,7 +127,7 @@ class _SearchSetSheetState extends State { onChanged: (v) => setState(() => _query = v), decoration: InputDecoration( isDense: true, - hintText: 'Filter providers…', + hintText: 'search.filter_providers'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint, size: 20), @@ -149,8 +151,8 @@ class _SearchSetSheetState extends State { borderRadius: BorderRadius.circular(8), ), child: Text( - 'Searching ${_selected.length} sources may be slow. ' - 'The app stays responsive, but fewer is snappier.', + 'search.many_sources_warning' + .tr(args: ['${_selected.length}']), style: const TextStyle( color: Colors.orange, fontSize: 11.5, height: 1.3), ), @@ -214,7 +216,8 @@ class _SearchSetSheetState extends State { style: FilledButton.styleFrom( backgroundColor: AppColors.primary), onPressed: () => Navigator.of(context).pop(_selected), - child: Text('Apply (${_selected.length})'), + child: Text('search.apply_n' + .tr(args: ['${_selected.length}'])), ), ), ), diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index fda01348..70d55c31 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -1,13 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; -import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; +import 'package:soplay/features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; +import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; class SearchContentView extends StatelessWidget { const SearchContentView({ @@ -17,6 +18,11 @@ class SearchContentView extends StatelessWidget { required this.topPad, required this.bottomPad, required this.onRetry, + required this.onSuggestion, + required this.onGenre, + required this.onRemoveRecent, + required this.onClearRecents, + required this.onTryAllSources, }); final SearchState state; @@ -24,119 +30,203 @@ class SearchContentView extends StatelessWidget { final double topPad; final double bottomPad; final VoidCallback onRetry; + final ValueChanged onSuggestion; + final ValueChanged onGenre; + final ValueChanged onRemoveRecent; + final VoidCallback onClearRecents; + final VoidCallback onTryAllSources; @override Widget build(BuildContext context) { - final currentState = state; - if (currentState is SearchLoaded) { - return _SearchResultsView( - state: currentState, - scrollController: scrollController, - topPad: topPad, - bottomPad: bottomPad, - ); - } - if (currentState is SearchLoading) { - return _SearchLoadingView(topPad: topPad); - } - if (currentState is SearchError) { - return _SearchErrorView(topPad: topPad, onRetry: onRetry); + return CustomScrollView( + controller: scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: topPad)), + if (state.status == SearchStatus.refreshing) + const SliverToBoxAdapter( + child: SizedBox( + height: 2, + child: LinearProgressIndicator( + minHeight: 2, + color: AppColors.primary, + backgroundColor: Colors.transparent, + ), + ), + ), + ..._body(context), + SliverToBoxAdapter(child: SizedBox(height: bottomPad + 90)), + ], + ); + } + + List _body(BuildContext context) { + switch (state.status) { + case SearchStatus.loading: + return [const _SearchSkeletonGrid()]; + case SearchStatus.error: + return [ + SliverFillRemaining( + hasScrollBody: false, + child: _SearchErrorView( + kind: state.errorKind, + message: state.errorMessage, + onRetry: onRetry, + ), + ), + ]; + case SearchStatus.empty: + return [ + SliverFillRemaining( + hasScrollBody: false, + child: _SearchEmptyView( + criteria: state.criteria, + onTryAllSources: onTryAllSources, + ), + ), + ]; + case SearchStatus.idle: + return [ + SliverToBoxAdapter( + child: _SearchIdleView( + recent: state.recent, + genres: state.genres, + genresLoading: state.genresLoading, + onSuggestion: onSuggestion, + onGenre: onGenre, + onRemoveRecent: onRemoveRecent, + onClearRecents: onClearRecents, + ), + ), + ]; + case SearchStatus.loaded: + case SearchStatus.refreshing: + return [ + SearchResultsGrid(items: state.items), + if (state.isLoadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + strokeWidth: 2, + ), + ), + ), + ), + ]; } - return _SearchPlaceholder(topPad: topPad); } } -class _SearchPlaceholder extends StatelessWidget { - const _SearchPlaceholder({required this.topPad}); +/// The one results grid for the feature. Cross-search's merged view uses it too. +class SearchResultsGrid extends StatelessWidget { + const SearchResultsGrid({super.key, required this.items}); - final double topPad; + final List items; @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: topPad), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_rounded, - color: AppColors.textHint.withValues(alpha: 0.45), - size: 68, - ), - const SizedBox(height: 16), - Text( - 'search.hint'.tr(), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, i) { + final movie = items[i]; + return SearchResultCard( + movie: movie, + // Results can carry a provider of their own — opening them + // against the app's "current" provider is how a result that + // looked fine in the grid failed to load its detail page. + provider: movie.provider.isEmpty ? null : movie.provider, + onTap: () { + if (movie.url.isEmpty) return; + context.push( + '/detail', + extra: DetailArgs( + contentUrl: movie.url, + preview: movie, + provider: movie.provider.isEmpty ? null : movie.provider, + ), + ); + }, + ); + }, + childCount: items.length, ), + gridDelegate: searchGridDelegate(context), ), ); } } -class _SearchLoadingView extends StatelessWidget { - const _SearchLoadingView({required this.topPad}); - - final double topPad; +class _SearchSkeletonGrid extends StatelessWidget { + const _SearchSkeletonGrid(); @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: topPad), - child: const Center( - child: CircularProgressIndicator( - color: AppColors.primary, - strokeWidth: 2, + final width = MediaQuery.sizeOf(context).width; + final columns = searchGridColumns(width); + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, i) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 10, + ), + ), + childCount: columns * 3, ), + gridDelegate: searchGridDelegate(context), ), ); } } -class _SearchResultsView extends StatelessWidget { - const _SearchResultsView({ - required this.state, - required this.scrollController, - required this.topPad, - required this.bottomPad, +class _SearchIdleView extends StatelessWidget { + const _SearchIdleView({ + required this.recent, + required this.genres, + required this.genresLoading, + required this.onSuggestion, + required this.onGenre, + required this.onRemoveRecent, + required this.onClearRecents, }); - final SearchLoaded state; - final ScrollController scrollController; - final double topPad; - final double bottomPad; + final List recent; + final List genres; + final bool genresLoading; + final ValueChanged onSuggestion; + final ValueChanged onGenre; + final ValueChanged onRemoveRecent; + final VoidCallback onClearRecents; @override Widget build(BuildContext context) { - if (state.items.isEmpty) { + if (recent.isEmpty && genres.isEmpty) { return Padding( - padding: EdgeInsets.only(top: topPad), + padding: const EdgeInsets.only(top: 80), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.search_off_rounded, - color: AppColors.textHint, - size: 52, + Icon( + Icons.search_rounded, + color: AppColors.textHint.withValues(alpha: 0.45), + size: 68, ), - const SizedBox(height: 14), + const SizedBox(height: 16), Text( - 'search.no_results_for'.tr( - namedArgs: { - 'query': state.query.isNotEmpty ? state.query : state.genre, - }, - ), + 'search.hint'.tr(), style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 15, + color: AppColors.textHint, + fontSize: 16, + fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), @@ -146,218 +236,330 @@ class _SearchResultsView extends StatelessWidget { ); } - return CustomScrollView( - controller: scrollController, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverPadding( - padding: EdgeInsets.fromLTRB(16, topPad + 8, 16, 8), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, i) => _SearchMovieCard(movie: state.items[i]), - childCount: state.items.length, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recent.isNotEmpty) ...[ + Row( + children: [ + Expanded(child: _SectionTitle('search.recent'.tr())), + _TextAction( + label: 'search.clear_filter'.tr(), + onTap: onClearRecents, + ), + ], ), - gridDelegate: responsiveGridDelegate( - mobileCrossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.62, + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final q in recent) + _Chip( + label: q, + icon: Icons.history_rounded, + onTap: () => onSuggestion(q), + onRemove: () => onRemoveRecent(q), + ), + ], ), - ), - ), - if (state.isLoadingMore) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Center( - child: CircularProgressIndicator( - color: AppColors.primary, - strokeWidth: 2, - ), + const SizedBox(height: 26), + ], + if (genres.isNotEmpty) ...[ + _SectionTitle('search.categories'.tr()), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final g in genres) + _Chip( + label: g.name.isNotEmpty ? g.name : g.slug, + onTap: () => onGenre(g.slug), + ), + ], + ), + ] else if (genresLoading) + const ShimmerWrapper( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + HomeSkeletonBox(width: 92, height: 34, radius: 10), + HomeSkeletonBox(width: 68, height: 34, radius: 10), + HomeSkeletonBox(width: 110, height: 34, radius: 10), + HomeSkeletonBox(width: 80, height: 34, radius: 10), + ], ), ), - ), - SliverToBoxAdapter(child: SizedBox(height: bottomPad + 90)), - ], + ], + ), ); } } -class _SearchMovieCard extends StatelessWidget { - const _SearchMovieCard({required this.movie}); +class _SearchEmptyView extends StatelessWidget { + const _SearchEmptyView({ + required this.criteria, + required this.onTryAllSources, + }); - final MovieEntity movie; + final SearchCriteria criteria; + final VoidCallback onTryAllSources; @override Widget build(BuildContext context) { - return HoverTap( - behavior: HitTestBehavior.opaque, - onTap: () { - if (movie.url.isEmpty) return; - context.push( - '/detail', - extra: DetailArgs(contentUrl: movie.url, preview: movie), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, + return Padding( + padding: const EdgeInsets.fromLTRB(32, 60, 32, 32), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - movie.thumbnail != null - ? Image.network( - movie.thumbnail!, - fit: BoxFit.cover, - errorBuilder: (context, error, stack) => _placeholder(), - ) - : _placeholder(), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withValues(alpha: 0.85), - Colors.transparent, - ], - ), - ), - padding: const EdgeInsets.fromLTRB(6, 22, 6, 6), - child: Text( - movie.title, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), + const Icon( + Icons.search_off_rounded, + color: AppColors.textHint, + size: 52, ), - Positioned( - top: 6, - left: 6, - child: AnilistLinkedBadge(contentUrl: movie.url), + const SizedBox(height: 14), + Text( + 'search.no_results_for'.tr(namedArgs: {'query': criteria.label}), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + textAlign: TextAlign.center, ), - if (movie.rating != null) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star_rounded, - color: AppColors.rating, - size: 9, - ), - const SizedBox(width: 2), - Text( - '${movie.rating}', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), + if (criteria.text.isNotEmpty) ...[ + const SizedBox(height: 20), + _ActionChip( + icon: Icons.travel_explore_rounded, + label: 'search.try_all_sources'.tr(), + onTap: onTryAllSources, ), + ], ], ), - ), ); } +} + +class _SearchErrorView extends StatelessWidget { + const _SearchErrorView({ + required this.kind, + required this.message, + required this.onRetry, + }); + + final SearchFailureKind kind; + final String message; + final VoidCallback onRetry; - Widget _placeholder() { - return Container( - color: AppColors.surfaceVariant, - child: const Icon( - Icons.movie_rounded, - color: AppColors.textHint, - size: 32, + @override + Widget build(BuildContext context) { + final (icon, title) = switch (kind) { + SearchFailureKind.network => ( + Icons.wifi_off_rounded, + 'errors.network'.tr(), + ), + SearchFailureKind.source => ( + Icons.extension_off_rounded, + 'search.source_failed'.tr(), + ), + SearchFailureKind.unknown => ( + Icons.error_outline_rounded, + 'search.search_failed'.tr(), + ), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(32, 60, 32, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.textHint, size: 52), + const SizedBox(height: 14), + Text( + title, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + textAlign: TextAlign.center, + ), + if (message.isNotEmpty && kind != SearchFailureKind.network) ...[ + const SizedBox(height: 8), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 12.5), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 20), + _ActionChip( + icon: Icons.refresh_rounded, + label: 'general.retry'.tr(), + onTap: onRetry, + autofocus: true, + ), + ], ), ); } } -class _SearchErrorView extends StatelessWidget { - const _SearchErrorView({required this.topPad, required this.onRetry}); +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); - final double topPad; - final VoidCallback onRetry; + final String text; + + @override + Widget build(BuildContext context) => Text( + text.toUpperCase(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ); +} + +class _TextAction extends StatelessWidget { + const _TextAction({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final retryChip = Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), - ), + final child = Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: Text( - 'general.retry'.tr(), + label, style: const TextStyle( - color: AppColors.primary, - fontSize: 14, + color: AppColors.textSecondary, + fontSize: 12.5, fontWeight: FontWeight.w600, ), ), ); + if (isTvPlatform) { + return TvFocusable(onPressed: onTap, borderRadius: 8, child: child); + } + return GestureDetector(onTap: onTap, child: child); + } +} - return Padding( - padding: EdgeInsets.only(top: topPad), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.wifi_off_rounded, - color: AppColors.textHint, - size: 52, +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.onTap, + this.icon, + this.onRemove, + }); + + final String label; + final VoidCallback onTap; + final IconData? icon; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final chip = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: AppColors.textHint), + const SizedBox(width: 6), + ], + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(height: 14), - Text( - 'errors.network'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 15, - ), - textAlign: TextAlign.center, + ), + ), + if (onRemove != null) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: onRemove, + child: const Icon( + Icons.close_rounded, + size: 13, + color: AppColors.textHint, ), - const SizedBox(height: 20), - // Android TV: Retry is the only control on this screen; on a bare - // GestureDetector a network blip left the remote with no way out. - // Off TV the else branch is the original GestureDetector. - if (isTvPlatform) - TvFocusable( - onPressed: onRetry, - borderRadius: 10, - autofocus: true, - child: retryChip, - ) - else - GestureDetector(onTap: onRetry, child: retryChip), - ], + ), + ], + ], + ), + ); + + if (isTvPlatform) { + return TvFocusable(onPressed: onTap, borderRadius: 10, child: chip); + } + return GestureDetector(onTap: onTap, child: chip); + } +} + +class _ActionChip extends StatelessWidget { + const _ActionChip({ + required this.icon, + required this.label, + required this.onTap, + this.autofocus = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool autofocus; + + @override + Widget build(BuildContext context) { + final content = Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: AppColors.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - ), + ], ), ); + + // Android TV: this is often the only control on the screen, so it has to be + // a focus stop or the remote has no way out. + if (isTvPlatform) { + return TvFocusable( + onPressed: onTap, + borderRadius: 10, + autofocus: autofocus, + child: content, + ); + } + return GestureDetector(onTap: onTap, child: content); } } diff --git a/lib/features/tracker/presentation/pages/following_page.dart b/lib/features/tracker/presentation/pages/following_page.dart index cc655300..3266ad16 100644 --- a/lib/features/tracker/presentation/pages/following_page.dart +++ b/lib/features/tracker/presentation/pages/following_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_tab_bar.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/controllers/anilist_library_controller.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_library_page.dart'; @@ -65,35 +66,13 @@ class _FollowingPageState extends State icon: const Icon(Icons.link_rounded), ), ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(46), - child: Container( - alignment: Alignment.centerLeft, - decoration: const BoxDecoration( - border: - Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)), - ), - child: TabBar( - controller: _tabs, - isScrollable: true, - tabAlignment: TabAlignment.start, - dividerColor: Colors.transparent, - indicatorColor: AppColors.primary, - indicatorSize: TabBarIndicatorSize.label, - indicatorWeight: 2.5, - labelColor: AppColors.textPrimary, - unselectedLabelColor: AppColors.textSecondary, - labelStyle: - const TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800), - unselectedLabelStyle: - const TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600), - tabs: [ - Tab(height: 44, text: 'tracker.tab_following'.tr()), - Tab(height: 44, text: 'tracker.tab_upcoming'.tr()), - const Tab(height: 44, text: 'AniList'), - ], - ), - ), + bottom: AppTabBar( + controller: _tabs, + labels: [ + 'tracker.tab_following'.tr(), + 'tracker.tab_upcoming'.tr(), + 'AniList', + ], ), ), body: TabBarView( diff --git a/lib/features/user_lists/presentation/pages/user_lists_page.dart b/lib/features/user_lists/presentation/pages/user_lists_page.dart index 2bb3a143..7f89ba2e 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -46,10 +46,18 @@ class _UserListsPageState extends State backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: AppColors.background, - title: const Text('My Lists'), + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + titleSpacing: 16, + title: const Text( + 'My Lists', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + ), bottom: AppTabBar( - controller: _tabs, + // Two tabs: split the bar rather than hugging the left edge. isScrollable: false, + controller: _tabs, labels: [for (final k in _kinds) k.label], ), ), From 58e1dd2050dd3d04db69ebcc51827520dc5b9608 Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 17:24:59 +0500 Subject: [PATCH 5/9] feat(auth): forgot password on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend has served /auth/forgot-password and /auth/reset-password since before this app, emailing the code through Resend, and nothing on the phone reached them — so a forgotten password meant a new account. One page, two steps: ask for the address, then take the emailed code and the new password together. Splitting the second step across two screens would mean holding a verified code while navigating, and the code is only useful alongside the password anyway. The reset issues a session, so success lands in AuthLoaded and the router takes the user into the app rather than back to a login form they have just proved they can pass. Two details worth keeping: The server answers a forgot-password identically whether or not the address exists, and this screen does the same. Reporting "no such account" here is how an app leaks which emails are registered. The repeated-password field is checked before submitting rather than only server-side: a typo would otherwise burn the one-time code and cost another email. Resending goes through forgot-password again — the server treats a second call as a resend, and there is no separate endpoint for this flow to drift from. flutter analyze clean but for two pre-existing infos; debug APK builds. --- assets/translations/en.json | 14 +- assets/translations/ru.json | 14 +- assets/translations/uz.json | 14 +- lib/core/di/injection.dart | 9 + lib/core/router/app_router.dart | 6 + .../datasources/auth_remote_data_source.dart | 26 ++ .../repositories/auth_repository_impl.dart | 43 ++ .../domain/repositories/auth_repository.dart | 8 + .../usecases/forgot_password_usecase.dart | 31 ++ .../auth/presentation/bloc/auth_bloc.dart | 83 ++++ .../auth/presentation/bloc/auth_event.dart | 28 ++ .../auth/presentation/bloc/auth_state.dart | 52 +++ .../pages/forgot_password_page.dart | 440 ++++++++++++++++++ .../auth/presentation/pages/login_page.dart | 19 +- .../presentation/pages/cross_search_page.dart | 7 - 15 files changed, 783 insertions(+), 11 deletions(-) create mode 100644 lib/features/auth/domain/usecases/forgot_password_usecase.dart create mode 100644 lib/features/auth/presentation/pages/forgot_password_page.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index dc7228c7..26ebcbbe 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -51,7 +51,19 @@ "welcome_back": "Welcome back", "create_account": "Create account", "login_subtitle": "Sign in to use your profile and saved data.", - "register_subtitle": "Create an account with email, username, and password." + "register_subtitle": "Create an account with email, username, and password.", + "forgot_password_title": "Reset your password", + "forgot_password_subtitle": "Enter the email on your account and we will send a 6-digit code.", + "reset_password_subtitle": "We sent a code to {email}. Enter it with your new password.", + "send_reset_code": "Send code", + "reset_code_sent": "If that email is registered, a code is on its way", + "reset_code_hint": "6-digit code", + "invalid_reset_code": "Enter the 6-digit code", + "new_password_hint": "New password", + "confirm_password_hint": "Repeat new password", + "reset_password": "Reset password", + "resend_code": "Send another code", + "resend_in": "Send again in {seconds}s" }, "home": { "title": "Home", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 7a1a57a5..0738d31f 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -51,7 +51,19 @@ "welcome_back": "С возвращением", "create_account": "Создать аккаунт", "login_subtitle": "Войдите, чтобы использовать профиль и сохраненные данные.", - "register_subtitle": "Создайте аккаунт с email, именем пользователя и паролем." + "register_subtitle": "Создайте аккаунт с email, именем пользователя и паролем.", + "forgot_password_title": "Сброс пароля", + "forgot_password_subtitle": "Введите email вашего аккаунта — мы отправим 6-значный код.", + "reset_password_subtitle": "Код отправлен на {email}. Введите его и новый пароль.", + "send_reset_code": "Отправить код", + "reset_code_sent": "Если такой email зарегистрирован, код уже в пути", + "reset_code_hint": "6-значный код", + "invalid_reset_code": "Введите 6-значный код", + "new_password_hint": "Новый пароль", + "confirm_password_hint": "Повторите новый пароль", + "reset_password": "Сбросить пароль", + "resend_code": "Отправить ещё раз", + "resend_in": "Повторно через {seconds} с" }, "home": { "title": "Главная", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 5fdb373d..97972f9c 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -51,7 +51,19 @@ "welcome_back": "Xush kelibsiz", "create_account": "Akkaunt yaratish", "login_subtitle": "Profilingiz va saqlangan ma'lumotlaringiz uchun kiring.", - "register_subtitle": "Email, username va parol bilan yangi akkaunt oching." + "register_subtitle": "Email, username va parol bilan yangi akkaunt oching.", + "forgot_password_title": "Parolni tiklash", + "forgot_password_subtitle": "Hisobingiz emailini kiriting — 6 xonali kod yuboramiz.", + "reset_password_subtitle": "{email} manziliga kod yubordik. Uni yangi parol bilan kiriting.", + "send_reset_code": "Kod yuborish", + "reset_code_sent": "Agar bu email ro‘yxatdan o‘tgan bo‘lsa, kod yuborildi", + "reset_code_hint": "6 xonali kod", + "invalid_reset_code": "6 xonali kodni kiriting", + "new_password_hint": "Yangi parol", + "confirm_password_hint": "Yangi parolni takrorlang", + "reset_password": "Parolni tiklash", + "resend_code": "Yana kod yuborish", + "resend_in": "{seconds} s dan keyin qayta" }, "home": { "title": "Bosh sahifa", diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 194aa706..bfd03dc3 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -30,6 +30,7 @@ import 'package:soplay/features/history/data/history_sync_service.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; +import 'package:soplay/features/auth/domain/usecases/forgot_password_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/register_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/resend_otp_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/verify_otp_usecase.dart'; @@ -512,6 +513,12 @@ Future configureDependencies() async { getIt.registerSingleton( VerifyOtpUseCase(getIt()), ); + getIt.registerSingleton( + RequestPasswordResetUseCase(getIt()), + ); + getIt.registerSingleton( + ResetPasswordUseCase(getIt()), + ); getIt.registerSingleton( ResendOtpUseCase(getIt()), ); @@ -532,6 +539,8 @@ Future configureDependencies() async { registerUseCase: getIt(), verifyOtpUseCase: getIt(), resendOtpUseCase: getIt(), + requestPasswordResetUseCase: getIt(), + resetPasswordUseCase: getIt(), authRepository: getIt(), hiveService: getIt(), notificationService: getIt(), diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 565c6aa8..a6a17b37 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import 'package:soplay/features/app_lock/presentation/pages/app_lock_settings_pa import 'package:soplay/features/app_lock/presentation/pages/pin_setup_page.dart'; import 'package:soplay/features/app_lock/presentation/pages/pin_verify_page.dart'; import 'package:soplay/features/desktop_share/presentation/pages/desktop_share_page.dart'; +import 'package:soplay/features/auth/presentation/pages/forgot_password_page.dart'; import 'package:soplay/features/auth/presentation/pages/login_page.dart'; import 'package:soplay/features/auth/presentation/pages/otp_verify_page.dart'; import 'package:soplay/features/auth/presentation/pages/register_page.dart'; @@ -240,6 +241,11 @@ class AppRouter { GoRoute(path: '/splash', builder: (context, state) => const SplashPage()), GoRoute(path: '/main', builder: (context, state) => const MainPage()), GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + GoRoute( + path: '/forgot-password', + builder: (context, state) => + ForgotPasswordPage(initialEmail: state.extra as String?), + ), GoRoute( path: '/register', builder: (context, state) => const RegisterPage(), diff --git a/lib/features/auth/data/datasources/auth_remote_data_source.dart b/lib/features/auth/data/datasources/auth_remote_data_source.dart index e018cd6f..ef44565a 100644 --- a/lib/features/auth/data/datasources/auth_remote_data_source.dart +++ b/lib/features/auth/data/datasources/auth_remote_data_source.dart @@ -44,6 +44,32 @@ class AuthRemoteDataSource { return AuthModel.fromJson(response.data as Map); } + /// Asks the server to email a reset code. + /// + /// Answers the same way whether or not the address exists — the server + /// decides that, and it deliberately does not say, so this must not treat a + /// missing account as an error either. + Future requestPasswordReset(String email) async { + await dio.post('/auth/forgot-password', data: {'email': email}); + } + + /// Sets the new password and returns the session it issues. + /// + /// The server signs the user in as part of the reset, so there is no second + /// login round trip — and no window where they know the new password but the + /// app still holds the old session. + Future resetPassword({ + required String email, + required String code, + required String newPassword, + }) async { + final response = await dio.post( + '/auth/reset-password', + data: {'email': email, 'otp': code, 'newPassword': newPassword}, + ); + return AuthModel.fromJson(response.data as Map); + } + Future getProfile() async { final response = await dio.get('/auth/profile'); final data = response.data as Map; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 7ee7dbd1..fdb0e3b3 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -93,6 +93,49 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future> requestPasswordReset(String email) async { + try { + await _remoteDataSource.requestPasswordReset(email); + return const Success(null); + } on DioException catch (e) { + return Failure(Exception(_messageFrom(e))); + } catch (e) { + return Failure(Exception(e.toString())); + } + } + + @override + Future> resetPassword({ + required String email, + required String code, + required String newPassword, + }) async { + try { + final model = await _remoteDataSource.resetPassword( + email: email, + code: code, + newPassword: newPassword, + ); + if (model.accessToken.isEmpty) { + return Failure(Exception('Access token topilmadi')); + } + // Stored exactly like a verified registration: the reset issues a real + // session, and not saving it would leave the user staring at a login + // screen straight after proving who they are. + await _hiveService.saveAuth( + accessToken: model.accessToken, + refreshToken: model.refreshToken, + user: model.user as UserModel, + ); + return Success(model); + } on DioException catch (e) { + return Failure(Exception(_messageFrom(e))); + } catch (e) { + return Failure(Exception(e.toString())); + } + } + @override Future> getProfile() async { try { diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index 5c9aef9e..61b9a9c2 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -19,6 +19,14 @@ abstract class AuthRepository { required String code, }); + Future> requestPasswordReset(String email); + + Future> resetPassword({ + required String email, + required String code, + required String newPassword, + }); + Future> getProfile(); Future logout(); diff --git a/lib/features/auth/domain/usecases/forgot_password_usecase.dart b/lib/features/auth/domain/usecases/forgot_password_usecase.dart new file mode 100644 index 00000000..a50905b3 --- /dev/null +++ b/lib/features/auth/domain/usecases/forgot_password_usecase.dart @@ -0,0 +1,31 @@ +import '../../../../core/error/result.dart'; +import '../entities/auth_token.dart'; +import '../repositories/auth_repository.dart'; + +class RequestPasswordResetUseCase { + final AuthRepository _authRepository; + + RequestPasswordResetUseCase(this._authRepository); + + Future> call(String email) { + return _authRepository.requestPasswordReset(email); + } +} + +class ResetPasswordUseCase { + final AuthRepository _authRepository; + + ResetPasswordUseCase(this._authRepository); + + Future> call({ + required String email, + required String code, + required String newPassword, + }) { + return _authRepository.resetPassword( + email: email, + code: code, + newPassword: newPassword, + ); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index a3c5cb03..a3167f13 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -5,6 +5,7 @@ import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/auth/domain/entities/auth_token.dart'; import 'package:soplay/features/auth/domain/repositories/auth_repository.dart'; +import 'package:soplay/features/auth/domain/usecases/forgot_password_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/login_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/register_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/resend_otp_usecase.dart'; @@ -23,6 +24,8 @@ class AuthBloc extends Bloc { final RegisterUseCase registerUseCase; final VerifyOtpUseCase verifyOtpUseCase; final ResendOtpUseCase resendOtpUseCase; + final RequestPasswordResetUseCase requestPasswordResetUseCase; + final ResetPasswordUseCase resetPasswordUseCase; final AuthRepository authRepository; final HiveService hiveService; final NotificationService notificationService; @@ -35,6 +38,8 @@ class AuthBloc extends Bloc { required this.registerUseCase, required this.verifyOtpUseCase, required this.resendOtpUseCase, + required this.requestPasswordResetUseCase, + required this.resetPasswordUseCase, required this.authRepository, required this.hiveService, required this.notificationService, @@ -46,6 +51,8 @@ class AuthBloc extends Bloc { on(_onVerifyOtp); on(_onResendOtp); on((_, emit) => emit(AuthInitial())); + on(_onPasswordResetRequested); + on(_onPasswordResetSubmitted); on(_onLogout); on(_onSessionExpired); on(_onProfileRefresh); @@ -154,6 +161,82 @@ class AuthBloc extends Bloc { } } + Future _onPasswordResetRequested( + AuthPasswordResetRequested event, + Emitter emit, + ) async { + final current = state; + if (event.isResend && current is AuthPasswordResetPending) { + if (DateTime.now().isBefore(current.cooldownUntil)) return; + emit(current.copyWith(resending: true, clearError: true)); + } else if (!event.isResend) { + emit(AuthLoading()); + } + + final result = await requestPasswordResetUseCase(event.email); + final pending = state; + switch (result) { + case Success(): + // The server answers the same way whether or not the address exists, so + // this screen must too — telling the user "no such account" here is how + // an app leaks which emails are registered. + emit( + pending is AuthPasswordResetPending + ? pending.copyWith( + resending: false, + justSent: true, + cooldownUntil: DateTime.now().add(_resendCooldown), + clearError: true, + ) + : AuthPasswordResetPending( + email: event.email, + cooldownUntil: DateTime.now().add(_resendCooldown), + justSent: true, + ), + ); + case Failure(:final error): + final msg = _friendlyError(error); + emit( + pending is AuthPasswordResetPending + ? pending.copyWith(resending: false, error: msg) + : AuthError(message: msg), + ); + } + } + + Future _onPasswordResetSubmitted( + AuthPasswordResetSubmitted event, + Emitter emit, + ) async { + final current = state; + if (current is AuthPasswordResetPending) { + emit(current.copyWith(submitting: true, clearError: true)); + } + + final result = await resetPasswordUseCase( + email: event.email, + code: event.code, + newPassword: event.newPassword, + ); + // Re-read after the await, exactly as the register flow does: a resend may + // have replaced the pending state while this was in flight. + final pending = state; + switch (result) { + case Success(:final value): + emit(AuthLoaded(token: value)); + unawaited(syncFavorites()); + unawaited(_syncHistory()); + unawaited(notificationService.setup()); + case Failure(:final error): + final msg = _friendlyError(error); + emit( + pending is AuthPasswordResetPending + ? pending.copyWith(submitting: false, error: msg) + : AuthError(message: msg), + ); + } + } + Future _onResendOtp( AuthOtpResendRequested event, Emitter emit, diff --git a/lib/features/auth/presentation/bloc/auth_event.dart b/lib/features/auth/presentation/bloc/auth_event.dart index 19db63aa..e91aeb6b 100644 --- a/lib/features/auth/presentation/bloc/auth_event.dart +++ b/lib/features/auth/presentation/bloc/auth_event.dart @@ -62,6 +62,34 @@ class AuthOtpReset extends AuthEvent { const AuthOtpReset(); } +/// Ask for a reset code. Also the resend, since the server treats a second +/// forgot-password call as one — there is no separate resend endpoint for this +/// flow and inventing a client-side one would just drift. +class AuthPasswordResetRequested extends AuthEvent { + final String email; + final bool isResend; + + const AuthPasswordResetRequested({required this.email, this.isResend = false}); + + @override + List get props => [email, isResend]; +} + +class AuthPasswordResetSubmitted extends AuthEvent { + final String email; + final String code; + final String newPassword; + + const AuthPasswordResetSubmitted({ + required this.email, + required this.code, + required this.newPassword, + }); + + @override + List get props => [email, code, newPassword]; +} + class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } diff --git a/lib/features/auth/presentation/bloc/auth_state.dart b/lib/features/auth/presentation/bloc/auth_state.dart index 771f826d..14c51113 100644 --- a/lib/features/auth/presentation/bloc/auth_state.dart +++ b/lib/features/auth/presentation/bloc/auth_state.dart @@ -78,3 +78,55 @@ class AuthOtpPending extends AuthState { error, ]; } + +/// Waiting on the code emailed by a password reset. +/// +/// Separate from [AuthOtpPending] even though both hold an email and a +/// cooldown: that one means "finish signing up" and the screen behind it only +/// asks for a code, while this one also collects the new password. Sharing it +/// would leave the OTP screen unable to tell which it is looking at. +class AuthPasswordResetPending extends AuthState { + final String email; + final DateTime cooldownUntil; + final bool justSent; + final bool submitting; + final bool resending; + final String? error; + + AuthPasswordResetPending({ + required this.email, + required this.cooldownUntil, + this.justSent = false, + this.submitting = false, + this.resending = false, + this.error, + }); + + AuthPasswordResetPending copyWith({ + DateTime? cooldownUntil, + bool? justSent, + bool? submitting, + bool? resending, + String? error, + bool clearError = false, + }) { + return AuthPasswordResetPending( + email: email, + cooldownUntil: cooldownUntil ?? this.cooldownUntil, + justSent: justSent ?? this.justSent, + submitting: submitting ?? this.submitting, + resending: resending ?? this.resending, + error: clearError ? null : (error ?? this.error), + ); + } + + @override + List get props => [ + email, + cooldownUntil, + justSent, + submitting, + resending, + error, + ]; +} diff --git a/lib/features/auth/presentation/pages/forgot_password_page.dart b/lib/features/auth/presentation/pages/forgot_password_page.dart new file mode 100644 index 00000000..dea78cff --- /dev/null +++ b/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_event.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_state.dart'; + +/// Resetting a forgotten password. +/// +/// One page, two steps: ask for the address, then take the emailed code and the +/// new password together. Splitting the second step across two screens would +/// mean holding a verified code while navigating, and the code is only useful +/// alongside the password anyway. +/// +/// The server signs the user in as part of the reset, so a success here lands +/// in [AuthLoaded] and the router takes them into the app — there is no window +/// where they know the new password but still face a login form. +class ForgotPasswordPage extends StatefulWidget { + const ForgotPasswordPage({super.key, this.initialEmail}); + + final String? initialEmail; + + @override + State createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends State { + final _emailForm = GlobalKey(); + final _resetForm = GlobalKey(); + + late final _email = TextEditingController(text: widget.initialEmail ?? ''); + final _code = TextEditingController(); + final _password = TextEditingController(); + final _confirm = TextEditingController(); + + bool _obscure = true; + Timer? _ticker; + Duration _remaining = Duration.zero; + + @override + void dispose() { + _ticker?.cancel(); + _email.dispose(); + _code.dispose(); + _password.dispose(); + _confirm.dispose(); + super.dispose(); + } + + void _startCooldown(DateTime until) { + _ticker?.cancel(); + void tick() { + if (!mounted) return; + final left = until.difference(DateTime.now()); + setState(() => _remaining = left.isNegative ? Duration.zero : left); + if (left.isNegative) _ticker?.cancel(); + } + + tick(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick()); + } + + void _sendCode({bool resend = false}) { + if (!resend && !(_emailForm.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + context.read().add( + AuthPasswordResetRequested(email: _email.text.trim(), isResend: resend), + ); + } + + void _submitReset() { + if (!(_resetForm.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + context.read().add( + AuthPasswordResetSubmitted( + email: _email.text.trim(), + code: _code.text.trim(), + newPassword: _password.text, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.canPop() + ? context.pop() + : context.pushReplacement('/login'), + ), + ), + body: SafeArea( + child: BlocConsumer( + listenWhen: (a, b) => b is AuthPasswordResetPending || b is AuthError, + listener: (context, state) { + if (state is AuthPasswordResetPending) { + _startCooldown(state.cooldownUntil); + if (state.justSent) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('auth.reset_code_sent'.tr()), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + builder: (context, state) { + final pending = state is AuthPasswordResetPending ? state : null; + final sending = state is AuthLoading; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'auth.forgot_password_title'.tr(), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + pending == null + ? 'auth.forgot_password_subtitle'.tr() + : 'auth.reset_password_subtitle'.tr( + namedArgs: {'email': pending.email}, + ), + style: const TextStyle( + fontSize: 13.5, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 26), + + if (pending == null) + _EmailStep( + formKey: _emailForm, + controller: _email, + busy: sending, + onSubmit: _sendCode, + ) + else + _ResetStep( + formKey: _resetForm, + code: _code, + password: _password, + confirm: _confirm, + obscure: _obscure, + onToggleObscure: () => + setState(() => _obscure = !_obscure), + state: pending, + remaining: _remaining, + onResend: () => _sendCode(resend: true), + onSubmit: _submitReset, + ), + + if (state is AuthError) ...[ + const SizedBox(height: 14), + Text( + state.message, + style: const TextStyle( + color: AppColors.error, + fontSize: 12.5, + ), + ), + ], + ], + ), + ); + }, + ), + ), + ); + } +} + +class _EmailStep extends StatelessWidget { + const _EmailStep({ + required this.formKey, + required this.controller, + required this.busy, + required this.onSubmit, + }); + + final GlobalKey formKey; + final TextEditingController controller; + final bool busy; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column( + children: [ + _Field( + controller: controller, + hint: 'auth.email_hint'.tr(), + icon: Icons.mail_outline_rounded, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit(), + validator: (v) { + final value = (v ?? '').trim(); + if (value.isEmpty) return 'auth.email'.tr(); + if (!value.contains('@') || !value.contains('.')) { + return 'auth.invalid_email'.tr(); + } + return null; + }, + ), + const SizedBox(height: 20), + _PrimaryButton( + label: 'auth.send_reset_code'.tr(), + busy: busy, + onPressed: onSubmit, + ), + ], + ), + ); + } +} + +class _ResetStep extends StatelessWidget { + const _ResetStep({ + required this.formKey, + required this.code, + required this.password, + required this.confirm, + required this.obscure, + required this.onToggleObscure, + required this.state, + required this.remaining, + required this.onResend, + required this.onSubmit, + }); + + final GlobalKey formKey; + final TextEditingController code; + final TextEditingController password; + final TextEditingController confirm; + final bool obscure; + final VoidCallback onToggleObscure; + final AuthPasswordResetPending state; + final Duration remaining; + final VoidCallback onResend; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final canResend = remaining == Duration.zero && !state.resending; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Field( + controller: code, + hint: 'auth.reset_code_hint'.tr(), + icon: Icons.pin_outlined, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + validator: (v) { + final value = (v ?? '').trim(); + if (value.length != 6) return 'auth.invalid_reset_code'.tr(); + return null; + }, + ), + const SizedBox(height: 12), + _Field( + controller: password, + hint: 'auth.new_password_hint'.tr(), + icon: Icons.lock_outline_rounded, + obscureText: obscure, + textInputAction: TextInputAction.next, + suffix: IconButton( + onPressed: onToggleObscure, + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textHint, + ), + ), + validator: (v) { + if ((v ?? '').isEmpty) return 'auth.password'.tr(); + if ((v ?? '').length < 6) return 'auth.invalid_password'.tr(); + return null; + }, + ), + const SizedBox(height: 12), + _Field( + controller: confirm, + hint: 'auth.confirm_password_hint'.tr(), + icon: Icons.lock_reset_rounded, + obscureText: obscure, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit(), + validator: (v) { + // Checked here rather than only on the server: a typo would + // otherwise burn the one-time code and force another email. + if (v != password.text) return 'auth.passwords_not_match'.tr(); + return null; + }, + ), + + if (state.error != null) ...[ + const SizedBox(height: 12), + Text( + state.error!, + style: const TextStyle(color: AppColors.error, fontSize: 12.5), + ), + ], + + const SizedBox(height: 20), + _PrimaryButton( + label: 'auth.reset_password'.tr(), + busy: state.submitting, + onPressed: onSubmit, + ), + const SizedBox(height: 10), + Center( + child: TextButton( + onPressed: canResend ? onResend : null, + child: Text( + canResend + ? 'auth.resend_code'.tr() + : 'auth.resend_in'.tr( + namedArgs: {'seconds': '${remaining.inSeconds}'}, + ), + style: const TextStyle(fontSize: 12.5), + ), + ), + ), + ], + ), + ); + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.busy, + required this.onPressed, + }); + + final String label; + final bool busy; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: busy ? null : onPressed, + child: busy + ? const SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.2, + ), + ) + : Text(label), + ), + ); + } +} + +class _Field extends StatelessWidget { + const _Field({ + required this.controller, + required this.hint, + required this.icon, + this.keyboardType, + this.textInputAction, + this.obscureText = false, + this.suffix, + this.validator, + this.onFieldSubmitted, + this.inputFormatters, + }); + + final TextEditingController controller; + final String hint; + final IconData icon; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final bool obscureText; + final Widget? suffix; + final String? Function(String?)? validator; + final void Function(String)? onFieldSubmitted; + final List? inputFormatters; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + textInputAction: textInputAction, + obscureText: obscureText, + validator: validator, + onFieldSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: hint, + prefixIcon: Icon(icon, size: 20, color: AppColors.textHint), + suffixIcon: suffix, + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index a9e88eea..2f032d0b 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -117,7 +117,24 @@ class _LoginPageState extends State { ), validator: _validatePassword, ), - const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: TextButton( + // Carries whatever was typed, so the reset page + // does not ask for an address the user just gave. + onPressed: () => context.push( + '/forgot-password', + extra: _identifierController.text.contains('@') + ? _identifierController.text.trim() + : null, + ), + child: Text( + 'auth.forgot_password'.tr(), + style: const TextStyle(fontSize: 12.5), + ), + ), + ), + const SizedBox(height: 8), BlocBuilder( builder: (context, state) { final loading = state is AuthLoading; diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index 56bc0fbd..e6af6cb8 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -15,15 +15,8 @@ import 'package:soplay/features/search/domain/services/cross_search_engine.dart' import 'package:soplay/features/search/presentation/blocs/cross_search_controller.dart'; import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; import 'package:soplay/features/search/presentation/widgets/search_set_sheet.dart'; - -/// Search a curated set of providers at once. Results stream in and are merged -/// into one title per card — freeze-proof (bounded concurrency + per-provider -/// timeout in the engine), so a large or partly-broken set never blocks the UI. class CrossSearchPage extends StatefulWidget { const CrossSearchPage({super.key, this.initialQuery}); - - /// When set (e.g. opened from a title's "find on other sources"), the page - /// starts searching for this immediately and does not steal focus. final String? initialQuery; @override From 07c72316f310f89736cc42b6b4eb22d084c6322a Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 18:06:11 +0500 Subject: [PATCH 6/9] feat(remote): drive the TV from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone half. Pairing already existed and the server relays commands; this is the part a person touches. Two things earn the screen: a keyboard, because entering text with a d-pad is the worst thing about any TV app, and transport controls, because the physical remote is always on the other sofa. Typing here runs the search on the TV. Detail gains "Play on TV". The TITLE is what travels, not the url — the two apps do not share a source registry, so this app's contentUrl means nothing to a TV without that provider, and the TV resolves the title against its own sources. The device list is fetched on tap rather than when the sheet opens: most people never press it, and the ones who do can wait for one request instead of everyone paying for it on every open. One reachable TV is the normal case and is used without asking; several get a picker. State is polled every two seconds rather than pushed. The TV reports upward and the phone reads; a socket per remote would cost more than this does, and the remote only renders seconds. A 409 from the server means nothing is listening, which is the ordinary state of a TV that is switched off. It has its own exception type so the UI says "TV is offline" instead of showing a failure. flutter analyze clean but for two pre-existing infos; debug APK builds. --- assets/translations/en.json | 14 + assets/translations/ru.json | 14 + assets/translations/uz.json | 14 + lib/core/di/injection.dart | 4 + lib/core/router/app_router.dart | 5 + .../widgets/detail_more_sheet.dart | 88 +++ .../presentation/pages/link_tv_page.dart | 10 + .../remote/data/remote_control_service.dart | 172 ++++++ .../presentation/pages/tv_remote_page.dart | 554 ++++++++++++++++++ 9 files changed, 875 insertions(+) create mode 100644 lib/features/remote/data/remote_control_service.dart create mode 100644 lib/features/remote/presentation/pages/tv_remote_page.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 26ebcbbe..1c7818ec 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1009,5 +1009,19 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "TV remote", + "no_devices": "No linked TVs yet. Pair one from the TV app first.", + "load_failed": "Couldn't load your TVs.", + "tv_offline": "TV is offline", + "idle": "Nothing playing", + "command_failed": "That didn't reach the TV", + "search_on_tv": "SEARCH ON TV", + "search_hint": "Type here, search runs on the TV", + "back": "Back", + "home": "Home", + "open_on_tv": "Play on TV", + "sent_to_tv": "Sent to {device}" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 0738d31f..9400007e 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1009,5 +1009,19 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "Пульт для ТВ", + "no_devices": "Нет привязанных ТВ. Сначала свяжите его в приложении на ТВ.", + "load_failed": "Не удалось загрузить ваши ТВ.", + "tv_offline": "ТВ не в сети", + "idle": "Ничего не воспроизводится", + "command_failed": "Команда не дошла до ТВ", + "search_on_tv": "ПОИСК НА ТВ", + "search_hint": "Печатайте здесь — поиск идёт на ТВ", + "back": "Назад", + "home": "Главная", + "open_on_tv": "Смотреть на ТВ", + "sent_to_tv": "Отправлено на {device}" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 97972f9c..b22f1a46 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1009,5 +1009,19 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "TV pulti", + "no_devices": "Bog'langan TV yo'q. Avval TV ilovasidan bog'lang.", + "load_failed": "TVlaringizni yuklab bo‘lmadi.", + "tv_offline": "TV oflayn", + "idle": "Hech narsa o‘ynatilmayapti", + "command_failed": "Buyruq TVga yetmadi", + "search_on_tv": "TVDA QIDIRISH", + "search_hint": "Shu yerga yozing — qidiruv TVda ketadi", + "back": "Orqaga", + "home": "Bosh sahifa", + "open_on_tv": "TVda ko‘rish", + "sent_to_tv": "{device} ga yuborildi" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index bfd03dc3..9acb7ed1 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -19,6 +19,7 @@ import 'package:soplay/core/network/provider_interceptor.dart'; import 'package:soplay/core/player/local_hls_proxy.dart'; import 'package:soplay/core/player/webview_stream_extractor.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/remote/data/remote_control_service.dart'; import 'package:soplay/features/streak/data/streak_remote_data_source.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; @@ -190,6 +191,9 @@ Future configureDependencies() async { // AniList. Rides the authenticated backend client because the link lives on // the Sozo account — the client secret stays on the server, and a TV signed // into the same account inherits the connection without its own sign-in. + getIt.registerLazySingleton( + () => RemoteControlService(dio: getIt()), + ); getIt.registerSingleton(AnilistLinkStore()); getIt.registerSingleton( AnilistService( diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a6a17b37..01df3125 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -48,6 +48,7 @@ import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; import 'package:soplay/features/profile/presentation/pages/player_settings_page.dart'; import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; +import 'package:soplay/features/remote/presentation/pages/tv_remote_page.dart'; import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; import 'package:soplay/features/notifications/presentation/pages/notifications_page.dart'; import 'package:soplay/features/private_list/presentation/pages/private_list_page.dart'; @@ -180,6 +181,10 @@ class AppRouter { path: '/following', builder: (context, state) => const FollowingPage(), ), + GoRoute( + path: '/tv-remote', + builder: (context, state) => const TvRemotePage(), + ), GoRoute( path: '/connections', builder: (context, state) => const ConnectionsPage(), diff --git a/lib/features/detail/presentation/widgets/detail_more_sheet.dart b/lib/features/detail/presentation/widgets/detail_more_sheet.dart index 25028e48..4c804b54 100644 --- a/lib/features/detail/presentation/widgets/detail_more_sheet.dart +++ b/lib/features/detail/presentation/widgets/detail_more_sheet.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/features/remote/data/remote_control_service.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; @@ -64,6 +65,83 @@ class _DetailMoreSheet extends StatefulWidget { } class _DetailMoreSheetState extends State<_DetailMoreSheet> { + /// Hands this title to a linked TV. + /// + /// The device list is fetched on tap rather than when the sheet opens: most + /// people never press this, and the ones who do can wait for one request + /// instead of everyone paying for it on every open. + /// + /// The TITLE is what travels. The two apps do not share a source registry, so + /// this app's contentUrl means nothing to a TV without that provider — the TV + /// resolves the title against its own sources. + Future _playOnTv() async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final service = getIt(); + + void say(String message) => messenger.showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + + try { + final devices = await service.devices(); + final online = devices.where((d) => d.online).toList(); + + if (devices.isEmpty) { + say('remote.no_devices'.tr()); + return; + } + if (online.isEmpty) { + say('remote.tv_offline'.tr()); + return; + } + + // One reachable TV is the normal case; asking which would be a dialog + // with a single button in it. + final target = online.length == 1 + ? online.first + : await _pickDevice(online); + if (target == null) return; + + await service.openOnTv( + target.id, + title: widget.entity.title, + contentUrl: widget.entity.contentUrl, + provider: widget.entity.provider, + ); + if (!mounted) return; + navigator.pop(); + say('remote.sent_to_tv'.tr(namedArgs: {'device': target.name})); + } on RemoteOfflineException { + say('remote.tv_offline'.tr()); + } catch (_) { + say('remote.command_failed'.tr()); + } + } + + Future _pickDevice(List devices) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final device in devices) + ListTile( + leading: const Icon(Icons.tv_rounded, color: AppColors.textSecondary), + title: Text(device.name), + onTap: () => Navigator.of(sheetContext).pop(device), + ), + ], + ), + ), + ); + } + final UserListSync _listSync = UserListSync(); late bool _following = widget.isFollowing; @@ -167,6 +245,16 @@ class _DetailMoreSheetState extends State<_DetailMoreSheet> { ), onTap: () => _run(widget.onFindSources), ), + _SheetRow( + icon: Icons.cast_rounded, + label: 'remote.open_on_tv'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: _playOnTv, + ), _SheetRow( icon: Icons.ios_share_rounded, label: 'movie.share'.tr(), diff --git a/lib/features/link_tv/presentation/pages/link_tv_page.dart b/lib/features/link_tv/presentation/pages/link_tv_page.dart index 783efe93..d8b19901 100644 --- a/lib/features/link_tv/presentation/pages/link_tv_page.dart +++ b/lib/features/link_tv/presentation/pages/link_tv_page.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -112,6 +113,15 @@ class _LinkTvViewState extends State<_LinkTvView> { title: Text('link_tv.title'.tr(), style: const TextStyle(color: AppColors.textPrimary)), iconTheme: const IconThemeData(color: AppColors.textPrimary), + actions: [ + // Reached from here because this is where the TVs are: pairing + // one and then driving it are the same errand. + IconButton( + tooltip: 'remote.title'.tr(), + onPressed: () => context.push('/tv-remote'), + icon: const Icon(Icons.settings_remote_rounded), + ), + ], ), body: RefreshIndicator( color: AppColors.primary, diff --git a/lib/features/remote/data/remote_control_service.dart b/lib/features/remote/data/remote_control_service.dart new file mode 100644 index 00000000..352fe8b2 --- /dev/null +++ b/lib/features/remote/data/remote_control_service.dart @@ -0,0 +1,172 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// A TV this account can drive, and whether it is actually reachable. +@immutable +class RemoteDevice { + const RemoteDevice({ + required this.id, + required this.name, + required this.online, + this.lastSeenAt, + }); + + final String id; + final String name; + + /// Holding the channel open — not "signed in recently". A linked TV that is + /// switched off is not something you can press buttons at. + final bool online; + + final DateTime? lastSeenAt; + + static RemoteDevice? fromJson(Map json) { + final id = json['id']?.toString(); + if (id == null || id.isEmpty) return null; + return RemoteDevice( + id: id, + name: (json['name'] as String?)?.trim().isNotEmpty == true + ? json['name'] as String + : 'TV', + online: json['online'] == true, + lastSeenAt: DateTime.tryParse(json['lastSeenAt']?.toString() ?? ''), + ); + } +} + +/// What the TV last reported it was doing. +@immutable +class RemoteTvState { + const RemoteTvState({ + this.screen, + this.title, + this.episode, + this.playing = false, + this.positionMs, + this.durationMs, + }); + + final String? screen; + final String? title; + final String? episode; + final bool playing; + final int? positionMs; + final int? durationMs; + + bool get hasPlayback => (durationMs ?? 0) > 0; + + static RemoteTvState? fromJson(Map? json) { + if (json == null) return null; + return RemoteTvState( + screen: json['screen'] as String?, + title: json['title'] as String?, + episode: json['episode']?.toString(), + playing: json['playing'] == true, + positionMs: (json['positionMs'] as num?)?.toInt(), + durationMs: (json['durationMs'] as num?)?.toInt(), + ); + } +} + +/// Raised when the TV is not holding the channel open. +/// +/// Its own type because it is the one failure worth saying out loud: every +/// other error here means something went wrong, this one means "turn the TV on". +class RemoteOfflineException implements Exception { + const RemoteOfflineException(); +} + +/// The phone half of the remote control. +/// +/// Commands go through the server rather than the local network. The two +/// devices are frequently not on the same one, and a remote that only works at +/// home is not the feature people want. +class RemoteControlService { + const RemoteControlService({required Dio dio}) : _dio = dio; + + final Dio _dio; + + Future> devices() async { + final response = await _dio.get('/remote/devices'); + final items = (response.data as Map?)?['items']; + if (items is! List) return const []; + return items + .whereType() + .map((e) => RemoteDevice.fromJson(e.cast())) + .whereType() + .toList(growable: false); + } + + Future<({bool online, RemoteTvState? state})> state(String deviceId) async { + final response = await _dio.get( + '/remote/state', + queryParameters: {'deviceId': deviceId}, + ); + final data = response.data as Map?; + return ( + online: data?['online'] == true, + state: RemoteTvState.fromJson( + (data?['state'] as Map?)?.cast(), + ), + ); + } + + /// Sends one command. + /// + /// A 409 is the server saying nothing is listening, which is the normal state + /// of a TV that is off — surfaced as [RemoteOfflineException] so the UI can + /// say so rather than showing a stack of failures. + Future send( + String deviceId, + String type, { + Map args = const {}, + }) async { + try { + await _dio.post( + '/remote/command', + data: {'deviceId': deviceId, 'type': type, ...args}, + ); + } on DioException catch (e) { + if (e.response?.statusCode == 409) throw const RemoteOfflineException(); + rethrow; + } + } + + Future play(String id) => send(id, 'play'); + Future pause(String id) => send(id, 'pause'); + Future playPause(String id) => send(id, 'playpause'); + Future next(String id) => send(id, 'next'); + Future previous(String id) => send(id, 'prev'); + Future back(String id) => send(id, 'back'); + Future home(String id) => send(id, 'home'); + + Future seekTo(String id, int positionMs) => + send(id, 'seek', args: {'positionMs': positionMs}); + + Future seekBy(String id, int deltaMs) => + send(id, 'seekBy', args: {'deltaMs': deltaMs}); + + Future dpad(String id, String direction) => + send(id, 'dpad', args: {'direction': direction}); + + /// Types into the TV's search. The point of the whole feature. + Future type(String id, String text) => + send(id, 'text', args: {'text': text}); + + /// Plays something on the TV. + /// + /// The TITLE travels, not the url: the two apps do not share a source + /// registry, so a link from a provider installed here is meaningless to a TV + /// that does not have it. The TV resolves the title against its own sources. + Future openOnTv( + String id, { + required String title, + String? contentUrl, + String? provider, + }) => + send(id, 'open', args: { + 'title': title, + if (contentUrl != null && contentUrl.isNotEmpty) 'contentUrl': contentUrl, + if (provider != null && provider.isNotEmpty) 'provider': provider, + }); +} diff --git a/lib/features/remote/presentation/pages/tv_remote_page.dart b/lib/features/remote/presentation/pages/tv_remote_page.dart new file mode 100644 index 00000000..d71a6e37 --- /dev/null +++ b/lib/features/remote/presentation/pages/tv_remote_page.dart @@ -0,0 +1,554 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/remote/data/remote_control_service.dart'; + +/// The phone as a remote for the TV. +/// +/// Two halves that earn their place: a keyboard, because entering text with a +/// d-pad is the worst thing about any TV app, and transport controls, because +/// the physical remote is always on the other sofa. +class TvRemotePage extends StatefulWidget { + const TvRemotePage({super.key}); + + @override + State createState() => _TvRemotePageState(); +} + +class _TvRemotePageState extends State { + final RemoteControlService _service = getIt(); + final _search = TextEditingController(); + + List _devices = const []; + RemoteDevice? _selected; + RemoteTvState? _state; + bool _online = false; + bool _loading = true; + String? _error; + + Timer? _poll; + + @override + void initState() { + super.initState(); + _loadDevices(); + } + + @override + void dispose() { + _poll?.cancel(); + _search.dispose(); + super.dispose(); + } + + Future _loadDevices() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final devices = await _service.devices(); + if (!mounted) return; + setState(() { + _devices = devices; + // Prefer one that is actually reachable: the list is ordered by last + // seen, and the most recent TV is often the one that is switched off. + _selected = devices.firstWhere( + (d) => d.online, + orElse: () => devices.isNotEmpty + ? devices.first + : const RemoteDevice(id: '', name: '', online: false), + ); + if (_selected?.id.isEmpty ?? true) _selected = null; + _loading = false; + }); + _startPolling(); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'remote.load_failed'.tr(); + }); + } + } + + /// Polls the TV's state. + /// + /// Two seconds while something is playing, so the position moves; the TV + /// pushes nothing to the phone, and a socket per remote would cost more than + /// this does. + void _startPolling() { + _poll?.cancel(); + if (_selected == null) return; + _refreshState(); + _poll = Timer.periodic(const Duration(seconds: 2), (_) => _refreshState()); + } + + Future _refreshState() async { + final device = _selected; + if (device == null || !mounted) return; + try { + final result = await _service.state(device.id); + if (!mounted) return; + setState(() { + _online = result.online; + _state = result.state; + }); + } catch (_) { + // A dropped poll is not worth a message; the next one is two seconds away. + } + } + + Future _run(Future Function(String id) action) async { + final device = _selected; + if (device == null) return; + HapticFeedback.selectionClick(); + try { + await action(device.id); + unawaited(_refreshState()); + } on RemoteOfflineException { + if (!mounted) return; + setState(() => _online = false); + _toast('remote.tv_offline'.tr()); + } catch (_) { + if (!mounted) return; + _toast('remote.command_failed'.tr()); + } + } + + void _toast(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'remote.title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + actions: [ + IconButton( + onPressed: _loadDevices, + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + body: SafeArea(child: _body()), + ); + } + + Widget _body() { + if (_loading) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); + } + if (_error != null) { + return _Message(icon: Icons.cloud_off_rounded, text: _error!); + } + if (_devices.isEmpty) { + return _Message( + icon: Icons.tv_off_rounded, + text: 'remote.no_devices'.tr(), + ); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), + children: [ + _DevicePicker( + devices: _devices, + selected: _selected, + onChanged: (d) { + setState(() => _selected = d); + _startPolling(); + }, + ), + const SizedBox(height: 14), + _NowPlaying(online: _online, state: _state), + const SizedBox(height: 18), + _KeyboardCard( + controller: _search, + onSubmit: (text) { + if (text.trim().isEmpty) return; + _run((id) => _service.type(id, text.trim())); + }, + ), + const SizedBox(height: 18), + _DpadCard( + onDirection: (d) => _run((id) => _service.dpad(id, d)), + onBack: () => _run(_service.back), + onHome: () => _run(_service.home), + ), + const SizedBox(height: 18), + _TransportCard( + playing: _state?.playing ?? false, + onPlayPause: () => _run(_service.playPause), + onSeekBack: () => _run((id) => _service.seekBy(id, -10000)), + onSeekForward: () => _run((id) => _service.seekBy(id, 10000)), + onPrevious: () => _run(_service.previous), + onNext: () => _run(_service.next), + ), + ], + ); + } +} + +class _DevicePicker extends StatelessWidget { + const _DevicePicker({ + required this.devices, + required this.selected, + required this.onChanged, + }); + + final List devices; + final RemoteDevice? selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (devices.length == 1) return const SizedBox.shrink(); + return SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: devices.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final device = devices[i]; + final active = device.id == selected?.id; + return GestureDetector( + onTap: () => onChanged(device), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? AppColors.primary.withValues(alpha: 0.16) : AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: active ? AppColors.primary : Colors.transparent, + width: 1.2, + ), + ), + child: Row( + children: [ + Icon( + device.online ? Icons.tv_rounded : Icons.tv_off_rounded, + size: 15, + color: device.online ? AppColors.success : AppColors.textHint, + ), + const SizedBox(width: 6), + Text( + device.name, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: active ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _NowPlaying extends StatelessWidget { + const _NowPlaying({required this.online, required this.state}); + + final bool online; + final RemoteTvState? state; + + String _clock(int ms) { + final d = Duration(milliseconds: ms); + final h = d.inHours; + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final playing = state; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: online ? AppColors.success : AppColors.textHint, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + !online + ? 'remote.tv_offline'.tr() + : playing?.title ?? 'remote.idle'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (online && (playing?.hasPlayback ?? false)) ...[ + const SizedBox(height: 3), + Text( + '${_clock(playing!.positionMs ?? 0)} / ${_clock(playing.durationMs!)}', + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _KeyboardCard extends StatelessWidget { + const _KeyboardCard({required this.controller, required this.onSubmit}); + + final TextEditingController controller; + final ValueChanged onSubmit; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'remote.search_on_tv'.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: onSubmit, + decoration: InputDecoration( + hintText: 'remote.search_hint'.tr(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: () => onSubmit(controller.text), + icon: const Icon(Icons.send_rounded, size: 18), + ), + ], + ), + ], + ), + ); + } +} + +class _DpadCard extends StatelessWidget { + const _DpadCard({ + required this.onDirection, + required this.onBack, + required this.onHome, + }); + + final ValueChanged onDirection; + final VoidCallback onBack; + final VoidCallback onHome; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + _Key(icon: Icons.keyboard_arrow_up_rounded, onTap: () => onDirection('up')), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _Key(icon: Icons.keyboard_arrow_left_rounded, onTap: () => onDirection('left')), + const SizedBox(width: 10), + _Key( + icon: Icons.circle_outlined, + primary: true, + onTap: () => onDirection('center'), + ), + const SizedBox(width: 10), + _Key(icon: Icons.keyboard_arrow_right_rounded, onTap: () => onDirection('right')), + ], + ), + _Key(icon: Icons.keyboard_arrow_down_rounded, onTap: () => onDirection('down')), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: onBack, + icon: const Icon(Icons.arrow_back_rounded, size: 17), + label: Text('remote.back'.tr()), + ), + const SizedBox(width: 12), + TextButton.icon( + onPressed: onHome, + icon: const Icon(Icons.home_rounded, size: 17), + label: Text('remote.home'.tr()), + ), + ], + ), + ], + ), + ); + } +} + +class _TransportCard extends StatelessWidget { + const _TransportCard({ + required this.playing, + required this.onPlayPause, + required this.onSeekBack, + required this.onSeekForward, + required this.onPrevious, + required this.onNext, + }); + + final bool playing; + final VoidCallback onPlayPause; + final VoidCallback onSeekBack; + final VoidCallback onSeekForward; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _Key(icon: Icons.skip_previous_rounded, onTap: onPrevious), + _Key(icon: Icons.replay_10_rounded, onTap: onSeekBack), + _Key( + icon: playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + primary: true, + onTap: onPlayPause, + ), + _Key(icon: Icons.forward_10_rounded, onTap: onSeekForward), + _Key(icon: Icons.skip_next_rounded, onTap: onNext), + ], + ), + ); + } +} + +class _Key extends StatelessWidget { + const _Key({required this.icon, required this.onTap, this.primary = false}); + + final IconData icon; + final VoidCallback onTap; + final bool primary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: Material( + color: primary ? AppColors.primary : AppColors.surfaceVariant, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: primary ? 60 : 50, + height: primary ? 60 : 50, + child: Icon(icon, color: Colors.white, size: primary ? 28 : 24), + ), + ), + ), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 46, color: AppColors.textHint.withValues(alpha: 0.6)), + const SizedBox(height: 14), + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13.5, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} From 63cf22db065b7f5b10aca360b88982392b987fd3 Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 18:18:18 +0500 Subject: [PATCH 7/9] fix(remote): make the connection state obvious, and stop the poll stacking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things were wrong with the first pass. The polling timer fired every two seconds into an async body, which does not wait for the previous run. On a slow network the requests stack until the page stops responding — the exact failure the feature least survives. Polls now hold an in-flight guard, and the interval follows what the TV is doing: two seconds while something plays, five while it idles, fifteen while it is off. Polling also stops when the app leaves the foreground, since nobody is reading a remote from the app switcher. The device picker was hidden when there was only one TV, which is when "which one am I driving" is least obvious, not most. It is always shown, each entry with a dot for whether that TV is reachable, and a banner above it says in one line which TV this is, whether it is connected, and what it is playing — with a progress bar when there is something to show. Controls used to accept a tap and fail. They are dimmed and inert while the TV is not listening, so the answer arrives before the press rather than after it. The polling rules moved out of the widget into a controller: a rebuilding widget is a bad place to keep a timer, and these are the rules that were wrong. --- assets/translations/en.json | 8 +- assets/translations/ru.json | 8 +- assets/translations/uz.json | 8 +- .../presentation/pages/tv_remote_page.dart | 610 ++++++++++-------- .../presentation/remote_controller.dart | 190 ++++++ 5 files changed, 557 insertions(+), 267 deletions(-) create mode 100644 lib/features/remote/presentation/remote_controller.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 1c7818ec..0934249e 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1022,6 +1022,12 @@ "back": "Back", "home": "Home", "open_on_tv": "Play on TV", - "sent_to_tv": "Sent to {device}" + "sent_to_tv": "Sent to {device}", + "connected_to": "Connected to {device}", + "not_connected_to": "{device} is not connected", + "turn_tv_on": "Open Sozo on the TV to control it", + "retry": "Try again", + "navigation": "NAVIGATION", + "playback": "PLAYBACK" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 9400007e..a02a8b9b 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1022,6 +1022,12 @@ "back": "Назад", "home": "Главная", "open_on_tv": "Смотреть на ТВ", - "sent_to_tv": "Отправлено на {device}" + "sent_to_tv": "Отправлено на {device}", + "connected_to": "Подключено к {device}", + "not_connected_to": "{device} не подключён", + "turn_tv_on": "Откройте Sozo на ТВ, чтобы управлять", + "retry": "Повторить", + "navigation": "НАВИГАЦИЯ", + "playback": "ВОСПРОИЗВЕДЕНИЕ" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index b22f1a46..06cabc12 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1022,6 +1022,12 @@ "back": "Orqaga", "home": "Bosh sahifa", "open_on_tv": "TVda ko‘rish", - "sent_to_tv": "{device} ga yuborildi" + "sent_to_tv": "{device} ga yuborildi", + "connected_to": "{device} ga ulandi", + "not_connected_to": "{device} ulanmagan", + "turn_tv_on": "Boshqarish uchun TVda Sozo’ni oching", + "retry": "Qayta urinish", + "navigation": "NAVIGATSIYA", + "playback": "IJRO" } } diff --git a/lib/features/remote/presentation/pages/tv_remote_page.dart b/lib/features/remote/presentation/pages/tv_remote_page.dart index d71a6e37..c346d804 100644 --- a/lib/features/remote/presentation/pages/tv_remote_page.dart +++ b/lib/features/remote/presentation/pages/tv_remote_page.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,12 +5,13 @@ import 'package:flutter/services.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/remote/data/remote_control_service.dart'; +import 'package:soplay/features/remote/presentation/remote_controller.dart'; /// The phone as a remote for the TV. /// -/// Two halves that earn their place: a keyboard, because entering text with a -/// d-pad is the worst thing about any TV app, and transport controls, because -/// the physical remote is always on the other sofa. +/// Two halves earn the screen: a keyboard, because entering text with a d-pad +/// is the worst thing about any TV app, and transport controls, because the +/// physical remote is always on the other sofa. class TvRemotePage extends StatefulWidget { const TvRemotePage({super.key}); @@ -20,108 +19,53 @@ class TvRemotePage extends StatefulWidget { State createState() => _TvRemotePageState(); } -class _TvRemotePageState extends State { - final RemoteControlService _service = getIt(); +class _TvRemotePageState extends State + with WidgetsBindingObserver { + late final RemoteController _controller = RemoteController( + service: getIt(), + ); final _search = TextEditingController(); - List _devices = const []; - RemoteDevice? _selected; - RemoteTvState? _state; - bool _online = false; - bool _loading = true; - String? _error; - - Timer? _poll; - @override void initState() { super.initState(); - _loadDevices(); + WidgetsBinding.instance.addObserver(this); + _controller.addListener(_onChange); + _controller.load(); } @override void dispose() { - _poll?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _controller.removeListener(_onChange); + _controller.dispose(); _search.dispose(); super.dispose(); } - Future _loadDevices() async { - setState(() { - _loading = true; - _error = null; - }); - try { - final devices = await _service.devices(); - if (!mounted) return; - setState(() { - _devices = devices; - // Prefer one that is actually reachable: the list is ordered by last - // seen, and the most recent TV is often the one that is switched off. - _selected = devices.firstWhere( - (d) => d.online, - orElse: () => devices.isNotEmpty - ? devices.first - : const RemoteDevice(id: '', name: '', online: false), - ); - if (_selected?.id.isEmpty ?? true) _selected = null; - _loading = false; - }); - _startPolling(); - } catch (e) { - if (!mounted) return; - setState(() { - _loading = false; - _error = 'remote.load_failed'.tr(); - }); - } - } - - /// Polls the TV's state. - /// - /// Two seconds while something is playing, so the position moves; the TV - /// pushes nothing to the phone, and a socket per remote would cost more than - /// this does. - void _startPolling() { - _poll?.cancel(); - if (_selected == null) return; - _refreshState(); - _poll = Timer.periodic(const Duration(seconds: 2), (_) => _refreshState()); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Nobody is looking at the remote from the app switcher, and polling a TV + // from the background is how a remote left open drains a battery. + _controller.setPaused(state != AppLifecycleState.resumed); } - Future _refreshState() async { - final device = _selected; - if (device == null || !mounted) return; - try { - final result = await _service.state(device.id); - if (!mounted) return; - setState(() { - _online = result.online; - _state = result.state; - }); - } catch (_) { - // A dropped poll is not worth a message; the next one is two seconds away. - } + void _onChange() { + if (mounted) setState(() {}); } Future _run(Future Function(String id) action) async { - final device = _selected; - if (device == null) return; HapticFeedback.selectionClick(); - try { - await action(device.id); - unawaited(_refreshState()); - } on RemoteOfflineException { - if (!mounted) return; - setState(() => _online = false); - _toast('remote.tv_offline'.tr()); - } catch (_) { - if (!mounted) return; - _toast('remote.command_failed'.tr()); - } + final problem = await _controller.send(action); + if (problem == null || !mounted) return; + _say(switch (problem) { + 'offline' => 'remote.tv_offline'.tr(), + 'no-device' => 'remote.no_devices'.tr(), + _ => 'remote.command_failed'.tr(), + }); } - void _toast(String message) { + void _say(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); @@ -141,7 +85,7 @@ class _TvRemotePageState extends State { ), actions: [ IconButton( - onPressed: _loadDevices, + onPressed: _controller.load, icon: const Icon(Icons.refresh_rounded), ), ], @@ -151,165 +95,159 @@ class _TvRemotePageState extends State { } Widget _body() { - if (_loading) { - return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); - } - if (_error != null) { - return _Message(icon: Icons.cloud_off_rounded, text: _error!); - } - if (_devices.isEmpty) { - return _Message( - icon: Icons.tv_off_rounded, - text: 'remote.no_devices'.tr(), - ); - } + switch (_controller.status) { + case RemoteStatus.loading: + return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); + + case RemoteStatus.failed: + return _Message( + icon: Icons.cloud_off_rounded, + text: 'remote.load_failed'.tr(), + actionLabel: 'remote.retry'.tr(), + onAction: _controller.load, + ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), - children: [ - _DevicePicker( - devices: _devices, - selected: _selected, - onChanged: (d) { - setState(() => _selected = d); - _startPolling(); - }, - ), - const SizedBox(height: 14), - _NowPlaying(online: _online, state: _state), - const SizedBox(height: 18), - _KeyboardCard( - controller: _search, - onSubmit: (text) { - if (text.trim().isEmpty) return; - _run((id) => _service.type(id, text.trim())); - }, - ), - const SizedBox(height: 18), - _DpadCard( - onDirection: (d) => _run((id) => _service.dpad(id, d)), - onBack: () => _run(_service.back), - onHome: () => _run(_service.home), - ), - const SizedBox(height: 18), - _TransportCard( - playing: _state?.playing ?? false, - onPlayPause: () => _run(_service.playPause), - onSeekBack: () => _run((id) => _service.seekBy(id, -10000)), - onSeekForward: () => _run((id) => _service.seekBy(id, 10000)), - onPrevious: () => _run(_service.previous), - onNext: () => _run(_service.next), - ), - ], - ); + case RemoteStatus.noDevices: + return _Message( + icon: Icons.tv_off_rounded, + text: 'remote.no_devices'.tr(), + actionLabel: 'remote.retry'.tr(), + onAction: _controller.load, + ); + + case RemoteStatus.ready: + return _ready(); + } } -} -class _DevicePicker extends StatelessWidget { - const _DevicePicker({ - required this.devices, - required this.selected, - required this.onChanged, - }); + Widget _ready() { + final canControl = _controller.canControl; - final List devices; - final RemoteDevice? selected; - final ValueChanged onChanged; + return RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _controller.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + children: [ + _ConnectionBanner( + device: _controller.selected, + online: _controller.online, + state: _controller.tvState, + ), + const SizedBox(height: 12), + + // Shown even for a single TV: "which one am I driving" is the first + // question a remote has to answer, and hiding the answer when there + // is exactly one is how it stops being obvious. + _DeviceList( + devices: _controller.devices, + selected: _controller.selected, + onSelect: _controller.select, + ), - @override - Widget build(BuildContext context) { - if (devices.length == 1) return const SizedBox.shrink(); - return SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: devices.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final device = devices[i]; - final active = device.id == selected?.id; - return GestureDetector( - onTap: () => onChanged(device), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14), - alignment: Alignment.center, - decoration: BoxDecoration( - color: active ? AppColors.primary.withValues(alpha: 0.16) : AppColors.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: active ? AppColors.primary : Colors.transparent, - width: 1.2, - ), - ), - child: Row( - children: [ - Icon( - device.online ? Icons.tv_rounded : Icons.tv_off_rounded, - size: 15, - color: device.online ? AppColors.success : AppColors.textHint, - ), - const SizedBox(width: 6), - Text( - device.name, - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: active ? AppColors.primary : AppColors.textSecondary, - ), - ), - ], - ), + const SizedBox(height: 18), + _Disabled( + disabled: !canControl, + child: _KeyboardCard( + controller: _search, + onSubmit: (text) { + final query = text.trim(); + if (query.isEmpty) return; + _run((id) => getIt().type(id, query)); + }, ), - ); - }, + ), + + const SizedBox(height: 14), + _Disabled( + disabled: !canControl, + child: _DpadCard( + onDirection: (d) => + _run((id) => getIt().dpad(id, d)), + onBack: () => _run(getIt().back), + onHome: () => _run(getIt().home), + ), + ), + + const SizedBox(height: 14), + _Disabled( + disabled: !canControl, + child: _TransportCard( + playing: _controller.tvState?.playing ?? false, + onPlayPause: () => _run(getIt().playPause), + onSeekBack: () => + _run((id) => getIt().seekBy(id, -10000)), + onSeekForward: () => + _run((id) => getIt().seekBy(id, 10000)), + onPrevious: () => _run(getIt().previous), + onNext: () => _run(getIt().next), + ), + ), + ], ), ); } } -class _NowPlaying extends StatelessWidget { - const _NowPlaying({required this.online, required this.state}); +/// Says, in one line, which TV this is and whether it is listening. +class _ConnectionBanner extends StatelessWidget { + const _ConnectionBanner({ + required this.device, + required this.online, + required this.state, + }); + final RemoteDevice? device; final bool online; final RemoteTvState? state; String _clock(int ms) { final d = Duration(milliseconds: ms); - final h = d.inHours; final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return h > 0 ? '$h:$m:$s' : '$m:$s'; + return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s'; } @override Widget build(BuildContext context) { + final name = device?.name ?? ''; final playing = state; + final accent = online ? AppColors.success : AppColors.textHint; + return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(14), + border: Border.all(color: accent.withValues(alpha: 0.35)), ), child: Row( children: [ Container( - width: 8, - height: 8, + width: 40, + height: 40, decoration: BoxDecoration( - shape: BoxShape.circle, - color: online ? AppColors.success : AppColors.textHint, + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + online ? Icons.cast_connected_rounded : Icons.tv_off_rounded, + color: accent, + size: 21, ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - !online - ? 'remote.tv_offline'.tr() - : playing?.title ?? 'remote.idle'.tr(), + online + ? 'remote.connected_to'.tr(namedArgs: {'device': name}) + : 'remote.not_connected_to'.tr(namedArgs: {'device': name}), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -318,12 +256,34 @@ class _NowPlaying extends StatelessWidget { color: AppColors.textPrimary, ), ), + const SizedBox(height: 3), + Text( + !online + ? 'remote.turn_tv_on'.tr() + : playing?.title ?? 'remote.idle'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + ), + ), if (online && (playing?.hasPlayback ?? false)) ...[ - const SizedBox(height: 3), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 3, + value: (playing!.positionMs ?? 0) / playing.durationMs!, + backgroundColor: AppColors.surfaceVariant, + valueColor: const AlwaysStoppedAnimation(AppColors.primary), + ), + ), + const SizedBox(height: 4), Text( - '${_clock(playing!.positionMs ?? 0)} / ${_clock(playing.durationMs!)}', + '${_clock(playing.positionMs ?? 0)} / ${_clock(playing.durationMs!)}', style: const TextStyle( - fontSize: 11.5, + fontSize: 10.5, color: AppColors.textHint, ), ), @@ -337,6 +297,94 @@ class _NowPlaying extends StatelessWidget { } } +class _DeviceList extends StatelessWidget { + const _DeviceList({ + required this.devices, + required this.selected, + required this.onSelect, + }); + + final List devices; + final RemoteDevice? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: devices.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final device = devices[i]; + final active = device.id == selected?.id; + return GestureDetector( + onTap: () => onSelect(device), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: BorderRadius.circular(19), + border: Border.all( + color: active ? AppColors.primary : Colors.transparent, + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: device.online + ? AppColors.success + : AppColors.textHint, + ), + ), + const SizedBox(width: 7), + Text( + device.name, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: active + ? AppColors.primary + : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Dims and blocks a control group rather than letting every tap fail. +class _Disabled extends StatelessWidget { + const _Disabled({required this.disabled, required this.child}); + + final bool disabled; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: disabled ? 0.4 : 1, + child: IgnorePointer(ignoring: disabled, child: child), + ); + } +} + class _KeyboardCard extends StatelessWidget { const _KeyboardCard({required this.controller, required this.onSubmit}); @@ -345,44 +393,25 @@ class _KeyboardCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return _Card( + label: 'remote.search_on_tv'.tr(), + child: Row( children: [ - Text( - 'remote.search_on_tv'.tr(), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.6, - color: AppColors.textHint, + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: onSubmit, + decoration: InputDecoration( + hintText: 'remote.search_hint'.tr(), + isDense: true, + ), ), ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: TextField( - controller: controller, - textInputAction: TextInputAction.search, - onSubmitted: onSubmit, - decoration: InputDecoration( - hintText: 'remote.search_hint'.tr(), - isDense: true, - ), - ), - ), - const SizedBox(width: 8), - IconButton.filled( - onPressed: () => onSubmit(controller.text), - icon: const Icon(Icons.send_rounded, size: 18), - ), - ], + const SizedBox(width: 8), + IconButton.filled( + onPressed: () => onSubmit(controller.text), + icon: const Icon(Icons.send_rounded, size: 18), ), ], ), @@ -403,19 +432,21 @@ class _DpadCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - ), + return _Card( + label: 'remote.navigation'.tr(), child: Column( children: [ - _Key(icon: Icons.keyboard_arrow_up_rounded, onTap: () => onDirection('up')), + _Key( + icon: Icons.keyboard_arrow_up_rounded, + onTap: () => onDirection('up'), + ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _Key(icon: Icons.keyboard_arrow_left_rounded, onTap: () => onDirection('left')), + _Key( + icon: Icons.keyboard_arrow_left_rounded, + onTap: () => onDirection('left'), + ), const SizedBox(width: 10), _Key( icon: Icons.circle_outlined, @@ -423,11 +454,17 @@ class _DpadCard extends StatelessWidget { onTap: () => onDirection('center'), ), const SizedBox(width: 10), - _Key(icon: Icons.keyboard_arrow_right_rounded, onTap: () => onDirection('right')), + _Key( + icon: Icons.keyboard_arrow_right_rounded, + onTap: () => onDirection('right'), + ), ], ), - _Key(icon: Icons.keyboard_arrow_down_rounded, onTap: () => onDirection('down')), - const SizedBox(height: 8), + _Key( + icon: Icons.keyboard_arrow_down_rounded, + onTap: () => onDirection('down'), + ), + const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -469,12 +506,8 @@ class _TransportCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - ), + return _Card( + label: 'remote.playback'.tr(), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -493,6 +526,40 @@ class _TransportCard extends StatelessWidget { } } +class _Card extends StatelessWidget { + const _Card({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } +} + class _Key extends StatelessWidget { const _Key({required this.icon, required this.onTap, this.primary = false}); @@ -511,9 +578,9 @@ class _Key extends StatelessWidget { customBorder: const CircleBorder(), onTap: onTap, child: SizedBox( - width: primary ? 60 : 50, - height: primary ? 60 : 50, - child: Icon(icon, color: Colors.white, size: primary ? 28 : 24), + width: primary ? 58 : 48, + height: primary ? 58 : 48, + child: Icon(icon, color: Colors.white, size: primary ? 27 : 23), ), ), ), @@ -522,10 +589,17 @@ class _Key extends StatelessWidget { } class _Message extends StatelessWidget { - const _Message({required this.icon, required this.text}); + const _Message({ + required this.icon, + required this.text, + this.actionLabel, + this.onAction, + }); final IconData icon; final String text; + final String? actionLabel; + final VoidCallback? onAction; @override Widget build(BuildContext context) { @@ -535,7 +609,11 @@ class _Message extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 46, color: AppColors.textHint.withValues(alpha: 0.6)), + Icon( + icon, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), const SizedBox(height: 14), Text( text, @@ -546,6 +624,10 @@ class _Message extends StatelessWidget { height: 1.5, ), ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 14), + OutlinedButton(onPressed: onAction, child: Text(actionLabel!)), + ], ], ), ), diff --git a/lib/features/remote/presentation/remote_controller.dart b/lib/features/remote/presentation/remote_controller.dart new file mode 100644 index 00000000..6827c082 --- /dev/null +++ b/lib/features/remote/presentation/remote_controller.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:soplay/features/remote/data/remote_control_service.dart'; + +enum RemoteStatus { loading, noDevices, failed, ready } + +/// Owns the connection to one TV: which one, whether it is reachable, and what +/// it is doing. +/// +/// Pulled out of the page because the polling rules are the part that is easy +/// to get wrong, and a widget rebuilding is a bad place to keep a timer. +class RemoteController extends ChangeNotifier { + RemoteController({required RemoteControlService service}) : _service = service; + + final RemoteControlService _service; + + // Adaptive on purpose. A remote showing a moving position needs seconds; a + // TV that is switched off needs almost nothing, and asking it every two + // seconds is how a remote left open flattens a battery. + static const _whilePlaying = Duration(seconds: 2); + static const _whileIdle = Duration(seconds: 5); + static const _whileOffline = Duration(seconds: 15); + + RemoteStatus _status = RemoteStatus.loading; + List _devices = const []; + RemoteDevice? _selected; + RemoteTvState? _tvState; + bool _online = false; + + /// True only between a command leaving and its answer, so a button can show + /// it was pressed without the whole screen flickering. + bool _busy = false; + + Timer? _timer; + + /// Guards against overlapping polls. A periodic timer firing into an async + /// body does not wait for the previous one, so a slow network stacks requests + /// until the page appears frozen — the exact failure this exists to prevent. + bool _polling = false; + + bool _disposed = false; + bool _paused = false; + + RemoteStatus get status => _status; + List get devices => _devices; + RemoteDevice? get selected => _selected; + RemoteTvState? get tvState => _tvState; + bool get online => _online; + bool get busy => _busy; + + /// Whether commands can do anything right now. The UI disables on this rather + /// than letting every tap turn into a failed request. + bool get canControl => _selected != null && _online; + + Future load() async { + _status = RemoteStatus.loading; + _notify(); + try { + final devices = await _service.devices(); + if (_disposed) return; + _devices = devices; + + if (devices.isEmpty) { + _selected = null; + _status = RemoteStatus.noDevices; + _stopTimer(); + _notify(); + return; + } + + // Keep the current pick across a refresh; otherwise prefer one that is + // actually reachable, since the most recently used TV is often the one + // that is now switched off. + final keep = _selected; + _selected = keep != null && devices.any((d) => d.id == keep.id) + ? devices.firstWhere((d) => d.id == keep.id) + : devices.firstWhere((d) => d.online, orElse: () => devices.first); + + _status = RemoteStatus.ready; + _notify(); + await _refresh(); + _schedule(); + } catch (_) { + if (_disposed) return; + _status = RemoteStatus.failed; + _stopTimer(); + _notify(); + } + } + + void select(RemoteDevice device) { + if (device.id == _selected?.id) return; + _selected = device; + // Clear rather than carry: showing the previous TV's title under a new one + // is worse than showing nothing for a second. + _tvState = null; + _online = device.online; + _notify(); + unawaited(_refresh()); + _schedule(); + } + + /// Stops polling while the app is not in front of the user. + void setPaused(bool paused) { + if (_paused == paused) return; + _paused = paused; + if (paused) { + _stopTimer(); + } else { + unawaited(_refresh()); + _schedule(); + } + } + + /// Runs a command and reports what happened. + /// + /// Returns null on success, or a reason to show. The controller never throws + /// at the UI: a remote whose buttons can crash the page is worse than one + /// that says a button did not land. + Future send(Future Function(String id) action) async { + final device = _selected; + if (device == null) return 'no-device'; + _busy = true; + _notify(); + try { + await action(device.id); + await _refresh(); + return null; + } on RemoteOfflineException { + _online = false; + _schedule(); + return 'offline'; + } catch (_) { + return 'failed'; + } finally { + _busy = false; + _notify(); + } + } + + Future _refresh() async { + final device = _selected; + if (device == null || _polling || _disposed) return; + _polling = true; + try { + final result = await _service.state(device.id); + if (_disposed) return; + final wasOnline = _online; + _online = result.online; + _tvState = result.state; + _notify(); + // Coming back online should tighten the cadence immediately rather than + // after one more slow tick. + if (wasOnline != _online) _schedule(); + } catch (_) { + // A dropped poll is not worth surfacing; the next one is seconds away. + } finally { + _polling = false; + } + } + + void _schedule() { + _stopTimer(); + if (_disposed || _paused || _selected == null) return; + final interval = !_online + ? _whileOffline + : (_tvState?.playing ?? false) + ? _whilePlaying + : _whileIdle; + _timer = Timer.periodic(interval, (_) => unawaited(_refresh())); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _stopTimer(); + super.dispose(); + } +} From b82266e5a0b1c74f948c98cb3defbac363798625 Mon Sep 17 00:00:00 2001 From: azamov Date: Tue, 18 Aug 2026 18:24:38 +0500 Subject: [PATCH 8/9] fix: iOS volume and brightness swipes, swapped server/quality, loud search focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate reports. iOS gestures did nothing. The soplay/system_controls channel was implemented on Android only, so every call threw MissingPluginException straight into a catch (_) {} — the swipe indicator moved and the volume did not, silently, which is worse than a gesture that visibly fails. There is an implementation now, registered alongside the other iOS channels and added to the Xcode target, without which it would have compiled into nothing. Brightness there is a plain public API. Volume is not: iOS has no supported way for an app to set system volume, so it drives the slider inside an off-screen MPVolumeView — the long-standing workaround. Off-screen rather than hidden, because isHidden stops the slider responding at all, and the value is set a tick late, or the first swipe of a session is dropped. If Apple closes it, the call degrades to reporting the current volume rather than failing. Server and quality were swapped for some sources: the settings sheet offered a server called "480p" and a quality called "1-server". The splitter assumed the host came first, so any provider writing "480p · 1-server" landed backwards. It now decides by what a part looks like, not where it sits. Labels with no resolution anywhere stay whole — taking the first part of "SUB · Mp4Upload" would make a language tag into a host and the host into a quality. Ten tests cover that splitter now, including both orders and the empty case. The TV has the same feature but reads the host from its own field rather than parsing a label, so it never had this bug. Search focus painted a saturated red rectangle around the field, louder than anything else on screen. The brand colour carries in a soft glow instead. --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/SystemControls.swift | 97 +++++++++++++++++++ .../detail/domain/video_option_groups.dart | 17 +++- .../presentation/widgets/search_header.dart | 18 +++- .../detail/video_option_groups_test.dart | 71 ++++++++++++++ 6 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 ios/Runner/SystemControls.swift create mode 100644 test/features/detail/video_option_groups_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e3a08160..3cf1bf63 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* RepoFileImport.swift */; }; + A1B2C3D40000000000000003 /* SystemControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* SystemControls.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -58,6 +59,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; A1B2C3D40000000000000002 /* RepoFileImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepoFileImport.swift; sourceTree = ""; }; + A1B2C3D40000000000000004 /* SystemControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemControls.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -143,6 +145,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, A1B2C3D40000000000000002 /* RepoFileImport.swift */, + A1B2C3D40000000000000004 /* SystemControls.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -404,6 +407,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */, + A1B2C3D40000000000000003 /* SystemControls.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 51b1fa17..fdb9a370 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -67,6 +67,7 @@ import UIKit // "Open with Sozo" on an extension index file. Same channel name and // contract as the Android host, so the Flutter side is platform-agnostic. RepoFileImport.register(messenger: registrar.messenger()) + SystemControls.register(with: registrar) let previewChannel = FlutterMethodChannel( name: "soplay/preview", diff --git a/ios/Runner/SystemControls.swift b/ios/Runner/SystemControls.swift new file mode 100644 index 00000000..88d8e0b1 --- /dev/null +++ b/ios/Runner/SystemControls.swift @@ -0,0 +1,97 @@ +import AVFoundation +import Flutter +import MediaPlayer +import UIKit + +/// Screen brightness and system volume for the player's swipe gestures. +/// +/// The channel existed on Android only, so on iOS every call threw +/// MissingPluginException straight into a `catch (_) {}` in Dart: the swipe +/// indicator moved and nothing else happened, silently, which is worse than a +/// gesture that visibly does nothing. +/// +/// Brightness is a plain public API. Volume is not — iOS has no supported way +/// for an app to set the system volume, so this drives the slider inside a +/// hidden `MPVolumeView`, which is the long-standing workaround every video app +/// uses. If Apple ever closes it, `setVolume` degrades to reporting the current +/// value rather than failing, and only the gesture stops working. +enum SystemControls { + private static let channelName = "soplay/system_controls" + + /// Kept alive for the process: the slider must be in a window to work, and + /// rebuilding it per call loses the first change of every gesture. + private static var volumeView: MPVolumeView? + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: registrar.messenger() + ) + + channel.setMethodCallHandler { call, result in + // UIScreen and UIWindow are main-thread only, and Flutter does not + // promise which thread a call arrives on. + DispatchQueue.main.async { + switch call.method { + case "getBrightness": + result(Double(UIScreen.main.brightness)) + + case "setBrightness": + let value = doubleArg(call, "value") ?? 0.5 + UIScreen.main.brightness = CGFloat(max(0, min(1, value))) + result(Double(UIScreen.main.brightness)) + + case "resetBrightness": + // No per-window override on iOS the way Android has one, so there is + // nothing to hand back to the system — the user's last swipe stands. + result(true) + + case "getVolume": + result(Double(AVAudioSession.sharedInstance().outputVolume)) + + case "setVolume": + let value = doubleArg(call, "value") ?? 1.0 + setSystemVolume(Float(max(0, min(1, value)))) + result(Double(AVAudioSession.sharedInstance().outputVolume)) + + default: + result(FlutterMethodNotImplemented) + } + } + } + } + + private static func doubleArg(_ call: FlutterMethodCall, _ key: String) -> Double? { + guard let args = call.arguments as? [String: Any] else { return nil } + return (args[key] as? NSNumber)?.doubleValue + } + + private static func setSystemVolume(_ value: Float) { + let view = volumeView ?? makeVolumeView() + guard let slider = view.subviews.compactMap({ $0 as? UISlider }).first else { return } + // A tick late: setting `value` inside the same run loop turn the view was + // added in is ignored, and the first swipe of a session would be lost. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + slider.value = value + slider.sendActions(for: .valueChanged) + } + } + + private static func makeVolumeView() -> MPVolumeView { + // Off-screen rather than hidden: `isHidden` stops the slider responding at + // all, so it has to be in the hierarchy and simply out of sight. + let view = MPVolumeView(frame: CGRect(x: -2000, y: -2000, width: 1, height: 1)) + view.alpha = 0.001 + view.isUserInteractionEnabled = false + keyWindow()?.addSubview(view) + volumeView = view + return view + } + + private static func keyWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} diff --git a/lib/features/detail/domain/video_option_groups.dart b/lib/features/detail/domain/video_option_groups.dart index df918e20..707529e7 100644 --- a/lib/features/detail/domain/video_option_groups.dart +++ b/lib/features/detail/domain/video_option_groups.dart @@ -89,7 +89,22 @@ class VideoOptionGroups { if (part.trim().isNotEmpty) part.trim(), ]; if (parts.length > 1) { - return (server: parts.first, quality: parts.skip(1).join(' · ')); + // Which part is the quality is decided by what it LOOKS like, not by + // where it sits. Assuming the host comes first put "480p · 1-server" in + // backwards — the settings sheet offered a server called 480p and a + // quality called 1-server. + final at = parts.indexWhere(_resolution.hasMatch); + if (at >= 0) { + final host = [...parts.take(at), ...parts.skip(at + 1)].join(' · '); + return ( + server: host.isEmpty ? _fallbackServer : host, + quality: parts[at], + ); + } + // Nothing resolution-shaped anywhere: the whole label names a host, the + // way "SUB · Mp4Upload" does. Taking the first part as the server would + // make a language tag into a host and the host into a quality. + return (server: text, quality: ''); } final words = text.split(_spaces); diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index d4a3e2b7..3fb44317 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -215,17 +215,27 @@ class _SearchField extends StatelessWidget { duration: const Duration(milliseconds: 160), curve: Curves.easeOut, height: 46, + // Focus reads as lit, not as outlined. A saturated red rectangle + // around a text field is louder than anything else on the screen + // and fights the dark surface it sits on; the brand colour carries + // in a soft glow instead, where it says the same thing quietly. decoration: BoxDecoration( color: focused ? AppColors.surfaceVariant.withValues(alpha: 0.96) : AppColors.surface.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(14), border: Border.all( - color: focused - ? AppColors.primary.withValues(alpha: 0.58) - : Colors.white.withValues(alpha: 0.08), - width: focused ? 1.2 : 1, + color: Colors.white.withValues(alpha: focused ? 0.16 : 0.08), ), + boxShadow: focused + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.22), + blurRadius: 14, + spreadRadius: -2, + ), + ] + : null, ), child: child, ); diff --git a/test/features/detail/video_option_groups_test.dart b/test/features/detail/video_option_groups_test.dart new file mode 100644 index 00000000..82057019 --- /dev/null +++ b/test/features/detail/video_option_groups_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; + +void main() { + group('server and quality are told apart by shape, not by order', () { + test('host first', () { + expect(VideoOptionGroups.serverOf('SubsPlease · 1080p'), 'SubsPlease'); + expect(VideoOptionGroups.qualityOf('SubsPlease · 1080p'), '1080p'); + }); + + test('quality first — the case that shipped backwards', () { + // The settings sheet showed Server "480p" and Quality "1-server". + expect(VideoOptionGroups.serverOf('480p · 1-server'), '1-server'); + expect(VideoOptionGroups.qualityOf('480p · 1-server'), '480p'); + }); + + test('space separated, either order', () { + expect(VideoOptionGroups.serverOf('Vidstream 720p'), 'Vidstream'); + expect(VideoOptionGroups.qualityOf('Vidstream 720p'), '720p'); + expect(VideoOptionGroups.serverOf('1080p Doodstream'), 'Doodstream'); + expect(VideoOptionGroups.qualityOf('1080p Doodstream'), '1080p'); + }); + + test('no resolution at all means the whole label is a host', () { + expect(VideoOptionGroups.serverOf('Server 1'), 'Server 1'); + expect(VideoOptionGroups.qualityOf('Server 1'), ''); + expect(VideoOptionGroups.serverOf('SUB · Mp4Upload'), 'SUB · Mp4Upload'); + }); + + test('an empty label still groups somewhere', () { + expect(VideoOptionGroups.serverOf(''), 'Default'); + expect(VideoOptionGroups.serverOf(' '), 'Default'); + }); + }); + + group('grouping', () { + final labels = [ + 'Vidstream · 1080p', + 'Vidstream · 720p', + '480p · Doodstream', + 'Doodstream · 1080p', + ]; + + test('servers are listed once, in the order they appear', () { + expect(VideoOptionGroups.servers(labels), ['Vidstream', 'Doodstream']); + }); + + test('a host collects its entries whichever way its labels are written', () { + // Doodstream appears once with the quality first and once with it last. + expect(VideoOptionGroups.indicesFor(labels, 'Doodstream'), [2, 3]); + expect(VideoOptionGroups.qualitiesFor(labels, 'Doodstream'), ['480p', '1080p']); + }); + }); + + group('switching host', () { + final labels = ['A · 720p', 'A · 1080p', 'B · 360p', 'B · 720p', 'B · 1080p']; + + test('keeps the resolution you were on', () { + expect(VideoOptionGroups.switchTo(labels, 0, 'B'), 3); + }); + + test('falls back to the host\'s best, not its first', () { + // Arbitrary provider ordering must not drop someone from 1080p to 360p. + expect(VideoOptionGroups.switchTo(labels, 1, 'B'), 4); + }); + + test('an unknown host leaves the selection alone', () { + expect(VideoOptionGroups.switchTo(labels, 1, 'C'), 1); + }); + }); +} From 360ceced11898fe400f6a70ede3c03ca80ad1764 Mon Sep 17 00:00:00 2001 From: azamov Date: Wed, 19 Aug 2026 09:24:01 +0500 Subject: [PATCH 9/9] chore(ios): record mobile_scanner in the pod lockfile It was in pubspec but never in ios/Podfile.lock, so the iOS build resolved it fresh every time and the lockfile did not describe the app that ships. --- ios/Podfile.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 18a0a1e3..2b349926 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -163,6 +163,9 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) @@ -207,6 +210,7 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -263,6 +267,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/local_auth_darwin/darwin" media_kit_video: :path: ".symlinks/plugins/media_kit_video/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" open_filex: :path: ".symlinks/plugins/open_filex/ios" package_info_plus: @@ -309,6 +315,7 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb media_kit_video: f3b0d035d89def15cfbbcf7dc2ae278f201e2f83 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94