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

1502 lines
50 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/player_port.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.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 '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../home/home_feed_sections.dart';
import '../home/widgets/category_swipe_region.dart';
import '../home/widgets/home_feed_header.dart';
import '../mine/mine_view.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/drama_comments_sheet.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;
void _notifyImmersive() => widget.onImmersiveChanged?.call(logic.immersive);
Future<void> _setFullscreen(bool value) async {
if (logic.fullscreen == value || !mounted) return;
setState(() => logic.fullscreen = value);
_notifyImmersive();
try {
await SystemChrome.setPreferredOrientations(
value
? [
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]
: [DeviceOrientation.portraitUp],
);
await SystemChrome.setEnabledSystemUIMode(
value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge,
);
} catch (_) {
if (value && mounted) {
setState(() => logic.fullscreen = false);
_notifyImmersive();
}
}
}
Future<void> _enterPip() async {
if (logic.pip || logic.pipPreparing) return;
if (logic.session.active is! VideoPlayerPort) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
);
return;
}
if (logic.fullscreen) await _setFullscreen(false);
setState(() => logic.pipPreparing = true);
_notifyImmersive();
await WidgetsBinding.instance.endOfFrame;
bool entered = false;
try {
entered = await logic.pictureInPicture.enter();
} catch (_) {
// Android reports unavailable for devices without PiP support.
}
if (!mounted) return;
if (entered) {
setState(() {
logic.pip = true;
logic.pipPreparing = false;
});
_notifyImmersive();
logic.session.setAppResumed(true);
} else {
setState(() => logic.pipPreparing = false);
_notifyImmersive();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
);
}
}
void _onPipChanged(bool enabled) {
if (!mounted) return;
setState(() {
logic.pip = enabled;
logic.pipPreparing = false;
});
_notifyImmersive();
logic.session.setAppResumed(
enabled ||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed,
);
}
Future<void> _preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
final drama = logic.queue.entries
.firstWhere((e) => e.episode.id == episode.id)
.drama;
try {
await logic.pendingWrite;
final saved = await logic.history.positionFor(
demoProfileId,
drama.id,
episode.id,
);
if (saved != null) {
await player.controller.seekTo(
clampResumePosition(saved, player.controller.value.duration),
);
}
} catch (_) {
// A local preference failure does not prevent playback.
}
}
Future<void> _activatePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
final index = logic.session.currentIndex;
final drama = logic.queue.entryAt(index).drama;
await player.controller.setVolume(logic.muted ? 0 : 1);
if (logic.session.active != player) return;
logic.trackedPlayer = player;
logic.trackedEpisode = episode;
logic.trackedDrama = drama;
logic.lastProgressWrite = DateTime.now();
var completionHandled = false;
var completionProgressSaved = false;
player.controller.addListener(() {
if (logic.session.active != player || !player.controller.value.isInitialized) {
return;
}
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
_queueProgress(drama, episode, player.controller.value.duration),
);
}
if (!completionHandled &&
player.controller.value.isCompleted &&
logic.session.canAutoAdvance &&
logic.session.currentIndex == index) {
completionHandled = true;
unawaited(_advanceAfterEnd(player, index));
}
final now = DateTime.now();
if (now.difference(logic.lastProgressWrite) < const Duration(seconds: 5)) {
return;
}
logic.lastProgressWrite = now;
unawaited(
_queueProgress(drama, episode, player.controller.value.position),
);
});
}
Future<void> _advanceAfterEnd(VideoPlayerPort player, int index) async {
if (!mounted || logic.session.active != player || !logic.session.canAutoAdvance) {
return;
}
final next = logic.visibleIndices.indexOf(index) + 1;
if (next <= 0 || next >= logic.visibleIndices.length) {
logic.stoppedAtFeedEnd = true;
logic.session.setUserWantsPlay(false);
return;
}
final pages = logic.pages;
try {
if (!pages.hasClients) return;
await pages.animateToPage(
next,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} catch (_) {
// A category change can detach the old page controller mid-animation.
}
}
Future<void> _queueProgress(Drama drama, Episode episode, Duration position) {
logic.pendingWrite = logic.pendingWrite
.then(
(_) => logic.history.save(demoProfileId, drama.id, episode.id, position),
)
.catchError((Object _) {
// Playback continues if local history cannot be stored.
});
return logic.pendingWrite;
}
Future<void> _saveCurrentProgress() {
final player = logic.trackedPlayer;
final episode = logic.trackedEpisode;
final drama = logic.trackedDrama;
if (player == null ||
episode == null ||
drama == null ||
!player.controller.value.isInitialized) {
return logic.pendingWrite;
}
return _queueProgress(drama, episode, player.controller.value.position);
}
Future<void> _selectEpisode(int index) async {
final request = ++logic.pageRequest;
_cancelScrub();
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(_saveCurrentProgress());
if (request != logic.pageRequest || !mounted) return;
await logic.session.select(index, preloadIndex: _nextVisibleIndex(index));
}
int? _nextVisibleIndex(int index) {
final position = logic.visibleIndices.indexOf(index);
return position >= 0 && position + 1 < logic.visibleIndices.length
? logic.visibleIndices[position + 1]
: null;
}
void _shiftCategory(int offset) {
if (!widget.homeMode) return;
final next = logic.category.index + offset;
if (next < 0) {
widget.onMenu?.call();
return;
}
if (next >= HomeFeedCategory.values.length) return;
unawaited(_selectCategory(HomeFeedCategory.values[next]));
}
Future<void> _selectCategory(HomeFeedCategory category) async {
if (!widget.homeMode || category == logic.category) return;
final request = ++logic.categoryRequest;
++logic.pageRequest;
logic.session.setRouteVisible(false);
_cancelScrub();
await _saveCurrentProgress();
if (!mounted || request != logic.categoryRequest) return;
final oldPages = logic.pages;
final indices = logic.homeSections.indicesFor(category);
setState(() {
logic.category = category;
logic.visibleIndices = indices;
logic.pages = PageController();
});
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
if (indices.isEmpty) return;
widget.onDramaChanged?.call(logic.queue.entryAt(indices.first).drama);
await logic.session.select(
indices.first,
preloadIndex: _nextVisibleIndex(indices.first),
);
if (mounted && request == logic.categoryRequest) {
if (logic.stoppedAtFeedEnd) {
logic.stoppedAtFeedEnd = false;
logic.session.setUserWantsPlay(true);
}
logic.session.setRouteVisible(widget.active);
}
}
void _beginScrub(VideoPlayerPort player) {
if (logic.session.active != player) return;
logic.scrubbingPlayer = player;
logic.session.setOverlayOpen(true);
}
void _cancelScrub() {
if (logic.scrubbingPlayer == null) return;
logic.scrubbingPlayer = null;
logic.session.setOverlayOpen(false);
}
Future<void> _seekTo(VideoPlayerPort player, Duration position) async {
if (logic.scrubbingPlayer != player || logic.session.active != player) return;
try {
await logic.session.playbackSettled;
if (logic.scrubbingPlayer != player || logic.session.active != player) return;
await player.controller.seekTo(position);
if (logic.session.active == player) await _saveCurrentProgress();
} catch (_) {
if (mounted && logic.session.active == player) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
}
} finally {
if (logic.scrubbingPlayer == player) _cancelScrub();
}
}
Future<void> _leave() async {
await _saveCurrentProgress();
if (mounted) Navigator.of(context).pop();
}
@override
void initState() {
super.initState();
logic = PlayerBinding().create();
WidgetsBinding.instance.addObserver(this);
logic.pictureInPicture.setStateListener(_onPipChanged);
logic.queue = widget.feedDramas == null
? PlaybackQueue.series(widget.drama)
: PlaybackQueue.home(widget.feedDramas!);
logic.homeSections = HomeFeedSections([
for (final entry in logic.queue.entries) entry.drama,
]);
logic.visibleIndices = widget.homeMode
? logic.homeSections.indicesFor(logic.category)
: List<int>.generate(logic.queue.length, (index) => index);
logic.pages = PageController(initialPage: widget.initialIndex);
logic.session = PlaybackSession(
episodes: [for (final entry in logic.queue.entries) entry.episode],
factory: context.read<PlayerFactory>(),
canPlay: (episode) => episode.isFree,
prepare: _preparePlayer,
activate: _activatePlayer,
coordinator: context.read<PlaybackCoordinator>(),
);
if (!widget.active || logic.visibleIndices.isEmpty) {
logic.session.setRouteVisible(false);
}
unawaited(_selectEpisode(widget.initialIndex));
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.active != widget.active) {
logic.session.setRouteVisible(widget.active && logic.visibleIndices.isNotEmpty);
if (!widget.active) {
_cancelScrub();
unawaited(_saveCurrentProgress());
}
}
if (oldWidget.resumeRequest != widget.resumeRequest) {
logic.session.setUserWantsPlay(true);
}
if (oldWidget.backRequest != widget.backRequest) {
final request = widget.backRequest;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.backRequest == request) {
if (logic.fullscreen) {
unawaited(_setFullscreen(false));
} else {
_shiftCategory(-1);
}
}
});
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(_saveCurrentProgress());
logic.session.setAppResumed(
state == AppLifecycleState.resumed || logic.pip || logic.pipPreparing,
);
if (state != AppLifecycleState.resumed) _cancelScrub();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.pictureInPicture.setStateListener(null);
if (logic.fullscreen) {
unawaited(
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
);
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(_saveCurrentProgress());
logic.session.setRouteVisible(false);
_cancelScrub();
unawaited(logic.session.close());
logic.pages.dispose();
super.dispose();
}
Future<void> _openDetail() async {
logic.session.setRouteVisible(false);
await logic.session.playbackSettled;
await _saveCurrentProgress();
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
DramaDetailPage(drama: logic.queue.entryAt(logic.session.currentIndex).drama),
),
);
if (mounted) logic.session.setRouteVisible(widget.active);
}
Future<void> _showEpisodes() async {
if (widget.feedDramas != null) {
await _openDetail();
return;
}
final l10n = AppLocalizations.of(context)!;
logic.session.setOverlayOpen(true);
await logic.session.playbackSettled;
if (!mounted) {
logic.session.setOverlayOpen(false);
return;
}
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202024),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.episodes,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
itemCount: logic.queue.length,
itemBuilder: (context, index) {
final episode = logic.queue.entryAt(index).episode;
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF3A3030),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_arrow_rounded),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(
logic.pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
);
},
),
),
],
),
),
),
);
} finally {
logic.session.setOverlayOpen(false);
}
}
Future<void> _showMy({int initialSection = 0}) async {
if (widget.embedded) {
widget.onMy?.call();
return;
}
logic.session.setRouteVisible(false);
await logic.session.playbackSettled;
await _saveCurrentProgress();
if (!mounted) return;
final port = logic.session.active;
final drama = logic.queue.entryAt(logic.session.currentIndex).drama;
final duration = port is VideoPlayerPort
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<MyPageAction>(
MaterialPageRoute(
builder: (_) => MyPage(
drama: drama,
duration: duration,
initialSection: initialSection,
),
),
);
if (!mounted) return;
if (action == MyPageAction.discover) {
await _showDiscover();
} else {
logic.session.setRouteVisible(true);
}
if (action == MyPageAction.resume) logic.session.setUserWantsPlay(true);
}
Future<void> _showSearch() async {
if (widget.embedded) {
widget.onSearch?.call();
return;
}
await _showDiscover(focusSearch: true);
}
Future<void> _showDiscover({bool focusSearch = false}) async {
if (widget.embedded) {
widget.onDiscover?.call();
return;
}
logic.session.setRouteVisible(false);
await logic.session.playbackSettled;
await _saveCurrentProgress();
if (!mounted) return;
final port = logic.session.active;
final duration = port is VideoPlayerPort
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<DiscoverPageAction>(
MaterialPageRoute(
builder: (_) => DiscoverPage(
drama: widget.drama,
duration: duration,
focusSearch: focusSearch,
),
),
);
if (!mounted) return;
if (action == DiscoverPageAction.detail) {
await _openDetail();
} else if (action == DiscoverPageAction.my ||
action == DiscoverPageAction.watchlist) {
await _showMy(
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
);
} else {
logic.session.setRouteVisible(true);
if (action == DiscoverPageAction.resume) {
logic.session.setUserWantsPlay(true);
}
}
}
Future<void> _toggleMute() async {
final port = logic.session.active;
if (port is! VideoPlayerPort) return;
final next = !logic.muted;
await port.controller.setVolume(next ? 0 : 1);
if (mounted) setState(() => logic.muted = next);
}
Future<void> _showComments(Drama drama) async {
logic.session.setOverlayOpen(true);
await logic.session.playbackSettled;
if (!mounted) {
logic.session.setOverlayOpen(false);
return;
}
try {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF232328),
showDragHandle: true,
builder: (_) => DramaCommentsSheet(drama: drama),
);
} finally {
logic.session.setOverlayOpen(false);
}
}
Future<void> _shareDrama(Drama drama) async {
final l10n = AppLocalizations.of(context)!;
logic.session.setOverlayOpen(true);
await logic.session.playbackSettled;
try {
await const PlatformShareService().shareDrama(
title: localizedDramaTitle(l10n, drama),
synopsis: localizedDramaSynopsis(l10n, drama),
footer: l10n.shareFooter,
);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)),
);
}
} finally {
logic.session.setOverlayOpen(false);
}
}
@override
Widget build(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(_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: _shiftCategory,
child: ColoredBox(
color: const Color(0xFF111214),
child: SafeArea(
child: Column(
children: [
HomeFeedHeader(
l10n: l10n,
category: logic.category,
onCategorySelected: (value) =>
unawaited(_selectCategory(value)),
onCategoryShift: _shiftCategory,
onSearch: _showSearch,
onMy: _showMy,
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: _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: _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(_setFullscreen(false)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(_enterPip())
: null,
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
onSeek: ready ? (position) => _seekTo(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(_selectCategory(value)),
onCategoryShift: _shiftCategory,
onDetail: _openDetail,
onDiscover: _showDiscover,
onEpisodes: _showEpisodes,
onMy: _showMy,
onSearch: _showSearch,
onMenu: widget.onMenu,
onMute: _toggleMute,
onLike: () => engagement.toggleLiked(drama.id),
onSave: () => engagement.toggleSaved(drama.id),
onComments: () => _showComments(drama),
onShare: () => _shareDrama(drama),
onFullscreen: () => unawaited(_setFullscreen(true)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(_enterPip())
: null,
onScrubStart: ready ? () => _beginScrub(videoPort!) : null,
onSeek: ready ? (position) => _seekTo(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(_leave()),
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: _showMy,
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: _openDetail,
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(_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: _openDetail,
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: _showEpisodes,
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: _showEpisodes,
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 ? () => _beginScrub(videoPort!) : null,
onSeek: ready
? (position) => _seekTo(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: _showDiscover,
),
_BottomAction(
icon: Icons.person_outline_rounded,
label: l10n.myTab,
onTap: _showMy,
),
],
),
],
],
),
),
),
],
);
}
}
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),
),
),
],
),
),
);
}