feat: add swipe-up content strip for mobile video controls
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user