876 lines
27 KiB
Dart
876 lines
27 KiB
Dart
// Author: fruit.com.cn
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../../core/data/models/drama.dart';
|
||
import '../../core/data/models/play_ticket.dart';
|
||
import '../../core/data/models/watch_progress.dart';
|
||
import '../../core/data/models/player_port.dart';
|
||
import '../../core/data/remote/api_client.dart';
|
||
import '../../core/data/repositories/playback_repository.dart';
|
||
import '../../core/data/repositories/watch_history_repository.dart';
|
||
import '../../core/data/services/android_picture_in_picture.dart';
|
||
import '../../core/data/services/catalog_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 '../../core/theme/app_colors.dart';
|
||
import '../../l10n/app_localizations.dart';
|
||
import '../discover/discover_logic.dart';
|
||
import '../discover/discover_view.dart';
|
||
import '../drama_detail/drama_detail_view.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.playback,
|
||
required this.pictureInPicture,
|
||
});
|
||
|
||
final WatchHistoryRepository history;
|
||
final PlaybackRepository playback;
|
||
final AndroidPictureInPicture pictureInPicture;
|
||
|
||
late PageController pages;
|
||
late PlaybackQueue queue;
|
||
late List<int> visibleIndices;
|
||
late PlaybackSession session;
|
||
VideoPlayerPort? trackedPlayer;
|
||
Episode? trackedEpisode;
|
||
Drama? trackedDrama;
|
||
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
|
||
|
||
/// 进度上报节流:契约建议每 10 秒一次
|
||
Duration progressInterval = const Duration(seconds: 10);
|
||
Future<void> pendingWrite = Future<void>.value();
|
||
|
||
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
|
||
ResumePoint? resumePoint;
|
||
bool resumeLoaded = false;
|
||
|
||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||
bool ticketLoading = false;
|
||
String? ticketError;
|
||
PlayTicket? blockedTicket;
|
||
String? blockedEpisodeId;
|
||
List<Episode> sessionEpisodes = const [];
|
||
int pageRequest = 0;
|
||
bool muted = false;
|
||
bool stoppedAtFeedEnd = false;
|
||
VideoPlayerPort? scrubbingPlayer;
|
||
bool fullscreen = false;
|
||
bool pip = false;
|
||
bool pipPreparing = false;
|
||
bool screenCleared = false;
|
||
final Map<String, int> pickedEpisode = {};
|
||
|
||
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;
|
||
|
||
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||
bool _started = false;
|
||
|
||
bool get immersive => fullscreen || pip || pipPreparing;
|
||
|
||
bool get chromeHidden => immersive || screenCleared;
|
||
|
||
Episode displayEpisode(Drama drama, Episode fallback) {
|
||
final number = pickedEpisode[drama.id];
|
||
if (number == null) return fallback;
|
||
for (final episode in drama.episodes) {
|
||
if (episode.number == number) return episode;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
void setScreenCleared(bool value) {
|
||
if (screenCleared == value) return;
|
||
screenCleared = value;
|
||
notify();
|
||
notifyImmersive();
|
||
}
|
||
|
||
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,
|
||
}) {
|
||
_started = true;
|
||
pictureInPicture.setStateListener(onPipChanged);
|
||
queue = feedDramas == null
|
||
? PlaybackQueue.series(pageDrama)
|
||
: PlaybackQueue.home(feedDramas!);
|
||
visibleIndices = List<int>.generate(queue.length, (index) => index);
|
||
pages = PageController(initialPage: initialIndex);
|
||
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
|
||
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
|
||
session = PlaybackSession(
|
||
episodes: sessionEpisodes,
|
||
factory: factory,
|
||
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
|
||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||
prepare: preparePlayer,
|
||
activate: activatePlayer,
|
||
coordinator: coordinator,
|
||
);
|
||
if (!active || visibleIndices.isEmpty) {
|
||
session.setRouteVisible(false);
|
||
}
|
||
unawaited(loadResumePoint());
|
||
unawaited(selectEpisode(initialIndex));
|
||
}
|
||
|
||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||
Future<void> loadResumePoint() async {
|
||
final dramaId = queue.entryAt(initialIndex).drama.id;
|
||
try {
|
||
resumePoint = await history.resumePointFor(dramaId);
|
||
} catch (_) {
|
||
// 拿不到续看位置不影响播放:从当前集开头看
|
||
resumePoint = null;
|
||
} finally {
|
||
resumeLoaded = true;
|
||
if (isMounted()) notify();
|
||
}
|
||
}
|
||
|
||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
|
||
Duration? resumePositionFor(String episodeId) {
|
||
final point = resumePoint;
|
||
if (point == null || point.episodeId != episodeId) return null;
|
||
if (point.position <= Duration.zero) return null;
|
||
return point.position;
|
||
}
|
||
|
||
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
|
||
///
|
||
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
|
||
bool shouldReportProgress(DateTime now) {
|
||
if (now.difference(lastProgressWrite) < progressInterval) return false;
|
||
lastProgressWrite = now;
|
||
return true;
|
||
}
|
||
|
||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
|
||
Future<bool> ensureTicket(int index) async {
|
||
if (index < 0 || index >= sessionEpisodes.length) return false;
|
||
final episode = sessionEpisodes[index];
|
||
final request = pageRequest;
|
||
ticketLoading = true;
|
||
ticketError = null;
|
||
notify();
|
||
try {
|
||
final ticket = await playback.ticketFor(episode.id);
|
||
if (request != pageRequest) return false;
|
||
blockedTicket = null;
|
||
blockedEpisodeId = null;
|
||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||
return true;
|
||
}
|
||
blockedTicket = ticket;
|
||
blockedEpisodeId = episode.id;
|
||
return false;
|
||
} catch (exception) {
|
||
if (request != pageRequest) return false;
|
||
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
|
||
if (episode.videoUrl.isNotEmpty) {
|
||
ticketError = null;
|
||
return true;
|
||
}
|
||
ticketError = exception is ApiException
|
||
? exception.message
|
||
: 'ticketError';
|
||
return false;
|
||
} finally {
|
||
ticketLoading = false;
|
||
if (isMounted()) notify();
|
||
}
|
||
}
|
||
|
||
/// 重试当前集的票据(失败态的"重试"按钮)。
|
||
Future<void> retryTicket() async {
|
||
ticketError = null;
|
||
notify();
|
||
unawaited(selectEpisode(session.currentIndex));
|
||
}
|
||
|
||
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 if (screenCleared) {
|
||
setScreenCleared(false);
|
||
} else {
|
||
onMenu?.call();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
void onLifecycle(AppLifecycleState state) {
|
||
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
|
||
if (state == AppLifecycleState.resumed) {
|
||
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
|
||
// 否则会撞上 403(契约 §1.4)
|
||
final episode = trackedEpisode;
|
||
if (episode != null && !playback.isFresh(episode.id)) {
|
||
unawaited(selectEpisode(session.currentIndex));
|
||
}
|
||
}
|
||
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());
|
||
if (_started) {
|
||
session.setRouteVisible(false);
|
||
cancelScrub();
|
||
unawaited(session.close());
|
||
pages.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||
|
||
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;
|
||
try {
|
||
await pendingWrite;
|
||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||
if (resumePoint == null) await loadResumePoint();
|
||
final saved = resumePositionFor(episode.id);
|
||
if (saved != null) {
|
||
await player.controller.seekTo(
|
||
clampResumePosition(saved, player.controller.value.duration),
|
||
);
|
||
}
|
||
} catch (_) {
|
||
// 续看位置取不到不影响播放:从头播。
|
||
}
|
||
}
|
||
|
||
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,
|
||
duration: player.controller.value.duration,
|
||
finished: true,
|
||
),
|
||
);
|
||
}
|
||
if (!completionHandled &&
|
||
player.controller.value.isCompleted &&
|
||
session.canAutoAdvance &&
|
||
session.currentIndex == index) {
|
||
completionHandled = true;
|
||
unawaited(advanceAfterEnd(player, index));
|
||
}
|
||
if (!shouldReportProgress(DateTime.now())) return;
|
||
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 (_) {
|
||
// The page can leave the tree before the animation finishes.
|
||
}
|
||
}
|
||
|
||
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
|
||
Future<void> queueProgress(
|
||
Drama drama,
|
||
Episode episode,
|
||
Duration position, {
|
||
Duration? duration,
|
||
bool finished = false,
|
||
}) {
|
||
pendingWrite = pendingWrite
|
||
.then(
|
||
(_) => history.save(
|
||
episodeId: episode.id,
|
||
position: position,
|
||
duration: duration,
|
||
finished: finished,
|
||
),
|
||
)
|
||
.catchError((Object _) {
|
||
// 进度上报失败不影响播放(下次节流点会再报一次)。
|
||
return null;
|
||
});
|
||
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,
|
||
duration: player.controller.value.duration,
|
||
);
|
||
}
|
||
|
||
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;
|
||
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
|
||
final ready = await ensureTicket(index);
|
||
if (request != pageRequest || !isMounted()) return;
|
||
if (!ready && ticketError != null) 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 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> openSeriesAt(
|
||
BuildContext context,
|
||
Drama drama,
|
||
int index,
|
||
) async {
|
||
session.setRouteVisible(false);
|
||
await session.playbackSettled;
|
||
await saveCurrentProgress();
|
||
if (!context.mounted) return;
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
|
||
),
|
||
);
|
||
if (isMounted()) session.setRouteVisible(active);
|
||
}
|
||
|
||
Future<void> showEpisodes(BuildContext context) async {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final entry = queue.entryAt(session.currentIndex);
|
||
final drama = await _withFullEpisodes(context, entry.drama);
|
||
if (!context.mounted) return;
|
||
final homeFeed = feedDramas != null;
|
||
final episodes = drama.episodes;
|
||
final currentNumber = entry.episode.number;
|
||
session.setOverlayOpen(true);
|
||
await session.playbackSettled;
|
||
if (!context.mounted) {
|
||
session.setOverlayOpen(false);
|
||
return;
|
||
}
|
||
try {
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
backgroundColor: const Color(0xFF16171C),
|
||
showDragHandle: true,
|
||
builder: (sheetContext) => SafeArea(
|
||
child: SizedBox(
|
||
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||
child: Text(
|
||
l10n.episodes,
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||
child: Text(
|
||
localizedDramaTitle(l10n, drama),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: Color(0xFFB7B3B8),
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: GridView.builder(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||
gridDelegate:
|
||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 6,
|
||
mainAxisSpacing: 10,
|
||
crossAxisSpacing: 10,
|
||
childAspectRatio: 1.45,
|
||
),
|
||
itemCount: episodes.length,
|
||
itemBuilder: (_, index) => _episodeCell(
|
||
context,
|
||
sheetContext,
|
||
drama,
|
||
episodes[index],
|
||
index,
|
||
homeFeed,
|
||
currentNumber,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
} finally {
|
||
session.setOverlayOpen(false);
|
||
}
|
||
}
|
||
|
||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||
/// 取不到(或页面外没有目录来源)就退回卡片本身。
|
||
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
|
||
if (drama.detailLoaded) return drama;
|
||
final CatalogController catalog;
|
||
try {
|
||
catalog = context.read<CatalogController>();
|
||
} catch (_) {
|
||
return drama;
|
||
}
|
||
try {
|
||
return await catalog.requireDetail(drama);
|
||
} catch (_) {
|
||
return drama;
|
||
}
|
||
}
|
||
|
||
Widget _episodeCell(
|
||
BuildContext pageContext,
|
||
BuildContext sheetContext,
|
||
Drama drama,
|
||
Episode episode,
|
||
int index,
|
||
bool homeFeed,
|
||
int currentNumber,
|
||
) {
|
||
final selected = episode.number == currentNumber;
|
||
return Material(
|
||
color: selected ? AppColors.bluePressed : const Color(0xFF2A2C33),
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(8),
|
||
onTap: () {
|
||
Navigator.of(sheetContext).pop();
|
||
if (homeFeed) {
|
||
if (episode.number == currentNumber) return;
|
||
unawaited(openSeriesAt(pageContext, drama, index));
|
||
return;
|
||
}
|
||
if (index == session.currentIndex) return;
|
||
unawaited(
|
||
pages.animateToPage(
|
||
index,
|
||
duration: const Duration(milliseconds: 260),
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
);
|
||
},
|
||
child: Center(
|
||
child: Text(
|
||
'${episode.number}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|