Files
ReelShort/lib/pages/player/player_view.dart
T

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),
),
),
],
),
),
);
}