553 lines
20 KiB
Dart
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 'package:video_player/video_player.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 '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 '../../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;
|
|
Widget? video;
|
|
if (ready) {
|
|
video = GestureDetector(
|
|
onTap: () =>
|
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
|
child: FittedBox(
|
|
fit: theater ? BoxFit.contain : BoxFit.cover,
|
|
child: SizedBox(
|
|
width: videoPort!.controller.value.size.width,
|
|
height: videoPort.controller.value.size.height,
|
|
child: VideoPlayer(
|
|
videoPort.controller,
|
|
key: ValueKey(videoPort.controller),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (!ready)
|
|
const ColoredBox(
|
|
key: Key('player-fallback-background'),
|
|
color: Colors.black,
|
|
),
|
|
?video,
|
|
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(color: AppColors.blueSoft),
|
|
),
|
|
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
|
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(color: AppColors.blueSoft),
|
|
),
|
|
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: () => logic.showComments(context, drama),
|
|
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: () => logic.showComments(context, drama),
|
|
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)
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|