829 lines
26 KiB
Dart
829 lines
26 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/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;
|
|
|
|
@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: Colors.black, 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.fullscreen || 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: Colors.black),
|
|
?video,
|
|
if (!logic.chromeHidden)
|
|
const IgnorePointer(
|
|
child: 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 (selected && ready && !logic.session.userWantsPlay && !logic.pip)
|
|
const Center(
|
|
child: IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Color(0x73000000),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(10),
|
|
child: Icon(
|
|
Icons.play_arrow_rounded,
|
|
size: 48,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (logic.pip || logic.pipPreparing)
|
|
const SizedBox.expand()
|
|
else if (logic.fullscreen)
|
|
_FullscreenControls(
|
|
l10n: l10n,
|
|
controller: ready ? videoPort!.controller : null,
|
|
onClose: () => unawaited(logic.setFullscreen(false)),
|
|
onPip: logic.pictureInPicture.isPlatformSupported
|
|
? () => unawaited(logic.enterPip(context))
|
|
: null,
|
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
|
onSeek: ready
|
|
? (position) => logic.seekTo(context, videoPort!, position)
|
|
: null,
|
|
)
|
|
else if (!logic.screenCleared)
|
|
_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)),
|
|
onDiscover: () => logic.showDiscover(context),
|
|
onEpisodes: () => logic.showEpisodes(context),
|
|
onMy: () => logic.showMy(context),
|
|
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
|
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),
|
|
onClear: () => logic.setScreenCleared(true),
|
|
onPip: logic.pictureInPicture.isPlatformSupported
|
|
? () => unawaited(logic.enterPip(context))
|
|
: null,
|
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
|
onSeek: ready
|
|
? (position) => logic.seekTo(context, videoPort!, position)
|
|
: null,
|
|
showBottomNav: false,
|
|
),
|
|
if (logic.screenCleared)
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 4, right: 8),
|
|
child: IconButton(
|
|
tooltip: l10n.exitClearScreen,
|
|
onPressed: () => logic.setScreenCleared(false),
|
|
style: IconButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
backgroundColor: const Color(0x59000000),
|
|
),
|
|
icon: const Icon(Icons.close_fullscreen_rounded),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FullscreenControls extends StatelessWidget {
|
|
const _FullscreenControls({
|
|
required this.l10n,
|
|
required this.controller,
|
|
required this.onClose,
|
|
required this.onPip,
|
|
required this.onScrubStart,
|
|
required this.onSeek,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final VideoPlayerController? controller;
|
|
final VoidCallback onClose;
|
|
final VoidCallback? onPip;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
tooltip: l10n.exitFullscreen,
|
|
onPressed: onClose,
|
|
icon: const Icon(Icons.close_fullscreen_rounded),
|
|
),
|
|
const Spacer(),
|
|
if (onPip != null)
|
|
IconButton(
|
|
tooltip: l10n.pictureInPicture,
|
|
onPressed: onPip,
|
|
icon: const Icon(Icons.picture_in_picture_alt_outlined),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
|
child: SeekProgressBar(
|
|
valueListenable: controller,
|
|
semanticsLabel: l10n.seekVideo,
|
|
onScrubStart: onScrubStart,
|
|
onSeek: onSeek,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 不可播放提示:文案由后端返回的 `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: 36),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
needsUnlock
|
|
? Icons.lock_outline_rounded
|
|
: Icons.movie_filter_outlined,
|
|
color: Colors.white70,
|
|
size: 36,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.white70),
|
|
),
|
|
if (needsUnlock) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
unlockCoin > 0
|
|
? '${l10n.unlockComingSoon} · $unlockCoin ${l10n.coins}'
|
|
: l10n.unlockComingSoon,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlaybackOverlay extends StatelessWidget {
|
|
const _PlaybackOverlay({
|
|
required this.l10n,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.controller,
|
|
required this.liked,
|
|
required this.saved,
|
|
required this.onBack,
|
|
required this.onDiscover,
|
|
required this.onEpisodes,
|
|
required this.onMy,
|
|
required this.onLike,
|
|
required this.onSave,
|
|
required this.onDownload,
|
|
required this.onComments,
|
|
required this.onShare,
|
|
required this.onClear,
|
|
required this.onPip,
|
|
required this.onScrubStart,
|
|
required this.onSeek,
|
|
required this.showBottomNav,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final Drama drama;
|
|
final Episode episode;
|
|
final VideoPlayerController? controller;
|
|
final bool liked;
|
|
final bool saved;
|
|
final VoidCallback? onBack;
|
|
final VoidCallback onDiscover;
|
|
final VoidCallback onEpisodes;
|
|
final VoidCallback onMy;
|
|
final VoidCallback onLike;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onDownload;
|
|
final VoidCallback onComments;
|
|
final VoidCallback onShare;
|
|
final VoidCallback onClear;
|
|
final VoidCallback? onPip;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
final bool showBottomNav;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SafeArea(
|
|
bottom: showBottomNav,
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
const Spacer(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(child: _info()),
|
|
_rail(),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
16,
|
|
0,
|
|
16,
|
|
20 + MediaQuery.paddingOf(context).bottom,
|
|
),
|
|
child: SeekProgressBar(
|
|
valueListenable: controller,
|
|
semanticsLabel: l10n.seekVideo,
|
|
onScrubStart: onScrubStart,
|
|
onSeek: onSeek,
|
|
),
|
|
),
|
|
if (showBottomNav) _fallbackNav(),
|
|
],
|
|
),
|
|
if (onBack != null)
|
|
Positioned(
|
|
top: 4,
|
|
left: 8,
|
|
child: IconButton(
|
|
tooltip: l10n.backToSeries,
|
|
onPressed: onBack,
|
|
style: IconButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
backgroundColor: const Color(0x59000000),
|
|
),
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 4,
|
|
right: 8,
|
|
child: IconButton(
|
|
tooltip: l10n.clearScreen,
|
|
onPressed: onClear,
|
|
style: IconButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
backgroundColor: const Color(0x59000000),
|
|
),
|
|
icon: const Icon(Icons.fit_screen_outlined),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _info() => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1.15,
|
|
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _rail() => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_RailAction(
|
|
key: const Key('rail-like'),
|
|
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
label: l10n.like,
|
|
selected: liked,
|
|
onTap: onLike,
|
|
),
|
|
_RailAction(
|
|
key: const Key('rail-save'),
|
|
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
|
label: l10n.save,
|
|
selected: saved,
|
|
onTap: onSave,
|
|
),
|
|
_RailAction(
|
|
key: const Key('rail-download'),
|
|
icon: Icons.download_outlined,
|
|
label: l10n.downloads,
|
|
onTap: onDownload,
|
|
),
|
|
_RailAction(
|
|
icon: Icons.grid_view_rounded,
|
|
label: l10n.episodes,
|
|
onTap: onEpisodes,
|
|
),
|
|
_RailAction(
|
|
key: const Key('rail-comments'),
|
|
icon: Icons.mode_comment_outlined,
|
|
label: l10n.comments,
|
|
onTap: onComments,
|
|
),
|
|
_RailAction(
|
|
key: const Key('rail-share'),
|
|
icon: Icons.share_rounded,
|
|
label: l10n.share,
|
|
onTap: onShare,
|
|
),
|
|
if (onPip != null)
|
|
IconButton(
|
|
tooltip: l10n.pictureInPicture,
|
|
onPressed: onPip,
|
|
visualDensity: VisualDensity.compact,
|
|
iconSize: 20,
|
|
color: Colors.white,
|
|
icon: const Icon(Icons.picture_in_picture_alt_outlined),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _fallbackNav() => Container(
|
|
height: 62,
|
|
color: const Color(0xF20C0C0F),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_BottomAction(
|
|
icon: Icons.play_circle_outline_rounded,
|
|
label: l10n.homeTab,
|
|
selected: true,
|
|
onTap: () {},
|
|
),
|
|
_BottomAction(
|
|
icon: Icons.explore_outlined,
|
|
label: l10n.discoverTab,
|
|
onTap: onDiscover,
|
|
),
|
|
_BottomAction(
|
|
icon: Icons.video_library_outlined,
|
|
label: l10n.myTab,
|
|
onTap: onMy,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _RailAction extends StatelessWidget {
|
|
const _RailAction({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.selected = false,
|
|
});
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
final bool selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: label,
|
|
onPressed: onTap,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints.tightFor(width: 42, height: 36),
|
|
color: selected ? AppColors.blueSoft : Colors.white,
|
|
icon: Icon(icon, size: 24),
|
|
),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _BottomAction extends StatelessWidget {
|
|
const _BottomAction({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.selected = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool selected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: selected ? AppColors.blueSoft : const Color(0xFFF3EEEA),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: selected ? AppColors.blueSoft : const Color(0xFFF3EEEA),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|