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.new); final youtubeServiceProvider = Provider((ref) { final service = YouTubeService(); ref.onDispose(() => service.dispose()); return service; }); final plexServiceProvider = Provider((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((ref) { return SpotifyService(); }); class DisplayStateNotifier extends Notifier { Timer? _programTimer; @override DisplayState build() => DisplayState.initial(); void navigate(ScreenType target) { state = state.copyWith(activeScreen: target); _broadcast(); } // --- Program handling --- void handleProgram(ProgramAction action, Map? 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? 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? ?? []; final exercises = rawExercises .map((e) => ProgramExercise.fromJson(e as Map)) .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? 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 _searchYouTube(Map? 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 _playYouTube(Map? 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 _browsePlex(Map? payload) async { try { final plex = ref.read(plexServiceProvider); final libraryId = payload?['libraryId'] as String?; final itemId = payload?['itemId'] as String?; List> 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 _playPlex(Map? 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 _spotifyPlay(Map? 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 _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 _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 _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 _spotifyTransfer(Map? 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? 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 _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 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 payload) { final rawVideos = payload['videos'] as List; if (rawVideos.isEmpty) return; final queue = rawVideos.map((v) { final m = v as Map; 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)['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((ref) { const url = String.fromEnvironment('WORKOUT_PLAYER_URL', defaultValue: 'http://192.168.86.172:8091'); return WorkoutApiClient(baseUrl: url); });