import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; import '../services/fullscreen_state_manager.dart'; import 'desktop_app_bar.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( Player player, PlexClient client, PlexMetadata metadata, { VoidCallback? onNext, VoidCallback? onPrevious, }) { return PlexVideoControls( player: player, client: client, metadata: metadata, onNext: onNext, onPrevious: onPrevious, ); } class PlexVideoControls extends StatefulWidget { final Player player; final PlexClient client; final PlexMetadata metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; const PlexVideoControls({ super.key, required this.player, required this.client, 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; @override void initState() { super.initState(); _loadChapters(); _startHideTimer(); // Add window listener for tracking fullscreen state (for button icon) if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.addListener(this); } } @override void dispose() { _hideTimer?.cancel(); // 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 chapters = await widget.client.getChapters(widget.metadata.ratingKey); if (mounted) { setState(() { _chapters = chapters; _chaptersLoaded = true; }); } } bool _isMobile(BuildContext context) { final platform = Theme.of(context).platform; return platform == TargetPlatform.iOS || platform == TargetPlatform.android; } 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 (!_isMobile(context)) { setState(() { _isFullscreen = !_isFullscreen; }); if (Platform.isMacOS) { // Use native macOS fullscreen - titlebar is handled automatically if (_isFullscreen) { await WindowManipulator.enterFullscreen(); } else { await WindowManipulator.exitFullscreen(); } } else { // For Windows/Linux, use window_manager if (_isFullscreen) { await windowManager.setFullScreen(true); } else { await windowManager.setFullScreen(false); } } } } @override Widget build(BuildContext context) { final isMobile = _isMobile(context); return 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(), ), ), ), ), ), ], ); } 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: [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), 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 IconButton( icon: const Icon(Icons.audiotrack, color: Colors.white), onPressed: _showAudioBottomSheet, ), IconButton( icon: const Icon(Icons.subtitles, color: Colors.white), onPressed: _showSubtitleBottomSheet, ), IconButton( icon: const Icon(Icons.video_library, color: Colors.white), onPressed: _showChapterBottomSheet, ), ], ), ); // 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, top: isMacOS ? 8 : 16, bottom: isMacOS ? 8 : 16, ), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), 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 IconButton( icon: const Icon(Icons.audiotrack, color: Colors.white), onPressed: _showAudioBottomSheet, ), // Subtitle track IconButton( icon: const Icon(Icons.subtitles, color: Colors.white), onPressed: _showSubtitleBottomSheet, ), // Chapters IconButton( icon: const Icon(Icons.video_library, color: Colors.white), onPressed: _showChapterBottomSheet, ), // 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) { final selectedTrack = selectedSnapshot.data?.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) { final selectedTrack = selectedSnapshot.data?.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: const Text( 'Off', style: TextStyle(color: 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: Image.network( widget.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); }, ); }, ), ), ], ), ), ); }, ), ); } 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; } }