1208 lines
41 KiB
Dart
1208 lines
41 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/models/play_ticket.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/formatting/localized_content.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_overlay.dart';
|
|
import 'widgets/exit_clear_screen_button.dart';
|
|
import 'widgets/player_center_toggle.dart';
|
|
import 'widgets/quality_sheet.dart';
|
|
import 'widgets/player_featured_comment.dart';
|
|
import 'widgets/seek_progress_bar.dart';
|
|
import '../../core/data/repositories/watch_history_repository.dart';
|
|
import '../../core/data/repositories/playback_repository.dart';
|
|
|
|
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.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 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;
|
|
|
|
@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();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => _buildPlayer(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlayer(BuildContext context) {
|
|
final interactions = context.watch<InteractionController>();
|
|
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),
|
|
),
|
|
);
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.light,
|
|
child: Theme(
|
|
data: AppTheme.dark(),
|
|
child: widget.embedded
|
|
? content
|
|
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: [
|
|
const ColoredBox(color: AppColors.ink),
|
|
?video,
|
|
if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible))
|
|
IgnorePointer(
|
|
key: widget.homeMode ? const Key('home-feed-scrim') : null,
|
|
child: const DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Color(0x66000000),
|
|
Color(0x00000000),
|
|
Color(0x33000000),
|
|
Color(0xE6000000),
|
|
],
|
|
stops: [0, 0.16, 0.55, 1],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
|
|
if (selected && !ready && logic.blockedEpisodeId == episode.id)
|
|
_BlockedNotice(
|
|
l10n: l10n,
|
|
episode: episode,
|
|
accessType: logic.blockedTicket?.accessType,
|
|
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
|
|
unlocking: logic.unlockingEpisodeId == episode.id,
|
|
error: logic.unlockError,
|
|
onUnlock: logic.wallet == null
|
|
? null
|
|
: () => _unlockEpisode(context, index),
|
|
),
|
|
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
|
|
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)
|
|
_chromeVisible
|
|
? HomeFeedOverlay(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
liked: interactions.isLiked(drama.id),
|
|
likeCount: interactions.likeCount(drama.id),
|
|
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),
|
|
onEpisodes: () => logic.showEpisodes(context),
|
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
|
onClear: () => setState(() => _chromeVisible = false),
|
|
onSearch:
|
|
widget.onSearch ?? () => logic.showDiscover(context),
|
|
onPip: logic.pictureInPicture.isPlatformSupported
|
|
? () => unawaited(logic.enterPip(context))
|
|
: null,
|
|
)
|
|
: const SizedBox.shrink()
|
|
else
|
|
_PlaybackOverlay(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
controller: ready ? videoPort!.controller : null,
|
|
liked: interactions.isLiked(drama.id),
|
|
likeCount: interactions.likeCount(drama.id),
|
|
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
|
onBack: widget.embedded
|
|
? null
|
|
: () => unawaited(logic.leave(context)),
|
|
onEpisodes: () => logic.showEpisodes(context),
|
|
onNext: logic.hasNextEpisode
|
|
? () => unawaited(logic.playNextEpisode())
|
|
: null,
|
|
playing: logic.session.userWantsPlay,
|
|
playbackSpeed: _playbackSpeed,
|
|
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
|
qualities: logic.qualities,
|
|
onQualityTap: () => unawaited(_openQualitySheet(context)),
|
|
controlsVisible: _chromeVisible,
|
|
onClear: () => setState(() => _chromeVisible = false),
|
|
onExitClear: () => setState(() => _chromeVisible = true),
|
|
onTogglePlay: () =>
|
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
|
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
|
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)),
|
|
);
|
|
}
|
|
},
|
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
|
onComments: () => logic.showComments(context, drama),
|
|
onShare: () => logic.shareDrama(context, drama),
|
|
onPip: logic.pictureInPicture.isPlatformSupported
|
|
? () => unawaited(logic.enterPip(context))
|
|
: null,
|
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
|
onSeek: ready
|
|
? (position) => logic.seekTo(context, videoPort!, position)
|
|
: null,
|
|
),
|
|
if (widget.homeMode && !_chromeVisible)
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
key: const Key('home-feed-video-tap'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () =>
|
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
|
),
|
|
),
|
|
if (widget.homeMode && !_chromeVisible)
|
|
Positioned(
|
|
top: 0,
|
|
right: 0,
|
|
child: SafeArea(
|
|
left: false,
|
|
bottom: false,
|
|
minimum: const EdgeInsets.only(top: 8, right: 12),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _openSpeedSheet(BuildContext context) async {
|
|
final selected = await showModalBottomSheet<double>(
|
|
context: context,
|
|
backgroundColor: AppColors.card,
|
|
showDragHandle: true,
|
|
builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
|
|
);
|
|
if (selected == null || !mounted) return;
|
|
setState(() => _playbackSpeed = selected);
|
|
await logic.setPlaybackSpeed(selected);
|
|
}
|
|
|
|
Future<void> _openQualitySheet(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: AppColors.card,
|
|
showDragHandle: true,
|
|
builder: (_) => QualitySheet(qualities: logic.qualities),
|
|
);
|
|
}
|
|
|
|
Future<void> _unlockEpisode(BuildContext context, int index) async {
|
|
if (!await ensureSignedIn(context)) return;
|
|
if (!context.mounted) return;
|
|
final ok = await logic.unlockEpisode(index);
|
|
if (!context.mounted || ok) return;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
|
class _BlockedNotice extends StatelessWidget {
|
|
const _BlockedNotice({
|
|
required this.l10n,
|
|
required this.episode,
|
|
required this.accessType,
|
|
required this.unlockCoin,
|
|
required this.unlocking,
|
|
required this.error,
|
|
required this.onUnlock,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final Episode episode;
|
|
final String? accessType;
|
|
final int unlockCoin;
|
|
final bool unlocking;
|
|
final String? error;
|
|
final VoidCallback? onUnlock;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final needsUnlock =
|
|
accessType == PlayTicket.coinRequired ||
|
|
accessType == PlayTicket.vipRequired;
|
|
final title = needsUnlock
|
|
? l10n.lockedTitle(episode.number)
|
|
: accessType == PlayTicket.noMedia
|
|
? l10n.noMediaNotice
|
|
: episode.locked
|
|
? l10n.lockedTitle(episode.number)
|
|
: l10n.noMediaNotice;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
child: _UnlockCard(
|
|
locked: needsUnlock,
|
|
title: title,
|
|
coinLabel: needsUnlock && unlockCoin > 0
|
|
? '$unlockCoin ${l10n.coins}'
|
|
: null,
|
|
footnote: needsUnlock && error != null
|
|
? errorText(l10n, error, l10n.unlockFailed)
|
|
: null,
|
|
actionLabel: needsUnlock ? l10n.unlockWithCoins : null,
|
|
busy: unlocking,
|
|
onAction: needsUnlock ? onUnlock : null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Coin lock presented as a reward card: gold chip, chili only on the icon.
|
|
class _UnlockCard extends StatelessWidget {
|
|
const _UnlockCard({
|
|
required this.locked,
|
|
required this.title,
|
|
required this.coinLabel,
|
|
required this.footnote,
|
|
this.actionLabel,
|
|
this.busy = false,
|
|
this.onAction,
|
|
});
|
|
|
|
final bool locked;
|
|
final String title;
|
|
final String? coinLabel;
|
|
final String? footnote;
|
|
final String? actionLabel;
|
|
final bool busy;
|
|
final VoidCallback? onAction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined,
|
|
color: locked ? AppColors.gold : AppColors.cream,
|
|
size: 32,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (coinLabel != null) ...[
|
|
const SizedBox(height: 12),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.gold,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
child: Text(
|
|
coinLabel!,
|
|
style: const TextStyle(
|
|
color: AppColors.onGold,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (footnote != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
footnote!,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
|
),
|
|
],
|
|
if (actionLabel != null) ...[
|
|
const SizedBox(height: 14),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 40,
|
|
child: FilledButton(
|
|
key: const Key('unlock-with-coins'),
|
|
onPressed: busy ? null : onAction,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.gold,
|
|
foregroundColor: AppColors.onGold,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
),
|
|
child: busy
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(
|
|
actionLabel!,
|
|
style: const TextStyle(fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaybackOverlay extends StatefulWidget {
|
|
const _PlaybackOverlay({
|
|
required this.l10n,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.controller,
|
|
required this.liked,
|
|
this.likeCount,
|
|
required this.saved,
|
|
required this.playing,
|
|
required this.playbackSpeed,
|
|
required this.onSpeedTap,
|
|
required this.qualities,
|
|
required this.onQualityTap,
|
|
required this.controlsVisible,
|
|
required this.onTogglePlay,
|
|
required this.onClear,
|
|
required this.onExitClear,
|
|
required this.onBack,
|
|
required this.onEpisodes,
|
|
required this.onComments,
|
|
required this.onLike,
|
|
required this.onSave,
|
|
required this.onDownload,
|
|
required this.onShare,
|
|
required this.onPip,
|
|
required this.onScrubStart,
|
|
required this.onSeek,
|
|
this.onNext,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final Drama drama;
|
|
final Episode episode;
|
|
final VideoPlayerController? controller;
|
|
final bool liked;
|
|
final int? likeCount;
|
|
final bool saved;
|
|
final bool playing;
|
|
final double playbackSpeed;
|
|
final VoidCallback onSpeedTap;
|
|
final List<String> qualities;
|
|
final VoidCallback onQualityTap;
|
|
final bool controlsVisible;
|
|
final VoidCallback onTogglePlay;
|
|
final VoidCallback onClear;
|
|
final VoidCallback onExitClear;
|
|
final VoidCallback? onBack;
|
|
final VoidCallback onEpisodes;
|
|
final VoidCallback onComments;
|
|
final VoidCallback? onNext;
|
|
final VoidCallback onLike;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onDownload;
|
|
final VoidCallback onShare;
|
|
final VoidCallback? onPip;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
|
|
@override
|
|
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
|
|
}
|
|
|
|
class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
key: const Key('player-video-tap'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onTogglePlay,
|
|
),
|
|
),
|
|
if (widget.controlsVisible)
|
|
Column(
|
|
key: const Key('player-controls-visible'),
|
|
children: [
|
|
_topBar(context),
|
|
const Spacer(),
|
|
Padding(
|
|
key: const Key('player-controls-bottom'),
|
|
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
|
|
child: Column(
|
|
children: [
|
|
SeekProgressBar(
|
|
key: const Key('player-progress'),
|
|
valueListenable: widget.controller,
|
|
semanticsLabel: widget.l10n.seekVideo,
|
|
showTimes: true,
|
|
onScrubStart: widget.onScrubStart,
|
|
onSeek: widget.onSeek,
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _PlayerAction(
|
|
key: const Key('player-episodes'),
|
|
icon: Icons.view_list_rounded,
|
|
label: widget.l10n.episodes,
|
|
onTap: widget.onEpisodes,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _PlayerAction(
|
|
key: const Key('player-comments'),
|
|
icon: Icons.chat_bubble_outline_rounded,
|
|
label: widget.l10n.comments,
|
|
onTap: widget.onComments,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 32,
|
|
child: FilledButton(
|
|
key: const Key('player-next'),
|
|
onPressed: widget.onNext,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.blue,
|
|
disabledBackgroundColor: AppColors.raised,
|
|
disabledForegroundColor: AppColors.subtle,
|
|
foregroundColor: AppColors.onPrimary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
textStyle: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
widget.l10n.nextEpisode,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
const Icon(
|
|
Icons.skip_next_rounded,
|
|
size: 14,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
PlayerFeaturedComment(
|
|
drama: widget.drama,
|
|
onViewAll: widget.onComments,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (!widget.controlsVisible)
|
|
Positioned(
|
|
top: 8,
|
|
right: 12,
|
|
child: ExitClearScreenButton(
|
|
key: const Key('player-exit-clear-screen'),
|
|
tooltip: widget.l10n.exitClearScreen,
|
|
onPressed: widget.onExitClear,
|
|
),
|
|
),
|
|
if (!widget.playing)
|
|
Center(
|
|
child: PlayerCenterToggle(
|
|
key: const Key('player-play-toggle'),
|
|
playing: false,
|
|
onTap: widget.onTogglePlay,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _topBar(BuildContext context) {
|
|
return SizedBox(
|
|
key: const Key('player-controls-top'),
|
|
height: 46,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
child: Row(
|
|
children: [
|
|
if (widget.onBack != null)
|
|
_TopAction(
|
|
key: const Key('player-back'),
|
|
tooltip: widget.l10n.backToSeries,
|
|
icon: Icons.chevron_left_rounded,
|
|
onPressed: widget.onBack!,
|
|
)
|
|
else
|
|
const SizedBox(width: 36),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${localizedDramaTitle(widget.l10n, widget.drama)} · '
|
|
'${widget.l10n.episodeLabel(widget.episode.number)}',
|
|
key: const Key('player-title'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_TopAction(
|
|
key: const Key('player-clear-screen'),
|
|
tooltip: widget.l10n.clearScreen,
|
|
icon: Icons.fullscreen_rounded,
|
|
onPressed: widget.onClear,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_TopAction(
|
|
key: const Key('player-more'),
|
|
tooltip: widget.l10n.more,
|
|
icon: Icons.more_horiz_rounded,
|
|
onPressed: () => _openMore(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openMore(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: AppColors.card,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
key: const Key('player-more-speed'),
|
|
leading: const Icon(
|
|
Icons.speed_rounded,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(widget.l10n.playbackSpeed),
|
|
trailing: Text(
|
|
_speedLabel(widget.playbackSpeed),
|
|
style: const TextStyle(
|
|
color: AppColors.blue,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) widget.onSpeedTap();
|
|
});
|
|
},
|
|
),
|
|
if (widget.qualities.isNotEmpty)
|
|
ListTile(
|
|
key: const Key('player-more-quality'),
|
|
leading: const Icon(
|
|
Icons.high_quality_rounded,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(widget.l10n.playbackQuality),
|
|
trailing: Text(
|
|
widget.qualities.contains(QualitySheet.autoQuality)
|
|
? widget.l10n.qualityAuto
|
|
: widget.qualities.first,
|
|
style: const TextStyle(
|
|
color: AppColors.blue,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onQualityTap();
|
|
},
|
|
),
|
|
ListTile(
|
|
key: const Key('player-more-download'),
|
|
leading: const Icon(
|
|
Icons.download_outlined,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(widget.l10n.downloads),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onDownload();
|
|
},
|
|
),
|
|
if (widget.onPip != null)
|
|
ListTile(
|
|
key: const Key('player-more-pip'),
|
|
leading: const Icon(
|
|
Icons.picture_in_picture_alt_outlined,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(widget.l10n.pictureInPicture),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onPip!();
|
|
},
|
|
),
|
|
ListTile(
|
|
key: const Key('player-more-like'),
|
|
leading: Icon(
|
|
widget.liked
|
|
? Icons.favorite_rounded
|
|
: Icons.favorite_border_rounded,
|
|
color: widget.liked ? AppColors.blue : AppColors.muted,
|
|
),
|
|
title: Text(widget.l10n.like),
|
|
trailing: widget.likeCount == null
|
|
? null
|
|
: Text(
|
|
'${widget.likeCount}',
|
|
key: const Key('player-more-like-count'),
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onLike();
|
|
},
|
|
),
|
|
ListTile(
|
|
key: const Key('player-more-favorite'),
|
|
leading: Icon(
|
|
widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
|
color: widget.saved ? AppColors.blue : AppColors.muted,
|
|
),
|
|
title: Text(widget.l10n.favorite),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onSave();
|
|
},
|
|
),
|
|
ListTile(
|
|
key: const Key('player-more-share'),
|
|
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
|
|
title: Text(widget.l10n.share),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
widget.onShare();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
|
|
|
String _speedLabel(double value) => '${value.toString()}x';
|
|
|
|
class _PlaybackSpeedSheet extends StatelessWidget {
|
|
const _PlaybackSpeedSheet({required this.selected});
|
|
|
|
final double selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return SafeArea(
|
|
child: Column(
|
|
key: const Key('player-speed-sheet'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
l10n.playbackSpeed,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
for (final value in _speedOptions)
|
|
ListTile(
|
|
key: Key('player-speed-${value.toString()}'),
|
|
dense: true,
|
|
tileColor: value == selected
|
|
? AppColors.blue.withValues(alpha: 0.12)
|
|
: null,
|
|
title: Text(
|
|
_speedLabel(value),
|
|
style: TextStyle(
|
|
color: value == selected ? AppColors.blue : AppColors.cream,
|
|
fontSize: 13,
|
|
fontWeight: value == selected
|
|
? FontWeight.w700
|
|
: FontWeight.w500,
|
|
),
|
|
),
|
|
trailing: value == selected
|
|
? const Icon(
|
|
Icons.check_rounded,
|
|
color: AppColors.blue,
|
|
size: 18,
|
|
)
|
|
: null,
|
|
onTap: () => Navigator.pop(context, value),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TopAction extends StatelessWidget {
|
|
const _TopAction({
|
|
super.key,
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 36,
|
|
height: 36,
|
|
child: IconButton(
|
|
tooltip: tooltip,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: const Color(0xFF2C2D33),
|
|
foregroundColor: AppColors.onSurface,
|
|
),
|
|
icon: Icon(icon, size: 18),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlayerAction extends StatelessWidget {
|
|
const _PlayerAction({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: const Color(0xFF2A2A30),
|
|
borderRadius: BorderRadius.circular(9),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 32,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, size: 14, color: AppColors.secondary),
|
|
const SizedBox(width: 3),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|