Files
mirror-os-flutter/apps/display/lib/providers/display_state_provider.dart
T
2026-07-11 21:51:34 -05:00

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);
});