922 lines
28 KiB
Dart
922 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:mirror_protocol/mirror_protocol.dart';
|
|
import 'package:mirror_core/mirror_core.dart';
|
|
import '../services/ws_server.dart';
|
|
import 'player_provider.dart';
|
|
|
|
final displayStateProvider =
|
|
NotifierProvider<DisplayStateNotifier, DisplayState>(DisplayStateNotifier.new);
|
|
|
|
final youtubeServiceProvider = Provider<YouTubeService>((ref) {
|
|
final service = YouTubeService();
|
|
ref.onDispose(() => service.dispose());
|
|
return service;
|
|
});
|
|
|
|
final plexServiceProvider = Provider<PlexService>((ref) {
|
|
const host = String.fromEnvironment('PLEX_HOST', defaultValue: '192.168.86.172');
|
|
const port = int.fromEnvironment('PLEX_PORT', defaultValue: 32400);
|
|
const token = String.fromEnvironment('PLEX_TOKEN', defaultValue: '');
|
|
return PlexService(host: host, port: port, token: token);
|
|
});
|
|
|
|
final spotifyServiceProvider = Provider<SpotifyService>((ref) {
|
|
return SpotifyService();
|
|
});
|
|
|
|
class DisplayStateNotifier extends Notifier<DisplayState> {
|
|
Timer? _programTimer;
|
|
|
|
@override
|
|
DisplayState build() => DisplayState.initial();
|
|
|
|
void navigate(ScreenType target) {
|
|
state = state.copyWith(activeScreen: target);
|
|
_broadcast();
|
|
}
|
|
|
|
// --- Program handling ---
|
|
|
|
void handleProgram(ProgramAction action, Map<String, dynamic>? payload) {
|
|
switch (action) {
|
|
case ProgramAction.playWorkout:
|
|
_startProgramWorkout(payload);
|
|
case ProgramAction.skipExercise:
|
|
_advanceProgramExercise(1);
|
|
case ProgramAction.previousExercise:
|
|
_advanceProgramExercise(-1);
|
|
case ProgramAction.pauseExercise:
|
|
_pauseProgramExercise();
|
|
case ProgramAction.resumeExercise:
|
|
_resumeProgramExercise();
|
|
case ProgramAction.stopProgram:
|
|
_stopProgram();
|
|
case ProgramAction.browse:
|
|
break;
|
|
}
|
|
_broadcast();
|
|
}
|
|
|
|
void _startProgramWorkout(Map<String, dynamic>? payload) {
|
|
if (payload == null) return;
|
|
|
|
final programId = payload['programId'] as String? ?? '';
|
|
final programName = payload['programName'] as String? ?? '';
|
|
final workoutName = payload['workoutName'] as String? ?? '';
|
|
final rawExercises = payload['exercises'] as List<dynamic>? ?? [];
|
|
|
|
final exercises = rawExercises
|
|
.map((e) => ProgramExercise.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
if (exercises.isEmpty) return;
|
|
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.workout,
|
|
program: ProgramSessionState(
|
|
programId: programId,
|
|
programName: programName,
|
|
workoutName: workoutName,
|
|
exercises: exercises.map((e) => e.id).toList(),
|
|
currentExerciseIndex: 0,
|
|
phase: 'playing',
|
|
currentSet: 1,
|
|
totalSets: exercises.first.sets,
|
|
),
|
|
);
|
|
|
|
_playProgramExercise(exercises.first);
|
|
}
|
|
|
|
void _playProgramExercise(ProgramExercise exercise) {
|
|
_programTimer?.cancel();
|
|
|
|
switch (exercise.type) {
|
|
case 'video':
|
|
if (exercise.videoPath != null) {
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(exercise.videoPath!);
|
|
final duration = exercise.durationSeconds ?? 0;
|
|
|
|
state = state.copyWith(
|
|
playback: PlaybackState(
|
|
mediaId: exercise.id,
|
|
title: exercise.instructionTitle ?? 'Exercise',
|
|
source: MediaSource.workout,
|
|
position: Duration.zero,
|
|
duration: Duration(seconds: duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(streamUrl);
|
|
}
|
|
case 'youtube':
|
|
// YouTube exercises show an instruction card with countdown
|
|
final duration = exercise.setDurationSeconds ?? exercise.durationSeconds ?? 30;
|
|
final program = state.program;
|
|
if (program != null) {
|
|
state = state.copyWith(
|
|
program: program.copyWith(
|
|
phase: 'youtube',
|
|
timerRemaining: duration,
|
|
),
|
|
clearPlayback: true,
|
|
);
|
|
_startProgramCountdown(duration);
|
|
}
|
|
case 'instruction':
|
|
final duration = exercise.setDurationSeconds ?? exercise.durationSeconds ?? 30;
|
|
final program = state.program;
|
|
if (program != null) {
|
|
state = state.copyWith(
|
|
program: program.copyWith(
|
|
phase: 'instruction',
|
|
timerRemaining: duration,
|
|
),
|
|
clearPlayback: true,
|
|
);
|
|
_startProgramCountdown(duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _startProgramCountdown(int seconds) {
|
|
_programTimer?.cancel();
|
|
var remaining = seconds;
|
|
_programTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
remaining--;
|
|
final program = state.program;
|
|
if (program == null || remaining <= 0) {
|
|
timer.cancel();
|
|
if (program != null) {
|
|
_onProgramSetCompleted();
|
|
}
|
|
return;
|
|
}
|
|
state = state.copyWith(
|
|
program: program.copyWith(timerRemaining: remaining),
|
|
);
|
|
_broadcast();
|
|
});
|
|
}
|
|
|
|
void _onProgramSetCompleted() {
|
|
final program = state.program;
|
|
if (program == null) return;
|
|
|
|
if (program.currentSet < program.totalSets) {
|
|
// More sets remaining
|
|
state = state.copyWith(
|
|
program: program.copyWith(currentSet: program.currentSet + 1),
|
|
);
|
|
// Restart the same exercise
|
|
_replayCurrentProgramExercise();
|
|
} else {
|
|
// All sets done, advance to next exercise
|
|
_advanceProgramExercise(1);
|
|
}
|
|
}
|
|
|
|
void _replayCurrentProgramExercise() {
|
|
final program = state.program;
|
|
if (program == null) return;
|
|
// Re-fetch exercises from payload stored as IDs — we need to get fresh exercise data
|
|
// For now, re-trigger the timer for instruction/youtube types
|
|
final phase = program.phase;
|
|
if (phase == 'youtube' || phase == 'instruction') {
|
|
// Restart countdown (we'd need exercise duration, use timerRemaining as template)
|
|
// This is a simplified restart — ideally we store exercises in program state
|
|
_startProgramCountdown(program.timerRemaining > 0 ? program.timerRemaining : 30);
|
|
}
|
|
}
|
|
|
|
void _advanceProgramExercise(int direction) {
|
|
final program = state.program;
|
|
if (program == null) return;
|
|
|
|
_programTimer?.cancel();
|
|
ref.read(playerProvider).stop();
|
|
|
|
final nextIndex = program.currentExerciseIndex + direction;
|
|
if (nextIndex < 0 || nextIndex >= program.exercises.length) {
|
|
state = state.copyWith(
|
|
program: program.copyWith(phase: 'completed'),
|
|
clearPlayback: true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(
|
|
program: program.copyWith(
|
|
currentExerciseIndex: nextIndex,
|
|
currentSet: 1,
|
|
phase: 'playing',
|
|
),
|
|
);
|
|
// Note: the remote sends exercises in playWorkout payload.
|
|
// For skip/previous we broadcast state and let the remote re-send
|
|
// the exercise data if needed, or we handle it via a stored exercises list.
|
|
// For simplicity, we update the index and broadcast — the remote can
|
|
// send a new playWorkout if complex exercise data is needed.
|
|
}
|
|
|
|
void _pauseProgramExercise() {
|
|
_programTimer?.cancel();
|
|
final program = state.program;
|
|
if (program != null) {
|
|
if (program.phase == 'playing') {
|
|
ref.read(playerProvider).pause();
|
|
final current = state.playback;
|
|
if (current != null) {
|
|
state = state.copyWith(playback: current.copyWith(isPlaying: false));
|
|
}
|
|
}
|
|
// For timer-based exercises, stopping the timer is enough
|
|
}
|
|
}
|
|
|
|
void _resumeProgramExercise() {
|
|
final program = state.program;
|
|
if (program != null) {
|
|
if (program.phase == 'playing') {
|
|
ref.read(playerProvider).resume();
|
|
final current = state.playback;
|
|
if (current != null) {
|
|
state = state.copyWith(playback: current.copyWith(isPlaying: true));
|
|
}
|
|
} else if (program.phase == 'youtube' || program.phase == 'instruction') {
|
|
_startProgramCountdown(program.timerRemaining);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _stopProgram() {
|
|
_programTimer?.cancel();
|
|
ref.read(playerProvider).stop();
|
|
state = state.copyWith(clearProgram: true, clearPlayback: true);
|
|
}
|
|
|
|
// --- Media handling ---
|
|
|
|
void handleMedia(MediaAction action, Map<String, dynamic>? payload) {
|
|
switch (action) {
|
|
case MediaAction.searchYouTube:
|
|
_searchYouTube(payload);
|
|
case MediaAction.playYouTube:
|
|
_playYouTube(payload);
|
|
case MediaAction.browsePlex:
|
|
_browsePlex(payload);
|
|
case MediaAction.playPlex:
|
|
_playPlex(payload);
|
|
case MediaAction.spotifyPlay:
|
|
_spotifyPlay(payload);
|
|
case MediaAction.spotifyPause:
|
|
_spotifyPause();
|
|
case MediaAction.spotifyNext:
|
|
_spotifyNext();
|
|
case MediaAction.spotifyPrevious:
|
|
_spotifyPrevious();
|
|
case MediaAction.spotifyTransfer:
|
|
_spotifyTransfer(payload);
|
|
}
|
|
_broadcast();
|
|
}
|
|
|
|
Future<void> _searchYouTube(Map<String, dynamic>? payload) async {
|
|
final query = payload?['query'] as String?;
|
|
if (query == null || query.isEmpty) return;
|
|
|
|
try {
|
|
final yt = ref.read(youtubeServiceProvider);
|
|
final results = await yt.search(query);
|
|
final data = results
|
|
.map((v) => {
|
|
'id': v.id,
|
|
'title': v.title,
|
|
'author': v.author,
|
|
'durationMs': v.duration.inMilliseconds,
|
|
'thumbnailUrl': v.thumbnailUrl,
|
|
})
|
|
.toList();
|
|
// Broadcast search results as an event
|
|
ref.read(wsServerProvider).broadcast(
|
|
EventMessage(event: 'youtubeResults', data: {'results': data}),
|
|
);
|
|
} catch (e) {
|
|
print('YouTube search failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _playYouTube(Map<String, dynamic>? payload) async {
|
|
final videoId = payload?['videoId'] as String?;
|
|
final title = payload?['title'] as String? ?? 'YouTube';
|
|
if (videoId == null) return;
|
|
|
|
try {
|
|
final yt = ref.read(youtubeServiceProvider);
|
|
final streamUrl = await yt.getStreamUrl(videoId);
|
|
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.media,
|
|
playback: PlaybackState(
|
|
mediaId: videoId,
|
|
title: title,
|
|
source: MediaSource.youtube,
|
|
position: Duration.zero,
|
|
duration: Duration.zero,
|
|
isPlaying: true,
|
|
),
|
|
clearSpotify: true,
|
|
);
|
|
|
|
ref.read(playerProvider).play(streamUrl);
|
|
} catch (e) {
|
|
print('YouTube playback failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _browsePlex(Map<String, dynamic>? payload) async {
|
|
try {
|
|
final plex = ref.read(plexServiceProvider);
|
|
final libraryId = payload?['libraryId'] as String?;
|
|
final itemId = payload?['itemId'] as String?;
|
|
|
|
List<Map<String, dynamic>> items;
|
|
if (itemId != null) {
|
|
final children = await plex.fetchChildren(itemId);
|
|
items = children.map((c) => c.toJson()).toList();
|
|
} else if (libraryId != null) {
|
|
final results = await plex.fetchItems(libraryId);
|
|
items = results.map((r) => r.toJson()).toList();
|
|
} else {
|
|
final libraries = await plex.fetchLibraries();
|
|
items = libraries.map((l) => l.toJson()).toList();
|
|
}
|
|
|
|
ref.read(wsServerProvider).broadcast(
|
|
EventMessage(event: 'plexBrowse', data: {'items': items}),
|
|
);
|
|
} catch (e) {
|
|
print('Plex browse failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _playPlex(Map<String, dynamic>? payload) async {
|
|
final streamUrl = payload?['streamUrl'] as String?;
|
|
final title = payload?['title'] as String? ?? 'Plex';
|
|
if (streamUrl == null || streamUrl.isEmpty) return;
|
|
|
|
try {
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.media,
|
|
playback: PlaybackState(
|
|
mediaId: streamUrl,
|
|
title: title,
|
|
source: MediaSource.plex,
|
|
position: Duration.zero,
|
|
duration: Duration.zero,
|
|
isPlaying: true,
|
|
),
|
|
clearSpotify: true,
|
|
);
|
|
|
|
ref.read(playerProvider).play(streamUrl);
|
|
} catch (e) {
|
|
print('Plex playback failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _spotifyPlay(Map<String, dynamic>? payload) async {
|
|
try {
|
|
final spotify = ref.read(spotifyServiceProvider);
|
|
final token = payload?['token'] as String?;
|
|
if (token != null) spotify.setToken(token);
|
|
|
|
final deviceId = payload?['deviceId'] as String?;
|
|
await spotify.play(deviceId: deviceId);
|
|
|
|
// Fetch current state
|
|
final playback = await spotify.getCurrentPlayback();
|
|
if (playback != null) {
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.media,
|
|
spotify: SpotifyState(
|
|
isPlaying: playback.isPlaying,
|
|
trackName: playback.trackName,
|
|
artistName: playback.artistName,
|
|
albumImageUrl: playback.albumArt,
|
|
progressMs: playback.progressMs,
|
|
durationMs: playback.durationMs,
|
|
),
|
|
clearPlayback: true,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('Spotify play failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _spotifyPause() async {
|
|
try {
|
|
final spotify = ref.read(spotifyServiceProvider);
|
|
await spotify.pause();
|
|
final current = state.spotify;
|
|
if (current != null) {
|
|
state = state.copyWith(
|
|
spotify: current.copyWith(isPlaying: false),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('Spotify pause failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _spotifyNext() async {
|
|
try {
|
|
final spotify = ref.read(spotifyServiceProvider);
|
|
await spotify.next();
|
|
// Brief delay for Spotify to update
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
final playback = await spotify.getCurrentPlayback();
|
|
if (playback != null) {
|
|
state = state.copyWith(
|
|
spotify: SpotifyState(
|
|
isPlaying: playback.isPlaying,
|
|
trackName: playback.trackName,
|
|
artistName: playback.artistName,
|
|
albumImageUrl: playback.albumArt,
|
|
progressMs: playback.progressMs,
|
|
durationMs: playback.durationMs,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('Spotify next failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _spotifyPrevious() async {
|
|
try {
|
|
final spotify = ref.read(spotifyServiceProvider);
|
|
await spotify.previous();
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
final playback = await spotify.getCurrentPlayback();
|
|
if (playback != null) {
|
|
state = state.copyWith(
|
|
spotify: SpotifyState(
|
|
isPlaying: playback.isPlaying,
|
|
trackName: playback.trackName,
|
|
artistName: playback.artistName,
|
|
albumImageUrl: playback.albumArt,
|
|
progressMs: playback.progressMs,
|
|
durationMs: playback.durationMs,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('Spotify previous failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _spotifyTransfer(Map<String, dynamic>? payload) async {
|
|
final deviceId = payload?['deviceId'] as String?;
|
|
if (deviceId == null) return;
|
|
|
|
try {
|
|
final spotify = ref.read(spotifyServiceProvider);
|
|
await spotify.transferPlayback(deviceId);
|
|
} catch (e) {
|
|
print('Spotify transfer failed: $e');
|
|
}
|
|
}
|
|
|
|
void handlePlayback(PlaybackAction action, dynamic value) {
|
|
final player = ref.read(playerProvider);
|
|
switch (action) {
|
|
case PlaybackAction.pause:
|
|
final current = state.playback;
|
|
if (current != null) {
|
|
player.pause();
|
|
state = state.copyWith(playback: current.copyWith(isPlaying: false));
|
|
}
|
|
case PlaybackAction.resume:
|
|
final current = state.playback;
|
|
if (current != null) {
|
|
player.resume();
|
|
state = state.copyWith(playback: current.copyWith(isPlaying: true));
|
|
}
|
|
case PlaybackAction.seek:
|
|
final current = state.playback;
|
|
if (current != null && value is num) {
|
|
final pos = Duration(milliseconds: value.toInt());
|
|
player.seek(pos);
|
|
state = state.copyWith(playback: current.copyWith(position: pos));
|
|
}
|
|
case PlaybackAction.stop:
|
|
player.stop();
|
|
state = state.copyWith(clearPlayback: true);
|
|
case PlaybackAction.setVolume:
|
|
final current = state.playback;
|
|
if (current != null && value is num) {
|
|
player.setVolume(value.toDouble());
|
|
state = state.copyWith(playback: current.copyWith(volume: value.toDouble()));
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
_broadcast();
|
|
}
|
|
|
|
void handleWorkout(WorkoutAction action, Map<String, dynamic>? payload) {
|
|
final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle);
|
|
|
|
switch (action) {
|
|
case WorkoutAction.browse:
|
|
final path = payload?['path'] as String?;
|
|
final filter = payload?['filter'] != null
|
|
? WorkoutFilter.fromJson(payload!['filter'] as String)
|
|
: session.filter;
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.workout,
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.browsing,
|
|
currentPath: path,
|
|
clearCurrentPath: path == null || path.isEmpty,
|
|
filter: filter,
|
|
),
|
|
);
|
|
_fetchVideos(path, filter);
|
|
case WorkoutAction.playVideo:
|
|
final payload2 = payload;
|
|
if (payload2 != null && payload2.containsKey('path')) {
|
|
_playFromPayload(payload2);
|
|
} else {
|
|
final videoId = payload?['videoId'] as String?;
|
|
if (videoId != null) _startVideo(videoId);
|
|
}
|
|
case WorkoutAction.playFolder:
|
|
if (payload != null && payload.containsKey('videos')) {
|
|
_playFolderFromPayload(payload);
|
|
} else {
|
|
_playFolder(payload?['unwatchedOnly'] as bool? ?? false);
|
|
}
|
|
case WorkoutAction.pauseWorkout:
|
|
handlePlayback(PlaybackAction.pause, null);
|
|
case WorkoutAction.resumeWorkout:
|
|
handlePlayback(PlaybackAction.resume, null);
|
|
case WorkoutAction.skipVideo:
|
|
_advanceQueue();
|
|
case WorkoutAction.skipRest:
|
|
_skipRest();
|
|
case WorkoutAction.previousVideo:
|
|
_previousVideo();
|
|
case WorkoutAction.stopSession:
|
|
ref.read(playerProvider).stop();
|
|
state = state.copyWith(
|
|
workout: session.copyWith(phase: WorkoutPhase.idle),
|
|
clearPlayback: true,
|
|
);
|
|
default:
|
|
break;
|
|
}
|
|
_broadcast();
|
|
}
|
|
|
|
void updatePlaybackPosition(Duration position) {
|
|
final current = state.playback;
|
|
if (current != null) {
|
|
state = state.copyWith(playback: current.copyWith(position: position));
|
|
_broadcast();
|
|
}
|
|
}
|
|
|
|
void onMediaCompleted() {
|
|
final session = state.workout;
|
|
if (session != null && session.phase == WorkoutPhase.playing) {
|
|
_advanceQueue();
|
|
} else {
|
|
state = state.copyWith(clearPlayback: true);
|
|
}
|
|
_broadcast();
|
|
}
|
|
|
|
Future<void> _fetchVideos(String? path, WorkoutFilter filter) async {
|
|
try {
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final result = await client.fetchVideos(folder: path, filter: filter);
|
|
final stats = await client.fetchStats();
|
|
final session = state.workout;
|
|
if (session != null) {
|
|
state = state.copyWith(
|
|
workout: session.copyWith(
|
|
videos: result.videos,
|
|
folders: result.folders,
|
|
stats: stats,
|
|
),
|
|
);
|
|
_broadcast();
|
|
}
|
|
} catch (e) {
|
|
print('Failed to fetch videos: $e');
|
|
}
|
|
}
|
|
|
|
void _startVideo(String videoId) {
|
|
final session = state.workout;
|
|
if (session == null) return;
|
|
|
|
final video = session.videos.firstWhere(
|
|
(v) => v.id == videoId,
|
|
orElse: () => session.videos.first,
|
|
);
|
|
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(video.path);
|
|
final startPosition = video.lastPosition > 0 ? video.lastPosition : video.trimStart;
|
|
|
|
state = state.copyWith(
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.playing,
|
|
queue: [
|
|
QueueItemState(
|
|
videoId: video.id,
|
|
title: video.title,
|
|
path: video.path,
|
|
duration: video.duration,
|
|
trimStart: video.trimStart,
|
|
trimEnd: video.trimEnd,
|
|
restSeconds: video.restSeconds,
|
|
),
|
|
],
|
|
currentIndex: 0,
|
|
),
|
|
playback: PlaybackState(
|
|
mediaId: video.id,
|
|
title: video.title,
|
|
source: MediaSource.workout,
|
|
position: Duration(seconds: startPosition),
|
|
duration: Duration(seconds: video.duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(
|
|
streamUrl,
|
|
startPosition: startPosition > 0 ? Duration(seconds: startPosition) : null,
|
|
);
|
|
}
|
|
|
|
void _playFolder(bool unwatchedOnly) {
|
|
final session = state.workout;
|
|
if (session == null) return;
|
|
|
|
final videos = unwatchedOnly
|
|
? session.videos.where((v) => v.percentCompleted < 95).toList()
|
|
: session.videos;
|
|
|
|
if (videos.isEmpty) return;
|
|
|
|
final queue = videos
|
|
.map((v) => QueueItemState(
|
|
videoId: v.id,
|
|
title: v.title,
|
|
path: v.path,
|
|
duration: v.duration,
|
|
trimStart: v.trimStart,
|
|
trimEnd: v.trimEnd,
|
|
restSeconds: v.restSeconds,
|
|
))
|
|
.toList();
|
|
|
|
final first = videos.first;
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(first.path);
|
|
final startPos = first.lastPosition > 0 ? first.lastPosition : first.trimStart;
|
|
|
|
state = state.copyWith(
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.playing,
|
|
queue: queue,
|
|
currentIndex: 0,
|
|
),
|
|
playback: PlaybackState(
|
|
mediaId: first.id,
|
|
title: first.title,
|
|
source: MediaSource.workout,
|
|
position: Duration(seconds: startPos),
|
|
duration: Duration(seconds: first.duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(
|
|
streamUrl,
|
|
startPosition: startPos > 0 ? Duration(seconds: startPos) : null,
|
|
);
|
|
}
|
|
|
|
void _advanceQueue() {
|
|
final session = state.workout;
|
|
if (session == null) return;
|
|
|
|
final nextIndex = session.currentIndex + 1;
|
|
if (nextIndex >= session.queue.length) {
|
|
state = state.copyWith(
|
|
workout: session.copyWith(phase: WorkoutPhase.completed),
|
|
clearPlayback: true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
final next = session.queue[nextIndex];
|
|
if (next.restSeconds > 0) {
|
|
state = state.copyWith(
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.resting,
|
|
currentIndex: nextIndex,
|
|
restRemaining: next.restSeconds,
|
|
),
|
|
clearPlayback: true,
|
|
);
|
|
_startRestCountdown(next.restSeconds);
|
|
} else {
|
|
_playQueueItem(nextIndex);
|
|
}
|
|
}
|
|
|
|
void _previousVideo() {
|
|
final session = state.workout;
|
|
if (session == null || session.currentIndex <= 0) return;
|
|
_playQueueItem(session.currentIndex - 1);
|
|
}
|
|
|
|
void _skipRest() {
|
|
final session = state.workout;
|
|
if (session == null) return;
|
|
_playQueueItem(session.currentIndex);
|
|
}
|
|
|
|
void _playQueueItem(int index) {
|
|
final session = state.workout;
|
|
if (session == null || index >= session.queue.length) return;
|
|
|
|
final item = session.queue[index];
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(item.path);
|
|
|
|
state = state.copyWith(
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.playing,
|
|
currentIndex: index,
|
|
),
|
|
playback: PlaybackState(
|
|
mediaId: item.videoId,
|
|
title: item.title,
|
|
source: MediaSource.workout,
|
|
position: Duration(seconds: item.trimStart),
|
|
duration: Duration(seconds: item.duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(
|
|
streamUrl,
|
|
startPosition: item.trimStart > 0 ? Duration(seconds: item.trimStart) : null,
|
|
);
|
|
}
|
|
|
|
Timer? _restTimer;
|
|
|
|
void _playFromPayload(Map<String, dynamic> payload) {
|
|
final videoId = payload['videoId'] as String;
|
|
final title = payload['title'] as String? ?? '';
|
|
final path = payload['path'] as String;
|
|
final duration = (payload['duration'] as num?)?.toInt() ?? 0;
|
|
final trimStart = (payload['trimStart'] as num?)?.toInt() ?? 0;
|
|
final trimEnd = (payload['trimEnd'] as num?)?.toInt() ?? 0;
|
|
final restSeconds = (payload['restSeconds'] as num?)?.toInt() ?? 0;
|
|
final lastPosition = (payload['lastPosition'] as num?)?.toInt() ?? 0;
|
|
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(path);
|
|
final startPos = lastPosition > 0 ? lastPosition : trimStart;
|
|
|
|
final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle);
|
|
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.workout,
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.playing,
|
|
queue: [
|
|
QueueItemState(
|
|
videoId: videoId,
|
|
title: title,
|
|
path: path,
|
|
duration: duration,
|
|
trimStart: trimStart,
|
|
trimEnd: trimEnd,
|
|
restSeconds: restSeconds,
|
|
),
|
|
],
|
|
currentIndex: 0,
|
|
),
|
|
playback: PlaybackState(
|
|
mediaId: videoId,
|
|
title: title,
|
|
source: MediaSource.workout,
|
|
position: Duration(seconds: startPos),
|
|
duration: Duration(seconds: duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(
|
|
streamUrl,
|
|
startPosition: startPos > 0 ? Duration(seconds: startPos) : null,
|
|
);
|
|
_broadcast();
|
|
}
|
|
|
|
void _playFolderFromPayload(Map<String, dynamic> payload) {
|
|
final rawVideos = payload['videos'] as List<dynamic>;
|
|
if (rawVideos.isEmpty) return;
|
|
|
|
final queue = rawVideos.map((v) {
|
|
final m = v as Map<String, dynamic>;
|
|
return QueueItemState(
|
|
videoId: m['videoId'] as String,
|
|
title: m['title'] as String? ?? '',
|
|
path: m['path'] as String,
|
|
duration: (m['duration'] as num?)?.toInt() ?? 0,
|
|
trimStart: (m['trimStart'] as num?)?.toInt() ?? 0,
|
|
trimEnd: (m['trimEnd'] as num?)?.toInt() ?? 0,
|
|
restSeconds: (m['restSeconds'] as num?)?.toInt() ?? 0,
|
|
);
|
|
}).toList();
|
|
|
|
final first = queue.first;
|
|
final lastPosition = (rawVideos.first as Map<String, dynamic>)['lastPosition'] as num?;
|
|
final startPos = (lastPosition?.toInt() ?? 0) > 0
|
|
? lastPosition!.toInt()
|
|
: first.trimStart;
|
|
|
|
final client = ref.read(workoutApiClientProvider);
|
|
final streamUrl = client.streamUrl(first.path);
|
|
|
|
final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle);
|
|
|
|
state = state.copyWith(
|
|
activeScreen: ScreenType.workout,
|
|
workout: session.copyWith(
|
|
phase: WorkoutPhase.playing,
|
|
queue: queue,
|
|
currentIndex: 0,
|
|
),
|
|
playback: PlaybackState(
|
|
mediaId: first.videoId,
|
|
title: first.title,
|
|
source: MediaSource.workout,
|
|
position: Duration(seconds: startPos),
|
|
duration: Duration(seconds: first.duration),
|
|
isPlaying: true,
|
|
),
|
|
);
|
|
|
|
ref.read(playerProvider).play(
|
|
streamUrl,
|
|
startPosition: startPos > 0 ? Duration(seconds: startPos) : null,
|
|
);
|
|
_broadcast();
|
|
}
|
|
|
|
void _startRestCountdown(int seconds) {
|
|
_restTimer?.cancel();
|
|
var remaining = seconds;
|
|
_restTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
remaining--;
|
|
final session = state.workout;
|
|
if (session == null || remaining <= 0) {
|
|
timer.cancel();
|
|
if (session != null) _playQueueItem(session.currentIndex);
|
|
return;
|
|
}
|
|
state = state.copyWith(
|
|
workout: session.copyWith(restRemaining: remaining),
|
|
);
|
|
_broadcast();
|
|
});
|
|
}
|
|
|
|
void _broadcast() {
|
|
ref.read(wsServerProvider).broadcastState(state);
|
|
}
|
|
}
|
|
|
|
final workoutApiClientProvider = Provider<WorkoutApiClient>((ref) {
|
|
const url = String.fromEnvironment('WORKOUT_PLAYER_URL',
|
|
defaultValue: 'http://192.168.86.172:8091');
|
|
return WorkoutApiClient(baseUrl: url);
|
|
});
|