Files
ReelShort/lib/pages/player/player_view.dart
T

553 lines
20 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/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'comments_auth.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_header.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/exit_clear_screen_button.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
import 'widgets/player_speed_sheet.dart';
import 'widgets/player_unlock_sheet.dart';
import 'widgets/player_video_surface.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
part 'player_view_actions.dart';
const double _boostPlaybackSpeed = 2.0;
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
required this.drama,
required this.initialIndex,
this.homeMode = false,
this.embedded = false,
this.active = true,
this.resumeRequest = 0,
this.backRequest = 0,
this.onDiscover,
this.onMy,
this.onSearch,
this.onMenu,
this.feedDramas,
this.onDramaChanged,
this.onImmersiveChanged,
this.onOpenFullPlayer,
this.onRefreshFeed,
this.wallet,
this.downloads,
});
final Drama drama;
final int initialIndex;
final bool homeMode;
final bool embedded;
final bool active;
final int resumeRequest;
final int backRequest;
final VoidCallback? onDiscover;
final VoidCallback? onMy;
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
final Future<bool> Function()? onRefreshFeed;
final WalletController? wallet;
final DownloadController? downloads;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
bool _boosting = false;
double? _speedBeforeBoost;
bool? _chromeBeforeBoost;
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
@override
void initState() {
super.initState();
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
logic.onPageUpdated(oldWidget);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
logic.onLifecycle(state);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.dispose();
super.dispose();
}
void _updatePlaybackSpeed(double value) {
setState(() => _playbackSpeed = value);
}
void _hideChrome() {
setState(() => _chromeVisible = false);
}
/// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。
void _startBoost() {
if (_boosting) return;
// 暂停时长按两侧先恢复播放,再进入 2 倍速。
if (!logic.session.userWantsPlay) logic.session.setUserWantsPlay(true);
setState(() {
_boosting = true;
_speedBeforeBoost = _playbackSpeed;
_chromeBeforeBoost = _chromeVisible;
_playbackSpeed = _boostPlaybackSpeed;
_chromeVisible = false;
});
unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed));
}
/// 松手或手势取消:恢复长按前的倍速与清屏状态。
void _endBoost() {
if (!_boosting) return;
final speed = _speedBeforeBoost ?? _playbackSpeed;
final chrome = _chromeBeforeBoost ?? true;
setState(() {
_boosting = false;
_speedBeforeBoost = null;
_chromeBeforeBoost = null;
_playbackSpeed = speed;
_chromeVisible = chrome;
});
unawaited(logic.setPlaybackSpeed(speed));
}
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
setState(() {
_favoriteDelta[dramaId] =
(_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1);
});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _buildPlayer(context),
);
}
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
),
);
final body = Stack(
fit: StackFit.expand,
children: [
content,
if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: HomeFeedHeader(
l10n: l10n,
onRefresh: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
}
return ok;
},
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onMore: _openCurrentMore,
),
),
),
],
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Theme(
data: AppTheme.light(),
child: widget.embedded
? body
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
body: body,
),
),
);
}
void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode);
}
Widget _buildEpisode(
BuildContext context,
int index,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
: entry.episode;
final selected = index == logic.session.currentIndex;
// Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder.
final port =
(selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.pip || logic.pipPreparing;
return Stack(
fit: StackFit.expand,
children: [
if (widget.homeMode)
const ColoredBox(
key: Key('home-feed-black-background'),
color: Colors.black,
),
PlayerVideoSurface(
controller: videoPort?.controller,
posterUrl: drama.posterUrl,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
),
if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
Positioned.fill(
child: GestureDetector(
key: const Key('home-feed-video-toggle'),
behavior: HitTestBehavior.opaque,
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
),
),
// 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading)
const Center(
child: CircularProgressIndicator(
key: Key('player-ticket-loading'),
color: AppColors.blue,
),
),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(
child: CircularProgressIndicator(
key: Key('player-session-loading'),
color: AppColors.blue,
),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
if (selected &&
!ready &&
logic.blockedEpisodeId != episode.id &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (widget.homeMode)
HomeFeedOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
visible: _chromeVisible,
boosting: _boosting,
onBoostStart: _startBoost,
onBoostEnd: _endBoost,
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
commentCount: drama.commentCount,
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => unawaited(
openCommentsWithAuth(context, drama, show: logic.showComments),
),
onShare: () => logic.shareDrama(context, drama),
onDetail: () => unawaited(logic.openDetail(context)),
onOpenFullPlayer: () async {
await widget.onOpenFullPlayer?.call(drama, episode);
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
},
onMore: _openCurrentMore,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else
PlayerPlaybackOverlay(
data: PlayerOverlayData(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
commentCount: drama.commentCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
boosting: _boosting,
),
actions: PlayerOverlayActions(
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context),
onComments: () => unawaited(
openCommentsWithAuth(context, drama, show: logic.showComments),
),
onLike: () =>
unawaited(_toggleLike(context, interactions, drama)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode),
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
onLongPressCenter: () => _openMore(context, episode),
onBoostStart: _startBoost,
onBoostEnd: _endBoost,
),
controlsVisible: _chromeVisible,
),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !ready && logic.blockedEpisodeId == episode.id)
PlayerBlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
unlocking: logic.unlockingEpisodeId == episode.id,
error: logic.unlockError,
onOpenUnlock: logic.wallet == null
? null
: () => _openUnlockSheet(context, index, episode),
),
if (widget.homeMode && !_chromeVisible && !_boosting)
Positioned(
right: 12,
bottom: 56,
child: ExitClearScreenButton(
key: const Key('home-feed-exit-clear-screen'),
tooltip: l10n.exitClearScreen,
onPressed: () => setState(() => _chromeVisible = true),
),
),
if (widget.homeMode &&
!logic.chromeHidden &&
selected &&
!logic.session.userWantsPlay)
Center(
child: PlayerCenterToggle(
key: const Key('home-feed-center-play'),
playing: false,
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
),
),
],
);
}
}