import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.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'; 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 { late final Player _player; late final VideoController _videoController; bool _isPlaying = false; double _currentPosition = 0.0; double _totalDuration = 1.0; bool _isInitialized = false; bool _hasError = false; double? _inPoint; double? _outPoint; bool _isLooping = false; late StreamSubscription _positionSubscription; late StreamSubscription _durationSubscription; late StreamSubscription _playingSubscription; @override void initState() { super.initState(); _player = Player(); _videoController = VideoController( _player, configuration: const VideoControllerConfiguration( enableHardwareAcceleration: false, ), ); _setupStreams(); _initializeVideo(); } @override void didUpdateWidget(covariant AnalysisViewer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.session.videoPath != widget.session.videoPath) { _initializeVideo(); } } void _setupStreams() { _positionSubscription = _player.stream.position.listen((position) { final posSeconds = position.inMilliseconds / 1000.0; if (_isLooping && _outPoint != null && posSeconds >= _outPoint!) { _seekTo(_inPoint ?? 0.0); return; } setState(() => _currentPosition = posSeconds); }); _durationSubscription = _player.stream.duration.listen((duration) { final total = duration.inMilliseconds / 1000.0; setState(() => _totalDuration = total > 0 ? total : 1.0); }); _playingSubscription = _player.stream.playing.listen((isPlaying) { setState(() => _isPlaying = isPlaying); }); _player.stream.completed.listen((_) { setState(() => _isPlaying = false); }); _player.stream.error.listen((error) { debugPrint('Video player error: $error'); if (mounted) setState(() => _hasError = true); }); } Future _initializeVideo() async { final path = widget.session.videoPath; if (path == null) return; if (mounted) setState(() { _isInitialized = false; _hasError = false; }); await _player.open(Media(path), play: false); // Seek to zero so MPV renders the first frame before the user presses play. await _player.seek(Duration.zero); if (mounted) setState(() => _isInitialized = true); } @override void dispose() { _positionSubscription.cancel(); _durationSubscription.cancel(); _playingSubscription.cancel(); _player.dispose(); super.dispose(); } void _togglePlay() { if (_isPlaying) { _player.pause(); } else { if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) { _seekTo(_inPoint ?? 0.0); } _player.play(); } } void _seekTo(double value) { _player.seek(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); _player.play(); } @override Widget build(BuildContext context) { final controller = ref.read(analysisControllerProvider.notifier); return Column( children: [ Expanded( flex: 3, child: Container( color: Colors.black, alignment: Alignment.center, child: _hasError ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.videocam_off, color: Colors.grey, size: 40), SizedBox(height: 8), Text( 'Failed to load video', style: TextStyle(color: Colors.grey), ), ], ), ) : _isInitialized ? Stack( alignment: Alignment.bottomCenter, children: [ Video( controller: _videoController, controls: NoVideoControls, fit: BoxFit.contain, ), _buildTimelineControls(), ], ) : const Center(child: CircularProgressIndicator()), ), ), 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: () => _showAddAnnotationDialog(controller), icon: const Icon(Icons.add), label: const Text('Add from Selection'), ), ], ), ), const Divider(height: 1), Expanded( child: widget.annotations.isEmpty ? const Center( child: Text( 'No annotations yet.\nSet IN/OUT points and click "Add from Selection".', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), ) : 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 _AnnotationListItem( annotation: note, isActive: isActive, onPlay: () => _playRange(note.startTime, note.endTime), onEdit: () => _showEditAnnotationDialog(controller, note), onDelete: () => 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.withValues(alpha: 0.8), padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildAnnotationTimeline(), _buildSeekSlider(), _buildControlsRow(), ], ), ); } Widget _buildAnnotationTimeline() { return SizedBox( height: 20, child: LayoutBuilder( builder: (context, constraints) { return Stack( children: [ ...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.clamp(2.0, constraints.maxWidth), top: 4, bottom: 4, child: Container( decoration: BoxDecoration( color: _parseColor( note.color ?? 'grey', ).withValues(alpha: 0.6), borderRadius: BorderRadius.circular(2), ), ), ); }), if (_inPoint != null) Positioned( left: (_inPoint! / _totalDuration) * constraints.maxWidth, top: 0, bottom: 0, child: Container(width: 2, color: Colors.green), ), if (_outPoint != null) Positioned( left: (_outPoint! / _totalDuration) * constraints.maxWidth, top: 0, bottom: 0, child: Container(width: 2, color: Colors.red), ), ], ); }, ), ); } Widget _buildSeekSlider() { return 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: _seekTo, activeColor: Theme.of(context).colorScheme.primary, inactiveColor: Colors.grey, ), ); } Widget _buildControlsRow() { return 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), 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), ), ], ), ); } void _showAddAnnotationDialog(AnalysisController controller) { final double start = _inPoint ?? _currentPosition; double end = _outPoint ?? (_currentPosition + 5.0); if (end > _totalDuration) end = _totalDuration; final videoPath = widget.session.videoPath ?? ''; showDialog( context: context, builder: (dialogContext) => _AnnotationDialog( initialName: '', initialDescription: '', initialColor: 'red', startTime: start, endTime: end, videoPath: videoPath, onSave: ({ required String name, required String description, required String color, required bool createExercise, required String exerciseName, required String exerciseInstructions, }) async { await controller.addAnnotation( name: name, description: description, startTime: start, endTime: end, color: color, ); if (createExercise && exerciseName.isNotEmpty) { await controller.createExerciseFromAnnotation( name: exerciseName, instructions: exerciseInstructions, videoPath: videoPath, startTime: start, endTime: end, ); } }, ), ); } void _showEditAnnotationDialog( AnalysisController controller, AnnotationEntity annotation, ) { showDialog( context: context, builder: (dialogContext) => _AnnotationDialog( initialName: annotation.name ?? '', initialDescription: annotation.description ?? '', initialColor: annotation.color ?? 'red', startTime: annotation.startTime, endTime: annotation.endTime, videoPath: widget.session.videoPath ?? '', isEditing: true, onSave: ({ required String name, required String description, required String color, required bool createExercise, required String exerciseName, required String exerciseInstructions, }) async { await controller.updateAnnotation( annotation.copyWith( name: name, description: description, color: color, ), ); }, ), ); } 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; case 'orange': return Colors.orange; case 'purple': return Colors.purple; default: return Colors.grey; } } } class _AnnotationListItem extends StatelessWidget { final AnnotationEntity annotation; final bool isActive; final VoidCallback onPlay; final VoidCallback onEdit; final VoidCallback onDelete; const _AnnotationListItem({ required this.annotation, required this.isActive, required this.onPlay, required this.onEdit, required this.onDelete, }); @override Widget build(BuildContext context) { return Container( color: isActive ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.2) : null, child: ListTile( leading: Icon( Icons.label, color: _parseColor(annotation.color ?? 'grey'), ), title: Text(annotation.name ?? 'Untitled'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_formatDuration(annotation.startTime)} - ${_formatDuration(annotation.endTime)}', style: Theme.of(context).textTheme.bodySmall, ), if (annotation.description != null && annotation.description!.isNotEmpty) Text( annotation.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.play_circle_outline), onPressed: onPlay, tooltip: 'Play Range', ), IconButton( icon: const Icon(Icons.edit_outlined), onPressed: onEdit, tooltip: 'Edit', ), IconButton( icon: const Icon(Icons.delete_outline), onPressed: onDelete, tooltip: 'Delete', ), ], ), ), ); } 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; case 'orange': return Colors.orange; case 'purple': return Colors.purple; default: return Colors.grey; } } } typedef AnnotationSaveCallback = Future Function({ required String name, required String description, required String color, required bool createExercise, required String exerciseName, required String exerciseInstructions, }); class _AnnotationDialog extends StatefulWidget { final String initialName; final String initialDescription; final String initialColor; final double startTime; final double endTime; final String videoPath; final bool isEditing; final AnnotationSaveCallback onSave; const _AnnotationDialog({ required this.initialName, required this.initialDescription, required this.initialColor, required this.startTime, required this.endTime, required this.videoPath, required this.onSave, this.isEditing = false, }); @override State<_AnnotationDialog> createState() => _AnnotationDialogState(); } class _AnnotationDialogState extends State<_AnnotationDialog> { late final TextEditingController _nameController; late final TextEditingController _descriptionController; late final TextEditingController _exerciseNameController; late final TextEditingController _exerciseInstructionsController; late String _selectedColor; bool _createExercise = false; static const List _colorOptions = [ 'red', 'green', 'blue', 'yellow', 'orange', 'purple', 'grey', ]; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.initialName); _descriptionController = TextEditingController( text: widget.initialDescription, ); _exerciseNameController = TextEditingController(text: widget.initialName); _exerciseInstructionsController = TextEditingController(); _selectedColor = widget.initialColor; } @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); _exerciseNameController.dispose(); _exerciseInstructionsController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.isEditing ? 'Edit Annotation' : 'New Annotation'), content: SizedBox( width: UIConstants.dialogWidth, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_formatDuration(widget.startTime)} — ${_formatDuration(widget.endTime)}', style: const TextStyle(color: Colors.grey, fontSize: 12), ), const SizedBox(height: UIConstants.spacing12), TextField( controller: _nameController, decoration: const InputDecoration( labelText: 'Name *', hintText: 'e.g. Knee cave on squat', ), autofocus: true, ), const SizedBox(height: UIConstants.spacing12), TextField( controller: _descriptionController, decoration: const InputDecoration( labelText: 'Description', hintText: 'Additional notes...', ), maxLines: 2, ), const SizedBox(height: UIConstants.spacing12), const Text( 'Color', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 6), _buildColorPicker(), if (!widget.isEditing) ...[ const SizedBox(height: UIConstants.spacing16), const Divider(), const SizedBox(height: UIConstants.spacing8), CheckboxListTile( value: _createExercise, onChanged: (value) => setState(() => _createExercise = value ?? false), title: const Text('Also create an exercise from this clip'), subtitle: const Text( 'Saves this video segment as an exercise', style: TextStyle(fontSize: 11), ), contentPadding: EdgeInsets.zero, ), if (_createExercise) ...[ const SizedBox(height: UIConstants.spacing8), TextField( controller: _exerciseNameController, decoration: const InputDecoration( labelText: 'Exercise Name *', ), ), const SizedBox(height: UIConstants.spacing8), TextField( controller: _exerciseInstructionsController, decoration: const InputDecoration( labelText: 'Exercise Instructions', ), maxLines: 2, ), ], ], ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _handleSave, child: Text(widget.isEditing ? 'Update' : 'Save'), ), ], ); } Widget _buildColorPicker() { return Wrap( spacing: 8, children: _colorOptions.map((colorName) { final color = _colorFromName(colorName); final isSelected = _selectedColor == colorName; return GestureDetector( onTap: () => setState(() => _selectedColor = colorName), child: Container( width: 28, height: 28, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: isSelected ? Border.all(color: Colors.white, width: 2) : null, boxShadow: isSelected ? [ BoxShadow( color: color.withValues(alpha: 0.5), blurRadius: 4, spreadRadius: 1, ), ] : null, ), ), ); }).toList(), ); } Future _handleSave() async { if (_nameController.text.isEmpty) return; Navigator.pop(context); await widget.onSave( name: _nameController.text, description: _descriptionController.text, color: _selectedColor, createExercise: _createExercise, exerciseName: _exerciseNameController.text, exerciseInstructions: _exerciseInstructionsController.text, ); } 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 _colorFromName(String name) { switch (name) { case 'red': return Colors.red; case 'green': return Colors.green; case 'blue': return Colors.blue; case 'yellow': return Colors.yellow; case 'orange': return Colors.orange; case 'purple': return Colors.purple; default: return Colors.grey; } } }