668 lines
20 KiB
Dart
668 lines
20 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
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 extends ChangeNotifier {
|
|
PlayerLogic({required this.history, required this.pictureInPicture});
|
|
|
|
final WatchHistoryRepository history;
|
|
final AndroidPictureInPicture pictureInPicture;
|
|
|
|
late PageController pages;
|
|
late PlaybackQueue queue;
|
|
late HomeFeedSections homeSections;
|
|
late List<int> visibleIndices;
|
|
HomeFeedCategory category = HomeFeedCategory.recommended;
|
|
int categoryRequest = 0;
|
|
late PlaybackSession session;
|
|
VideoPlayerPort? trackedPlayer;
|
|
Episode? trackedEpisode;
|
|
Drama? trackedDrama;
|
|
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
|
|
Future<void> pendingWrite = Future<void>.value();
|
|
int pageRequest = 0;
|
|
bool muted = false;
|
|
bool stoppedAtFeedEnd = false;
|
|
VideoPlayerPort? scrubbingPlayer;
|
|
bool fullscreen = false;
|
|
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);
|
|
}
|
|
}
|
|
|
|
}
|