960 lines
33 KiB
Dart
960 lines
33 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
import '../../core/data/models/drama.dart';
|
|
import '../../core/data/services/engagement_controller.dart';
|
|
import '../../core/data/services/playback_session.dart';
|
|
import '../../core/data/services/video_player_port.dart';
|
|
import '../../core/formatting/localized_content.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../home/home_feed_sections.dart';
|
|
import '../home/widgets/category_swipe_region.dart';
|
|
import '../home/widgets/home_feed_header.dart';
|
|
import 'player_binding.dart';
|
|
import 'player_logic.dart';
|
|
import 'widgets/seek_progress_bar.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();
|
|
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 category = logic.category;
|
|
final indices = logic.visibleIndices;
|
|
final pages = logic.pages;
|
|
final content = AnimatedBuilder(
|
|
animation: logic.session,
|
|
builder: (context, _) => indices.isEmpty && widget.homeMode
|
|
? _buildEmptyCategory(context)
|
|
: PageView.builder(
|
|
key: ValueKey(category),
|
|
controller: pages,
|
|
scrollDirection: Axis.vertical,
|
|
allowImplicitScrolling: true,
|
|
itemCount: indices.length,
|
|
onPageChanged: (position) {
|
|
if (category != logic.category) return;
|
|
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 _buildEmptyCategory(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return CategorySwipeRegion(
|
|
key: const Key('home-empty-swipe'),
|
|
onShift: logic.shiftCategory,
|
|
child: ColoredBox(
|
|
color: const Color(0xFF111214),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
HomeFeedHeader(
|
|
l10n: l10n,
|
|
category: logic.category,
|
|
onCategorySelected: (value) =>
|
|
unawaited(logic.selectCategory(value)),
|
|
onCategoryShift: logic.shiftCategory,
|
|
onSearch: () => logic.showSearch(context),
|
|
onMy: () => logic.showMy(context),
|
|
onMenu: widget.onMenu,
|
|
),
|
|
const Spacer(),
|
|
const Icon(
|
|
Icons.video_library_outlined,
|
|
size: 48,
|
|
color: Color(0xFFE6E1E3),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.noCategoryDramas,
|
|
style: const TextStyle(fontSize: 16, color: Colors.white),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Text(
|
|
l10n.demoCatalogNotice,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 13, color: Color(0xFFF3EEEA)),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 = 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;
|
|
Widget? video;
|
|
if (ready) {
|
|
video = GestureDetector(
|
|
onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
|
child: FittedBox(
|
|
fit: logic.immersive ? 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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (widget.homeMode) {
|
|
video = CategorySwipeRegion(
|
|
key: selected ? const Key('home-video-swipe') : null,
|
|
onShift: logic.shiftCategory,
|
|
child: video,
|
|
);
|
|
}
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (widget.homeMode && !ready && !logic.immersive)
|
|
CategorySwipeRegion(
|
|
key: selected ? const Key('home-video-swipe') : null,
|
|
onShift: logic.shiftCategory,
|
|
child: const ColoredBox(color: Colors.black),
|
|
)
|
|
else
|
|
const ColoredBox(color: Colors.black),
|
|
?video,
|
|
if (!logic.immersive)
|
|
const IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Color(0xD6000000),
|
|
Color(0x66000000),
|
|
Color(0x33000000),
|
|
Color(0xF0000000),
|
|
],
|
|
stops: [0, 0.22, 0.5, 1],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (selected && logic.session.loading)
|
|
const Center(
|
|
child: CircularProgressIndicator(color: Color(0xFFFF6B45)),
|
|
),
|
|
if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
|
|
Center(
|
|
child: IconButton.filled(
|
|
iconSize: 48,
|
|
onPressed: () => logic.session.setUserWantsPlay(true),
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
),
|
|
),
|
|
if (widget.homeMode && (logic.pip || logic.pipPreparing))
|
|
const SizedBox.expand()
|
|
else if (widget.homeMode && 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 (widget.homeMode)
|
|
_StitchHomeOverlay(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
controller: ready ? videoPort!.controller : null,
|
|
liked: engagement.isLiked(drama.id),
|
|
saved: engagement.isSaved(drama.id),
|
|
muted: logic.muted,
|
|
category: logic.category,
|
|
onCategorySelected: (value) => unawaited(logic.selectCategory(value)),
|
|
onCategoryShift: logic.shiftCategory,
|
|
onDetail: () => logic.openDetail(context),
|
|
onDiscover: () => logic.showDiscover(context),
|
|
onEpisodes: () => logic.showEpisodes(context),
|
|
onMy: () => logic.showMy(context),
|
|
onSearch: () => logic.showSearch(context),
|
|
onMenu: widget.onMenu,
|
|
onMute: logic.toggleMute,
|
|
onLike: () => engagement.toggleLiked(drama.id),
|
|
onSave: () => engagement.toggleSaved(drama.id),
|
|
onComments: () => logic.showComments(context, drama),
|
|
onShare: () => logic.shareDrama(context, drama),
|
|
onFullscreen: () => unawaited(logic.setFullscreen(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: !widget.embedded,
|
|
)
|
|
else
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (!widget.homeMode)
|
|
IconButton(
|
|
onPressed: () => unawaited(logic.leave(context)),
|
|
tooltip: l10n.backToSeries,
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
)
|
|
else
|
|
const Icon(
|
|
Icons.play_circle_fill_rounded,
|
|
color: Color(0xFFFF6B45),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
widget.homeMode
|
|
? l10n.appName
|
|
: localizedDramaTitle(l10n, drama),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.language,
|
|
onPressed: () => logic.showMy(context),
|
|
icon: const Icon(Icons.language_rounded),
|
|
),
|
|
],
|
|
),
|
|
if (widget.homeMode)
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
l10n.homeTab,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w800,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
const SizedBox(width: 22),
|
|
TextButton(
|
|
onPressed: () => logic.openDetail(context),
|
|
child: Text(
|
|
l10n.discoverTab,
|
|
style: const TextStyle(
|
|
color: Color(0xFFF3EEEA),
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
if (selected && logic.session.error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: FilledButton(
|
|
onPressed: () => unawaited(logic.selectEpisode(index)),
|
|
child: Text('${l10n.playbackError} ${l10n.retry}'),
|
|
),
|
|
),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.demoTestMedia,
|
|
style: const TextStyle(
|
|
color: Color(0xFFFFB199),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.w800,
|
|
shadows: [
|
|
Shadow(blurRadius: 12, color: Colors.black),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
localizedDramaSynopsis(l10n, drama),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: Color(0xFFF7F4F2)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
children: [
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.exploreSeries,
|
|
onPressed: () => logic.openDetail(context),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
icon: const Icon(Icons.info_outline_rounded),
|
|
),
|
|
const SizedBox(height: 8),
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.episodes,
|
|
onPressed: () => logic.showEpisodes(context),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
icon: const Icon(Icons.grid_view_rounded),
|
|
),
|
|
const SizedBox(height: 8),
|
|
IconButton.filledTonal(
|
|
tooltip: logic.session.userWantsPlay
|
|
? l10n.pause
|
|
: l10n.play,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black45,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: ready
|
|
? () => logic.session.setUserWantsPlay(
|
|
!logic.session.userWantsPlay,
|
|
)
|
|
: null,
|
|
icon: Icon(
|
|
logic.session.userWantsPlay
|
|
? Icons.pause_rounded
|
|
: Icons.play_arrow_rounded,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextButton.icon(
|
|
onPressed: () => logic.showEpisodes(context),
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
label: Text(
|
|
'${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}',
|
|
),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
|
),
|
|
SeekProgressBar(
|
|
valueListenable: ready ? videoPort!.controller : null,
|
|
semanticsLabel: l10n.seekVideo,
|
|
showTimes: false,
|
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
|
onSeek: ready
|
|
? (position) => logic.seekTo(context, videoPort!, position)
|
|
: null,
|
|
),
|
|
if (widget.homeMode) ...[
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_BottomAction(
|
|
icon: Icons.home_rounded,
|
|
label: l10n.homeTab,
|
|
selected: true,
|
|
onTap: () {},
|
|
),
|
|
_BottomAction(
|
|
icon: Icons.explore_outlined,
|
|
label: l10n.discoverTab,
|
|
onTap: () => logic.showDiscover(context),
|
|
),
|
|
_BottomAction(
|
|
icon: Icons.person_outline_rounded,
|
|
label: l10n.myTab,
|
|
onTap: () => logic.showMy(context),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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.symmetric(horizontal: 20),
|
|
child: SeekProgressBar(
|
|
valueListenable: controller,
|
|
semanticsLabel: l10n.seekVideo,
|
|
compactIdle: true,
|
|
onScrubStart: onScrubStart,
|
|
onSeek: onSeek,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _StitchHomeOverlay extends StatelessWidget {
|
|
const _StitchHomeOverlay({
|
|
required this.l10n,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.controller,
|
|
required this.liked,
|
|
required this.saved,
|
|
required this.muted,
|
|
required this.category,
|
|
required this.onCategorySelected,
|
|
required this.onCategoryShift,
|
|
required this.onDetail,
|
|
required this.onDiscover,
|
|
required this.onEpisodes,
|
|
required this.onMy,
|
|
required this.onSearch,
|
|
required this.onMenu,
|
|
required this.onMute,
|
|
required this.onLike,
|
|
required this.onSave,
|
|
required this.onComments,
|
|
required this.onShare,
|
|
required this.onFullscreen,
|
|
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 bool muted;
|
|
final HomeFeedCategory category;
|
|
final ValueChanged<HomeFeedCategory> onCategorySelected;
|
|
final ValueChanged<int> onCategoryShift;
|
|
final VoidCallback onDetail;
|
|
final VoidCallback onDiscover;
|
|
final VoidCallback onEpisodes;
|
|
final VoidCallback onMy;
|
|
final VoidCallback onSearch;
|
|
final VoidCallback? onMenu;
|
|
final VoidCallback onMute;
|
|
final VoidCallback onLike;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onComments;
|
|
final VoidCallback onShare;
|
|
final VoidCallback onFullscreen;
|
|
final VoidCallback? onPip;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
final bool showBottomNav;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SafeArea(
|
|
bottom: showBottomNav,
|
|
child: Column(
|
|
children: [
|
|
HomeFeedHeader(
|
|
l10n: l10n,
|
|
category: category,
|
|
onCategorySelected: onCategorySelected,
|
|
onCategoryShift: onCategoryShift,
|
|
onSearch: onSearch,
|
|
onMy: onMy,
|
|
onMenu: onMenu,
|
|
onMute: onMute,
|
|
muted: muted,
|
|
),
|
|
const Spacer(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 12, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_MetaChip(text: l10n.freeTestMedia, accent: true),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w800,
|
|
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
localizedDramaSynopsis(l10n, drama),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
height: 1.2,
|
|
color: Colors.white,
|
|
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
),
|
|
),
|
|
Tooltip(
|
|
message: l10n.exploreSeries,
|
|
child: TextButton(
|
|
onPressed: onDetail,
|
|
style: TextButton.styleFrom(
|
|
padding: EdgeInsets.zero,
|
|
minimumSize: const Size(0, 28),
|
|
foregroundColor: const Color(0xFFFFA18B),
|
|
),
|
|
child: Text(
|
|
l10n.exploreSeries,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_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-comments'),
|
|
icon: Icons.mode_comment_outlined,
|
|
label: l10n.comments,
|
|
onTap: onComments,
|
|
),
|
|
_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-share'),
|
|
icon: Icons.share_rounded,
|
|
label: l10n.share,
|
|
onTap: onShare,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Tooltip(
|
|
message: l10n.watchFullscreen,
|
|
child: TextButton.icon(
|
|
onPressed: onFullscreen,
|
|
icon: const Icon(Icons.screen_rotation_outlined, size: 15),
|
|
label: Text(l10n.watchFullscreen),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
textStyle: const TextStyle(fontSize: 11),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
),
|
|
if (onPip != null)
|
|
IconButton(
|
|
tooltip: l10n.pictureInPicture,
|
|
onPressed: onPip,
|
|
visualDensity: VisualDensity.compact,
|
|
iconSize: 17,
|
|
icon: const Icon(Icons.picture_in_picture_alt_outlined),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Tooltip(
|
|
message: l10n.episodes,
|
|
child: InkWell(
|
|
onTap: onEpisodes,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
height: 42,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xE6212125),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: IconTheme(
|
|
data: const IconThemeData(color: Colors.white),
|
|
child: DefaultTextStyle.merge(
|
|
style: const TextStyle(color: Colors.white),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.play_circle_outline_rounded, size: 19),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: Text(
|
|
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'${l10n.episodes} (${drama.episodes.length})',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Icon(Icons.keyboard_arrow_up_rounded, size: 18),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: SeekProgressBar(
|
|
valueListenable: controller,
|
|
semanticsLabel: l10n.seekVideo,
|
|
onScrubStart: onScrubStart,
|
|
onSeek: onSeek,
|
|
compactIdle: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
if (showBottomNav)
|
|
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 _MetaChip extends StatelessWidget {
|
|
const _MetaChip({required this.text, this.accent = false});
|
|
final String text;
|
|
final bool accent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xB51F1F22),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: accent ? const Color(0xFFFFC2B0) : const Color(0xFFF7F4F2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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.filledTonal(
|
|
tooltip: label,
|
|
onPressed: onTap,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: const Color(0xB719191B),
|
|
foregroundColor: selected ? const Color(0xFFFF6B45) : Colors.white,
|
|
),
|
|
icon: Icon(icon, size: 22),
|
|
),
|
|
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(0xFFFF6B45) : const Color(0xFFF3EEEA),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|