feat: 逻辑与页面布局分离
This commit is contained in:
1 parent
b1f6ce4bf9
commit
1e61ae7a0b
11 files changed
+1233
-907
No files matched your search
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.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}) {
|
DiscoverLogic({required this.history, required this.drama}) {
|
||||||
refreshPosition();
|
refreshPosition();
|
||||||
}
|
}
|
||||||
@@ -15,6 +18,11 @@ class DiscoverLogic {
|
|||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
int category = 0;
|
int category = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void refreshPosition() {
|
void refreshPosition() {
|
||||||
position = history.positionFor(
|
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<DiscoverPageAction>? onAction,
|
||||||
|
}) {
|
||||||
|
if (embedded) {
|
||||||
|
onAction?.call(action);
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Drama? dramaFor(String? dramaId, List<Drama> dramas) {
|
||||||
|
for (final item in dramas) {
|
||||||
|
if (item.id == dramaId) return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void openCard(
|
||||||
|
BuildContext context,
|
||||||
|
Drama? selected, {
|
||||||
|
required bool embedded,
|
||||||
|
ValueChanged<Drama>? onDramaSelected,
|
||||||
|
ValueChanged<DiscoverPageAction>? 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<void>(
|
||||||
|
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() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
search.dispose();
|
search.dispose();
|
||||||
searchFocus.dispose();
|
searchFocus.dispose();
|
||||||
scroll.dispose();
|
scroll.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,6 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import 'discover_binding.dart';
|
import 'discover_binding.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
|
|
||||||
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
|
||||||
|
|
||||||
class DiscoverPage extends StatefulWidget {
|
class DiscoverPage extends StatefulWidget {
|
||||||
const DiscoverPage({
|
const DiscoverPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -55,42 +53,15 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant DiscoverPage oldWidget) {
|
void didUpdateWidget(covariant DiscoverPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.active &&
|
logic.syncDrama(
|
||||||
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
|
active: widget.active,
|
||||||
logic.drama = widget.drama;
|
wasActive: oldWidget.active,
|
||||||
setState(logic.refreshPosition);
|
nextDrama: widget.drama,
|
||||||
}
|
dramaChanged: oldWidget.drama.id != widget.drama.id,
|
||||||
if (widget.active && widget.searchRequest != oldWidget.searchRequest) {
|
searchRequest: widget.searchRequest,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
previousSearchRequest: oldWidget.searchRequest,
|
||||||
if (mounted && widget.active) logic.searchFocus.requestFocus();
|
isActive: () => mounted && widget.active,
|
||||||
});
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -99,57 +70,15 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unavailable() {
|
@override
|
||||||
final l10n = AppLocalizations.of(context)!;
|
Widget build(BuildContext context) {
|
||||||
ScaffoldMessenger.of(
|
return ListenableBuilder(
|
||||||
context,
|
listenable: logic,
|
||||||
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
|
builder: (context, _) => _page(context),
|
||||||
}
|
|
||||||
|
|
||||||
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<void>(
|
|
||||||
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 _page(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
@@ -241,7 +170,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => _act(DiscoverPageAction.my),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.my,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
child: const CircleAvatar(
|
child: const CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: Color(0xFF34353B),
|
backgroundColor: Color(0xFF34353B),
|
||||||
@@ -269,7 +203,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
controller: logic.search,
|
controller: logic.search,
|
||||||
focusNode: logic.searchFocus,
|
focusNode: logic.searchFocus,
|
||||||
autofocus: widget.focusSearch,
|
autofocus: widget.focusSearch,
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => logic.onSearchChanged(),
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: l10n.searchDiscover,
|
hintText: l10n.searchDiscover,
|
||||||
@@ -284,7 +218,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.filter,
|
tooltip: l10n.filter,
|
||||||
onPressed: _showFilters,
|
onPressed: () => logic.showFilters(context),
|
||||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -307,7 +241,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
itemBuilder: (context, index) => ChoiceChip(
|
itemBuilder: (context, index) => ChoiceChip(
|
||||||
label: Text(labels[index]),
|
label: Text(labels[index]),
|
||||||
selected: logic.category == index,
|
selected: logic.category == index,
|
||||||
onSelected: (_) => setState(() => logic.category = index),
|
onSelected: (_) => logic.selectCategory(index),
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||||
@@ -413,7 +347,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 9),
|
const SizedBox(width: 9),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _act(DiscoverPageAction.resume),
|
onPressed: () => logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.resume,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFFFFBD5C),
|
backgroundColor: const Color(0xFFFFBD5C),
|
||||||
foregroundColor: const Color(0xFF2A160B),
|
foregroundColor: const Color(0xFF2A160B),
|
||||||
@@ -435,14 +374,23 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
|
|
||||||
Widget _quickActions(AppLocalizations l10n) {
|
Widget _quickActions(AppLocalizations l10n) {
|
||||||
final actions = [
|
final actions = [
|
||||||
(Icons.tune_rounded, l10n.filter, _showFilters),
|
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
|
||||||
(Icons.bar_chart_rounded, l10n.topCharts, _catalogInfo),
|
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
|
||||||
(Icons.auto_awesome_rounded, l10n.newReleases, _catalogInfo),
|
(
|
||||||
|
Icons.auto_awesome_rounded,
|
||||||
|
l10n.newReleases,
|
||||||
|
() => logic.catalogInfo(context),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Icons.bookmark_outline_rounded,
|
Icons.bookmark_outline_rounded,
|
||||||
l10n.watchlist,
|
l10n.watchlist,
|
||||||
() {
|
() {
|
||||||
_act(DiscoverPageAction.watchlist);
|
logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.watchlist,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -509,7 +457,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => logic.category = 0),
|
onPressed: () => logic.selectCategory(0),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.seeAll,
|
l10n.seeAll,
|
||||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
||||||
@@ -519,9 +467,16 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
||||||
final playable = _dramaFor(card) != null;
|
final playable =
|
||||||
|
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
||||||
return InkWell(
|
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),
|
borderRadius: BorderRadius.circular(11),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -609,7 +564,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
_DiscoverNavItem(
|
_DiscoverNavItem(
|
||||||
icon: Icons.play_circle_outline_rounded,
|
icon: Icons.play_circle_outline_rounded,
|
||||||
label: l10n.homeTab,
|
label: l10n.homeTab,
|
||||||
onTap: () => Navigator.pop(context, DiscoverPageAction.home),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.home,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_DiscoverNavItem(
|
_DiscoverNavItem(
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
@@ -620,7 +580,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
_DiscoverNavItem(
|
_DiscoverNavItem(
|
||||||
icon: Icons.video_library_outlined,
|
icon: Icons.video_library_outlined,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
onTap: () => Navigator.pop(context, DiscoverPageAction.my),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.my,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,19 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.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});
|
DramaDetailLogic({required this.drama, required this.history});
|
||||||
|
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
int? continueIndex;
|
int? continueIndex;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadContinue() async {
|
Future<void> loadContinue() async {
|
||||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
try {
|
||||||
if (id == null) return;
|
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||||
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
if (id == null || _disposed) return;
|
||||||
if (index >= 0 && drama.episodes[index].isFree) {
|
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
||||||
continueIndex = index;
|
if (index >= 0 && drama.episodes[index].isFree) {
|
||||||
|
continueIndex = index;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep the first episode available if preferences cannot be read.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openPlayer(BuildContext context, int index) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await loadContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../player/player_view.dart';
|
|
||||||
import 'drama_detail_binding.dart';
|
import 'drama_detail_binding.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
|
|
||||||
@@ -22,29 +21,24 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||||
_loadContinue();
|
logic.loadContinue();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadContinue() async {
|
@override
|
||||||
try {
|
void dispose() {
|
||||||
await logic.loadContinue();
|
logic.dispose();
|
||||||
if (mounted) setState(() {});
|
super.dispose();
|
||||||
} catch (_) {
|
|
||||||
// Keep the first episode available if preferences cannot be read.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openPlayer(BuildContext context, int index) async {
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => PlayerPage(drama: logic.drama, initialIndex: index),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await _loadContinue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => _page(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final drama = logic.drama;
|
final drama = logic.drama;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -87,7 +81,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _openPlayer(context, logic.continueIndex ?? 0),
|
onPressed: () =>
|
||||||
|
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
logic.continueIndex == null
|
logic.continueIndex == null
|
||||||
@@ -124,7 +119,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
title: Text(l10n.episodeLabel(episode.number)),
|
title: Text(l10n.episodeLabel(episode.number)),
|
||||||
subtitle: Text(l10n.freeTestMedia),
|
subtitle: Text(l10n.freeTestMedia),
|
||||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||||
onTap: () => _openPlayer(context, index),
|
onTap: () => logic.openPlayer(context, index),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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<Drama> dramas) : currentDrama = dramas.first;
|
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
||||||
|
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
@@ -16,6 +19,11 @@ class HomeLogic {
|
|||||||
bool drawerOpen = false;
|
bool drawerOpen = false;
|
||||||
bool detailOpen = false;
|
bool detailOpen = false;
|
||||||
bool videoImmersive = false;
|
bool videoImmersive = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void select(
|
void select(
|
||||||
int nextTab, {
|
int nextTab, {
|
||||||
@@ -32,5 +40,105 @@ class HomeLogic {
|
|||||||
sectionRequest++;
|
sectionRequest++;
|
||||||
}
|
}
|
||||||
if (resume) resumeRequest++;
|
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<void> openCurrentSeries(BuildContext context) async {
|
||||||
|
detailOpen = true;
|
||||||
|
notify();
|
||||||
|
scaffoldKey.currentState?.closeDrawer();
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
try {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => DramaDetailPage(drama: currentDrama),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
detailOpen = false;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void openDrama(BuildContext context, Drama drama) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+31
-115
@@ -5,7 +5,6 @@ import '../../core/data/models/drama.dart';
|
|||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../discover/discover_view.dart';
|
import '../discover/discover_view.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
|
||||||
import '../mine/mine_view.dart';
|
import '../mine/mine_view.dart';
|
||||||
import '../player/player_view.dart';
|
import '../player/player_view.dart';
|
||||||
import 'home_binding.dart';
|
import 'home_binding.dart';
|
||||||
@@ -79,163 +78,80 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
logic = HomeBinding().create(widget.dramas);
|
logic = HomeBinding().create(widget.dramas);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _select(
|
@override
|
||||||
int tab, {
|
void dispose() {
|
||||||
bool search = false,
|
logic.dispose();
|
||||||
bool watchlist = false,
|
super.dispose();
|
||||||
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<void> _openCurrentSeries() async {
|
|
||||||
setState(() => logic.detailOpen = true);
|
|
||||||
logic.scaffoldKey.currentState?.closeDrawer();
|
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
|
||||||
if (!mounted) return;
|
|
||||||
try {
|
|
||||||
await Navigator.of(context).push<void>(
|
|
||||||
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<void>(
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => _scaffold(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _scaffold(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
if (logic.drawerOpen) {
|
logic.onSystemBack();
|
||||||
logic.scaffoldKey.currentState?.closeDrawer();
|
|
||||||
} else if (logic.videoImmersive) {
|
|
||||||
setState(() => logic.backRequest++);
|
|
||||||
} else if (logic.tab != 0) {
|
|
||||||
_select(0);
|
|
||||||
} else {
|
|
||||||
setState(() => logic.backRequest++);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: logic.scaffoldKey,
|
key: logic.scaffoldKey,
|
||||||
backgroundColor: const Color(0xFF0B0C10),
|
backgroundColor: const Color(0xFF0B0C10),
|
||||||
drawer: HomeQuickDrawer(
|
drawer: HomeQuickDrawer(
|
||||||
drama: logic.currentDrama,
|
drama: logic.currentDrama,
|
||||||
onOpenSeries: _openCurrentSeries,
|
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||||
onDiscover: () => _selectFromMenu(1),
|
onDiscover: () => logic.selectFromMenu(1),
|
||||||
onHistory: () => _selectFromMenu(2, mySection: 0),
|
onHistory: () => logic.selectFromMenu(2, mySection: 0),
|
||||||
onWatchlist: () => _selectFromMenu(2, mySection: 1),
|
onWatchlist: () => logic.selectFromMenu(2, mySection: 1),
|
||||||
onLiked: () => _selectFromMenu(2, mySection: 2),
|
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||||
),
|
),
|
||||||
drawerEnableOpenDragGesture: false,
|
drawerEnableOpenDragGesture: false,
|
||||||
onDrawerChanged: (opened) {
|
onDrawerChanged: logic.onDrawerChanged,
|
||||||
if (logic.drawerOpen != opened) {
|
|
||||||
setState(() => logic.drawerOpen = opened);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: logic.tab,
|
index: logic.tab,
|
||||||
children: [
|
children: [
|
||||||
PlayerPage(
|
PlayerPage(
|
||||||
drama: widget.dramas.first,
|
drama: widget.dramas.first,
|
||||||
feedDramas: widget.dramas,
|
feedDramas: widget.dramas,
|
||||||
onDramaChanged: (drama) =>
|
onDramaChanged: logic.onDramaChanged,
|
||||||
setState(() => logic.currentDrama = drama),
|
|
||||||
initialIndex: 0,
|
initialIndex: 0,
|
||||||
homeMode: true,
|
homeMode: true,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen,
|
active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen,
|
||||||
resumeRequest: logic.resumeRequest,
|
resumeRequest: logic.resumeRequest,
|
||||||
backRequest: logic.backRequest,
|
backRequest: logic.backRequest,
|
||||||
onDiscover: () => _select(1),
|
onDiscover: () => logic.select(1),
|
||||||
onMy: () => _select(2),
|
onMy: () => logic.select(2),
|
||||||
onSearch: () => _select(1, search: true),
|
onSearch: () => logic.select(1, search: true),
|
||||||
onMenu: _openMenu,
|
onMenu: logic.openMenu,
|
||||||
onImmersiveChanged: (value) =>
|
onImmersiveChanged: logic.onImmersiveChanged,
|
||||||
setState(() => logic.videoImmersive = value),
|
|
||||||
),
|
),
|
||||||
DiscoverPage(
|
DiscoverPage(
|
||||||
drama: logic.currentDrama,
|
drama: logic.currentDrama,
|
||||||
dramas: widget.dramas,
|
dramas: widget.dramas,
|
||||||
onDramaSelected: (drama) => Navigator.of(context).push(
|
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => DramaDetailPage(drama: drama),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
active: logic.tab == 1,
|
active: logic.tab == 1,
|
||||||
searchRequest: logic.searchRequest,
|
searchRequest: logic.searchRequest,
|
||||||
onAction: _discoverAction,
|
onAction: (action) => logic.discoverAction(context, action),
|
||||||
),
|
),
|
||||||
MyPage(
|
MyPage(
|
||||||
drama: logic.currentDrama,
|
drama: logic.currentDrama,
|
||||||
dramas: widget.dramas,
|
dramas: widget.dramas,
|
||||||
onDramaSelected: (drama) => Navigator.of(context).push(
|
onDramaSelected: (drama) => logic.openDrama(context, drama),
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => DramaDetailPage(drama: drama),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
initialSection: logic.mySection,
|
initialSection: logic.mySection,
|
||||||
sectionRequest: logic.sectionRequest,
|
sectionRequest: logic.sectionRequest,
|
||||||
embedded: true,
|
embedded: true,
|
||||||
active: logic.tab == 2,
|
active: logic.tab == 2,
|
||||||
onAction: _myAction,
|
onAction: logic.myAction,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -258,21 +174,21 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
icon: Icons.play_circle_outline_rounded,
|
icon: Icons.play_circle_outline_rounded,
|
||||||
label: l10n.homeTab,
|
label: l10n.homeTab,
|
||||||
selected: logic.tab == 0,
|
selected: logic.tab == 0,
|
||||||
onTap: () => _select(0),
|
onTap: () => logic.select(0),
|
||||||
),
|
),
|
||||||
_TabButton(
|
_TabButton(
|
||||||
key: const Key('tab-discover'),
|
key: const Key('tab-discover'),
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
label: l10n.discoverTab,
|
label: l10n.discoverTab,
|
||||||
selected: logic.tab == 1,
|
selected: logic.tab == 1,
|
||||||
onTap: () => _select(1),
|
onTap: () => logic.select(1),
|
||||||
),
|
),
|
||||||
_TabButton(
|
_TabButton(
|
||||||
key: const Key('tab-my'),
|
key: const Key('tab-my'),
|
||||||
icon: Icons.video_library_outlined,
|
icon: Icons.video_library_outlined,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
selected: logic.tab == 2,
|
selected: logic.tab == 2,
|
||||||
onTap: () => _select(2),
|
onTap: () => logic.select(2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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/models/drama.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.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({
|
MineLogic({
|
||||||
required this.history,
|
required this.history,
|
||||||
required this.drama,
|
required this.drama,
|
||||||
@@ -15,6 +22,11 @@ class MineLogic {
|
|||||||
late Future<Duration?> position;
|
late Future<Duration?> position;
|
||||||
int section;
|
int section;
|
||||||
int filter = 0;
|
int filter = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void refreshPosition() {
|
void refreshPosition() {
|
||||||
position = history.positionFor(
|
position = history.positionFor(
|
||||||
@@ -23,4 +35,82 @@ class MineLogic {
|
|||||||
drama.episodes.first.id,
|
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<MyPageAction>? onAction,
|
||||||
|
}) {
|
||||||
|
if (embedded) {
|
||||||
|
onAction?.call(action);
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showLanguage(BuildContext context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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<LocaleController>().select(code);
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/config/locale_controller.dart';
|
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
@@ -10,8 +9,6 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import 'mine_binding.dart';
|
import 'mine_binding.dart';
|
||||||
import 'mine_logic.dart';
|
import 'mine_logic.dart';
|
||||||
|
|
||||||
enum MyPageAction { home, discover, resume }
|
|
||||||
|
|
||||||
class MyPage extends StatefulWidget {
|
class MyPage extends StatefulWidget {
|
||||||
const MyPage({
|
const MyPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -58,55 +55,32 @@ class _MyPageState extends State<MyPage> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant MyPage oldWidget) {
|
void didUpdateWidget(covariant MyPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.active &&
|
logic.syncPage(
|
||||||
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
|
active: widget.active,
|
||||||
logic.drama = widget.drama;
|
wasActive: oldWidget.active,
|
||||||
setState(logic.refreshPosition);
|
nextDrama: widget.drama,
|
||||||
}
|
dramaChanged: oldWidget.drama.id != widget.drama.id,
|
||||||
if (widget.sectionRequest != oldWidget.sectionRequest) {
|
sectionRequest: widget.sectionRequest,
|
||||||
setState(() => logic.section = widget.initialSection);
|
previousSectionRequest: oldWidget.sectionRequest,
|
||||||
}
|
initialSection: widget.initialSection,
|
||||||
}
|
|
||||||
|
|
||||||
void _act(MyPageAction action) {
|
|
||||||
if (widget.embedded) {
|
|
||||||
widget.onAction?.call(action);
|
|
||||||
} else {
|
|
||||||
Navigator.pop(context, action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showLanguage() async {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
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<LocaleController>().select(code);
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
logic.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => _page(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
@@ -179,7 +153,12 @@ class _MyPageState extends State<MyPage> {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.search,
|
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),
|
icon: const Icon(Icons.search_rounded, size: 23),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
@@ -324,7 +303,12 @@ class _MyPageState extends State<MyPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => _act(MyPageAction.resume),
|
onPressed: () => logic.act(
|
||||||
|
context,
|
||||||
|
MyPageAction.resume,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: _orange,
|
backgroundColor: _orange,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -368,7 +352,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
children: [
|
children: [
|
||||||
for (var index = 0; index < tabs.length; index++) ...[
|
for (var index = 0; index < tabs.length; index++) ...[
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => setState(() => logic.section = index),
|
onTap: () => logic.selectSection(index),
|
||||||
child: Text(
|
child: Text(
|
||||||
tabs[index],
|
tabs[index],
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -397,7 +381,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
child: ChoiceChip(
|
child: ChoiceChip(
|
||||||
label: Text(filters[index]),
|
label: Text(filters[index]),
|
||||||
selected: logic.filter == index,
|
selected: logic.filter == index,
|
||||||
onSelected: (_) => setState(() => logic.filter = index),
|
onSelected: (_) => logic.selectFilter(index),
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
backgroundColor: const Color(0xFF242328),
|
backgroundColor: const Color(0xFF242328),
|
||||||
@@ -418,7 +402,12 @@ class _MyPageState extends State<MyPage> {
|
|||||||
if (logic.section == 0) const SizedBox(height: 11),
|
if (logic.section == 0) const SizedBox(height: 11),
|
||||||
if (logic.section == 0 && hasHistory)
|
if (logic.section == 0 && hasHistory)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => _act(MyPageAction.resume),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
MyPageAction.resume,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(9),
|
padding: const EdgeInsets.all(9),
|
||||||
@@ -499,7 +488,12 @@ class _MyPageState extends State<MyPage> {
|
|||||||
if (widget.onDramaSelected != null) {
|
if (widget.onDramaSelected != null) {
|
||||||
widget.onDramaSelected!(drama);
|
widget.onDramaSelected!(drama);
|
||||||
} else if (drama.id == widget.drama.id) {
|
} 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),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -588,7 +582,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
const Icon(Icons.chevron_right_rounded, size: 18),
|
const Icon(Icons.chevron_right_rounded, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: _showLanguage,
|
onTap: () => logic.showLanguage(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -606,12 +600,22 @@ class _MyPageState extends State<MyPage> {
|
|||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.play_circle_outline_rounded,
|
icon: Icons.play_circle_outline_rounded,
|
||||||
label: l10n.homeTab,
|
label: l10n.homeTab,
|
||||||
onTap: () => Navigator.pop(context, MyPageAction.home),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
MyPageAction.home,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
label: l10n.discoverTab,
|
label: l10n.discoverTab,
|
||||||
onTap: () => Navigator.pop(context, MyPageAction.discover),
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
MyPageAction.discover,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: Icons.video_library_outlined,
|
icon: Icons.video_library_outlined,
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/android_picture_in_picture.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_queue.dart';
|
||||||
import '../../core/data/services/playback_session.dart';
|
import '../../core/data/services/playback_session.dart';
|
||||||
import '../../core/data/services/video_player_port.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 '../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});
|
PlayerLogic({required this.history, required this.pictureInPicture});
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
@@ -34,5 +48,618 @@ class PlayerLogic {
|
|||||||
bool pip = false;
|
bool pip = false;
|
||||||
bool pipPreparing = 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<Drama>? feedDramas;
|
||||||
|
VoidCallback? onDiscover;
|
||||||
|
VoidCallback? onMy;
|
||||||
|
VoidCallback? onSearch;
|
||||||
|
VoidCallback? onMenu;
|
||||||
|
ValueChanged<Drama>? onDramaChanged;
|
||||||
|
ValueChanged<bool>? onImmersiveChanged;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
bool get immersive => fullscreen || pip || pipPreparing;
|
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<int>.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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> leave(BuildContext context) async {
|
||||||
|
await saveCurrentProgress();
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openDetail(BuildContext context) async {
|
||||||
|
session.setRouteVisible(false);
|
||||||
|
await session.playbackSettled;
|
||||||
|
await saveCurrentProgress();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) =>
|
||||||
|
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (isMounted()) session.setRouteVisible(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void>(
|
||||||
|
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<void> 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<MyPageAction>(
|
||||||
|
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<void> showSearch(BuildContext context) async {
|
||||||
|
if (embedded) {
|
||||||
|
onSearch?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await showDiscover(context, focusSearch: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<DiscoverPageAction>(
|
||||||
|
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<void> 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<void> showComments(BuildContext context, Drama drama) async {
|
||||||
|
session.setOverlayOpen(true);
|
||||||
|
await session.playbackSettled;
|
||||||
|
if (!context.mounted) {
|
||||||
|
session.setOverlayOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: const Color(0xFF232328),
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => DramaCommentsSheet(drama: drama),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
session.setOverlayOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,24 +6,16 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.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/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/playback_session.dart';
|
||||||
import '../../core/data/services/video_player_port.dart';
|
import '../../core/data/services/video_player_port.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
import '../../l10n/app_localizations.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/home_feed_sections.dart';
|
||||||
import '../home/widgets/category_swipe_region.dart';
|
import '../home/widgets/category_swipe_region.dart';
|
||||||
import '../home/widgets/home_feed_header.dart';
|
import '../home/widgets/home_feed_header.dart';
|
||||||
import '../mine/mine_view.dart';
|
|
||||||
import 'player_binding.dart';
|
import 'player_binding.dart';
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
import 'widgets/drama_comments_sheet.dart';
|
|
||||||
import 'widgets/seek_progress_bar.dart';
|
import 'widgets/seek_progress_bar.dart';
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
@@ -67,582 +59,46 @@ class PlayerPage extends StatefulWidget {
|
|||||||
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||||
late final PlayerLogic logic;
|
late final PlayerLogic logic;
|
||||||
|
|
||||||
void _notifyImmersive() => widget.onImmersiveChanged?.call(logic.immersive);
|
|
||||||
|
|
||||||
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _leave() async {
|
|
||||||
await _saveCurrentProgress();
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
logic = PlayerBinding().create();
|
logic = PlayerBinding().create();
|
||||||
|
logic.attach(widget, isMounted: () => mounted);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
logic.pictureInPicture.setStateListener(_onPipChanged);
|
logic.start(
|
||||||
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<int>.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],
|
|
||||||
factory: context.read<PlayerFactory>(),
|
factory: context.read<PlayerFactory>(),
|
||||||
canPlay: (episode) => episode.isFree,
|
|
||||||
prepare: _preparePlayer,
|
|
||||||
activate: _activatePlayer,
|
|
||||||
coordinator: context.read<PlaybackCoordinator>(),
|
coordinator: context.read<PlaybackCoordinator>(),
|
||||||
);
|
);
|
||||||
if (!widget.active || logic.visibleIndices.isEmpty) {
|
|
||||||
logic.session.setRouteVisible(false);
|
|
||||||
}
|
|
||||||
unawaited(_selectEpisode(widget.initialIndex));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PlayerPage oldWidget) {
|
void didUpdateWidget(covariant PlayerPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.active != widget.active) {
|
logic.attach(widget, isMounted: () => mounted);
|
||||||
logic.session.setRouteVisible(widget.active && logic.visibleIndices.isNotEmpty);
|
logic.onPageUpdated(oldWidget);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state != AppLifecycleState.resumed) unawaited(_saveCurrentProgress());
|
logic.onLifecycle(state);
|
||||||
logic.session.setAppResumed(
|
|
||||||
state == AppLifecycleState.resumed || logic.pip || logic.pipPreparing,
|
|
||||||
);
|
|
||||||
if (state != AppLifecycleState.resumed) _cancelScrub();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
logic.pictureInPicture.setStateListener(null);
|
logic.dispose();
|
||||||
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();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDetail() async {
|
|
||||||
logic.session.setRouteVisible(false);
|
|
||||||
await logic.session.playbackSettled;
|
|
||||||
await _saveCurrentProgress();
|
|
||||||
if (!mounted) return;
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) =>
|
|
||||||
DramaDetailPage(drama: logic.queue.entryAt(logic.session.currentIndex).drama),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (mounted) logic.session.setRouteVisible(widget.active);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<void>(
|
|
||||||
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<void> _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<MyPageAction>(
|
|
||||||
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<void> _showSearch() async {
|
|
||||||
if (widget.embedded) {
|
|
||||||
widget.onSearch?.call();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _showDiscover(focusSearch: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<DiscoverPageAction>(
|
|
||||||
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<void> _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<void> _showComments(Drama drama) async {
|
|
||||||
logic.session.setOverlayOpen(true);
|
|
||||||
await logic.session.playbackSettled;
|
|
||||||
if (!mounted) {
|
|
||||||
logic.session.setOverlayOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: const Color(0xFF232328),
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (_) => DramaCommentsSheet(drama: drama),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
logic.session.setOverlayOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => _buildPlayer(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlayer(BuildContext context) {
|
||||||
final engagement = context.watch<EngagementController>();
|
final engagement = context.watch<EngagementController>();
|
||||||
final category = logic.category;
|
final category = logic.category;
|
||||||
final indices = logic.visibleIndices;
|
final indices = logic.visibleIndices;
|
||||||
@@ -661,7 +117,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
if (category != logic.category) return;
|
if (category != logic.category) return;
|
||||||
final index = indices[position];
|
final index = indices[position];
|
||||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||||
unawaited(_selectEpisode(index));
|
unawaited(logic.selectEpisode(index));
|
||||||
},
|
},
|
||||||
itemBuilder: (context, position) =>
|
itemBuilder: (context, position) =>
|
||||||
_buildEpisode(context, indices[position], engagement),
|
_buildEpisode(context, indices[position], engagement),
|
||||||
@@ -676,13 +132,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
: Scaffold(backgroundColor: Colors.black, body: content),
|
: Scaffold(backgroundColor: Colors.black, body: content),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyCategory(BuildContext context) {
|
Widget _buildEmptyCategory(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return CategorySwipeRegion(
|
return CategorySwipeRegion(
|
||||||
key: const Key('home-empty-swipe'),
|
key: const Key('home-empty-swipe'),
|
||||||
onShift: _shiftCategory,
|
onShift: logic.shiftCategory,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: const Color(0xFF111214),
|
color: const Color(0xFF111214),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -692,10 +148,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
category: logic.category,
|
category: logic.category,
|
||||||
onCategorySelected: (value) =>
|
onCategorySelected: (value) =>
|
||||||
unawaited(_selectCategory(value)),
|
unawaited(logic.selectCategory(value)),
|
||||||
onCategoryShift: _shiftCategory,
|
onCategoryShift: logic.shiftCategory,
|
||||||
onSearch: _showSearch,
|
onSearch: () => logic.showSearch(context),
|
||||||
onMy: _showMy,
|
onMy: () => logic.showMy(context),
|
||||||
onMenu: widget.onMenu,
|
onMenu: widget.onMenu,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -761,7 +217,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
if (widget.homeMode) {
|
if (widget.homeMode) {
|
||||||
video = CategorySwipeRegion(
|
video = CategorySwipeRegion(
|
||||||
key: selected ? const Key('home-video-swipe') : null,
|
key: selected ? const Key('home-video-swipe') : null,
|
||||||
onShift: _shiftCategory,
|
onShift: logic.shiftCategory,
|
||||||
child: video,
|
child: video,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -773,7 +229,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
if (widget.homeMode && !ready && !logic.immersive)
|
if (widget.homeMode && !ready && !logic.immersive)
|
||||||
CategorySwipeRegion(
|
CategorySwipeRegion(
|
||||||
key: selected ? const Key('home-video-swipe') : null,
|
key: selected ? const Key('home-video-swipe') : null,
|
||||||
onShift: _shiftCategory,
|
onShift: logic.shiftCategory,
|
||||||
child: const ColoredBox(color: Colors.black),
|
child: const ColoredBox(color: Colors.black),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -815,12 +271,12 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
_FullscreenControls(
|
_FullscreenControls(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
controller: ready ? videoPort!.controller : null,
|
controller: ready ? videoPort!.controller : null,
|
||||||
onClose: () => unawaited(_setFullscreen(false)),
|
onClose: () => unawaited(logic.setFullscreen(false)),
|
||||||
onPip: logic.pictureInPicture.isPlatformSupported
|
onPip: logic.pictureInPicture.isPlatformSupported
|
||||||
? () => unawaited(_enterPip())
|
? () => unawaited(logic.enterPip(context))
|
||||||
: null,
|
: null,
|
||||||
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||||
onSeek: ready ? (position) => _seekTo(videoPort!, position) : null,
|
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null,
|
||||||
)
|
)
|
||||||
else if (widget.homeMode)
|
else if (widget.homeMode)
|
||||||
_StitchHomeOverlay(
|
_StitchHomeOverlay(
|
||||||
@@ -832,25 +288,25 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
saved: engagement.isSaved(drama.id),
|
saved: engagement.isSaved(drama.id),
|
||||||
muted: logic.muted,
|
muted: logic.muted,
|
||||||
category: logic.category,
|
category: logic.category,
|
||||||
onCategorySelected: (value) => unawaited(_selectCategory(value)),
|
onCategorySelected: (value) => unawaited(logic.selectCategory(value)),
|
||||||
onCategoryShift: _shiftCategory,
|
onCategoryShift: logic.shiftCategory,
|
||||||
onDetail: _openDetail,
|
onDetail: () => logic.openDetail(context),
|
||||||
onDiscover: _showDiscover,
|
onDiscover: () => logic.showDiscover(context),
|
||||||
onEpisodes: _showEpisodes,
|
onEpisodes: () => logic.showEpisodes(context),
|
||||||
onMy: _showMy,
|
onMy: () => logic.showMy(context),
|
||||||
onSearch: _showSearch,
|
onSearch: () => logic.showSearch(context),
|
||||||
onMenu: widget.onMenu,
|
onMenu: widget.onMenu,
|
||||||
onMute: _toggleMute,
|
onMute: logic.toggleMute,
|
||||||
onLike: () => engagement.toggleLiked(drama.id),
|
onLike: () => engagement.toggleLiked(drama.id),
|
||||||
onSave: () => engagement.toggleSaved(drama.id),
|
onSave: () => engagement.toggleSaved(drama.id),
|
||||||
onComments: () => _showComments(drama),
|
onComments: () => logic.showComments(context, drama),
|
||||||
onShare: () => _shareDrama(drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onFullscreen: () => unawaited(_setFullscreen(true)),
|
onFullscreen: () => unawaited(logic.setFullscreen(true)),
|
||||||
onPip: logic.pictureInPicture.isPlatformSupported
|
onPip: logic.pictureInPicture.isPlatformSupported
|
||||||
? () => unawaited(_enterPip())
|
? () => unawaited(logic.enterPip(context))
|
||||||
: null,
|
: null,
|
||||||
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||||
onSeek: ready ? (position) => _seekTo(videoPort!, position) : null,
|
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null,
|
||||||
showBottomNav: !widget.embedded,
|
showBottomNav: !widget.embedded,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -864,7 +320,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
if (!widget.homeMode)
|
if (!widget.homeMode)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => unawaited(_leave()),
|
onPressed: () => unawaited(logic.leave(context)),
|
||||||
tooltip: l10n.backToSeries,
|
tooltip: l10n.backToSeries,
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
)
|
)
|
||||||
@@ -886,7 +342,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.language,
|
tooltip: l10n.language,
|
||||||
onPressed: _showMy,
|
onPressed: () => logic.showMy(context),
|
||||||
icon: const Icon(Icons.language_rounded),
|
icon: const Icon(Icons.language_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -906,7 +362,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 22),
|
const SizedBox(width: 22),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _openDetail,
|
onPressed: () => logic.openDetail(context),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.discoverTab,
|
l10n.discoverTab,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -922,7 +378,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () => unawaited(_selectEpisode(index)),
|
onPressed: () => unawaited(logic.selectEpisode(index)),
|
||||||
child: Text('${l10n.playbackError} ${l10n.retry}'),
|
child: Text('${l10n.playbackError} ${l10n.retry}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -968,7 +424,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: l10n.exploreSeries,
|
tooltip: l10n.exploreSeries,
|
||||||
onPressed: _openDetail,
|
onPressed: () => logic.openDetail(context),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.black45,
|
backgroundColor: Colors.black45,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -978,7 +434,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: l10n.episodes,
|
tooltip: l10n.episodes,
|
||||||
onPressed: _showEpisodes,
|
onPressed: () => logic.showEpisodes(context),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.black45,
|
backgroundColor: Colors.black45,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -1011,7 +467,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _showEpisodes,
|
onPressed: () => logic.showEpisodes(context),
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
'${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}',
|
'${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}',
|
||||||
@@ -1022,9 +478,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
valueListenable: ready ? videoPort!.controller : null,
|
valueListenable: ready ? videoPort!.controller : null,
|
||||||
semanticsLabel: l10n.seekVideo,
|
semanticsLabel: l10n.seekVideo,
|
||||||
showTimes: false,
|
showTimes: false,
|
||||||
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => _seekTo(videoPort!, position)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
if (widget.homeMode) ...[
|
if (widget.homeMode) ...[
|
||||||
@@ -1041,12 +497,12 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
_BottomAction(
|
_BottomAction(
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.explore_outlined,
|
||||||
label: l10n.discoverTab,
|
label: l10n.discoverTab,
|
||||||
onTap: _showDiscover,
|
onTap: () => logic.showDiscover(context),
|
||||||
),
|
),
|
||||||
_BottomAction(
|
_BottomAction(
|
||||||
icon: Icons.person_outline_rounded,
|
icon: Icons.person_outline_rounded,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
onTap: _showMy,
|
onTap: () => logic.showMy(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,24 +37,30 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SeekProgressBarState extends State<SeekProgressBar> {
|
class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||||
double? _previewFraction;
|
final _previewFraction = ValueNotifier<double?>(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
|
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.valueListenable != widget.valueListenable) {
|
if (oldWidget.valueListenable != widget.valueListenable) {
|
||||||
_previewFraction = null;
|
_previewFraction.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_previewFraction.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _finish(double fraction, Duration duration) async {
|
Future<void> _finish(double fraction, Duration duration) async {
|
||||||
final source = widget.valueListenable;
|
final source = widget.valueListenable;
|
||||||
setState(() => _previewFraction = fraction);
|
_previewFraction.value = fraction;
|
||||||
try {
|
try {
|
||||||
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted && widget.valueListenable == source) {
|
if (mounted && widget.valueListenable == source) {
|
||||||
setState(() => _previewFraction = null);
|
_previewFraction.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,11 +69,18 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final source = widget.valueListenable;
|
final source = widget.valueListenable;
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
return _buildTrack(context, const VideoPlayerValue.uninitialized());
|
return ValueListenableBuilder<double?>(
|
||||||
|
valueListenable: _previewFraction,
|
||||||
|
builder: (context, _, _) =>
|
||||||
|
_buildTrack(context, const VideoPlayerValue.uninitialized()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ValueListenableBuilder<VideoPlayerValue>(
|
return ValueListenableBuilder<VideoPlayerValue>(
|
||||||
valueListenable: source,
|
valueListenable: source,
|
||||||
builder: (context, value, _) => _buildTrack(context, value),
|
builder: (context, value, _) => ValueListenableBuilder<double?>(
|
||||||
|
valueListenable: _previewFraction,
|
||||||
|
builder: (context, _, _) => _buildTrack(context, value),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +90,11 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
final played = total > 0
|
final played = total > 0
|
||||||
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
|
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
final fraction = _previewFraction ?? played;
|
final fraction = _previewFraction.value ?? played;
|
||||||
final previewPosition = _previewFraction == null
|
final previewPosition = _previewFraction.value == null
|
||||||
? video.position
|
? video.position
|
||||||
: seekPositionForFraction(fraction, video.duration);
|
: seekPositionForFraction(fraction, video.duration);
|
||||||
final scrubbing = _previewFraction != null;
|
final scrubbing = _previewFraction.value != null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -107,12 +120,12 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
value: fraction,
|
value: fraction,
|
||||||
onChangeStart: enabled
|
onChangeStart: enabled
|
||||||
? (value) {
|
? (value) {
|
||||||
setState(() => _previewFraction = value);
|
_previewFraction.value = value;
|
||||||
widget.onScrubStart?.call();
|
widget.onScrubStart?.call();
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (value) => setState(() => _previewFraction = value)
|
? (value) => _previewFraction.value = value
|
||||||
: null,
|
: null,
|
||||||
onChangeEnd: enabled
|
onChangeEnd: enabled
|
||||||
? (value) => unawaited(_finish(value, video.duration))
|
? (value) => unawaited(_finish(value, video.duration))
|
||||||
|
|||||||
Reference in new issue
Block a user