Files
ReelShort/lib/pages/player/player_logic.dart
T
Vicky 77d5748dbb chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。

只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
2026-10-15 17:53:27 +08:00

735 lines
22 KiB
Dart

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/player_port.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 '../../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.pictureInPicture});
final WatchHistoryRepository history;
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);
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 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;
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,
}) {
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
visibleIndices = 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.videoUrl.isNotEmpty,
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 if (screenCleared) {
setScreenCleared(false);
} else {
onMenu?.call();
}
}
});
}
}
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(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;
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 (_) {
// The page can leave the tree before the animation finishes.
}
}
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 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 ? const Color(0xFF2F7BFF) : 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);
}
}
}