diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index f59584cd..2298c39a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -9,6 +9,7 @@ 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/video_controls_header.dart'; @@ -20,7 +21,11 @@ import 'widgets/video_timeline_bar.dart'; /// - 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 -class MobileVideoControls extends StatelessWidget { +/// +/// 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; @@ -54,6 +59,21 @@ class MobileVideoControls extends StatelessWidget { /// Channel name for live TV display final String? liveChannelName; + /// 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, @@ -78,20 +98,183 @@ class MobileVideoControls extends StatelessWidget { this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, + 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: [ - // Top bar with back button and track/chapter controls _buildTopBar(context), - const Spacer(), - // Centered large playback controls - _buildPlaybackControls(context), - const Spacer(), - // Progress bar at bottom - _buildBottomBar(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: 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, + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), ], ); } @@ -103,10 +286,10 @@ class MobileVideoControls extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: VideoControlsHeader( - metadata: metadata, + metadata: widget.metadata, style: VideoHeaderStyle.multiLine, - trailing: trackChapterControls, - onBack: onBack, + trailing: widget.trackChapterControls, + onBack: widget.onBack, ), ), ); @@ -116,29 +299,29 @@ class MobileVideoControls extends StatelessWidget { Widget _buildPlaybackControls(BuildContext _) { // Hide all playback controls in host-only mode for non-host - if (!canControl) { + if (!widget.canControl) { return const SizedBox.shrink(); } - return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildPlaybackControlsContent(context)); + return FirstFrameGuard(hasFirstFrame: widget.hasFirstFrame, builder: (context) => _buildPlaybackControlsContent(context)); } Widget _buildPlaybackControlsContent(BuildContext _) { - final hasChapters = !isLive && chaptersLoaded && chapters.isNotEmpty; + final hasChapters = !widget.isLive && widget.chaptersLoaded && widget.chapters.isNotEmpty; return PlayPauseStreamBuilder( - player: player, + player: widget.player, builder: (context, isPlaying) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (!isLive) ...[ + if (!widget.isLive) ...[ if (hasChapters) ...[ CircularControlButton( semanticLabel: t.videoControls.previousChapterButton, icon: Symbols.fast_rewind_rounded, iconSize: 32, - onPressed: onSeekToPreviousChapter, + onPressed: widget.onSeekToPreviousChapter, ), const SizedBox(width: 16), ], @@ -147,7 +330,7 @@ class MobileVideoControls extends StatelessWidget { semanticLabel: t.videoControls.previousButton, icon: Symbols.skip_previous_rounded, iconSize: 48, - onPressed: onPrevious, + onPressed: widget.onPrevious, ), const SizedBox(width: 24), ], @@ -157,22 +340,22 @@ class MobileVideoControls extends StatelessWidget { iconSize: 72, onPressed: () { if (isPlaying) { - player.pause(); - onCancelAutoHide?.call(); // Cancel auto-hide when paused + widget.player.pause(); + widget.onCancelAutoHide?.call(); // Cancel auto-hide when paused } else { - player.play(); - onStartAutoHide?.call(); // Start auto-hide when playing + widget.player.play(); + widget.onStartAutoHide?.call(); // Start auto-hide when playing } }, ), - if (!isLive) ...[ + 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: onNext, + onPressed: widget.onNext, ), if (hasChapters) ...[ const SizedBox(width: 16), @@ -180,7 +363,7 @@ class MobileVideoControls extends StatelessWidget { semanticLabel: t.videoControls.nextChapterButton, icon: Symbols.fast_forward_rounded, iconSize: 32, - onPressed: onSeekToNextChapter, + onPressed: widget.onSeekToNextChapter, ), ], ], @@ -191,7 +374,7 @@ class MobileVideoControls extends StatelessWidget { } Widget _buildBottomBar(BuildContext _) { - if (isLive) { + if (widget.isLive) { // For live TV, show channel name instead of timeline return Padding( padding: const EdgeInsets.all(16), @@ -209,7 +392,7 @@ class MobileVideoControls extends StatelessWidget { ), ); } - return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildBottomBarContent(context)); + return FirstFrameGuard(hasFirstFrame: widget.hasFirstFrame, builder: (context) => _buildBottomBarContent(context)); } Widget _buildBottomBarContent(BuildContext context) { @@ -219,15 +402,15 @@ class MobileVideoControls extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: VideoTimelineBar( - player: player, - chapters: chapters, - chaptersLoaded: chaptersLoaded, - onSeek: onSeek, - onSeekEnd: onSeekEnd, + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + onSeek: widget.onSeek, + onSeekEnd: widget.onSeekEnd, horizontalLayout: false, - enabled: canControl, + enabled: widget.canControl, showFinishTime: true, - thumbnailDataBuilder: thumbnailDataBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, ), ), ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a538dd02..288083af 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -211,6 +211,7 @@ class _PlexVideoControlsState extends State with WindowListen int _subtitleSyncOffset = 0; // Default, loaded from settings bool _isRotationLocked = true; // Default locked (landscape only) bool _clickVideoTogglesPlayback = false; // Default, loaded from settings + bool _isContentStripVisible = false; // Whether the swipe-up content strip is showing // GlobalKey to access DesktopVideoControls state for focus management final GlobalKey _desktopControlsKey = GlobalKey(); @@ -718,6 +719,7 @@ class _PlexVideoControlsState extends State with WindowListen if (!mounted || !_showControls || _forceShowControls) return; setState(() { _showControls = false; + _isContentStripVisible = false; // Dismiss skip button with controls — after this it only re-appears with controls if (_currentMarker != null) { _skipButtonDismissed = true; @@ -984,7 +986,7 @@ class _PlexVideoControlsState extends State with WindowListen return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers); } - Widget _buildTrackChapterControlsWidget() { + Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { final playbackState = context.watch(); return TrackChapterControls( @@ -1027,6 +1029,7 @@ class _PlexVideoControlsState extends State with WindowListen isLive: widget.isLive, showQueueButton: playbackState.isQueueActive, onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + hideChaptersAndQueue: hideChaptersAndQueue, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, @@ -1911,31 +1914,53 @@ class _PlexVideoControlsState extends State with WindowListen child: isMobile ? Listener( behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: MobileVideoControls( - player: widget.player, - metadata: widget.metadata, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - trackChapterControls: _buildTrackChapterControlsWidget(), - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - onSeekCompleted: widget.onSeekCompleted, - // ignore: no-empty-block - play/pause handled by parent VideoControlsState - onPlayPause: () {}, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onBack: widget.onBack, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - onSeekToPreviousChapter: _seekToPreviousChapter, - onSeekToNextChapter: _seekToNextChapter, - canControl: widget.canControl, - hasFirstFrame: widget.hasFirstFrame, - thumbnailDataBuilder: widget.thumbnailDataBuilder, - isLive: widget.isLive, - liveChannelName: widget.liveChannelName, + onPointerDown: (_) { + if (!_isContentStripVisible) _restartHideTimerIfPlaying(); + }, + child: Builder( + builder: (context) { + final playbackState = context.watch(); + final hasStripContent = _chapters.isNotEmpty || playbackState.isQueueActive; + return MobileVideoControls( + player: widget.player, + metadata: widget.metadata, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + trackChapterControls: _buildTrackChapterControlsWidget( + hideChaptersAndQueue: hasStripContent, + ), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + onSeekCompleted: widget.onSeekCompleted, + // ignore: no-empty-block - play/pause handled by parent VideoControlsState + onPlayPause: () {}, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onBack: widget.onBack, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, + thumbnailDataBuilder: widget.thumbnailDataBuilder, + isLive: widget.isLive, + liveChannelName: widget.liveChannelName, + serverId: widget.metadata.serverId, + showQueueTab: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + controlsVisible: widget.controlsVisible, + onStripVisibilityChanged: (visible) { + setState(() => _isContentStripVisible = visible); + if (visible) { + _hideTimer?.cancel(); + } else { + _restartHideTimerIfPlaying(); + } + }, + ); + }, ), ) : _buildDesktopControlsListener(), @@ -1968,6 +1993,7 @@ class _PlexVideoControlsState extends State with WindowListen right: 24, bottom: () { if (!_showControls) return 24.0; + if (_isContentStripVisible) return 180.0; return isMobile ? 80.0 : 115.0; }(), child: AnimatedOpacity( diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart new file mode 100644 index 00000000..e7e72d8f --- /dev/null +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../../i18n/strings.g.dart'; +import '../../../mpv/mpv.dart'; +import '../../../models/plex_media_info.dart'; +import '../../../models/plex_metadata.dart'; +import '../../../providers/playback_state_provider.dart'; +import '../../../services/download_storage_service.dart'; +import '../../../services/plex_client.dart'; +import '../../../theme/mono_tokens.dart'; +import '../../../utils/formatters.dart'; +import '../../../utils/provider_extensions.dart'; +import '../../app_icon.dart'; +import '../../plex_optimized_image.dart'; + +/// Horizontal scrollable strip of chapter/queue items shown on swipe-up. +class ContentStrip extends StatefulWidget { + final Player player; + final List chapters; + final bool chaptersLoaded; + final String? serverId; + final bool showQueueTab; + final Function(PlexMetadata)? onQueueItemSelected; + + const ContentStrip({ + super.key, + required this.player, + required this.chapters, + required this.chaptersLoaded, + this.serverId, + this.showQueueTab = false, + this.onQueueItemSelected, + }); + + @override + State createState() => _ContentStripState(); +} + +enum _StripTab { chapters, queue } + +class _ContentStripState extends State { + late _StripTab _activeTab; + final ScrollController _chapterScrollController = ScrollController(); + final ScrollController _queueScrollController = ScrollController(); + bool _hasAutoScrolledChapters = false; + bool _hasAutoScrolledQueue = false; + + bool get _hasChapters => widget.chapters.isNotEmpty; + bool get _hasQueue => widget.showQueueTab && widget.onQueueItemSelected != null; + bool get _hasBothTabs => _hasChapters && _hasQueue; + + @override + void initState() { + super.initState(); + _activeTab = _hasChapters ? _StripTab.chapters : _StripTab.queue; + } + + @override + void dispose() { + _chapterScrollController.dispose(); + _queueScrollController.dispose(); + super.dispose(); + } + + PlexClient? _tryGetClient(BuildContext context, String? serverId) { + if (serverId == null) return null; + try { + return context.getClientForServer(serverId); + } catch (_) { + return null; + } + } + + void _autoScrollTo(ScrollController controller, int index, {bool force = false}) { + if (!controller.hasClients) return; + const itemWidth = 132.0; // 120 thumb + 12 padding + final target = (index * itemWidth - 60).clamp(0.0, controller.position.maxScrollExtent); + if (force || (target - controller.offset).abs() > itemWidth) { + controller.jumpTo(target); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasBothTabs) _buildTabBar(context), + const SizedBox(height: 8), + SizedBox( + height: 106, + child: _activeTab == _StripTab.chapters ? _buildChapterStrip(context) : _buildQueueStrip(context), + ), + ], + ), + ), + ); + } + + Widget _buildTabBar(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildTabLabel(context, t.videoControls.chapters, _StripTab.chapters), + const SizedBox(width: 24), + _buildTabLabel(context, t.videoControls.queue, _StripTab.queue), + ], + ); + } + + Widget _buildTabLabel(BuildContext context, String label, _StripTab tab) { + final isActive = _activeTab == tab; + return GestureDetector( + onTap: () => setState(() => _activeTab = tab), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: isActive ? Colors.white : Colors.white54, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Container( + height: 2, + width: 40, + color: isActive ? Colors.blue : Colors.transparent, + ), + ], + ), + ); + } + + Widget _buildChapterStrip(BuildContext context) { + return StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + final currentPosition = positionSnapshot.data ?? Duration.zero; + final currentPositionMs = currentPosition.inMilliseconds; + + int? currentChapterIndex; + for (int i = 0; i < widget.chapters.length; i++) { + final chapter = widget.chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = chapter.endTimeOffset ?? + (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + currentChapterIndex = i; + break; + } + } + + // Auto-scroll to current chapter on first build + if (!_hasAutoScrolledChapters && currentChapterIndex != null) { + _hasAutoScrolledChapters = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoScrollTo(_chapterScrollController, currentChapterIndex!); + }); + } + + return ListView.builder( + controller: _chapterScrollController, + scrollDirection: Axis.horizontal, + itemCount: widget.chapters.length, + padding: const EdgeInsets.symmetric(horizontal: 4), + itemBuilder: (context, index) { + final chapter = widget.chapters[index]; + final isCurrent = currentChapterIndex == index; + + final localThumbPath = widget.serverId != null && chapter.thumb != null + ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) + : null; + + return _buildStripItem( + context: context, + isCurrent: isCurrent, + thumbnail: chapter.thumb != null + ? PlexOptimizedImage.thumb( + client: _tryGetClient(context, widget.serverId), + imagePath: chapter.thumb, + localFilePath: localThumbPath, + width: 120, + height: 68, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => + const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ) + : null, + title: chapter.label, + subtitle: formatDurationTimestamp(chapter.startTime), + onTap: () => widget.player.seek(chapter.startTime), + ); + }, + ); + }, + ); + } + + Widget _buildQueueStrip(BuildContext context) { + return Consumer( + builder: (context, playbackState, _) { + final items = playbackState.loadedItems; + final currentItemID = playbackState.currentPlayQueueItemID; + final currentIndex = items.indexWhere((item) => item.playQueueItemID == currentItemID); + + if (!_hasAutoScrolledQueue && currentIndex >= 0) { + _hasAutoScrolledQueue = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoScrollTo(_queueScrollController, currentIndex); + }); + } + + return ListView.builder( + controller: _queueScrollController, + scrollDirection: Axis.horizontal, + itemCount: items.length, + padding: const EdgeInsets.symmetric(horizontal: 4), + itemBuilder: (context, index) { + final item = items[index]; + final isCurrent = item.playQueueItemID == currentItemID; + + PlexClient? client; + if (item.serverId != null) { + try { + client = context.getClientForServer(item.serverId!); + } catch (_) {} + } + + return _buildStripItem( + context: context, + isCurrent: isCurrent, + thumbnail: item.thumb != null + ? PlexOptimizedImage.thumb( + client: client, + imagePath: item.thumb, + width: 120, + height: 68, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => + const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ) + : null, + title: item.title, + subtitle: _buildQueueSubtitle(item), + onTap: () => widget.onQueueItemSelected?.call(item), + ); + }, + ); + }, + ); + } + + String _buildQueueSubtitle(PlexMetadata item) { + if (item.grandparentTitle != null && item.parentIndex != null && item.index != null) { + return '${item.grandparentTitle} \u00b7 S${item.parentIndex}E${item.index}'; + } + if (item.grandparentTitle != null) return item.grandparentTitle!; + if (item.year != null) return '${item.year}'; + return item.type; + } + + Widget _buildStripItem({ + required BuildContext context, + required bool isCurrent, + required Widget? thumbnail, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 120, + margin: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Thumbnail + SizedBox( + width: 120, + height: 68, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(6)), + child: thumbnail ?? + Container( + color: Colors.white10, + child: const Center( + child: AppIcon(Symbols.movie_rounded, fill: 1, color: Colors.white38, size: 28), + ), + ), + ), + if (isCurrent) + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + // Title + Text( + title, + style: TextStyle( + color: isCurrent ? Colors.blue : Colors.white, + fontSize: 11, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Subtitle + Text( + subtitle, + style: TextStyle( + color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, + fontSize: 10, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 91a1f015..049ed11d 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -81,6 +81,9 @@ class TrackChapterControls extends StatelessWidget { /// Callback when a queue item is selected final Function(PlexMetadata)? onQueueItemSelected; + /// Whether to hide the chapters and queue buttons (mobile uses content strip instead) + final bool hideChaptersAndQueue; + const TrackChapterControls({ super.key, required this.player, @@ -115,6 +118,7 @@ class TrackChapterControls extends StatelessWidget { this.subtitlesVisible = true, this.showQueueButton = false, this.onQueueItemSelected, + this.hideChaptersAndQueue = false, this.shaderService, this.onShaderChanged, this.isAmbientLightingEnabled = false, @@ -275,8 +279,8 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } - // Chapters button - if (chapters.isNotEmpty) { + // Chapters button (hidden on mobile when content strip is available) + if (chapters.isNotEmpty && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -303,8 +307,8 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } - // Queue button - if (showQueueButton && onQueueItemSelected != null) { + // Queue button (hidden on mobile when content strip is available) + if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -454,8 +458,8 @@ class TrackChapterControls extends StatelessWidget { int _getButtonCount(Tracks? tracks, bool isMobile, bool isDesktop) { int count = 1; // Settings button always shown if (_hasMultipleAudioTracks(tracks) || _hasSubtitles(tracks)) count++; - if (chapters.isNotEmpty) count++; - if (showQueueButton && onQueueItemSelected != null) count++; + if (chapters.isNotEmpty && !hideChaptersAndQueue) count++; + if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++; if (availableVersions.length > 1 && onSwitchVersion != null) count++; if (onTogglePIPMode != null) count++; if (onCycleBoxFitMode != null) count++;