Initial commit
This commit is contained in:
470
lib/presentation/analysis/widgets/analysis_viewer.dart
Normal file
470
lib/presentation/analysis/widgets/analysis_viewer.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
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<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> {
|
||||
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<void> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user