Preserve Flutter mirror-os codebase
This commit is contained in:
@@ -0,0 +1,921 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user