955 lines
32 KiB
Dart
955 lines
32 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/interaction_controller.dart';
|
|
import '../../core/data/services/playback_session.dart';
|
|
import '../../core/data/services/video_player_port.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/player_center_toggle.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,
|
|
});
|
|
|
|
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;
|
|
|
|
@override
|
|
State<PlayerPage> createState() => _PlayerPageState();
|
|
}
|
|
|
|
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|
late final PlayerLogic logic;
|
|
bool _feedChromeVisible = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
logic = PlayerBinding().create(
|
|
history: context.read<WatchHistoryRepository>(),
|
|
playback: context.read<PlaybackRepository>(),
|
|
);
|
|
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 || _feedChromeVisible))
|
|
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 (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,
|
|
),
|
|
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
|
|
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)
|
|
_feedChromeVisible
|
|
? HomeFeedOverlay(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
liked: interactions.isLiked(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(() => _feedChromeVisible = 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),
|
|
// 收藏以服务端为准(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,
|
|
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 && !_feedChromeVisible)
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
key: const Key('home-feed-video-tap'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => setState(() => _feedChromeVisible = true),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
|
class _BlockedNotice extends StatelessWidget {
|
|
const _BlockedNotice({
|
|
required this.l10n,
|
|
required this.episode,
|
|
required this.accessType,
|
|
required this.unlockCoin,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final Episode episode;
|
|
final String? accessType;
|
|
final int unlockCoin;
|
|
|
|
@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 ? l10n.unlockComingSoon : 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,
|
|
});
|
|
|
|
final bool locked;
|
|
final String title;
|
|
final String? coinLabel;
|
|
final String? footnote;
|
|
|
|
@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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaybackOverlay extends StatefulWidget {
|
|
const _PlaybackOverlay({
|
|
required this.l10n,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.controller,
|
|
required this.liked,
|
|
required this.saved,
|
|
required this.playing,
|
|
required this.onTogglePlay,
|
|
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 bool saved;
|
|
final bool playing;
|
|
final VoidCallback onTogglePlay;
|
|
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> {
|
|
bool _controlsVisible = true;
|
|
|
|
void _onVideoTap() {
|
|
if (!_controlsVisible) {
|
|
setState(() => _controlsVisible = true);
|
|
} else if (!widget.playing) {
|
|
widget.onTogglePlay();
|
|
}
|
|
}
|
|
|
|
void _clearScreen() => setState(() => _controlsVisible = false);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
key: const Key('player-video-tap'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: _onVideoTap,
|
|
),
|
|
),
|
|
if (_controlsVisible)
|
|
Column(
|
|
key: const Key('player-controls-visible'),
|
|
children: [
|
|
_topBar(context),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
if (widget.playing)
|
|
Center(
|
|
child: PlayerCenterToggle(
|
|
key: const Key('player-play-toggle'),
|
|
playing: true,
|
|
onTap: widget.onTogglePlay,
|
|
),
|
|
),
|
|
const Positioned(
|
|
right: 10,
|
|
bottom: 14,
|
|
child: Text(
|
|
'1.0x',
|
|
style: TextStyle(
|
|
color: Color(0xFFDDDDDD),
|
|
fontSize: 9,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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: false,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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: _clearScreen,
|
|
),
|
|
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-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),
|
|
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();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|