feat: 逻辑与页面布局分离

This commit is contained in:
Vicky committed 2026-10-13 16:52:14 +08:00
1 parent b1f6ce4bf9
commit 1e61ae7a0b
11 files changed
+1233 -907

No files matched your search

+628 -1
View File
@@ -1,14 +1,28 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/localized_content.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../home/home_feed_sections.dart';
import '../mine/mine_logic.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
class PlayerLogic {
class PlayerLogic extends ChangeNotifier {
PlayerLogic({required this.history, required this.pictureInPicture});
final WatchHistoryRepository history;
@@ -34,5 +48,618 @@ class PlayerLogic {
bool pip = false;
bool pipPreparing = false;
bool Function() isMounted = () => false;
bool homeMode = false;
bool embedded = false;
bool active = true;
int resumeRequest = 0;
int backRequest = 0;
int initialIndex = 0;
late Drama pageDrama;
List<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;
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);
}
}
}
+51 -595
View File
@@ -6,24 +6,16 @@ import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/localized_content.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../home/home_feed_sections.dart';
import '../home/widgets/category_swipe_region.dart';
import '../home/widgets/home_feed_header.dart';
import '../mine/mine_view.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/drama_comments_sheet.dart';
import 'widgets/seek_progress_bar.dart';
class PlayerPage extends StatefulWidget {
@@ -67,582 +59,46 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
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
void initState() {
super.initState();
logic = PlayerBinding().create();
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.pictureInPicture.setStateListener(_onPipChanged);
logic.queue = widget.feedDramas == null
? PlaybackQueue.series(widget.drama)
: PlaybackQueue.home(widget.feedDramas!);
logic.homeSections = HomeFeedSections([
for (final entry in logic.queue.entries) entry.drama,
]);
logic.visibleIndices = widget.homeMode
? logic.homeSections.indicesFor(logic.category)
: List<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],
logic.start(
factory: context.read<PlayerFactory>(),
canPlay: (episode) => episode.isFree,
prepare: _preparePlayer,
activate: _activatePlayer,
coordinator: context.read<PlaybackCoordinator>(),
);
if (!widget.active || logic.visibleIndices.isEmpty) {
logic.session.setRouteVisible(false);
}
unawaited(_selectEpisode(widget.initialIndex));
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.active != widget.active) {
logic.session.setRouteVisible(widget.active && logic.visibleIndices.isNotEmpty);
if (!widget.active) {
_cancelScrub();
unawaited(_saveCurrentProgress());
}
}
if (oldWidget.resumeRequest != widget.resumeRequest) {
logic.session.setUserWantsPlay(true);
}
if (oldWidget.backRequest != widget.backRequest) {
final request = widget.backRequest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.backRequest == request) {
if (logic.fullscreen) {
unawaited(_setFullscreen(false));
} else {
_shiftCategory(-1);
}
}
});
}
logic.attach(widget, isMounted: () => mounted);
logic.onPageUpdated(oldWidget);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(_saveCurrentProgress());
logic.session.setAppResumed(
state == AppLifecycleState.resumed || logic.pip || logic.pipPreparing,
);
if (state != AppLifecycleState.resumed) _cancelScrub();
logic.onLifecycle(state);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.pictureInPicture.setStateListener(null);
if (logic.fullscreen) {
unawaited(
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
);
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(_saveCurrentProgress());
logic.session.setRouteVisible(false);
_cancelScrub();
unawaited(logic.session.close());
logic.pages.dispose();
logic.dispose();
super.dispose();
}
Future<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
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _buildPlayer(context),
);
}
Widget _buildPlayer(BuildContext context) {
final engagement = context.watch<EngagementController>();
final category = logic.category;
final indices = logic.visibleIndices;
@@ -661,7 +117,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
if (category != logic.category) return;
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(_selectEpisode(index));
unawaited(logic.selectEpisode(index));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], engagement),
@@ -676,13 +132,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
: Scaffold(backgroundColor: Colors.black, body: content),
),
);
}
}
Widget _buildEmptyCategory(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CategorySwipeRegion(
key: const Key('home-empty-swipe'),
onShift: _shiftCategory,
onShift: logic.shiftCategory,
child: ColoredBox(
color: const Color(0xFF111214),
child: SafeArea(
@@ -692,10 +148,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
l10n: l10n,
category: logic.category,
onCategorySelected: (value) =>
unawaited(_selectCategory(value)),
onCategoryShift: _shiftCategory,
onSearch: _showSearch,
onMy: _showMy,
unawaited(logic.selectCategory(value)),
onCategoryShift: logic.shiftCategory,
onSearch: () => logic.showSearch(context),
onMy: () => logic.showMy(context),
onMenu: widget.onMenu,
),
const Spacer(),
@@ -761,7 +217,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
if (widget.homeMode) {
video = CategorySwipeRegion(
key: selected ? const Key('home-video-swipe') : null,
onShift: _shiftCategory,
onShift: logic.shiftCategory,
child: video,
);
}
@@ -773,7 +229,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
if (widget.homeMode && !ready && !logic.immersive)
CategorySwipeRegion(
key: selected ? const Key('home-video-swipe') : null,
onShift: _shiftCategory,
onShift: logic.shiftCategory,
child: const ColoredBox(color: Colors.black),
)
else
@@ -815,12 +271,12 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
_FullscreenControls(
l10n: l10n,
controller: ready ? videoPort!.controller : null,
onClose: () => unawaited(_setFullscreen(false)),
onClose: () => unawaited(logic.setFullscreen(false)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(_enterPip())
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
onSeek: ready ? (position) => _seekTo(videoPort!, position) : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null,
)
else if (widget.homeMode)
_StitchHomeOverlay(
@@ -832,25 +288,25 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
saved: engagement.isSaved(drama.id),
muted: logic.muted,
category: logic.category,
onCategorySelected: (value) => unawaited(_selectCategory(value)),
onCategoryShift: _shiftCategory,
onDetail: _openDetail,
onDiscover: _showDiscover,
onEpisodes: _showEpisodes,
onMy: _showMy,
onSearch: _showSearch,
onCategorySelected: (value) => unawaited(logic.selectCategory(value)),
onCategoryShift: logic.shiftCategory,
onDetail: () => logic.openDetail(context),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context),
onSearch: () => logic.showSearch(context),
onMenu: widget.onMenu,
onMute: _toggleMute,
onMute: logic.toggleMute,
onLike: () => engagement.toggleLiked(drama.id),
onSave: () => engagement.toggleSaved(drama.id),
onComments: () => _showComments(drama),
onShare: () => _shareDrama(drama),
onFullscreen: () => unawaited(_setFullscreen(true)),
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onFullscreen: () => unawaited(logic.setFullscreen(true)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(_enterPip())
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
onSeek: ready ? (position) => _seekTo(videoPort!, position) : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null,
showBottomNav: !widget.embedded,
)
else
@@ -864,7 +320,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
children: [
if (!widget.homeMode)
IconButton(
onPressed: () => unawaited(_leave()),
onPressed: () => unawaited(logic.leave(context)),
tooltip: l10n.backToSeries,
icon: const Icon(Icons.arrow_back_rounded),
)
@@ -886,7 +342,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
IconButton(
tooltip: l10n.language,
onPressed: _showMy,
onPressed: () => logic.showMy(context),
icon: const Icon(Icons.language_rounded),
),
],
@@ -906,7 +362,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
const SizedBox(width: 22),
TextButton(
onPressed: _openDetail,
onPressed: () => logic.openDetail(context),
child: Text(
l10n.discoverTab,
style: const TextStyle(
@@ -922,7 +378,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FilledButton(
onPressed: () => unawaited(_selectEpisode(index)),
onPressed: () => unawaited(logic.selectEpisode(index)),
child: Text('${l10n.playbackError} ${l10n.retry}'),
),
),
@@ -968,7 +424,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
children: [
IconButton.filledTonal(
tooltip: l10n.exploreSeries,
onPressed: _openDetail,
onPressed: () => logic.openDetail(context),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
@@ -978,7 +434,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
const SizedBox(height: 8),
IconButton.filledTonal(
tooltip: l10n.episodes,
onPressed: _showEpisodes,
onPressed: () => logic.showEpisodes(context),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
@@ -1011,7 +467,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
const SizedBox(height: 18),
TextButton.icon(
onPressed: _showEpisodes,
onPressed: () => logic.showEpisodes(context),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
'${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}',
@@ -1022,9 +478,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
valueListenable: ready ? videoPort!.controller : null,
semanticsLabel: l10n.seekVideo,
showTimes: false,
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => _seekTo(videoPort!, position)
? (position) => logic.seekTo(context, videoPort!, position)
: null,
),
if (widget.homeMode) ...[
@@ -1041,12 +497,12 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
_BottomAction(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: _showDiscover,
onTap: () => logic.showDiscover(context),
),
_BottomAction(
icon: Icons.person_outline_rounded,
label: l10n.myTab,
onTap: _showMy,
onTap: () => logic.showMy(context),
),
],
),
+24 -11
View File
@@ -37,24 +37,30 @@ class SeekProgressBar extends StatefulWidget {
}
class _SeekProgressBarState extends State<SeekProgressBar> {
double? _previewFraction;
final _previewFraction = ValueNotifier<double?>(null);
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_previewFraction = null;
_previewFraction.value = null;
}
}
@override
void dispose() {
_previewFraction.dispose();
super.dispose();
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
setState(() => _previewFraction = fraction);
_previewFraction.value = fraction;
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
setState(() => _previewFraction = null);
_previewFraction.value = null;
}
}
}
@@ -63,11 +69,18 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
Widget build(BuildContext context) {
final source = widget.valueListenable;
if (source == null) {
return _buildTrack(context, const VideoPlayerValue.uninitialized());
return ValueListenableBuilder<double?>(
valueListenable: _previewFraction,
builder: (context, _, _) =>
_buildTrack(context, const VideoPlayerValue.uninitialized()),
);
}
return ValueListenableBuilder<VideoPlayerValue>(
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
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
final fraction = _previewFraction ?? played;
final previewPosition = _previewFraction == null
final fraction = _previewFraction.value ?? played;
final previewPosition = _previewFraction.value == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction != null;
final scrubbing = _previewFraction.value != null;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -107,12 +120,12 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
value: fraction,
onChangeStart: enabled
? (value) {
setState(() => _previewFraction = value);
_previewFraction.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => setState(() => _previewFraction = value)
? (value) => _previewFraction.value = value
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))