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 '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.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? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; @override State createState() => _PlayerPageState(); } class _PlayerPageState extends State 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(), coordinator: context.read(), ); } @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(); 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( 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.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), ), ], ), ), ), // 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。 if (selected && !ready && 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), saved: engagement.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 { if (await ensureSignedIn(context)) { unawaited(engagement.toggleSaved(drama.id)); } }, onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, showBottomNav: false, ), if (logic.screenCleared) SafeArea( child: Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.only(top: 4, right: 8), child: IconButton( tooltip: l10n.exitClearScreen, onPressed: () => logic.setScreenCleared(false), style: IconButton.styleFrom( foregroundColor: Colors.white, backgroundColor: const Color(0x59000000), ), icon: const Icon(Icons.close_fullscreen_rounded), ), ), ), ), ], ); } } class _FullscreenControls extends StatelessWidget { const _FullscreenControls({ required this.l10n, required this.controller, required this.onClose, required this.onPip, required this.onScrubStart, required this.onSeek, }); final AppLocalizations l10n; final VideoPlayerController? controller; final VoidCallback onClose; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future 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, ), ), ], ), ); } 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 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), ), ), ], ), ), ); }