import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:trainhub_flutter/domain/entities/analysis_session.dart'; import 'package:trainhub_flutter/domain/entities/annotation.dart'; import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart'; import 'package:video_player/video_player.dart'; class AnalysisViewer extends ConsumerStatefulWidget { final AnalysisSessionEntity session; final List annotations; final VoidCallback onClose; const AnalysisViewer({ super.key, required this.session, required this.annotations, required this.onClose, }); @override ConsumerState createState() => _AnalysisViewerState(); } class _AnalysisViewerState extends ConsumerState { VideoPlayerController? _videoController; bool _isPlaying = false; double _currentPosition = 0.0; double _totalDuration = 1.0; // IN/OUT points double? _inPoint; double? _outPoint; bool _isLooping = false; @override void initState() { super.initState(); _initializeVideo(); } @override void didUpdateWidget(covariant AnalysisViewer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.session.videoPath != widget.session.videoPath) { _initializeVideo(); } } Future _initializeVideo() async { final path = widget.session.videoPath; if (path == null) return; _videoController?.dispose(); final file = File(path); if (await file.exists()) { _videoController = VideoPlayerController.file(file); await _videoController!.initialize(); setState(() { _totalDuration = _videoController!.value.duration.inSeconds.toDouble(); }); _videoController!.addListener(_videoListener); } } void _videoListener() { if (_videoController == null) return; final bool isPlaying = _videoController!.value.isPlaying; final double position = _videoController!.value.position.inMilliseconds / 1000.0; // Loop logic if (_isLooping && _outPoint != null && position >= _outPoint!) { _seekTo(_inPoint ?? 0.0); return; } if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) { setState(() { _isPlaying = isPlaying; _currentPosition = position; }); } } @override void dispose() { _videoController?.removeListener(_videoListener); _videoController?.dispose(); super.dispose(); } void _togglePlay() { if (_videoController == null) return; if (_videoController!.value.isPlaying) { _videoController!.pause(); } else { if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) { _seekTo(_inPoint ?? 0.0); } _videoController!.play(); } } void _seekTo(double value) { if (_videoController == null) return; _videoController!.seekTo(Duration(milliseconds: (value * 1000).toInt())); } void _setInPoint() { setState(() { _inPoint = _currentPosition; if (_outPoint != null && _inPoint! > _outPoint!) { _outPoint = null; } }); } void _setOutPoint() { setState(() { _outPoint = _currentPosition; if (_inPoint != null && _outPoint! < _inPoint!) { _inPoint = null; } }); } void _clearPoints() { setState(() { _inPoint = null; _outPoint = null; _isLooping = false; }); } void _toggleLoop() { setState(() { _isLooping = !_isLooping; }); } void _playRange(double start, double end) { setState(() { _inPoint = start; _outPoint = end; _isLooping = true; }); _seekTo(start); _videoController?.play(); } @override Widget build(BuildContext context) { final controller = ref.read(analysisControllerProvider.notifier); return Column( children: [ // Video Area Expanded( flex: 3, child: Container( color: Colors.black, alignment: Alignment.center, child: _videoController != null && _videoController!.value.isInitialized ? Stack( alignment: Alignment.bottomCenter, children: [ Center( child: AspectRatio( aspectRatio: _videoController!.value.aspectRatio, child: VideoPlayer(_videoController!), ), ), _buildTimelineControls(), ], ) : const Center(child: CircularProgressIndicator()), ), ), // Annotations List Expanded( flex: 2, child: Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Annotations", style: Theme.of(context).textTheme.titleMedium, ), ElevatedButton.icon( onPressed: () { double start = _inPoint ?? _currentPosition; double end = _outPoint ?? (_currentPosition + 5.0); if (end > _totalDuration) end = _totalDuration; controller.addAnnotation( name: "New Annotation", description: "${_formatDuration(start)} - ${_formatDuration(end)}", startTime: start, endTime: end, color: "red", ); }, icon: const Icon(Icons.add), label: const Text("Add from Selection"), ), ], ), ), const Divider(height: 1), Expanded( child: ListView.separated( separatorBuilder: (context, index) => const Divider(height: 1), itemCount: widget.annotations.length, itemBuilder: (context, index) { final note = widget.annotations[index]; final bool isActive = _currentPosition >= note.startTime && _currentPosition <= note.endTime; return Container( color: isActive ? Theme.of( context, ).colorScheme.primaryContainer.withOpacity(0.2) : null, child: ListTile( leading: Icon( Icons.label, color: _parseColor(note.color ?? 'grey'), ), title: Text(note.name ?? 'Untitled'), subtitle: Text( "${_formatDuration(note.startTime)} - ${_formatDuration(note.endTime)}", style: Theme.of(context).textTheme.bodySmall, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.play_circle_outline), onPressed: () => _playRange(note.startTime, note.endTime), tooltip: "Play Range", ), IconButton( icon: const Icon(Icons.delete_outline), onPressed: () => controller.deleteAnnotation(note.id), ), ], ), ), ); }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( width: double.infinity, child: TextButton( onPressed: widget.onClose, child: const Text('Back'), ), ), ), ], ), ), ], ); } Widget _buildTimelineControls() { return Container( color: Colors.black.withOpacity(0.8), padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Timeline Visualization SizedBox( height: 20, child: LayoutBuilder( builder: (context, constraints) { return Stack( children: [ // Annotations ...widget.annotations.map((note) { final left = (note.startTime / _totalDuration) * constraints.maxWidth; final width = ((note.endTime - note.startTime) / _totalDuration) * constraints.maxWidth; return Positioned( left: left, width: width, top: 4, bottom: 4, child: Container( decoration: BoxDecoration( color: _parseColor( note.color ?? 'grey', ).withOpacity(0.6), borderRadius: BorderRadius.circular(2), ), ), ); }), // IN Point if (_inPoint != null) Positioned( left: (_inPoint! / _totalDuration) * constraints.maxWidth, top: 0, bottom: 0, child: Container(width: 2, color: Colors.green), ), // OUT Point if (_outPoint != null) Positioned( left: (_outPoint! / _totalDuration) * constraints.maxWidth, top: 0, bottom: 0, child: Container(width: 2, color: Colors.red), ), ], ); }, ), ), // Slider SliderTheme( data: SliderTheme.of(context).copyWith( thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), trackHeight: 2, ), child: Slider( value: _currentPosition.clamp(0.0, _totalDuration), min: 0.0, max: _totalDuration, onChanged: (value) => _seekTo(value), activeColor: Theme.of(context).colorScheme.primary, inactiveColor: Colors.grey, ), ), // Controls Row Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _formatDuration(_currentPosition), style: const TextStyle(color: Colors.white, fontSize: 12), ), const SizedBox(width: 16), IconButton( icon: const Icon( Icons.keyboard_arrow_left, color: Colors.white, ), onPressed: () => _seekTo(_currentPosition - 1), ), IconButton( icon: Icon( _isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, ), onPressed: _togglePlay, ), IconButton( icon: const Icon( Icons.keyboard_arrow_right, color: Colors.white, ), onPressed: () => _seekTo(_currentPosition + 1), ), const SizedBox(width: 16), // IN/OUT Controls IconButton( icon: const Icon(Icons.login, color: Colors.green), tooltip: "Set IN Point", onPressed: _setInPoint, ), IconButton( icon: const Icon(Icons.logout, color: Colors.red), tooltip: "Set OUT Point", onPressed: _setOutPoint, ), IconButton( icon: Icon( Icons.loop, color: _isLooping ? Theme.of(context).colorScheme.primary : Colors.white, ), tooltip: "Toggle Loop", onPressed: _toggleLoop, ), if (_inPoint != null || _outPoint != null) IconButton( icon: const Icon( Icons.cancel_outlined, color: Colors.white, ), tooltip: "Clear Points", onPressed: _clearPoints, ), const Spacer(), Text( _formatDuration(_totalDuration), style: const TextStyle(color: Colors.white, fontSize: 12), ), ], ), ), ], ), ); } String _formatDuration(double seconds) { final duration = Duration(milliseconds: (seconds * 1000).toInt()); final mins = duration.inMinutes; final secs = duration.inSeconds % 60; return '$mins:${secs.toString().padLeft(2, '0')}'; } Color _parseColor(String colorName) { switch (colorName.toLowerCase()) { case 'red': return Colors.red; case 'green': return Colors.green; case 'blue': return Colors.blue; case 'yellow': return Colors.yellow; default: return Colors.grey; } } }