import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; import '../../models/plex_media_info.dart'; import '../../models/plex_metadata.dart'; import '../../utils/desktop_window_padding.dart'; import '../../i18n/strings.g.dart'; import 'widgets/circular_control_button.dart'; import 'widgets/content_strip.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/live_timeline_bar.dart'; import 'widgets/video_controls_header.dart'; import 'widgets/video_timeline_bar.dart'; /// Mobile video controls layout for Plex video player /// /// Displays a full-screen overlay with: /// - Top bar: Back button, title, and track/chapter controls /// - Center: Large playback controls (seek backward, play/pause, seek forward) /// - Bottom bar: Timeline slider with chapter markers and timestamps /// /// When chapters or queue are available, the user can swipe up on the bottom /// area to slide a content strip into view. The playback controls and timeline /// fade out while the strip slides up — only the top bar stays fixed. class MobileVideoControls extends StatefulWidget { final Player player; final PlexMetadata metadata; final List chapters; final bool chaptersLoaded; final int seekTimeSmall; final Widget trackChapterControls; final Function(Duration) onSeek; final Function(Duration) onSeekEnd; final Function(Duration)? onSeekCompleted; final VoidCallback onPlayPause; final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; final VoidCallback? onBack; final VoidCallback? onNext; final VoidCallback? onPrevious; /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; /// Optional callback that returns thumbnail image bytes for a given timestamp. final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; /// Channel name for live TV display final String? liveChannelName; // Live TV time-shift final CaptureBuffer? captureBuffer; final bool isAtLiveEdge; final int? programBeginsAt; final int? programEndsAt; final int? currentPositionEpoch; final ValueChanged? onLiveSeek; final VoidCallback? onJumpToLive; /// Server ID for chapter thumbnails in the content strip final String? serverId; /// Whether to show the queue tab in the content strip final bool showQueueTab; /// Callback when a queue item is selected from the content strip final Function(PlexMetadata)? onQueueItemSelected; /// Notifier for controls visibility (used to reset strip on hide) final ValueNotifier? controlsVisible; /// Called when the content strip visibility changes final ValueChanged? onStripVisibilityChanged; const MobileVideoControls({ super.key, required this.player, required this.metadata, required this.chapters, required this.chaptersLoaded, required this.seekTimeSmall, required this.trackChapterControls, required this.onSeek, required this.onSeekEnd, required this.onPlayPause, this.onSeekCompleted, this.onCancelAutoHide, this.onStartAutoHide, this.onBack, this.onNext, this.onPrevious, this.canControl = true, this.hasFirstFrame, this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.captureBuffer, this.isAtLiveEdge = true, this.programBeginsAt, this.programEndsAt, this.currentPositionEpoch, this.onLiveSeek, this.onJumpToLive, this.serverId, this.showQueueTab = false, this.onQueueItemSelected, this.controlsVisible, this.onStripVisibilityChanged, }); @override State createState() => _MobileVideoControlsState(); } class _MobileVideoControlsState extends State with SingleTickerProviderStateMixin { late final AnimationController _stripAnim; bool _stripVisible = false; /// Drag distance (in pixels) required to fully reveal the strip. static const _dragExtent = 150.0; bool get _hasStripContent => widget.chapters.isNotEmpty || (widget.showQueueTab && widget.onQueueItemSelected != null); @override void initState() { super.initState(); _stripAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 250), ); _stripAnim.addListener(_onStripAnimChanged); widget.controlsVisible?.addListener(_onControlsVisibilityChanged); } @override void didUpdateWidget(MobileVideoControls oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controlsVisible != widget.controlsVisible) { oldWidget.controlsVisible?.removeListener(_onControlsVisibilityChanged); widget.controlsVisible?.addListener(_onControlsVisibilityChanged); } } @override void dispose() { widget.controlsVisible?.removeListener(_onControlsVisibilityChanged); _stripAnim.removeListener(_onStripAnimChanged); _stripAnim.dispose(); super.dispose(); } void _onStripAnimChanged() { final visible = _stripAnim.value > 0.5; if (visible != _stripVisible) { _stripVisible = visible; widget.onStripVisibilityChanged?.call(visible); } } void _onControlsVisibilityChanged() { if (widget.controlsVisible?.value == false && _stripVisible) { // Just notify parent that strip is no longer active — don't animate, // let the overlay fade out with the strip still showing. _stripVisible = false; widget.onStripVisibilityChanged?.call(false); } else if (widget.controlsVisible?.value == true && _stripAnim.value > 0) { // Reset strip when controls reappear so page 0 is shown. _stripAnim.value = 0; } } void _onVerticalDragUpdate(DragUpdateDetails details) { // Negative primaryDelta = swipe up = reveal strip (increase value) _stripAnim.value -= (details.primaryDelta ?? 0) / _dragExtent; } void _onVerticalDragEnd(DragEndDetails details) { final velocity = details.primaryVelocity ?? 0; // Fast swipe up or past halfway without fast swipe down → show strip if (velocity < -200 || (_stripAnim.value > 0.5 && velocity < 200)) { _stripAnim.forward(); } else { _stripAnim.reverse(); } } @override Widget build(BuildContext context) { if (!_hasStripContent) { return Column( children: [ _buildTopBar(context), const Spacer(), _buildPlaybackControls(context), const Spacer(), _buildBottomBar(context), ], ); } // Top bar stays fixed. Everything below it is a Stack where the controls // fade out and the content strip slides up from the bottom on swipe. return Column( children: [ _buildTopBar(context), Expanded( child: GestureDetector( onVerticalDragUpdate: _onVerticalDragUpdate, onVerticalDragEnd: _onVerticalDragEnd, behavior: HitTestBehavior.translucent, child: ListenableBuilder( listenable: _stripAnim, builder: (context, _) { final t = _stripAnim.value; return Stack( clipBehavior: Clip.none, children: [ // Normal controls — fade out as strip appears Positioned.fill( child: IgnorePointer( ignoring: t > 0.5, child: Opacity( opacity: (1 - t * 2).clamp(0.0, 1.0), child: Stack( children: [ Column( children: [ const Spacer(), _buildPlaybackControls(context), const Spacer(), _buildBottomBar(context), ], ), const Positioned( left: 0, right: 0, bottom: 12, child: Icon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), ), ], ), ), ), ), // Content strip — slides up from below the bottom edge Align( alignment: Alignment.bottomCenter, child: FractionalTranslation( translation: Offset(0, 1 - t), child: IgnorePointer( ignoring: t < 0.5, child: Opacity( opacity: (t * 2).clamp(0.0, 1.0), child: Container( padding: const EdgeInsets.only(top: 32), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.65), Colors.black.withValues(alpha: 0.7), ], stops: const [0.0, 0.42, 1.0], ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), const SizedBox(height: 4), ContentStrip( player: widget.player, chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, serverId: widget.serverId, showQueueTab: widget.showQueueTab, onQueueItemSelected: widget.onQueueItemSelected, ), ], ), ), ), ), ), ), ], ); }, ), ), ), ], ); } Widget _buildTopBar(BuildContext context) { final topBar = _conditionalSafeArea( context: context, bottom: false, // Only respect top safe area when in portrait child: Padding( padding: const EdgeInsets.all(16), child: VideoControlsHeader( metadata: widget.metadata, style: VideoHeaderStyle.multiLine, trailing: widget.trackChapterControls, onBack: widget.onBack, ), ), ); return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true); } Widget _buildPlaybackControls(BuildContext _) { // Hide all playback controls in host-only mode for non-host if (!widget.canControl) { return const SizedBox.shrink(); } return FirstFrameGuard(hasFirstFrame: widget.hasFirstFrame, builder: (context) => _buildPlaybackControlsContent(context)); } Widget _buildPlaybackControlsContent(BuildContext _) { return PlayPauseStreamBuilder( player: widget.player, builder: (context, isPlaying) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!widget.isLive) ...[ // Previous episode button (greyed out when unavailable) CircularControlButton( semanticLabel: t.videoControls.previousButton, icon: Symbols.skip_previous_rounded, iconSize: 48, onPressed: widget.onPrevious, ), const SizedBox(width: 24), ], CircularControlButton( semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 72, onPressed: () { if (isPlaying) { widget.player.pause(); widget.onCancelAutoHide?.call(); // Cancel auto-hide when paused } else { widget.player.play(); widget.onStartAutoHide?.call(); // Start auto-hide when playing } }, ), if (!widget.isLive) ...[ const SizedBox(width: 24), // Next episode button (greyed out when unavailable) CircularControlButton( semanticLabel: t.videoControls.nextButton, icon: Symbols.skip_next_rounded, iconSize: 48, onPressed: widget.onNext, ), ], ], ); }, ); } Widget _buildBottomBar(BuildContext _) { if (widget.isLive) { if (widget.captureBuffer != null && widget.currentPositionEpoch != null) { // Live TV with time-shift: show seekable timeline return FirstFrameGuard( hasFirstFrame: widget.hasFirstFrame, builder: (context) => LiveTimelineBar( captureBuffer: widget.captureBuffer!, programBeginsAt: widget.programBeginsAt, programEndsAt: widget.programEndsAt, currentPositionEpoch: widget.currentPositionEpoch!, isAtLiveEdge: widget.isAtLiveEdge, onSeekEnd: widget.onLiveSeek, onJumpToLive: widget.onJumpToLive, horizontalLayout: false, enabled: widget.canControl, ), ); } // Fallback: static LIVE badge (no capture buffer) return Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))), child: Text( t.liveTv.live, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), ), ), ], ), ); } return FirstFrameGuard(hasFirstFrame: widget.hasFirstFrame, builder: (context) => _buildBottomBarContent(context)); } Widget _buildBottomBarContent(BuildContext context) { return _conditionalSafeArea( context: context, top: false, // Only respect bottom safe area when in portrait child: Padding( padding: const EdgeInsets.all(16), child: VideoTimelineBar( player: widget.player, chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, onSeek: widget.onSeek, onSeekEnd: widget.onSeekEnd, horizontalLayout: false, enabled: widget.canControl, showFinishTime: true, thumbnailDataBuilder: widget.thumbnailDataBuilder, ), ), ); } /// Conditionally wraps child with SafeArea only in portrait mode Widget _conditionalSafeArea({ required BuildContext context, required Widget child, bool top = true, bool bottom = true, }) { final orientation = MediaQuery.of(context).orientation; final isPortrait = orientation == Orientation.portrait; // Only apply SafeArea in portrait mode if (isPortrait) { return SafeArea(top: top, bottom: bottom, child: child); } // In landscape, return child without SafeArea return child; } }