diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 39f6591..dce193f 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../l10n/app_localizations.dart'; -class DiscoverLogic { +enum DiscoverPageAction { home, my, watchlist, detail, resume } + +class DiscoverLogic extends ChangeNotifier { DiscoverLogic({required this.history, required this.drama}) { refreshPosition(); } @@ -15,6 +18,11 @@ class DiscoverLogic { final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); int category = 0; + bool _disposed = false; + + void notify() { + if (!_disposed) notifyListeners(); + } void refreshPosition() { position = history.positionFor( @@ -24,9 +32,125 @@ class DiscoverLogic { ); } + void selectCategory(int index) { + category = index; + notify(); + } + + void onSearchChanged() => notify(); + + void syncDrama({ + required bool active, + required bool wasActive, + required Drama nextDrama, + required bool dramaChanged, + required int searchRequest, + required int previousSearchRequest, + required bool Function() isActive, + }) { + if (active && (!wasActive || dramaChanged)) { + drama = nextDrama; + refreshPosition(); + notify(); + } + if (active && searchRequest != previousSearchRequest) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isActive()) searchFocus.requestFocus(); + }); + } + } + + void act( + BuildContext context, + DiscoverPageAction action, { + required bool embedded, + ValueChanged? onAction, + }) { + if (embedded) { + onAction?.call(action); + } else { + Navigator.pop(context, action); + } + } + + Drama? dramaFor(String? dramaId, List dramas) { + for (final item in dramas) { + if (item.id == dramaId) return item; + } + return null; + } + + void openCard( + BuildContext context, + Drama? selected, { + required bool embedded, + ValueChanged? onDramaSelected, + ValueChanged? onAction, + }) { + if (selected == null) { + unavailable(context); + } else if (embedded && onDramaSelected != null) { + onDramaSelected(selected); + } else { + act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction); + } + } + + void unavailable(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice))); + } + + void catalogInfo(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); + } + + void showFilters(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final labels = [ + l10n.featured, + l10n.suspense, + l10n.romance, + l10n.actionGenre, + ]; + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF202126), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.filter)), + for (var index = 0; index < labels.length; index++) + ListTile( + title: Text(labels[index]), + trailing: index == category + ? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D)) + : null, + onTap: () { + selectCategory(index); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override void dispose() { + _disposed = true; search.dispose(); searchFocus.dispose(); scroll.dispose(); + super.dispose(); } } diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 0c7740b..c60f3f6 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -8,8 +8,6 @@ import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; -enum DiscoverPageAction { home, my, watchlist, detail, resume } - class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -55,42 +53,15 @@ class _DiscoverPageState extends State { @override void didUpdateWidget(covariant DiscoverPage oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.active && - (!oldWidget.active || oldWidget.drama.id != widget.drama.id)) { - logic.drama = widget.drama; - setState(logic.refreshPosition); - } - if (widget.active && widget.searchRequest != oldWidget.searchRequest) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.active) logic.searchFocus.requestFocus(); - }); - } - } - - void _act(DiscoverPageAction action) { - if (widget.embedded) { - widget.onAction?.call(action); - } else { - Navigator.pop(context, action); - } - } - - Drama? _dramaFor(_ShowcaseCard card) { - for (final drama in widget.dramas ?? [logic.drama]) { - if (drama.id == card.dramaId) return drama; - } - return null; - } - - void _openCard(_ShowcaseCard card) { - final drama = _dramaFor(card); - if (drama == null) { - _unavailable(); - } else if (widget.embedded && widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else { - _act(DiscoverPageAction.detail); - } + logic.syncDrama( + active: widget.active, + wasActive: oldWidget.active, + nextDrama: widget.drama, + dramaChanged: oldWidget.drama.id != widget.drama.id, + searchRequest: widget.searchRequest, + previousSearchRequest: oldWidget.searchRequest, + isActive: () => mounted && widget.active, + ); } @override @@ -99,57 +70,15 @@ class _DiscoverPageState extends State { super.dispose(); } - void _unavailable() { - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice))); - } - - void _catalogInfo() { - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); - } - - void _showFilters() { - final l10n = AppLocalizations.of(context)!; - final labels = [ - l10n.featured, - l10n.suspense, - l10n.romance, - l10n.actionGenre, - ]; - showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF202126), - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.filter)), - for (var index = 0; index < labels.length; index++) - ListTile( - title: Text(labels[index]), - trailing: index == logic.category - ? const Icon(Icons.check_rounded, color: _orange) - : null, - onTap: () { - setState(() => logic.category = index); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), ); } - @override - Widget build(BuildContext context) { + Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final cards = _cardsFor(l10n, logic.category, logic.search.text); final content = SafeArea( @@ -241,7 +170,12 @@ class _DiscoverPageState extends State { ), const SizedBox(width: 4), InkWell( - onTap: () => _act(DiscoverPageAction.my), + onTap: () => logic.act( + context, + DiscoverPageAction.my, + embedded: widget.embedded, + onAction: widget.onAction, + ), child: const CircleAvatar( radius: 14, backgroundColor: Color(0xFF34353B), @@ -269,7 +203,7 @@ class _DiscoverPageState extends State { controller: logic.search, focusNode: logic.searchFocus, autofocus: widget.focusSearch, - onChanged: (_) => setState(() {}), + onChanged: (_) => logic.onSearchChanged(), style: const TextStyle(fontSize: 12), decoration: InputDecoration( hintText: l10n.searchDiscover, @@ -284,7 +218,7 @@ class _DiscoverPageState extends State { ), IconButton( tooltip: l10n.filter, - onPressed: _showFilters, + onPressed: () => logic.showFilters(context), icon: const Icon(Icons.tune_rounded, size: 20), ), ], @@ -307,7 +241,7 @@ class _DiscoverPageState extends State { itemBuilder: (context, index) => ChoiceChip( label: Text(labels[index]), selected: logic.category == index, - onSelected: (_) => setState(() => logic.category = index), + onSelected: (_) => logic.selectCategory(index), showCheckmark: false, visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 7), @@ -413,7 +347,12 @@ class _DiscoverPageState extends State { ), const SizedBox(width: 9), FilledButton.icon( - onPressed: () => _act(DiscoverPageAction.resume), + onPressed: () => logic.act( + context, + DiscoverPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ), style: FilledButton.styleFrom( backgroundColor: const Color(0xFFFFBD5C), foregroundColor: const Color(0xFF2A160B), @@ -435,14 +374,23 @@ class _DiscoverPageState extends State { Widget _quickActions(AppLocalizations l10n) { final actions = [ - (Icons.tune_rounded, l10n.filter, _showFilters), - (Icons.bar_chart_rounded, l10n.topCharts, _catalogInfo), - (Icons.auto_awesome_rounded, l10n.newReleases, _catalogInfo), + (Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)), + (Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)), + ( + Icons.auto_awesome_rounded, + l10n.newReleases, + () => logic.catalogInfo(context), + ), ( Icons.bookmark_outline_rounded, l10n.watchlist, () { - _act(DiscoverPageAction.watchlist); + logic.act( + context, + DiscoverPageAction.watchlist, + embedded: widget.embedded, + onAction: widget.onAction, + ); }, ), ]; @@ -509,7 +457,7 @@ class _DiscoverPageState extends State { ), ), TextButton( - onPressed: () => setState(() => logic.category = 0), + onPressed: () => logic.selectCategory(0), child: Text( l10n.seeAll, style: const TextStyle(color: Color(0xFFFFB3A1)), @@ -519,9 +467,16 @@ class _DiscoverPageState extends State { ); Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) { - final playable = _dramaFor(card) != null; + final playable = + logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null; return InkWell( - onTap: () => _openCard(card), + onTap: () => logic.openCard( + context, + logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]), + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), borderRadius: BorderRadius.circular(11), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -609,7 +564,12 @@ class _DiscoverPageState extends State { _DiscoverNavItem( icon: Icons.play_circle_outline_rounded, label: l10n.homeTab, - onTap: () => Navigator.pop(context, DiscoverPageAction.home), + onTap: () => logic.act( + context, + DiscoverPageAction.home, + embedded: widget.embedded, + onAction: widget.onAction, + ), ), _DiscoverNavItem( icon: Icons.explore_outlined, @@ -620,7 +580,12 @@ class _DiscoverPageState extends State { _DiscoverNavItem( icon: Icons.video_library_outlined, label: l10n.myTab, - onTap: () => Navigator.pop(context, DiscoverPageAction.my), + onTap: () => logic.act( + context, + DiscoverPageAction.my, + embedded: widget.embedded, + onAction: widget.onAction, + ), ), ], ), diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 8acb22b..e6d8dfb 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -1,19 +1,47 @@ +import 'package:flutter/material.dart'; + import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../player/player_view.dart'; -class DramaDetailLogic { +class DramaDetailLogic extends ChangeNotifier { DramaDetailLogic({required this.drama, required this.history}); final Drama drama; final WatchHistoryRepository history; int? continueIndex; + bool _disposed = false; + + void notify() { + if (!_disposed) notifyListeners(); + } Future loadContinue() async { - final id = await history.lastEpisodeId(demoProfileId, drama.id); - if (id == null) return; - final index = drama.episodes.indexWhere((episode) => episode.id == id); - if (index >= 0 && drama.episodes[index].isFree) { - continueIndex = index; + try { + final id = await history.lastEpisodeId(demoProfileId, drama.id); + if (id == null || _disposed) return; + final index = drama.episodes.indexWhere((episode) => episode.id == id); + if (index >= 0 && drama.episodes[index].isFree) { + continueIndex = index; + notify(); + } + } catch (_) { + // Keep the first episode available if preferences cannot be read. } } + + Future openPlayer(BuildContext context, int index) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage(drama: drama, initialIndex: index), + ), + ); + await loadContinue(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 39a5c0b..264febd 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/formatting/localized_content.dart'; import '../../l10n/app_localizations.dart'; -import '../player/player_view.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; @@ -22,29 +21,24 @@ class _DramaDetailPageState extends State { void initState() { super.initState(); logic = DramaDetailBinding().create(drama: widget.drama); - _loadContinue(); + logic.loadContinue(); } - Future _loadContinue() async { - try { - await logic.loadContinue(); - if (mounted) setState(() {}); - } catch (_) { - // Keep the first episode available if preferences cannot be read. - } - } - - Future _openPlayer(BuildContext context, int index) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage(drama: logic.drama, initialIndex: index), - ), - ); - await _loadContinue(); + @override + void dispose() { + logic.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ); + } + + Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final drama = logic.drama; return Scaffold( @@ -87,7 +81,8 @@ class _DramaDetailPageState extends State { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: () => _openPlayer(context, logic.continueIndex ?? 0), + onPressed: () => + logic.openPlayer(context, logic.continueIndex ?? 0), icon: const Icon(Icons.play_arrow_rounded), label: Text( logic.continueIndex == null @@ -124,7 +119,7 @@ class _DramaDetailPageState extends State { title: Text(l10n.episodeLabel(episode.number)), subtitle: Text(l10n.freeTestMedia), trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => _openPlayer(context, index), + onTap: () => logic.openPlayer(context, index), ), ); }, diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 646bd07..f31090f 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../discover/discover_logic.dart'; +import '../drama_detail/drama_detail_view.dart'; +import '../mine/mine_logic.dart'; -class HomeLogic { +class HomeLogic extends ChangeNotifier { HomeLogic(List dramas) : currentDrama = dramas.first; final scaffoldKey = GlobalKey(); @@ -16,6 +19,11 @@ class HomeLogic { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; + bool _disposed = false; + + void notify() { + if (!_disposed) notifyListeners(); + } void select( int nextTab, { @@ -32,5 +40,105 @@ class HomeLogic { sectionRequest++; } if (resume) resumeRequest++; + notify(); + } + + void openMenu() { + drawerOpen = true; + notify(); + scaffoldKey.currentState?.openDrawer(); + } + + void selectFromMenu(int nextTab, {int? mySection}) { + scaffoldKey.currentState?.closeDrawer(); + select(nextTab, mySection: mySection); + } + + Future openCurrentSeries(BuildContext context) async { + detailOpen = true; + notify(); + scaffoldKey.currentState?.closeDrawer(); + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return; + try { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DramaDetailPage(drama: currentDrama), + ), + ); + } finally { + if (context.mounted) { + detailOpen = false; + notify(); + } + } + } + + void openDrama(BuildContext context, Drama drama) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + void discoverAction(BuildContext context, DiscoverPageAction action) { + switch (action) { + case DiscoverPageAction.home: + select(0); + case DiscoverPageAction.my: + select(2); + case DiscoverPageAction.watchlist: + select(2, watchlist: true); + case DiscoverPageAction.resume: + select(0, resume: true); + case DiscoverPageAction.detail: + openDrama(context, currentDrama); + } + } + + void myAction(MyPageAction action) { + switch (action) { + case MyPageAction.home: + select(0); + case MyPageAction.discover: + select(1, search: true); + case MyPageAction.resume: + select(0, resume: true); + } + } + + void onSystemBack() { + if (drawerOpen) { + scaffoldKey.currentState?.closeDrawer(); + } else if (videoImmersive) { + backRequest++; + notify(); + } else if (tab != 0) { + select(0); + } else { + backRequest++; + notify(); + } + } + + void onDrawerChanged(bool opened) { + if (drawerOpen == opened) return; + drawerOpen = opened; + notify(); + } + + void onDramaChanged(Drama drama) { + currentDrama = drama; + notify(); + } + + void onImmersiveChanged(bool value) { + videoImmersive = value; + notify(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); } } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 7fc7337..32db9d5 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -5,7 +5,6 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_view.dart'; -import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_view.dart'; import '../player/player_view.dart'; import 'home_binding.dart'; @@ -79,163 +78,80 @@ class _HomeTabsState extends State<_HomeTabs> { logic = HomeBinding().create(widget.dramas); } - void _select( - int tab, { - bool search = false, - bool watchlist = false, - bool resume = false, - int? mySection, - }) { - setState( - () => logic.select( - tab, - search: search, - watchlist: watchlist, - resume: resume, - mySection: mySection, - ), - ); - } - - void _openMenu() { - setState(() => logic.drawerOpen = true); - logic.scaffoldKey.currentState?.openDrawer(); - } - - void _selectFromMenu(int tab, {int? mySection}) { - logic.scaffoldKey.currentState?.closeDrawer(); - _select(tab, mySection: mySection); - } - - Future _openCurrentSeries() async { - setState(() => logic.detailOpen = true); - logic.scaffoldKey.currentState?.closeDrawer(); - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - try { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: logic.currentDrama), - ), - ); - } finally { - if (mounted) setState(() => logic.detailOpen = false); - } - } - - void _discoverAction(DiscoverPageAction action) { - switch (action) { - case DiscoverPageAction.home: - _select(0); - case DiscoverPageAction.my: - _select(2); - case DiscoverPageAction.watchlist: - _select(2, watchlist: true); - case DiscoverPageAction.resume: - _select(0, resume: true); - case DiscoverPageAction.detail: - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: logic.currentDrama), - ), - ); - } - } - - void _myAction(MyPageAction action) { - switch (action) { - case MyPageAction.home: - _select(0); - case MyPageAction.discover: - _select(1, search: true); - case MyPageAction.resume: - _select(0, resume: true); - } + @override + void dispose() { + logic.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _scaffold(context), + ); + } + + Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (logic.drawerOpen) { - logic.scaffoldKey.currentState?.closeDrawer(); - } else if (logic.videoImmersive) { - setState(() => logic.backRequest++); - } else if (logic.tab != 0) { - _select(0); - } else { - setState(() => logic.backRequest++); - } + logic.onSystemBack(); }, child: Scaffold( key: logic.scaffoldKey, backgroundColor: const Color(0xFF0B0C10), drawer: HomeQuickDrawer( drama: logic.currentDrama, - onOpenSeries: _openCurrentSeries, - onDiscover: () => _selectFromMenu(1), - onHistory: () => _selectFromMenu(2, mySection: 0), - onWatchlist: () => _selectFromMenu(2, mySection: 1), - onLiked: () => _selectFromMenu(2, mySection: 2), + onOpenSeries: () => logic.openCurrentSeries(context), + onDiscover: () => logic.selectFromMenu(1), + onHistory: () => logic.selectFromMenu(2, mySection: 0), + onWatchlist: () => logic.selectFromMenu(2, mySection: 1), + onLiked: () => logic.selectFromMenu(2, mySection: 2), ), drawerEnableOpenDragGesture: false, - onDrawerChanged: (opened) { - if (logic.drawerOpen != opened) { - setState(() => logic.drawerOpen = opened); - } - }, + onDrawerChanged: logic.onDrawerChanged, body: IndexedStack( index: logic.tab, children: [ PlayerPage( drama: widget.dramas.first, feedDramas: widget.dramas, - onDramaChanged: (drama) => - setState(() => logic.currentDrama = drama), + onDramaChanged: logic.onDramaChanged, initialIndex: 0, homeMode: true, embedded: true, active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen, resumeRequest: logic.resumeRequest, backRequest: logic.backRequest, - onDiscover: () => _select(1), - onMy: () => _select(2), - onSearch: () => _select(1, search: true), - onMenu: _openMenu, - onImmersiveChanged: (value) => - setState(() => logic.videoImmersive = value), + onDiscover: () => logic.select(1), + onMy: () => logic.select(2), + onSearch: () => logic.select(1, search: true), + onMenu: logic.openMenu, + onImmersiveChanged: logic.onImmersiveChanged, ), DiscoverPage( drama: logic.currentDrama, dramas: widget.dramas, - onDramaSelected: (drama) => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: drama), - ), - ), + onDramaSelected: (drama) => logic.openDrama(context, drama), duration: Duration.zero, embedded: true, active: logic.tab == 1, searchRequest: logic.searchRequest, - onAction: _discoverAction, + onAction: (action) => logic.discoverAction(context, action), ), MyPage( drama: logic.currentDrama, dramas: widget.dramas, - onDramaSelected: (drama) => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: drama), - ), - ), + onDramaSelected: (drama) => logic.openDrama(context, drama), duration: Duration.zero, initialSection: logic.mySection, sectionRequest: logic.sectionRequest, embedded: true, active: logic.tab == 2, - onAction: _myAction, + onAction: logic.myAction, ), ], ), @@ -258,21 +174,21 @@ class _HomeTabsState extends State<_HomeTabs> { icon: Icons.play_circle_outline_rounded, label: l10n.homeTab, selected: logic.tab == 0, - onTap: () => _select(0), + onTap: () => logic.select(0), ), _TabButton( key: const Key('tab-discover'), icon: Icons.explore_outlined, label: l10n.discoverTab, selected: logic.tab == 1, - onTap: () => _select(1), + onTap: () => logic.select(1), ), _TabButton( key: const Key('tab-my'), icon: Icons.video_library_outlined, label: l10n.myTab, selected: logic.tab == 2, - onTap: () => _select(2), + onTap: () => logic.select(2), ), ], ), diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 42d267a..749d1c3 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -1,7 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../l10n/app_localizations.dart'; -class MineLogic { +enum MyPageAction { home, discover, resume } + +class MineLogic extends ChangeNotifier { MineLogic({ required this.history, required this.drama, @@ -15,6 +22,11 @@ class MineLogic { late Future position; int section; int filter = 0; + bool _disposed = false; + + void notify() { + if (!_disposed) notifyListeners(); + } void refreshPosition() { position = history.positionFor( @@ -23,4 +35,82 @@ class MineLogic { drama.episodes.first.id, ); } + + void selectSection(int index) { + section = index; + notify(); + } + + void selectFilter(int index) { + filter = index; + notify(); + } + + void syncPage({ + required bool active, + required bool wasActive, + required Drama nextDrama, + required bool dramaChanged, + required int sectionRequest, + required int previousSectionRequest, + required int initialSection, + }) { + if (active && (!wasActive || dramaChanged)) { + drama = nextDrama; + refreshPosition(); + notify(); + } + if (sectionRequest != previousSectionRequest) { + section = initialSection; + notify(); + } + } + + void act( + BuildContext context, + MyPageAction action, { + required bool embedded, + ValueChanged? onAction, + }) { + if (embedded) { + onAction?.call(action); + } else { + Navigator.pop(context, action); + } + } + + Future showLanguage(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1C1D22), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.language)), + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ]) + ListTile( + title: Text(label), + onTap: () { + context.read().select(code); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 4ec0152..4e0e738 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/config/locale_controller.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -10,8 +9,6 @@ import '../../l10n/app_localizations.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; -enum MyPageAction { home, discover, resume } - class MyPage extends StatefulWidget { const MyPage({ super.key, @@ -58,55 +55,32 @@ class _MyPageState extends State { @override void didUpdateWidget(covariant MyPage oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.active && - (!oldWidget.active || oldWidget.drama.id != widget.drama.id)) { - logic.drama = widget.drama; - setState(logic.refreshPosition); - } - if (widget.sectionRequest != oldWidget.sectionRequest) { - setState(() => logic.section = widget.initialSection); - } - } - - void _act(MyPageAction action) { - if (widget.embedded) { - widget.onAction?.call(action); - } else { - Navigator.pop(context, action); - } - } - - Future _showLanguage() async { - final l10n = AppLocalizations.of(context)!; - await showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF1C1D22), - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.language)), - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ]) - ListTile( - title: Text(label), - onTap: () { - context.read().select(code); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), + logic.syncPage( + active: widget.active, + wasActive: oldWidget.active, + nextDrama: widget.drama, + dramaChanged: oldWidget.drama.id != widget.drama.id, + sectionRequest: widget.sectionRequest, + previousSectionRequest: oldWidget.sectionRequest, + initialSection: widget.initialSection, ); } + @override + void dispose() { + logic.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ); + } + + Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final content = SafeArea( bottom: !widget.embedded, @@ -179,7 +153,12 @@ class _MyPageState extends State { const Spacer(), IconButton( tooltip: l10n.search, - onPressed: () => _act(MyPageAction.discover), + onPressed: () => logic.act( + context, + MyPageAction.discover, + embedded: widget.embedded, + onAction: widget.onAction, + ), icon: const Icon(Icons.search_rounded, size: 23), ), const SizedBox(width: 6), @@ -324,7 +303,12 @@ class _MyPageState extends State { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => _act(MyPageAction.resume), + onPressed: () => logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ), style: FilledButton.styleFrom( backgroundColor: _orange, foregroundColor: Colors.white, @@ -368,7 +352,7 @@ class _MyPageState extends State { children: [ for (var index = 0; index < tabs.length; index++) ...[ InkWell( - onTap: () => setState(() => logic.section = index), + onTap: () => logic.selectSection(index), child: Text( tabs[index], style: TextStyle( @@ -397,7 +381,7 @@ class _MyPageState extends State { child: ChoiceChip( label: Text(filters[index]), selected: logic.filter == index, - onSelected: (_) => setState(() => logic.filter = index), + onSelected: (_) => logic.selectFilter(index), showCheckmark: false, visualDensity: VisualDensity.compact, backgroundColor: const Color(0xFF242328), @@ -418,7 +402,12 @@ class _MyPageState extends State { if (logic.section == 0) const SizedBox(height: 11), if (logic.section == 0 && hasHistory) InkWell( - onTap: () => _act(MyPageAction.resume), + onTap: () => logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(9), @@ -499,7 +488,12 @@ class _MyPageState extends State { if (widget.onDramaSelected != null) { widget.onDramaSelected!(drama); } else if (drama.id == widget.drama.id) { - _act(MyPageAction.resume); + logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ); } }, borderRadius: BorderRadius.circular(12), @@ -588,7 +582,7 @@ class _MyPageState extends State { const Icon(Icons.chevron_right_rounded, size: 18), ], ), - onTap: _showLanguage, + onTap: () => logic.showLanguage(context), ), ), ], @@ -606,12 +600,22 @@ class _MyPageState extends State { _NavItem( icon: Icons.play_circle_outline_rounded, label: l10n.homeTab, - onTap: () => Navigator.pop(context, MyPageAction.home), + onTap: () => logic.act( + context, + MyPageAction.home, + embedded: widget.embedded, + onAction: widget.onAction, + ), ), _NavItem( icon: Icons.explore_outlined, label: l10n.discoverTab, - onTap: () => Navigator.pop(context, MyPageAction.discover), + onTap: () => logic.act( + context, + MyPageAction.discover, + embedded: widget.embedded, + onAction: widget.onAction, + ), ), _NavItem( icon: Icons.video_library_outlined, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 623af68..b5085e8 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -1,14 +1,28 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/player_port.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; +import '../../core/data/services/platform_share_service.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/formatting/localized_content.dart'; +import '../../l10n/app_localizations.dart'; +import '../discover/discover_logic.dart'; +import '../discover/discover_view.dart'; +import '../drama_detail/drama_detail_view.dart'; import '../home/home_feed_sections.dart'; +import '../mine/mine_logic.dart'; +import '../mine/mine_view.dart'; +import 'player_view.dart'; +import 'widgets/drama_comments_sheet.dart'; -class PlayerLogic { +class PlayerLogic extends ChangeNotifier { PlayerLogic({required this.history, required this.pictureInPicture}); final WatchHistoryRepository history; @@ -34,5 +48,618 @@ class PlayerLogic { bool pip = false; bool pipPreparing = false; + bool Function() isMounted = () => false; + bool homeMode = false; + bool embedded = false; + bool active = true; + int resumeRequest = 0; + int backRequest = 0; + int initialIndex = 0; + late Drama pageDrama; + List? feedDramas; + VoidCallback? onDiscover; + VoidCallback? onMy; + VoidCallback? onSearch; + VoidCallback? onMenu; + ValueChanged? onDramaChanged; + ValueChanged? onImmersiveChanged; + bool _disposed = false; + bool get immersive => fullscreen || pip || pipPreparing; + + void notify() { + if (!_disposed) notifyListeners(); + } + + void attach(PlayerPage page, {required bool Function() isMounted}) { + this.isMounted = isMounted; + homeMode = page.homeMode; + embedded = page.embedded; + active = page.active; + resumeRequest = page.resumeRequest; + backRequest = page.backRequest; + initialIndex = page.initialIndex; + pageDrama = page.drama; + feedDramas = page.feedDramas; + onDiscover = page.onDiscover; + onMy = page.onMy; + onSearch = page.onSearch; + onMenu = page.onMenu; + onDramaChanged = page.onDramaChanged; + onImmersiveChanged = page.onImmersiveChanged; + } + + void start({ + required PlayerFactory factory, + required PlaybackCoordinator coordinator, + }) { + pictureInPicture.setStateListener(onPipChanged); + queue = feedDramas == null + ? PlaybackQueue.series(pageDrama) + : PlaybackQueue.home(feedDramas!); + homeSections = HomeFeedSections([ + for (final entry in queue.entries) entry.drama, + ]); + visibleIndices = homeMode + ? homeSections.indicesFor(category) + : List.generate(queue.length, (index) => index); + pages = PageController(initialPage: initialIndex); + session = PlaybackSession( + episodes: [for (final entry in queue.entries) entry.episode], + factory: factory, + canPlay: (episode) => episode.isFree, + prepare: preparePlayer, + activate: activatePlayer, + coordinator: coordinator, + ); + if (!active || visibleIndices.isEmpty) { + session.setRouteVisible(false); + } + unawaited(selectEpisode(initialIndex)); + } + + void onPageUpdated(PlayerPage previous) { + if (previous.active != active) { + session.setRouteVisible(active && visibleIndices.isNotEmpty); + if (!active) { + cancelScrub(); + unawaited(saveCurrentProgress()); + } + } + if (previous.resumeRequest != resumeRequest) { + session.setUserWantsPlay(true); + } + if (previous.backRequest != backRequest) { + final request = backRequest; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isMounted() && backRequest == request) { + if (fullscreen) { + unawaited(setFullscreen(false)); + } else { + shiftCategory(-1); + } + } + }); + } + } + + void onLifecycle(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); + session.setAppResumed( + state == AppLifecycleState.resumed || pip || pipPreparing, + ); + if (state != AppLifecycleState.resumed) cancelScrub(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + pictureInPicture.setStateListener(null); + if (fullscreen) { + unawaited( + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), + ); + unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); + } + unawaited(saveCurrentProgress()); + session.setRouteVisible(false); + cancelScrub(); + unawaited(session.close()); + pages.dispose(); + super.dispose(); + } + + void notifyImmersive() => onImmersiveChanged?.call(immersive); + + Future setFullscreen(bool value) async { + if (fullscreen == value || !isMounted()) return; + fullscreen = value; + notify(); + notifyImmersive(); + try { + await SystemChrome.setPreferredOrientations( + value + ? [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ] + : [DeviceOrientation.portraitUp], + ); + await SystemChrome.setEnabledSystemUIMode( + value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + ); + } catch (_) { + if (value && isMounted()) { + fullscreen = false; + notify(); + notifyImmersive(); + } + } + } + + Future enterPip(BuildContext context) async { + if (pip || pipPreparing) return; + if (session.active is! VideoPlayerPort) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), + ); + return; + } + if (fullscreen) await setFullscreen(false); + pipPreparing = true; + notify(); + notifyImmersive(); + await WidgetsBinding.instance.endOfFrame; + bool entered = false; + try { + entered = await pictureInPicture.enter(); + } catch (_) { + // Android reports unavailable for devices without PiP support. + } + if (!context.mounted) return; + if (entered) { + pip = true; + pipPreparing = false; + notify(); + notifyImmersive(); + session.setAppResumed(true); + } else { + pipPreparing = false; + notify(); + notifyImmersive(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), + ); + } + } + + void onPipChanged(bool enabled) { + if (!isMounted()) return; + pip = enabled; + pipPreparing = false; + notify(); + notifyImmersive(); + session.setAppResumed( + enabled || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + } + + Future preparePlayer(Episode episode, PlayerPort player) async { + if (player is! VideoPlayerPort) return; + final drama = queue.entries + .firstWhere((e) => e.episode.id == episode.id) + .drama; + try { + await pendingWrite; + final saved = await history.positionFor( + demoProfileId, + drama.id, + episode.id, + ); + if (saved != null) { + await player.controller.seekTo( + clampResumePosition(saved, player.controller.value.duration), + ); + } + } catch (_) { + // A local preference failure does not prevent playback. + } + } + + Future activatePlayer(Episode episode, PlayerPort player) async { + if (player is! VideoPlayerPort) return; + final index = session.currentIndex; + final drama = queue.entryAt(index).drama; + await player.controller.setVolume(muted ? 0 : 1); + if (session.active != player) return; + trackedPlayer = player; + trackedEpisode = episode; + trackedDrama = drama; + lastProgressWrite = DateTime.now(); + var completionHandled = false; + var completionProgressSaved = false; + player.controller.addListener(() { + if (session.active != player || !player.controller.value.isInitialized) { + return; + } + if (player.controller.value.isCompleted && !completionProgressSaved) { + completionProgressSaved = true; + unawaited( + queueProgress(drama, episode, player.controller.value.duration), + ); + } + if (!completionHandled && + player.controller.value.isCompleted && + session.canAutoAdvance && + session.currentIndex == index) { + completionHandled = true; + unawaited(advanceAfterEnd(player, index)); + } + final now = DateTime.now(); + if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) { + return; + } + lastProgressWrite = now; + unawaited( + queueProgress(drama, episode, player.controller.value.position), + ); + }); + } + + Future advanceAfterEnd(VideoPlayerPort player, int index) async { + if (!isMounted() || session.active != player || !session.canAutoAdvance) { + return; + } + final next = visibleIndices.indexOf(index) + 1; + if (next <= 0 || next >= visibleIndices.length) { + stoppedAtFeedEnd = true; + session.setUserWantsPlay(false); + return; + } + final controller = pages; + try { + if (!controller.hasClients) return; + await controller.animateToPage( + next, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } catch (_) { + // A category change can detach the old page controller mid-animation. + } + } + + Future queueProgress(Drama drama, Episode episode, Duration position) { + pendingWrite = pendingWrite + .then( + (_) => history.save(demoProfileId, drama.id, episode.id, position), + ) + .catchError((Object _) { + // Playback continues if local history cannot be stored. + }); + return pendingWrite; + } + + Future saveCurrentProgress() { + final player = trackedPlayer; + final episode = trackedEpisode; + final drama = trackedDrama; + if (player == null || + episode == null || + drama == null || + !player.controller.value.isInitialized) { + return pendingWrite; + } + return queueProgress(drama, episode, player.controller.value.position); + } + + Future selectEpisode(int index) async { + final request = ++pageRequest; + cancelScrub(); + // Capture the old position synchronously, persist without blocking a swipe. + unawaited(saveCurrentProgress()); + if (request != pageRequest || !isMounted()) return; + await session.select(index, preloadIndex: nextVisibleIndex(index)); + } + + int? nextVisibleIndex(int index) { + final position = visibleIndices.indexOf(index); + return position >= 0 && position + 1 < visibleIndices.length + ? visibleIndices[position + 1] + : null; + } + + void shiftCategory(int offset) { + if (!homeMode) return; + final next = category.index + offset; + if (next < 0) { + onMenu?.call(); + return; + } + if (next >= HomeFeedCategory.values.length) return; + unawaited(selectCategory(HomeFeedCategory.values[next])); + } + + Future selectCategory(HomeFeedCategory next) async { + if (!homeMode || next == category) return; + final request = ++categoryRequest; + ++pageRequest; + session.setRouteVisible(false); + cancelScrub(); + await saveCurrentProgress(); + if (!isMounted() || request != categoryRequest) return; + + final oldPages = pages; + final indices = homeSections.indicesFor(next); + category = next; + visibleIndices = indices; + pages = PageController(); + notify(); + WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose()); + + if (indices.isEmpty) return; + onDramaChanged?.call(queue.entryAt(indices.first).drama); + await session.select( + indices.first, + preloadIndex: nextVisibleIndex(indices.first), + ); + if (isMounted() && request == categoryRequest) { + if (stoppedAtFeedEnd) { + stoppedAtFeedEnd = false; + session.setUserWantsPlay(true); + } + session.setRouteVisible(active); + } + } + + void beginScrub(VideoPlayerPort player) { + if (session.active != player) return; + scrubbingPlayer = player; + session.setOverlayOpen(true); + } + + void cancelScrub() { + if (scrubbingPlayer == null) return; + scrubbingPlayer = null; + session.setOverlayOpen(false); + } + + Future seekTo(BuildContext context, VideoPlayerPort player, Duration position) async { + if (scrubbingPlayer != player || session.active != player) return; + try { + await session.playbackSettled; + if (scrubbingPlayer != player || session.active != player) return; + await player.controller.seekTo(position); + if (session.active == player) await saveCurrentProgress(); + } catch (_) { + if (context.mounted && session.active == player) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); + } + } finally { + if (scrubbingPlayer == player) cancelScrub(); + } + } + + Future leave(BuildContext context) async { + await saveCurrentProgress(); + if (context.mounted) Navigator.of(context).pop(); + } + + Future openDetail(BuildContext context) async { + session.setRouteVisible(false); + await session.playbackSettled; + await saveCurrentProgress(); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), + ), + ); + if (isMounted()) session.setRouteVisible(active); + } + + Future showEpisodes(BuildContext context) async { + if (feedDramas != null) { + await openDetail(context); + return; + } + final l10n = AppLocalizations.of(context)!; + session.setOverlayOpen(true); + await session.playbackSettled; + if (!context.mounted) { + session.setOverlayOpen(false); + return; + } + try { + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF202024), + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Text( + l10n.episodes, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: queue.length, + itemBuilder: (context, index) { + final episode = queue.entryAt(index).episode; + return ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF3A3030), + child: Text('${episode.number}'), + ), + title: Text(l10n.episodeLabel(episode.number)), + subtitle: Text(l10n.freeTestMedia), + trailing: const Icon(Icons.play_arrow_rounded), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited( + pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } finally { + session.setOverlayOpen(false); + } + } + + Future showMy(BuildContext context, {int initialSection = 0}) async { + if (embedded) { + onMy?.call(); + return; + } + session.setRouteVisible(false); + await session.playbackSettled; + await saveCurrentProgress(); + if (!context.mounted) return; + final port = session.active; + final drama = queue.entryAt(session.currentIndex).drama; + final duration = port is VideoPlayerPort + ? port.controller.value.duration + : Duration.zero; + final action = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MyPage( + drama: drama, + duration: duration, + initialSection: initialSection, + ), + ), + ); + if (!context.mounted) return; + if (action == MyPageAction.discover) { + await showDiscover(context); + } else { + session.setRouteVisible(true); + } + if (action == MyPageAction.resume) session.setUserWantsPlay(true); + } + + Future showSearch(BuildContext context) async { + if (embedded) { + onSearch?.call(); + return; + } + await showDiscover(context, focusSearch: true); + } + + Future showDiscover(BuildContext context, {bool focusSearch = false}) async { + if (embedded) { + onDiscover?.call(); + return; + } + session.setRouteVisible(false); + await session.playbackSettled; + await saveCurrentProgress(); + if (!context.mounted) return; + final port = session.active; + final duration = port is VideoPlayerPort + ? port.controller.value.duration + : Duration.zero; + final action = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DiscoverPage( + drama: pageDrama, + duration: duration, + focusSearch: focusSearch, + ), + ), + ); + if (!context.mounted) return; + if (action == DiscoverPageAction.detail) { + await openDetail(context); + } else if (action == DiscoverPageAction.my || + action == DiscoverPageAction.watchlist) { + await showMy(context, + initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, + ); + } else { + session.setRouteVisible(true); + if (action == DiscoverPageAction.resume) { + session.setUserWantsPlay(true); + } + } + } + + Future toggleMute() async { + final port = session.active; + if (port is! VideoPlayerPort) return; + final next = !muted; + await port.controller.setVolume(next ? 0 : 1); + if (isMounted()) muted = next; + notify(); + } + + Future showComments(BuildContext context, Drama drama) async { + session.setOverlayOpen(true); + await session.playbackSettled; + if (!context.mounted) { + session.setOverlayOpen(false); + return; + } + try { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF232328), + showDragHandle: true, + builder: (_) => DramaCommentsSheet(drama: drama), + ); + } finally { + session.setOverlayOpen(false); + } + } + + Future shareDrama(BuildContext context, Drama drama) async { + final l10n = AppLocalizations.of(context)!; + session.setOverlayOpen(true); + await session.playbackSettled; + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)), + ); + } + } finally { + session.setOverlayOpen(false); + } + } + } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6a95274..9b2a104 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -6,24 +6,16 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/player_port.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/engagement_controller.dart'; -import '../../core/data/services/platform_share_service.dart'; -import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/localized_content.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_view.dart'; -import '../drama_detail/drama_detail_view.dart'; import '../home/home_feed_sections.dart'; import '../home/widgets/category_swipe_region.dart'; import '../home/widgets/home_feed_header.dart'; -import '../mine/mine_view.dart'; import 'player_binding.dart'; import 'player_logic.dart'; -import 'widgets/drama_comments_sheet.dart'; import 'widgets/seek_progress_bar.dart'; class PlayerPage extends StatefulWidget { @@ -67,582 +59,46 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; - void _notifyImmersive() => widget.onImmersiveChanged?.call(logic.immersive); - - Future _setFullscreen(bool value) async { - if (logic.fullscreen == value || !mounted) return; - setState(() => logic.fullscreen = value); - _notifyImmersive(); - try { - await SystemChrome.setPreferredOrientations( - value - ? [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ] - : [DeviceOrientation.portraitUp], - ); - await SystemChrome.setEnabledSystemUIMode( - value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, - ); - } catch (_) { - if (value && mounted) { - setState(() => logic.fullscreen = false); - _notifyImmersive(); - } - } - } - - Future _enterPip() async { - if (logic.pip || logic.pipPreparing) return; - if (logic.session.active is! VideoPlayerPort) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), - ); - return; - } - if (logic.fullscreen) await _setFullscreen(false); - setState(() => logic.pipPreparing = true); - _notifyImmersive(); - await WidgetsBinding.instance.endOfFrame; - bool entered = false; - try { - entered = await logic.pictureInPicture.enter(); - } catch (_) { - // Android reports unavailable for devices without PiP support. - } - if (!mounted) return; - if (entered) { - setState(() { - logic.pip = true; - logic.pipPreparing = false; - }); - _notifyImmersive(); - logic.session.setAppResumed(true); - } else { - setState(() => logic.pipPreparing = false); - _notifyImmersive(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), - ); - } - } - - void _onPipChanged(bool enabled) { - if (!mounted) return; - setState(() { - logic.pip = enabled; - logic.pipPreparing = false; - }); - _notifyImmersive(); - logic.session.setAppResumed( - enabled || - WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, - ); - } - - Future _preparePlayer(Episode episode, PlayerPort player) async { - if (player is! VideoPlayerPort) return; - final drama = logic.queue.entries - .firstWhere((e) => e.episode.id == episode.id) - .drama; - try { - await logic.pendingWrite; - final saved = await logic.history.positionFor( - demoProfileId, - drama.id, - episode.id, - ); - if (saved != null) { - await player.controller.seekTo( - clampResumePosition(saved, player.controller.value.duration), - ); - } - } catch (_) { - // A local preference failure does not prevent playback. - } - } - - Future _activatePlayer(Episode episode, PlayerPort player) async { - if (player is! VideoPlayerPort) return; - final index = logic.session.currentIndex; - final drama = logic.queue.entryAt(index).drama; - await player.controller.setVolume(logic.muted ? 0 : 1); - if (logic.session.active != player) return; - logic.trackedPlayer = player; - logic.trackedEpisode = episode; - logic.trackedDrama = drama; - logic.lastProgressWrite = DateTime.now(); - var completionHandled = false; - var completionProgressSaved = false; - player.controller.addListener(() { - if (logic.session.active != player || !player.controller.value.isInitialized) { - return; - } - if (player.controller.value.isCompleted && !completionProgressSaved) { - completionProgressSaved = true; - unawaited( - _queueProgress(drama, episode, player.controller.value.duration), - ); - } - if (!completionHandled && - player.controller.value.isCompleted && - logic.session.canAutoAdvance && - logic.session.currentIndex == index) { - completionHandled = true; - unawaited(_advanceAfterEnd(player, index)); - } - final now = DateTime.now(); - if (now.difference(logic.lastProgressWrite) < const Duration(seconds: 5)) { - return; - } - logic.lastProgressWrite = now; - unawaited( - _queueProgress(drama, episode, player.controller.value.position), - ); - }); - } - - Future _advanceAfterEnd(VideoPlayerPort player, int index) async { - if (!mounted || logic.session.active != player || !logic.session.canAutoAdvance) { - return; - } - final next = logic.visibleIndices.indexOf(index) + 1; - if (next <= 0 || next >= logic.visibleIndices.length) { - logic.stoppedAtFeedEnd = true; - logic.session.setUserWantsPlay(false); - return; - } - final pages = logic.pages; - try { - if (!pages.hasClients) return; - await pages.animateToPage( - next, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } catch (_) { - // A category change can detach the old page controller mid-animation. - } - } - - Future _queueProgress(Drama drama, Episode episode, Duration position) { - logic.pendingWrite = logic.pendingWrite - .then( - (_) => logic.history.save(demoProfileId, drama.id, episode.id, position), - ) - .catchError((Object _) { - // Playback continues if local history cannot be stored. - }); - return logic.pendingWrite; - } - - Future _saveCurrentProgress() { - final player = logic.trackedPlayer; - final episode = logic.trackedEpisode; - final drama = logic.trackedDrama; - if (player == null || - episode == null || - drama == null || - !player.controller.value.isInitialized) { - return logic.pendingWrite; - } - return _queueProgress(drama, episode, player.controller.value.position); - } - - Future _selectEpisode(int index) async { - final request = ++logic.pageRequest; - _cancelScrub(); - // Capture the old position synchronously, persist without blocking a swipe. - unawaited(_saveCurrentProgress()); - if (request != logic.pageRequest || !mounted) return; - await logic.session.select(index, preloadIndex: _nextVisibleIndex(index)); - } - - int? _nextVisibleIndex(int index) { - final position = logic.visibleIndices.indexOf(index); - return position >= 0 && position + 1 < logic.visibleIndices.length - ? logic.visibleIndices[position + 1] - : null; - } - - void _shiftCategory(int offset) { - if (!widget.homeMode) return; - final next = logic.category.index + offset; - if (next < 0) { - widget.onMenu?.call(); - return; - } - if (next >= HomeFeedCategory.values.length) return; - unawaited(_selectCategory(HomeFeedCategory.values[next])); - } - - Future _selectCategory(HomeFeedCategory category) async { - if (!widget.homeMode || category == logic.category) return; - final request = ++logic.categoryRequest; - ++logic.pageRequest; - logic.session.setRouteVisible(false); - _cancelScrub(); - await _saveCurrentProgress(); - if (!mounted || request != logic.categoryRequest) return; - - final oldPages = logic.pages; - final indices = logic.homeSections.indicesFor(category); - setState(() { - logic.category = category; - logic.visibleIndices = indices; - logic.pages = PageController(); - }); - WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose()); - - if (indices.isEmpty) return; - widget.onDramaChanged?.call(logic.queue.entryAt(indices.first).drama); - await logic.session.select( - indices.first, - preloadIndex: _nextVisibleIndex(indices.first), - ); - if (mounted && request == logic.categoryRequest) { - if (logic.stoppedAtFeedEnd) { - logic.stoppedAtFeedEnd = false; - logic.session.setUserWantsPlay(true); - } - logic.session.setRouteVisible(widget.active); - } - } - - void _beginScrub(VideoPlayerPort player) { - if (logic.session.active != player) return; - logic.scrubbingPlayer = player; - logic.session.setOverlayOpen(true); - } - - void _cancelScrub() { - if (logic.scrubbingPlayer == null) return; - logic.scrubbingPlayer = null; - logic.session.setOverlayOpen(false); - } - - Future _seekTo(VideoPlayerPort player, Duration position) async { - if (logic.scrubbingPlayer != player || logic.session.active != player) return; - try { - await logic.session.playbackSettled; - if (logic.scrubbingPlayer != player || logic.session.active != player) return; - await player.controller.seekTo(position); - if (logic.session.active == player) await _saveCurrentProgress(); - } catch (_) { - if (mounted && logic.session.active == player) { - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); - } - } finally { - if (logic.scrubbingPlayer == player) _cancelScrub(); - } - } - - Future _leave() async { - await _saveCurrentProgress(); - if (mounted) Navigator.of(context).pop(); - } - @override void initState() { super.initState(); logic = PlayerBinding().create(); + logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); - logic.pictureInPicture.setStateListener(_onPipChanged); - logic.queue = widget.feedDramas == null - ? PlaybackQueue.series(widget.drama) - : PlaybackQueue.home(widget.feedDramas!); - logic.homeSections = HomeFeedSections([ - for (final entry in logic.queue.entries) entry.drama, - ]); - logic.visibleIndices = widget.homeMode - ? logic.homeSections.indicesFor(logic.category) - : List.generate(logic.queue.length, (index) => index); - logic.pages = PageController(initialPage: widget.initialIndex); - logic.session = PlaybackSession( - episodes: [for (final entry in logic.queue.entries) entry.episode], + logic.start( factory: context.read(), - canPlay: (episode) => episode.isFree, - prepare: _preparePlayer, - activate: _activatePlayer, coordinator: context.read(), ); - if (!widget.active || logic.visibleIndices.isEmpty) { - logic.session.setRouteVisible(false); - } - unawaited(_selectEpisode(widget.initialIndex)); } @override void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.active != widget.active) { - logic.session.setRouteVisible(widget.active && logic.visibleIndices.isNotEmpty); - if (!widget.active) { - _cancelScrub(); - unawaited(_saveCurrentProgress()); - } - } - if (oldWidget.resumeRequest != widget.resumeRequest) { - logic.session.setUserWantsPlay(true); - } - if (oldWidget.backRequest != widget.backRequest) { - final request = widget.backRequest; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.backRequest == request) { - if (logic.fullscreen) { - unawaited(_setFullscreen(false)); - } else { - _shiftCategory(-1); - } - } - }); - } + logic.attach(widget, isMounted: () => mounted); + logic.onPageUpdated(oldWidget); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state != AppLifecycleState.resumed) unawaited(_saveCurrentProgress()); - logic.session.setAppResumed( - state == AppLifecycleState.resumed || logic.pip || logic.pipPreparing, - ); - if (state != AppLifecycleState.resumed) _cancelScrub(); + logic.onLifecycle(state); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - logic.pictureInPicture.setStateListener(null); - if (logic.fullscreen) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), - ); - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } - unawaited(_saveCurrentProgress()); - logic.session.setRouteVisible(false); - _cancelScrub(); - unawaited(logic.session.close()); - logic.pages.dispose(); + logic.dispose(); super.dispose(); } - Future _openDetail() async { - logic.session.setRouteVisible(false); - await logic.session.playbackSettled; - await _saveCurrentProgress(); - if (!mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - DramaDetailPage(drama: logic.queue.entryAt(logic.session.currentIndex).drama), - ), - ); - if (mounted) logic.session.setRouteVisible(widget.active); - } - - Future _showEpisodes() async { - if (widget.feedDramas != null) { - await _openDetail(); - return; - } - final l10n = AppLocalizations.of(context)!; - logic.session.setOverlayOpen(true); - await logic.session.playbackSettled; - if (!mounted) { - logic.session.setOverlayOpen(false); - return; - } - try { - await showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF202024), - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: SizedBox( - height: MediaQuery.sizeOf(context).height * 0.6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: Text( - l10n.episodes, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: logic.queue.length, - itemBuilder: (context, index) { - final episode = logic.queue.entryAt(index).episode; - return ListTile( - leading: CircleAvatar( - backgroundColor: const Color(0xFF3A3030), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: Text(l10n.freeTestMedia), - trailing: const Icon(Icons.play_arrow_rounded), - onTap: () { - Navigator.of(sheetContext).pop(); - unawaited( - logic.pages.animateToPage( - index, - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - ), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - } finally { - logic.session.setOverlayOpen(false); - } - } - - Future _showMy({int initialSection = 0}) async { - if (widget.embedded) { - widget.onMy?.call(); - return; - } - logic.session.setRouteVisible(false); - await logic.session.playbackSettled; - await _saveCurrentProgress(); - if (!mounted) return; - final port = logic.session.active; - final drama = logic.queue.entryAt(logic.session.currentIndex).drama; - final duration = port is VideoPlayerPort - ? port.controller.value.duration - : Duration.zero; - final action = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MyPage( - drama: drama, - duration: duration, - initialSection: initialSection, - ), - ), - ); - if (!mounted) return; - if (action == MyPageAction.discover) { - await _showDiscover(); - } else { - logic.session.setRouteVisible(true); - } - if (action == MyPageAction.resume) logic.session.setUserWantsPlay(true); - } - - Future _showSearch() async { - if (widget.embedded) { - widget.onSearch?.call(); - return; - } - await _showDiscover(focusSearch: true); - } - - Future _showDiscover({bool focusSearch = false}) async { - if (widget.embedded) { - widget.onDiscover?.call(); - return; - } - logic.session.setRouteVisible(false); - await logic.session.playbackSettled; - await _saveCurrentProgress(); - if (!mounted) return; - final port = logic.session.active; - final duration = port is VideoPlayerPort - ? port.controller.value.duration - : Duration.zero; - final action = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DiscoverPage( - drama: widget.drama, - duration: duration, - focusSearch: focusSearch, - ), - ), - ); - if (!mounted) return; - if (action == DiscoverPageAction.detail) { - await _openDetail(); - } else if (action == DiscoverPageAction.my || - action == DiscoverPageAction.watchlist) { - await _showMy( - initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, - ); - } else { - logic.session.setRouteVisible(true); - if (action == DiscoverPageAction.resume) { - logic.session.setUserWantsPlay(true); - } - } - } - - Future _toggleMute() async { - final port = logic.session.active; - if (port is! VideoPlayerPort) return; - final next = !logic.muted; - await port.controller.setVolume(next ? 0 : 1); - if (mounted) setState(() => logic.muted = next); - } - - Future _showComments(Drama drama) async { - logic.session.setOverlayOpen(true); - await logic.session.playbackSettled; - if (!mounted) { - logic.session.setOverlayOpen(false); - return; - } - try { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: const Color(0xFF232328), - showDragHandle: true, - builder: (_) => DramaCommentsSheet(drama: drama), - ); - } finally { - logic.session.setOverlayOpen(false); - } - } - - Future _shareDrama(Drama drama) async { - final l10n = AppLocalizations.of(context)!; - logic.session.setOverlayOpen(true); - await logic.session.playbackSettled; - try { - await const PlatformShareService().shareDrama( - title: localizedDramaTitle(l10n, drama), - synopsis: localizedDramaSynopsis(l10n, drama), - footer: l10n.shareFooter, - ); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)), - ); - } - } finally { - logic.session.setOverlayOpen(false); - } - } - @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: logic, + builder: (context, _) => _buildPlayer(context), + ); + } + + Widget _buildPlayer(BuildContext context) { final engagement = context.watch(); final category = logic.category; final indices = logic.visibleIndices; @@ -661,7 +117,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (category != logic.category) return; final index = indices[position]; widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(_selectEpisode(index)); + unawaited(logic.selectEpisode(index)); }, itemBuilder: (context, position) => _buildEpisode(context, indices[position], engagement), @@ -676,13 +132,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { : Scaffold(backgroundColor: Colors.black, body: content), ), ); - } + } Widget _buildEmptyCategory(BuildContext context) { final l10n = AppLocalizations.of(context)!; return CategorySwipeRegion( key: const Key('home-empty-swipe'), - onShift: _shiftCategory, + onShift: logic.shiftCategory, child: ColoredBox( color: const Color(0xFF111214), child: SafeArea( @@ -692,10 +148,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, category: logic.category, onCategorySelected: (value) => - unawaited(_selectCategory(value)), - onCategoryShift: _shiftCategory, - onSearch: _showSearch, - onMy: _showMy, + unawaited(logic.selectCategory(value)), + onCategoryShift: logic.shiftCategory, + onSearch: () => logic.showSearch(context), + onMy: () => logic.showMy(context), onMenu: widget.onMenu, ), const Spacer(), @@ -761,7 +217,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (widget.homeMode) { video = CategorySwipeRegion( key: selected ? const Key('home-video-swipe') : null, - onShift: _shiftCategory, + onShift: logic.shiftCategory, child: video, ); } @@ -773,7 +229,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (widget.homeMode && !ready && !logic.immersive) CategorySwipeRegion( key: selected ? const Key('home-video-swipe') : null, - onShift: _shiftCategory, + onShift: logic.shiftCategory, child: const ColoredBox(color: Colors.black), ) else @@ -815,12 +271,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { _FullscreenControls( l10n: l10n, controller: ready ? videoPort!.controller : null, - onClose: () => unawaited(_setFullscreen(false)), + onClose: () => unawaited(logic.setFullscreen(false)), onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(_enterPip()) + ? () => unawaited(logic.enterPip(context)) : null, - onScrubStart: ready ? () => _beginScrub(videoPort!) : null, - onSeek: ready ? (position) => _seekTo(videoPort!, position) : null, + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, ) else if (widget.homeMode) _StitchHomeOverlay( @@ -832,25 +288,25 @@ class _PlayerPageState extends State with WidgetsBindingObserver { saved: engagement.isSaved(drama.id), muted: logic.muted, category: logic.category, - onCategorySelected: (value) => unawaited(_selectCategory(value)), - onCategoryShift: _shiftCategory, - onDetail: _openDetail, - onDiscover: _showDiscover, - onEpisodes: _showEpisodes, - onMy: _showMy, - onSearch: _showSearch, + onCategorySelected: (value) => unawaited(logic.selectCategory(value)), + onCategoryShift: logic.shiftCategory, + onDetail: () => logic.openDetail(context), + onDiscover: () => logic.showDiscover(context), + onEpisodes: () => logic.showEpisodes(context), + onMy: () => logic.showMy(context), + onSearch: () => logic.showSearch(context), onMenu: widget.onMenu, - onMute: _toggleMute, + onMute: logic.toggleMute, onLike: () => engagement.toggleLiked(drama.id), onSave: () => engagement.toggleSaved(drama.id), - onComments: () => _showComments(drama), - onShare: () => _shareDrama(drama), - onFullscreen: () => unawaited(_setFullscreen(true)), + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onFullscreen: () => unawaited(logic.setFullscreen(true)), onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(_enterPip()) + ? () => unawaited(logic.enterPip(context)) : null, - onScrubStart: ready ? () => _beginScrub(videoPort!) : null, - onSeek: ready ? (position) => _seekTo(videoPort!, position) : null, + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, showBottomNav: !widget.embedded, ) else @@ -864,7 +320,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ if (!widget.homeMode) IconButton( - onPressed: () => unawaited(_leave()), + onPressed: () => unawaited(logic.leave(context)), tooltip: l10n.backToSeries, icon: const Icon(Icons.arrow_back_rounded), ) @@ -886,7 +342,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), IconButton( tooltip: l10n.language, - onPressed: _showMy, + onPressed: () => logic.showMy(context), icon: const Icon(Icons.language_rounded), ), ], @@ -906,7 +362,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), const SizedBox(width: 22), TextButton( - onPressed: _openDetail, + onPressed: () => logic.openDetail(context), child: Text( l10n.discoverTab, style: const TextStyle( @@ -922,7 +378,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Padding( padding: const EdgeInsets.only(bottom: 12), child: FilledButton( - onPressed: () => unawaited(_selectEpisode(index)), + onPressed: () => unawaited(logic.selectEpisode(index)), child: Text('${l10n.playbackError} ${l10n.retry}'), ), ), @@ -968,7 +424,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ IconButton.filledTonal( tooltip: l10n.exploreSeries, - onPressed: _openDetail, + onPressed: () => logic.openDetail(context), style: IconButton.styleFrom( backgroundColor: Colors.black45, foregroundColor: Colors.white, @@ -978,7 +434,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { const SizedBox(height: 8), IconButton.filledTonal( tooltip: l10n.episodes, - onPressed: _showEpisodes, + onPressed: () => logic.showEpisodes(context), style: IconButton.styleFrom( backgroundColor: Colors.black45, foregroundColor: Colors.white, @@ -1011,7 +467,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), const SizedBox(height: 18), TextButton.icon( - onPressed: _showEpisodes, + onPressed: () => logic.showEpisodes(context), icon: const Icon(Icons.play_arrow_rounded), label: Text( '${l10n.episodeProgress(episode.number, drama.episodes.length)} ยท ${l10n.episodes}', @@ -1022,9 +478,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { valueListenable: ready ? videoPort!.controller : null, semanticsLabel: l10n.seekVideo, showTimes: false, - onScrubStart: ready ? () => _beginScrub(videoPort!) : null, + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready - ? (position) => _seekTo(videoPort!, position) + ? (position) => logic.seekTo(context, videoPort!, position) : null, ), if (widget.homeMode) ...[ @@ -1041,12 +497,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { _BottomAction( icon: Icons.explore_outlined, label: l10n.discoverTab, - onTap: _showDiscover, + onTap: () => logic.showDiscover(context), ), _BottomAction( icon: Icons.person_outline_rounded, label: l10n.myTab, - onTap: _showMy, + onTap: () => logic.showMy(context), ), ], ), diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 71ee09a..d5080b6 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -37,24 +37,30 @@ class SeekProgressBar extends StatefulWidget { } class _SeekProgressBarState extends State { - double? _previewFraction; + final _previewFraction = ValueNotifier(null); @override void didUpdateWidget(covariant SeekProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { - _previewFraction = null; + _previewFraction.value = null; } } + @override + void dispose() { + _previewFraction.dispose(); + super.dispose(); + } + Future _finish(double fraction, Duration duration) async { final source = widget.valueListenable; - setState(() => _previewFraction = fraction); + _previewFraction.value = fraction; try { await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } finally { if (mounted && widget.valueListenable == source) { - setState(() => _previewFraction = null); + _previewFraction.value = null; } } } @@ -63,11 +69,18 @@ class _SeekProgressBarState extends State { Widget build(BuildContext context) { final source = widget.valueListenable; if (source == null) { - return _buildTrack(context, const VideoPlayerValue.uninitialized()); + return ValueListenableBuilder( + valueListenable: _previewFraction, + builder: (context, _, _) => + _buildTrack(context, const VideoPlayerValue.uninitialized()), + ); } return ValueListenableBuilder( valueListenable: source, - builder: (context, value, _) => _buildTrack(context, value), + builder: (context, value, _) => ValueListenableBuilder( + valueListenable: _previewFraction, + builder: (context, _, _) => _buildTrack(context, value), + ), ); } @@ -77,11 +90,11 @@ class _SeekProgressBarState extends State { final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) : 0.0; - final fraction = _previewFraction ?? played; - final previewPosition = _previewFraction == null + final fraction = _previewFraction.value ?? played; + final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); - final scrubbing = _previewFraction != null; + final scrubbing = _previewFraction.value != null; return Column( mainAxisSize: MainAxisSize.min, @@ -107,12 +120,12 @@ class _SeekProgressBarState extends State { value: fraction, onChangeStart: enabled ? (value) { - setState(() => _previewFraction = value); + _previewFraction.value = value; widget.onScrubStart?.call(); } : null, onChanged: enabled - ? (value) => setState(() => _previewFraction = value) + ? (value) => _previewFraction.value = value : null, onChangeEnd: enabled ? (value) => unawaited(_finish(value, video.duration))