import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../services/keyboard_shortcuts_service.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import 'app_bar_back_button.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( Player player, PlexMetadata metadata, { VoidCallback? onNext, VoidCallback? onPrevious, }) { return PlexVideoControls( player: player, metadata: metadata, onNext: onNext, onPrevious: onPrevious, ); } class PlexVideoControls extends StatefulWidget { final Player player; final PlexMetadata metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; const PlexVideoControls({ super.key, required this.player, required this.metadata, this.onNext, this.onPrevious, }); @override State createState() => _PlexVideoControlsState(); } class _PlexVideoControlsState extends State with WindowListener { bool _showControls = true; List _chapters = []; bool _chaptersLoaded = false; Timer? _hideTimer; bool _isFullscreen = false; late final FocusNode _focusNode; KeyboardShortcutsService? _keyboardService; @override void initState() { super.initState(); _focusNode = FocusNode(); _loadChapters(); _startHideTimer(); _initKeyboardService(); // Add window listener for tracking fullscreen state (for button icon) if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.addListener(this); } } Future _initKeyboardService() async { _keyboardService = await KeyboardShortcutsService.getInstance(); } void _toggleSubtitles() { // Toggle subtitle visibility - this would need to be implemented based on your subtitle system // For now, this is a placeholder } void _nextAudioTrack() { // Switch to next audio track - this would need to be implemented based on your track system // For now, this is a placeholder } void _nextSubtitleTrack() { // Switch to next subtitle track - this would need to be implemented based on your subtitle system // For now, this is a placeholder } void _nextChapter() { // Go to next chapter - this would use your existing chapter navigation if (widget.onNext != null) { widget.onNext!(); } } void _previousChapter() { // Go to previous chapter - this would use your existing chapter navigation if (widget.onPrevious != null) { widget.onPrevious!(); } } @override void dispose() { _hideTimer?.cancel(); _focusNode.dispose(); // Remove window listener if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.removeListener(this); } super.dispose(); } @override void onWindowEnterFullScreen() { if (mounted) { setState(() { _isFullscreen = true; }); } } @override void onWindowLeaveFullScreen() { if (mounted) { setState(() { _isFullscreen = false; }); } } @override void onWindowMaximize() { // On macOS, maximize is the same as fullscreen (green button) if (mounted && Platform.isMacOS) { setState(() { _isFullscreen = true; }); } } @override void onWindowUnmaximize() { // On macOS, unmaximize means exiting fullscreen if (mounted && Platform.isMacOS) { setState(() { _isFullscreen = false; }); } } void _startHideTimer() { _hideTimer?.cancel(); // Only auto-hide if playing if (widget.player.state.playing) { _hideTimer = Timer(const Duration(seconds: 3), () { if (mounted && widget.player.state.playing) { setState(() { _showControls = false; }); // Hide traffic lights on macOS when controls auto-hide if (Platform.isMacOS) { _updateTrafficLightVisibility(); } } }); } } void _toggleControls() { setState(() { _showControls = !_showControls; }); if (_showControls) { _startHideTimer(); } // On macOS, hide/show traffic lights with controls if (Platform.isMacOS) { _updateTrafficLightVisibility(); } } void _updateTrafficLightVisibility() async { if (Platform.isMacOS) { if (_showControls) { await WindowManipulator.showCloseButton(); await WindowManipulator.showMiniaturizeButton(); await WindowManipulator.showZoomButton(); } else { await WindowManipulator.hideCloseButton(); await WindowManipulator.hideMiniaturizeButton(); await WindowManipulator.hideZoomButton(); } } } Future _loadChapters() async { final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) return; final chapters = await client.getChapters(widget.metadata.ratingKey); if (mounted) { setState(() { _chapters = chapters; _chaptersLoaded = true; }); } } bool _hasMultipleAudioTracks(Tracks? tracks) { if (tracks == null) return false; final audioTracks = tracks.audio .where((track) => track.id != 'auto' && track.id != 'no') .toList(); return audioTracks.length > 1; } bool _hasSubtitles(Tracks? tracks) { if (tracks == null) return false; final subtitles = tracks.subtitle .where((track) => track.id != 'auto' && track.id != 'no') .toList(); return subtitles.isNotEmpty; } Widget _buildTrackAndChapterControls() { return StreamBuilder( stream: widget.player.stream.tracks, initialData: widget.player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.speed, color: Colors.white), onPressed: _showPlaybackSpeedBottomSheet, ), if (_hasMultipleAudioTracks(tracks)) IconButton( icon: const Icon(Icons.audiotrack, color: Colors.white), onPressed: _showAudioBottomSheet, ), if (_hasSubtitles(tracks)) IconButton( icon: const Icon(Icons.subtitles, color: Colors.white), onPressed: _showSubtitleBottomSheet, ), if (_chapters.isNotEmpty) IconButton( icon: const Icon(Icons.video_library, color: Colors.white), onPressed: _showChapterBottomSheet, ), ], ); }, ); } void _seekToPreviousChapter() { if (_chapters.isEmpty) { // No chapters - seek backward 10 seconds final currentPosition = widget.player.state.position; widget.player.seek(currentPosition - const Duration(seconds: 10)); return; } final currentPosition = widget.player.state.position.inMilliseconds; // Find current chapter for (int i = _chapters.length - 1; i >= 0; i--) { final chapterStart = _chapters[i].startTimeOffset ?? 0; if (currentPosition > chapterStart + 3000) { // If more than 3 seconds into chapter, go to start of current chapter widget.player.seek(Duration(milliseconds: chapterStart)); return; } } // If at start of first chapter, go to beginning widget.player.seek(Duration.zero); } void _seekToNextChapter() { if (_chapters.isEmpty) { // No chapters - seek forward 10 seconds final currentPosition = widget.player.state.position; widget.player.seek(currentPosition + const Duration(seconds: 10)); return; } final currentPosition = widget.player.state.position.inMilliseconds; // Find next chapter for (int i = 0; i < _chapters.length; i++) { final chapterStart = _chapters[i].startTimeOffset ?? 0; if (chapterStart > currentPosition) { widget.player.seek(Duration(milliseconds: chapterStart)); return; } } } Future _toggleFullscreen() async { if (!PlatformDetector.isMobile(context)) { // Query actual window state to determine what action to take // This ensures we always toggle correctly regardless of local state final isCurrentlyFullscreen = await windowManager.isFullScreen(); if (Platform.isMacOS) { // Use native macOS fullscreen - titlebar is handled automatically // Window listener will update _isFullscreen for UI if (isCurrentlyFullscreen) { await WindowManipulator.exitFullscreen(); } else { await WindowManipulator.enterFullscreen(); } } else { // For Windows/Linux, use window_manager // Window listener will update _isFullscreen for UI if (isCurrentlyFullscreen) { await windowManager.setFullScreen(false); } else { await windowManager.setFullScreen(true); } } } } @override Widget build(BuildContext context) { final isMobile = PlatformDetector.isMobile(context); return Focus( focusNode: _focusNode, autofocus: true, onKeyEvent: (node, event) { if (_keyboardService == null) return KeyEventResult.ignored; return _keyboardService!.handleVideoPlayerKeyEvent( event, widget.player, _toggleFullscreen, _toggleSubtitles, _nextAudioTrack, _nextSubtitleTrack, _nextChapter, _previousChapter, ); }, child: MouseRegion( cursor: _showControls ? SystemMouseCursors.basic : SystemMouseCursors.none, onHover: (_) { // Show controls when mouse moves if (!_showControls) { setState(() { _showControls = true; }); _startHideTimer(); // On macOS, show traffic lights when controls appear if (Platform.isMacOS) { _updateTrafficLightVisibility(); } } }, child: Stack( children: [ // Invisible tap detector that always covers the full area Positioned.fill( child: GestureDetector( onTap: _toggleControls, behavior: HitTestBehavior.opaque, child: Container(color: Colors.transparent), ), ), // Custom controls overlay - use AnimatedOpacity to keep widget tree alive Positioned.fill( child: IgnorePointer( ignoring: !_showControls, child: AnimatedOpacity( opacity: _showControls ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: GestureDetector( onTap: _toggleControls, behavior: HitTestBehavior.deferToChild, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.7), Colors.transparent, Colors.transparent, Colors.black.withValues(alpha: 0.7), ], stops: const [0.0, 0.2, 0.8, 1.0], ), ), child: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), ), ), ), ), ), // Middle area double-tap detector for fullscreen (desktop only) // Only covers the clear video area (20% to 80% vertically) if (!isMobile) Positioned( top: 0, left: 0, right: 0, bottom: 0, child: LayoutBuilder( builder: (context, constraints) { final height = constraints.maxHeight; final topExclude = height * 0.20; // Top 20% final bottomExclude = height * 0.20; // Bottom 20% return Stack( children: [ Positioned( top: topExclude, left: 0, right: 0, bottom: bottomExclude, child: GestureDetector( onTap: _toggleControls, onDoubleTap: _toggleFullscreen, behavior: HitTestBehavior.translucent, child: Container(color: Colors.transparent), ), ), ], ); }, ), ), ], ), ), ); } Widget _buildMobileLayout() { return Column( children: [ // Top bar with back button and track/chapter controls _buildMobileTopBar(), const Spacer(), // Centered large playback controls _buildMobilePlaybackControls(), const Spacer(), // Progress bar at bottom _buildMobileBottomBar(), ], ); } Widget _buildDesktopLayout() { return Column( children: [ // Top bar with back button and title _buildDesktopTopBar(), const Spacer(), // Bottom controls _buildDesktopBottomControls(), ], ); } // Mobile layout components Widget _buildMobileTopBar() { final topBar = Padding( padding: const EdgeInsets.all(16), child: Row( children: [ AppBarBackButton( style: BackButtonStyle.video, onPressed: () => Navigator.of(context).pop(true), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.metadata.grandparentTitle ?? widget.metadata.title, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (widget.metadata.parentIndex != null && widget.metadata.index != null) Text( 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', style: const TextStyle(color: Colors.white70, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // Track and chapter controls in top right _buildTrackAndChapterControls(), ], ), ); // On macOS, wrap with GestureDetector to prevent window dragging if (Platform.isMacOS) { return GestureDetector( behavior: HitTestBehavior.opaque, onPanDown: (_) {}, // Consume pan gestures to prevent window dragging child: topBar, ); } return topBar; } Widget _buildMobilePlaybackControls() { return StreamBuilder( stream: widget.player.stream.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: IconButton( icon: const Icon( Icons.replay_10, color: Colors.white, size: 48, ), iconSize: 48, onPressed: () { final currentPosition = widget.player.state.position; widget.player.seek( currentPosition - const Duration(seconds: 10), ); }, ), ), const SizedBox(width: 48), Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: IconButton( icon: Icon( isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 72, ), iconSize: 72, onPressed: () { if (isPlaying) { widget.player.pause(); _hideTimer?.cancel(); // Cancel auto-hide when paused } else { widget.player.play(); _startHideTimer(); // Start auto-hide when playing } }, ), ), const SizedBox(width: 48), Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: IconButton( icon: const Icon( Icons.forward_10, color: Colors.white, size: 48, ), iconSize: 48, onPressed: () { final currentPosition = widget.player.state.position; widget.player.seek( currentPosition + const Duration(seconds: 10), ); }, ), ), ], ); }, ); } Widget _buildMobileBottomBar() { return Padding( padding: const EdgeInsets.all(16), child: StreamBuilder( stream: widget.player.stream.position, builder: (context, positionSnapshot) { return StreamBuilder( stream: widget.player.stream.duration, builder: (context, durationSnapshot) { final position = positionSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero; return Column( children: [ _buildTimelineWithChapters( position: position, duration: duration, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration(position), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), Text( _formatDuration(duration), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), ], ), ), ], ); }, ); }, ), ); } // Desktop layout components Widget _buildDesktopTopBar() { // Use global fullscreen state for padding return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { final isFullscreen = FullscreenStateManager().isFullscreen; // In fullscreen on macOS, use less left padding since traffic lights auto-hide // In normal mode on macOS, need more padding to avoid traffic lights final leftPadding = Platform.isMacOS ? (isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft) : DesktopWindowPadding.macOSLeftFullscreen; return _buildDesktopTopBarContent(leftPadding); }, ); } Widget _buildDesktopTopBarContent(double leftPadding) { // On macOS, use single-line layout to align with traffic lights // On other platforms, use two-line layout with series and episode info final isMacOS = Platform.isMacOS; final topBar = Padding( padding: EdgeInsets.only(left: leftPadding, right: 16), child: Row( children: [ AppBarBackButton( style: BackButtonStyle.video, onPressed: () => Navigator.of(context).pop(true), ), const SizedBox(width: 16), Expanded( child: isMacOS ? _buildMacOSSingleLineTitle() : _buildMultiLineTitle(), ), ], ), ); // On macOS, wrap with GestureDetector to prevent window dragging if (Platform.isMacOS) { return GestureDetector( behavior: HitTestBehavior.opaque, onPanDown: (_) {}, // Consume pan gestures to prevent window dragging child: topBar, ); } return topBar; } Widget _buildMacOSSingleLineTitle() { // Build single-line title combining series and episode info final seriesName = widget.metadata.grandparentTitle ?? widget.metadata.title; final hasEpisodeInfo = widget.metadata.parentIndex != null && widget.metadata.index != null; final titleText = hasEpisodeInfo ? '$seriesName · S${widget.metadata.parentIndex} E${widget.metadata.index} · ${widget.metadata.title}' : seriesName; return Text( titleText, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ); } Widget _buildMultiLineTitle() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.metadata.grandparentTitle ?? widget.metadata.title, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (widget.metadata.parentIndex != null && widget.metadata.index != null) Text( 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', style: const TextStyle(color: Colors.white70, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ); } Widget _buildDesktopBottomControls() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( children: [ // Row 1: Timeline with time indicators StreamBuilder( stream: widget.player.stream.position, builder: (context, positionSnapshot) { return StreamBuilder( stream: widget.player.stream.duration, builder: (context, durationSnapshot) { final position = positionSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero; return Row( children: [ Text( _formatDuration(position), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), const SizedBox(width: 12), Expanded( child: _buildTimelineWithChapters( position: position, duration: duration, ), ), const SizedBox(width: 12), Text( _formatDuration(duration), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), ], ); }, ); }, ), const SizedBox(height: 4), // Row 2: Playback controls and options Row( children: [ // Previous item IconButton( icon: Icon( Icons.skip_previous, color: widget.onPrevious != null ? Colors.white : Colors.white54, ), onPressed: widget.onPrevious, ), // Previous chapter (or -10s if no chapters) IconButton( icon: Icon( _chapters.isEmpty ? Icons.replay_10 : Icons.fast_rewind, color: Colors.white, ), onPressed: _seekToPreviousChapter, ), // Play/Pause StreamBuilder( stream: widget.player.stream.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; return IconButton( icon: Icon( isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 32, ), iconSize: 32, onPressed: () { if (isPlaying) { widget.player.pause(); _hideTimer?.cancel(); // Cancel auto-hide when paused } else { widget.player.play(); _startHideTimer(); // Start auto-hide when playing } }, ); }, ), // Next chapter (or +10s if no chapters) IconButton( icon: Icon( _chapters.isEmpty ? Icons.forward_10 : Icons.fast_forward, color: Colors.white, ), onPressed: _seekToNextChapter, ), // Next item IconButton( icon: Icon( Icons.skip_next, color: widget.onNext != null ? Colors.white : Colors.white54, ), onPressed: widget.onNext, ), const Spacer(), // Volume control _buildVolumeControl(), const SizedBox(width: 16), // Audio track, subtitle, and chapter controls _buildTrackAndChapterControls(), // Fullscreen IconButton( icon: Icon( _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, color: Colors.white, ), onPressed: _toggleFullscreen, ), ], ), ], ), ); } Widget _buildTimelineWithChapters({ required Duration position, required Duration duration, }) { return Stack( alignment: Alignment.center, children: [ // Chapter markers layer if (_chaptersLoaded && _chapters.isNotEmpty && duration.inMilliseconds > 0) Positioned.fill( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: _chapters.map((chapter) { final chapterPosition = (chapter.startTimeOffset ?? 0) / duration.inMilliseconds; return Expanded( flex: (chapterPosition * 1000).toInt(), child: const SizedBox(), ); }).toList()..add( Expanded( flex: 1000 - _chapters.fold( 0, (sum, chapter) => sum + ((chapter.startTimeOffset ?? 0) / duration.inMilliseconds * 1000) .toInt(), ), child: const SizedBox(), ), ), ), ), ), // Slider Slider( value: duration.inMilliseconds > 0 ? position.inMilliseconds.toDouble() : 0.0, min: 0.0, max: duration.inMilliseconds.toDouble(), onChanged: (value) { widget.player.seek(Duration(milliseconds: value.toInt())); }, activeColor: Colors.white, inactiveColor: Colors.white.withValues(alpha: 0.3), ), // Chapter marker indicators if (_chaptersLoaded && _chapters.isNotEmpty && duration.inMilliseconds > 0) Positioned.fill( child: IgnorePointer( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: CustomPaint( painter: ChapterMarkerPainter( chapters: _chapters, duration: duration, ), ), ), ), ), ], ); } Widget _buildVolumeControl() { return StreamBuilder( stream: widget.player.stream.volume, initialData: widget.player.state.volume, builder: (context, snapshot) { final volume = snapshot.data ?? 100.0; final isMuted = volume == 0; return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( isMuted ? Icons.volume_off : Icons.volume_up, color: Colors.white, ), onPressed: () { if (isMuted) { widget.player.setVolume(100.0); } else { widget.player.setVolume(0.0); } }, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(width: 8), SizedBox( width: 100, child: SliderTheme( data: SliderThemeData( trackHeight: 3, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 6, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 12, ), ), child: Slider( value: volume, min: 0.0, max: 100.0, onChanged: (value) { widget.player.setVolume(value); }, activeColor: Colors.white, inactiveColor: Colors.white.withValues(alpha: 0.3), ), ), ), ], ); }, ); } BoxConstraints _getBottomSheetConstraints() { final size = MediaQuery.of(context).size; final isDesktop = size.width > 600; return BoxConstraints( maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75, minHeight: isDesktop ? 300 : size.height * 0.5, ); } void _showAudioBottomSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: _getBottomSheetConstraints(), builder: (context) => StreamBuilder( stream: widget.player.stream.tracks, initialData: widget.player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; final audioTracks = (tracks?.audio ?? []) .where((track) => track.id != 'auto' && track.id != 'no') .toList(); return SafeArea( child: SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.audiotrack, color: Colors.white), const SizedBox(width: 12), const Text( 'Audio Tracks', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), const Divider(color: Colors.white24, height: 1), if (audioTracks.isEmpty) const Expanded( child: Center( child: Text( 'No audio tracks available', style: TextStyle(color: Colors.white70), ), ), ) else Expanded( child: StreamBuilder( stream: widget.player.stream.track, initialData: widget.player.state.track, builder: (context, selectedSnapshot) { // Use snapshot data or fall back to current state final currentTrack = selectedSnapshot.data ?? widget.player.state.track; final selectedTrack = currentTrack.audio; final selectedId = selectedTrack?.id; return ListView.builder( itemCount: audioTracks.length, itemBuilder: (context, index) { final audioTrack = audioTracks[index]; final isSelected = audioTrack.id == selectedId; final parts = []; if (audioTrack.title != null && audioTrack.title!.isNotEmpty) { parts.add(audioTrack.title!); } if (audioTrack.language != null && audioTrack.language!.isNotEmpty) { parts.add(audioTrack.language!.toUpperCase()); } if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) { parts.add(audioTrack.codec!.toUpperCase()); } if (audioTrack.channelscount != null) { parts.add('${audioTrack.channelscount}ch'); } final label = parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · '); return ListTile( title: Text( label, style: TextStyle( color: isSelected ? Colors.blue : Colors.white, ), ), trailing: isSelected ? const Icon( Icons.check, color: Colors.blue, ) : null, onTap: () { widget.player.setAudioTrack(audioTrack); Navigator.pop(context); }, ); }, ); }, ), ), ], ), ), ); }, ), ); } void _showSubtitleBottomSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: _getBottomSheetConstraints(), builder: (context) => StreamBuilder( stream: widget.player.stream.tracks, initialData: widget.player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; final subtitles = (tracks?.subtitle ?? []) .where((track) => track.id != 'auto' && track.id != 'no') .toList(); return SafeArea( child: SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.subtitles, color: Colors.white), const SizedBox(width: 12), const Text( 'Subtitles', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), const Divider(color: Colors.white24, height: 1), if (subtitles.isEmpty) const Expanded( child: Center( child: Text( 'No subtitles available', style: TextStyle(color: Colors.white70), ), ), ) else Expanded( child: StreamBuilder( stream: widget.player.stream.track, initialData: widget.player.state.track, builder: (context, selectedSnapshot) { // Use snapshot data or fall back to current state final currentTrack = selectedSnapshot.data ?? widget.player.state.track; final selectedTrack = currentTrack.subtitle; final selectedId = selectedTrack?.id; final isOffSelected = selectedId == 'no'; return ListView.builder( itemCount: subtitles.length + 1, // +1 for "Off" option itemBuilder: (context, index) { // First item is "Off" if (index == 0) { return ListTile( title: Text( 'Off', style: TextStyle( color: isOffSelected ? Colors.blue : Colors.white, ), ), trailing: isOffSelected ? const Icon( Icons.check, color: Colors.blue, ) : null, onTap: () { widget.player.setSubtitleTrack( SubtitleTrack.no(), ); Navigator.pop(context); }, ); } // Subsequent items are subtitle tracks final subtitle = subtitles[index - 1]; final isSelected = subtitle.id == selectedId; // Build label with available info final parts = []; if (subtitle.title != null && subtitle.title!.isNotEmpty) { parts.add(subtitle.title!); } if (subtitle.language != null && subtitle.language!.isNotEmpty) { parts.add(subtitle.language!.toUpperCase()); } if (subtitle.codec != null && subtitle.codec!.isNotEmpty) { // Format codec names nicely String codecName = subtitle.codec! .toUpperCase(); if (codecName == 'SUBRIP') { codecName = 'SRT'; } else if (codecName == 'DVD_SUBTITLE') { codecName = 'DVD'; } else if (codecName == 'ASS' || codecName == 'SSA') { codecName = codecName; // Keep as-is } else if (codecName == 'WEBVTT') { codecName = 'VTT'; } parts.add(codecName); } final label = parts.isEmpty ? 'Track $index' : parts.join(' · '); return ListTile( title: Text( label, style: TextStyle( color: isSelected ? Colors.blue : Colors.white, ), ), trailing: isSelected ? const Icon( Icons.check, color: Colors.blue, ) : null, onTap: () { widget.player.setSubtitleTrack(subtitle); Navigator.pop(context); }, ); }, ); }, ), ), ], ), ), ); }, ), ); } void _showChapterBottomSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: _getBottomSheetConstraints(), builder: (context) => StreamBuilder( stream: widget.player.stream.position, initialData: widget.player.state.position, builder: (context, positionSnapshot) { final currentPosition = positionSnapshot.data ?? Duration.zero; final currentPositionMs = currentPosition.inMilliseconds; // Find the current chapter based on position int? currentChapterIndex; for (int i = 0; i < _chapters.length; i++) { final chapter = _chapters[i]; final startMs = chapter.startTimeOffset ?? 0; final endMs = chapter.endTimeOffset ?? (i < _chapters.length - 1 ? _chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); if (currentPositionMs >= startMs && currentPositionMs < endMs) { currentChapterIndex = i; break; } } return SafeArea( child: SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.video_library, color: Colors.white), const SizedBox(width: 12), const Text( 'Chapters', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), const Divider(color: Colors.white24, height: 1), if (!_chaptersLoaded) const Expanded( child: Center(child: CircularProgressIndicator()), ) else if (_chapters.isEmpty) const Expanded( child: Center( child: Text( 'No chapters available', style: TextStyle(color: Colors.white70), ), ), ) else Expanded( child: ListView.builder( itemCount: _chapters.length, itemBuilder: (context, index) { final chapter = _chapters[index]; final isCurrentChapter = currentChapterIndex == index; return ListTile( leading: chapter.thumb != null ? Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { return const Icon( Icons.image, color: Colors.white54, size: 34, ); } return Image.network( client.getThumbnailUrl( chapter.thumb, ), width: 60, height: 34, fit: BoxFit.cover, errorBuilder: ( context, error, stackTrace, ) => const Icon( Icons.image, color: Colors.white54, size: 34, ), ); }, ), ), if (isCurrentChapter) Positioned.fill( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( color: Colors.blue, width: 2, ), ), ), ), ], ) : null, title: Text( chapter.label, style: TextStyle( color: isCurrentChapter ? Colors.blue : Colors.white, fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal, ), ), subtitle: Text( _formatDuration(chapter.startTime), style: TextStyle( color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : Colors.white70, fontSize: 12, ), ), trailing: isCurrentChapter ? const Icon( Icons.play_circle_filled, color: Colors.blue, ) : null, onTap: () { widget.player.seek(chapter.startTime); Navigator.pop(context); }, ); }, ), ), ], ), ), ); }, ), ); } void _showPlaybackSpeedBottomSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: _getBottomSheetConstraints(), builder: (context) => StreamBuilder( stream: widget.player.stream.rate, initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; // Define available playback speeds final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; return SafeArea( child: SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.speed, color: Colors.white), const SizedBox(width: 12), const Text( 'Playback Speed', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), const Divider(color: Colors.white24, height: 1), Expanded( child: ListView.builder( itemCount: speeds.length, itemBuilder: (context, index) { final speed = speeds[index]; final isSelected = (currentRate - speed).abs() < 0.01; // Format speed label final label = speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x'; return ListTile( title: Text( label, style: TextStyle( color: isSelected ? Colors.blue : Colors.white, ), ), trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, onTap: () { widget.player.setRate(speed); Navigator.pop(context); }, ); }, ), ), ], ), ), ); }, ), ); } String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); final seconds = duration.inSeconds.remainder(60); if (hours > 0) { return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } else { return '$minutes:${seconds.toString().padLeft(2, '0')}'; } } } /// Custom painter for drawing chapter markers on the timeline class ChapterMarkerPainter extends CustomPainter { final List chapters; final Duration duration; ChapterMarkerPainter({required this.chapters, required this.duration}); @override void paint(Canvas canvas, Size size) { if (duration.inMilliseconds == 0) return; final paint = Paint() ..color = Colors.white.withValues(alpha: 0.7) ..strokeWidth = 2 ..strokeCap = StrokeCap.round; for (final chapter in chapters) { final startMs = chapter.startTimeOffset ?? 0; if (startMs == 0) continue; // Skip first chapter marker at 0:00 final position = (startMs / duration.inMilliseconds) * size.width; // Draw short vertical line for chapter marker (centered on slider track) canvas.drawLine( Offset(position, size.height * 0.45), Offset(position, size.height * 0.55), paint, ); } } @override bool shouldRepaint(ChapterMarkerPainter oldDelegate) { return oldDelegate.chapters != chapters || oldDelegate.duration != duration; } }