Files
trainhub-flutter/lib/presentation/analysis/widgets/analysis_viewer.dart
Kazimierz Ciołek 0c9eb8878d
Some checks failed
Build Linux App / build (push) Failing after 1m33s
Refactoring
2026-02-23 10:02:23 -05:00

866 lines
26 KiB
Dart

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<AnnotationEntity> annotations;
final VoidCallback onClose;
const AnalysisViewer({
super.key,
required this.session,
required this.annotations,
required this.onClose,
});
@override
ConsumerState<AnalysisViewer> createState() => _AnalysisViewerState();
}
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
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<Duration> _positionSubscription;
late StreamSubscription<Duration> _durationSubscription;
late StreamSubscription<bool> _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<void> _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<void> 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<String> _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<void> _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;
}
}
}