feat: add swipe-up content strip for mobile video controls

This commit is contained in:
edde746
2026-03-03 11:03:31 +01:00
parent cd36e3acb0
commit 1629801074
4 changed files with 622 additions and 66 deletions
@@ -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<PlexChapter> 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<bool>? controlsVisible;
/// Called when the content strip visibility changes
final ValueChanged<bool>? 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<MobileVideoControls> createState() => _MobileVideoControlsState();
}
class _MobileVideoControlsState extends State<MobileVideoControls>
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,
),
),
);
+52 -26
View File
@@ -211,6 +211,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<DesktopVideoControlsState> _desktopControlsKey = GlobalKey<DesktopVideoControlsState>();
@@ -718,6 +719,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> with WindowListen
return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers);
}
Widget _buildTrackChapterControlsWidget() {
Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) {
final playbackState = context.watch<PlaybackStateProvider>();
return TrackChapterControls(
@@ -1027,6 +1029,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<PlaybackStateProvider>();
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<PlexVideoControls> with WindowListen
right: 24,
bottom: () {
if (!_showControls) return 24.0;
if (_isContentStripVisible) return 180.0;
return isMobile ? 80.0 : 115.0;
}(),
child: AnimatedOpacity(
@@ -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<PlexChapter> 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<ContentStrip> createState() => _ContentStripState();
}
enum _StripTab { chapters, queue }
class _ContentStripState extends State<ContentStrip> {
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<Duration>(
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<PlaybackStateProvider>(
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,
),
],
),
),
);
}
}
@@ -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++;