后端接口从 6 个扩到 13 个,全部走真数据:
- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等
测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
802 lines
25 KiB
Dart
802 lines
25 KiB
Dart
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/engagement_controller.dart';
|
|
import '../../core/data/services/favorite_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/widgets/auth_gate.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 engagement = context.watch<EngagementController>();
|
|
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], engagement),
|
|
),
|
|
);
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.light,
|
|
child: Theme(
|
|
data: ThemeData.dark(useMaterial3: true),
|
|
child: widget.embedded
|
|
? content
|
|
: Scaffold(backgroundColor: Colors.black, body: content),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEpisode(
|
|
BuildContext context,
|
|
int index,
|
|
EngagementController engagement,
|
|
) {
|
|
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: Color(0xFF2F7BFF)),
|
|
),
|
|
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
|
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: Color(0xFF2F7BFF)),
|
|
),
|
|
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: engagement.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)) {
|
|
unawaited(engagement.toggleLiked(drama.id));
|
|
}
|
|
},
|
|
onSave: () async {
|
|
final favorites = context.read<FavoriteController>();
|
|
if (await ensureSignedIn(context)) {
|
|
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),
|
|
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.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 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(
|
|
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 ? const Color(0xFF8FB4FF) : 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 ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: selected
|
|
? const Color(0xFF2F7BFF)
|
|
: const Color(0xFFF3EEEA),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|