Preserve Flutter mirror-os codebase
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import 'package:mirror_ui/mirror_ui.dart';
|
||||
import 'providers/display_state_provider.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/workout_screen.dart';
|
||||
import 'screens/media_screen.dart';
|
||||
import 'screens/standby_screen.dart';
|
||||
|
||||
class MirrorDisplayApp extends ConsumerWidget {
|
||||
const MirrorDisplayApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeScreen = ref.watch(
|
||||
displayStateProvider.select((s) => s.activeScreen),
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Mirror OS',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MirrorTheme.dark,
|
||||
home: Scaffold(
|
||||
body: AnimatedSwitcher(
|
||||
duration: MirrorAnimations.screenTransitionDuration,
|
||||
transitionBuilder: MirrorAnimations.screenTransitionBuilder,
|
||||
child: _buildScreen(activeScreen),
|
||||
),
|
||||
),
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _ExitIntent(),
|
||||
},
|
||||
actions: {
|
||||
_ExitIntent: CallbackAction<_ExitIntent>(
|
||||
onInvoke: (_) => SystemNavigator.pop(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScreen(ScreenType screen) => switch (screen) {
|
||||
ScreenType.dashboard => const DashboardScreen(key: ValueKey('dashboard')),
|
||||
ScreenType.workout => const WorkoutScreen(key: ValueKey('workout')),
|
||||
ScreenType.media => const MediaScreen(key: ValueKey('media')),
|
||||
ScreenType.standby => const StandbyScreen(key: ValueKey('standby')),
|
||||
};
|
||||
}
|
||||
|
||||
class _ExitIntent extends Intent {
|
||||
const _ExitIntent();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'app.dart';
|
||||
import 'providers/layout_provider.dart';
|
||||
import 'services/ws_server.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
final container = ProviderContainer();
|
||||
|
||||
// Load layout from disk before starting WS server
|
||||
await container.read(layoutProvider.future);
|
||||
|
||||
final wsServer = container.read(wsServerProvider);
|
||||
await wsServer.start();
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MirrorDisplayApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import '../services/layout_persistence.dart';
|
||||
import '../services/ws_server.dart';
|
||||
|
||||
final layoutProvider =
|
||||
AsyncNotifierProvider<LayoutNotifier, DashboardLayoutConfig>(
|
||||
LayoutNotifier.new);
|
||||
|
||||
class LayoutNotifier extends AsyncNotifier<DashboardLayoutConfig> {
|
||||
late final LayoutPersistence _persistence;
|
||||
|
||||
@override
|
||||
Future<DashboardLayoutConfig> build() async {
|
||||
_persistence = LayoutPersistence();
|
||||
return _persistence.load();
|
||||
}
|
||||
|
||||
Future<void> updateLayout(DashboardLayoutConfig config) async {
|
||||
state = AsyncData(config);
|
||||
await _persistence.save(config);
|
||||
// Broadcast to connected clients
|
||||
ref
|
||||
.read(wsServerProvider)
|
||||
.broadcast(DashboardConfigResponse(layout: config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import 'display_state_provider.dart';
|
||||
|
||||
final playerProvider = Provider<PlayerService>((ref) {
|
||||
final service = PlayerService(ref);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
final videoControllerProvider = Provider<VideoController>((ref) {
|
||||
return ref.watch(playerProvider).videoController;
|
||||
});
|
||||
|
||||
class PlayerService {
|
||||
final Ref _ref;
|
||||
late final Player _player;
|
||||
late final VideoController _videoController;
|
||||
StreamSubscription? _positionSub;
|
||||
StreamSubscription? _playingSub;
|
||||
StreamSubscription? _completedSub;
|
||||
StreamSubscription? _durationSub;
|
||||
Timer? _progressSaveTimer;
|
||||
|
||||
PlayerService(this._ref) {
|
||||
_player = Player();
|
||||
_videoController = VideoController(_player);
|
||||
|
||||
_positionSub = _player.stream.position.listen((position) {
|
||||
_ref.read(displayStateProvider.notifier).updatePlaybackPosition(position);
|
||||
});
|
||||
|
||||
_playingSub = _player.stream.playing.listen((playing) {
|
||||
final state = _ref.read(displayStateProvider);
|
||||
final current = state.playback;
|
||||
if (current != null && current.isPlaying != playing) {
|
||||
_ref.read(displayStateProvider.notifier).updatePlaybackPosition(
|
||||
_player.state.position,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_durationSub = _player.stream.duration.listen((duration) {
|
||||
final state = _ref.read(displayStateProvider);
|
||||
final current = state.playback;
|
||||
if (current != null && current.duration != duration) {
|
||||
// Update duration if it wasn't known initially
|
||||
}
|
||||
});
|
||||
|
||||
_completedSub = _player.stream.completed.listen((completed) {
|
||||
if (completed) {
|
||||
_ref.read(displayStateProvider.notifier).onMediaCompleted();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
VideoController get videoController => _videoController;
|
||||
Player get player => _player;
|
||||
|
||||
Future<void> play(String url, {Duration? startPosition}) async {
|
||||
await _player.open(Media(url));
|
||||
if (startPosition != null && startPosition > Duration.zero) {
|
||||
// Wait briefly for the player to initialize before seeking
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await _player.seek(startPosition);
|
||||
}
|
||||
_startProgressSaving();
|
||||
}
|
||||
|
||||
Future<void> pause() async => await _player.pause();
|
||||
|
||||
Future<void> resume() async => await _player.play();
|
||||
|
||||
Future<void> seek(Duration position) async => await _player.seek(position);
|
||||
|
||||
Future<void> setVolume(double volume) async =>
|
||||
await _player.setVolume(volume * 100);
|
||||
|
||||
Future<void> stop() async {
|
||||
_stopProgressSaving();
|
||||
await _player.stop();
|
||||
}
|
||||
|
||||
void _startProgressSaving() {
|
||||
_stopProgressSaving();
|
||||
_progressSaveTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
final state = _ref.read(displayStateProvider);
|
||||
final workout = state.workout;
|
||||
final playback = state.playback;
|
||||
|
||||
if (workout != null &&
|
||||
workout.phase == WorkoutPhase.playing &&
|
||||
playback != null &&
|
||||
playback.source == MediaSource.workout) {
|
||||
final client = _ref.read(workoutApiClientProvider);
|
||||
final percent = playback.duration.inSeconds > 0
|
||||
? (playback.position.inSeconds / playback.duration.inSeconds * 100).round()
|
||||
: 0;
|
||||
client.updateProgress(
|
||||
videoId: playback.mediaId,
|
||||
position: playback.position.inSeconds,
|
||||
percent: percent,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressSaving() {
|
||||
_progressSaveTimer?.cancel();
|
||||
_progressSaveTimer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_stopProgressSaving();
|
||||
_positionSub?.cancel();
|
||||
_playingSub?.cancel();
|
||||
_completedSub?.cancel();
|
||||
_durationSub?.cancel();
|
||||
_player.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import 'package:mirror_ui/mirror_ui.dart';
|
||||
import '../providers/display_state_provider.dart';
|
||||
import '../providers/layout_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
late Stream<DateTime> _clockStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clockStream = Stream.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layoutAsync = ref.watch(layoutProvider);
|
||||
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: layoutAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: MirrorTheme.accent),
|
||||
),
|
||||
error: (_, __) => _buildWithLayout(DashboardLayoutConfig.defaultLayout()),
|
||||
data: (layout) => _buildWithLayout(layout),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWithLayout(DashboardLayoutConfig layout) {
|
||||
final visibleWidgets = layout.widgets
|
||||
.where((w) => w.visible)
|
||||
.toList()
|
||||
..sort((a, b) => a.position.compareTo(b.position));
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80, vertical: 60),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (int i = 0; i < visibleWidgets.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 32),
|
||||
_buildWidget(visibleWidgets[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidget(DashboardWidgetConfig widget) {
|
||||
return switch (widget.type) {
|
||||
'clock' => _ClockWidget(clockStream: _clockStream),
|
||||
'weather' => _WeatherWidget(config: widget.config),
|
||||
'calendar' => const _CalendarWidget(),
|
||||
'ha_entities' => _HaEntitiesWidget(config: widget.config),
|
||||
'now_playing' => const _NowPlayingWidget(),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Clock Widget ---
|
||||
|
||||
class _ClockWidget extends StatelessWidget {
|
||||
final Stream<DateTime> clockStream;
|
||||
|
||||
const _ClockWidget({required this.clockStream});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<DateTime>(
|
||||
stream: clockStream,
|
||||
builder: (context, snapshot) {
|
||||
final now = snapshot.data ?? DateTime.now();
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
_formatDate(now),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_formatTime(now),
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
fontSize: 120,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final hour = dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
|
||||
final min = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$min';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
const days = [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday',
|
||||
'Thursday', 'Friday', 'Saturday',
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
return '${days[dt.weekday % 7]}, ${months[dt.month - 1]} ${dt.day}';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Weather Widget ---
|
||||
|
||||
class _WeatherWidget extends ConsumerWidget {
|
||||
final Map<String, dynamic> config;
|
||||
|
||||
const _WeatherWidget({required this.config});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dashboard =
|
||||
ref.watch(displayStateProvider.select((s) => s.dashboard));
|
||||
final weather = dashboard.weather;
|
||||
|
||||
final temp = weather?.temp ?? 72.0;
|
||||
final condition = weather?.condition ?? 'clear';
|
||||
final humidity = weather?.humidity ?? 45;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_weatherIcon(condition),
|
||||
size: 56,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${temp.round()}°',
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_capitalizeCondition(condition),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.water_drop_outlined,
|
||||
size: 20, color: MirrorTheme.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$humidity%',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _weatherIcon(String condition) => switch (condition.toLowerCase()) {
|
||||
'clear' || 'sunny' => Icons.wb_sunny,
|
||||
'cloudy' || 'overcast' => Icons.cloud,
|
||||
'partlycloudy' || 'partly_cloudy' => Icons.cloud_queue,
|
||||
'rainy' || 'rain' => Icons.water_drop,
|
||||
'snowy' || 'snow' => Icons.ac_unit,
|
||||
'stormy' || 'thunderstorm' => Icons.flash_on,
|
||||
'foggy' || 'fog' => Icons.blur_on,
|
||||
'windy' => Icons.air,
|
||||
_ => Icons.wb_sunny,
|
||||
};
|
||||
|
||||
String _capitalizeCondition(String condition) {
|
||||
if (condition.isEmpty) return '';
|
||||
return condition[0].toUpperCase() + condition.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Calendar Widget ---
|
||||
|
||||
class _CalendarWidget extends ConsumerWidget {
|
||||
const _CalendarWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final events = ref.watch(
|
||||
displayStateProvider.select((s) => s.dashboard.calendarEvents));
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No upcoming events',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 20, color: MirrorTheme.accent),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'UPCOMING',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
...events.take(3).map((event) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (event.startTime.isNotEmpty)
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
_formatEventTime(event.startTime),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event.startTime.isNotEmpty) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.summary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatEventTime(String isoTime) {
|
||||
try {
|
||||
final dt = DateTime.parse(isoTime);
|
||||
final hour =
|
||||
dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
|
||||
final min = dt.minute.toString().padLeft(2, '0');
|
||||
final period = dt.hour >= 12 ? 'PM' : 'AM';
|
||||
return '$hour:$min $period';
|
||||
} catch (_) {
|
||||
return isoTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- HA Entities Widget ---
|
||||
|
||||
class _HaEntitiesWidget extends ConsumerWidget {
|
||||
final Map<String, dynamic> config;
|
||||
|
||||
const _HaEntitiesWidget({required this.config});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final haEntities =
|
||||
ref.watch(displayStateProvider.select((s) => s.dashboard.haEntities));
|
||||
|
||||
// Filter to only show entities listed in widget config
|
||||
final configuredEntities =
|
||||
(config['entities'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
|
||||
final entitiesToShow = configuredEntities.isEmpty
|
||||
? haEntities
|
||||
: haEntities.where((e) {
|
||||
final id = e['entity_id'] as String? ?? '';
|
||||
return configuredEntities.contains(id);
|
||||
}).toList();
|
||||
|
||||
if (entitiesToShow.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No entities configured',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.sensors, size: 20, color: MirrorTheme.accent),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'HOME',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
...entitiesToShow.map((entity) {
|
||||
final attrs =
|
||||
(entity['attributes'] as Map<String, dynamic>?) ?? {};
|
||||
final friendlyName =
|
||||
attrs['friendly_name'] as String? ??
|
||||
(entity['entity_id'] as String? ?? '');
|
||||
final stateValue = entity['state'] as String? ?? 'unknown';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
friendlyName,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
stateValue,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Now Playing Widget ---
|
||||
|
||||
class _NowPlayingWidget extends ConsumerWidget {
|
||||
const _NowPlayingWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playback = ref.watch(displayStateProvider.select((s) => s.playback));
|
||||
final spotify = ref.watch(displayStateProvider.select((s) => s.spotify));
|
||||
|
||||
// Show nothing if nothing is playing
|
||||
final hasPlayback = playback != null && playback.isPlaying;
|
||||
final hasSpotify = spotify != null && spotify.isPlaying;
|
||||
|
||||
if (!hasPlayback && !hasSpotify) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final title = hasSpotify
|
||||
? (spotify.trackName ?? 'Unknown Track')
|
||||
: (playback?.title ?? '');
|
||||
final subtitle = hasSpotify ? (spotify.artistName ?? '') : '';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.accentDim,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: MirrorTheme.accent,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_filled,
|
||||
color: MirrorTheme.accent,
|
||||
size: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import 'package:mirror_ui/mirror_ui.dart';
|
||||
import '../providers/display_state_provider.dart';
|
||||
import '../providers/player_provider.dart';
|
||||
|
||||
class MediaScreen extends ConsumerWidget {
|
||||
const MediaScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playback = ref.watch(displayStateProvider.select((s) => s.playback));
|
||||
final spotify = ref.watch(displayStateProvider.select((s) => s.spotify));
|
||||
|
||||
// Spotify playback
|
||||
if (spotify != null && spotify.trackName != null) {
|
||||
return _buildSpotify(context, spotify);
|
||||
}
|
||||
|
||||
// Video playback (YouTube / Plex)
|
||||
if (playback != null && playback.source != MediaSource.workout) {
|
||||
return _buildVideoPlayback(context, ref, playback);
|
||||
}
|
||||
|
||||
// Nothing playing
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 64, color: MirrorTheme.textMuted),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Nothing playing',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpotify(BuildContext context, SpotifyState spotify) {
|
||||
final progress = spotify.durationMs > 0
|
||||
? spotify.progressMs / spotify.durationMs
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Source indicator
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 20, color: MirrorTheme.accent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'SPOTIFY',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
// Album art
|
||||
if (spotify.albumImageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.network(
|
||||
spotify.albumImageUrl!,
|
||||
width: 400,
|
||||
height: 400,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _albumArtPlaceholder(),
|
||||
),
|
||||
)
|
||||
else
|
||||
_albumArtPlaceholder(),
|
||||
const SizedBox(height: 60),
|
||||
// Track name
|
||||
Text(
|
||||
spotify.trackName ?? '',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Artist name
|
||||
Text(
|
||||
spotify.artistName ?? '',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
// Progress bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: MirrorTheme.surfaceLight,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(Duration(milliseconds: spotify.progressMs)),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(Duration(milliseconds: spotify.durationMs)),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Icon(
|
||||
spotify.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
size: 72,
|
||||
color: MirrorTheme.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoPlayback(BuildContext context, WidgetRef ref, PlaybackState playback) {
|
||||
final progress = playback.duration.inMilliseconds > 0
|
||||
? playback.position.inMilliseconds / playback.duration.inMilliseconds
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Video output
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final controller = ref.watch(videoControllerProvider);
|
||||
return Video(controller: controller);
|
||||
},
|
||||
),
|
||||
|
||||
// Paused indicator
|
||||
if (!playback.isPlaying)
|
||||
const Center(
|
||||
child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70),
|
||||
),
|
||||
|
||||
// Source indicator top-left
|
||||
Positioned(
|
||||
top: 30,
|
||||
left: 30,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_sourceIcon(playback.source),
|
||||
size: 18,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
playback.source.toJson().toUpperCase(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom overlay
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black87],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
playback.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white24,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(playback.position),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(playback.duration),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _albumArtPlaceholder() {
|
||||
return Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
color: MirrorTheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 120,
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _sourceIcon(MediaSource source) => switch (source) {
|
||||
MediaSource.youtube => Icons.smart_display,
|
||||
MediaSource.plex => Icons.movie,
|
||||
MediaSource.local => Icons.folder,
|
||||
_ => Icons.music_note,
|
||||
};
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes;
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StandbyScreen extends StatelessWidget {
|
||||
const StandbyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import 'package:mirror_ui/mirror_ui.dart';
|
||||
import '../providers/display_state_provider.dart';
|
||||
import '../providers/player_provider.dart';
|
||||
|
||||
class WorkoutScreen extends ConsumerWidget {
|
||||
const WorkoutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final workout = ref.watch(displayStateProvider.select((s) => s.workout));
|
||||
final program = ref.watch(displayStateProvider.select((s) => s.program));
|
||||
final playback = ref.watch(displayStateProvider.select((s) => s.playback));
|
||||
|
||||
// Program session takes priority if active
|
||||
if (program != null && program.phase != 'completed') {
|
||||
return _buildProgramSession(context, ref, program, playback);
|
||||
}
|
||||
|
||||
if (workout == null || workout.phase == WorkoutPhase.idle) {
|
||||
return _buildIdle(context);
|
||||
}
|
||||
|
||||
return switch (workout.phase) {
|
||||
WorkoutPhase.browsing => _buildBrowsing(context),
|
||||
WorkoutPhase.playing => _buildPlaying(context, workout, playback),
|
||||
WorkoutPhase.resting => _buildResting(context, workout),
|
||||
WorkoutPhase.completed => _buildCompleted(context),
|
||||
_ => _buildIdle(context),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Program session rendering ---
|
||||
|
||||
Widget _buildProgramSession(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgramSessionState program,
|
||||
PlaybackState? playback,
|
||||
) {
|
||||
return switch (program.phase) {
|
||||
'playing' => _buildProgramVideo(context, ref, program, playback),
|
||||
'youtube' => _buildProgramYouTube(context, program),
|
||||
'instruction' => _buildProgramInstruction(context, program),
|
||||
_ => _buildCompleted(context),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildProgramVideo(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgramSessionState program,
|
||||
PlaybackState? playback,
|
||||
) {
|
||||
if (playback == null) return _buildIdle(context);
|
||||
|
||||
final progress = playback.duration.inMilliseconds > 0
|
||||
? playback.position.inMilliseconds / playback.duration.inMilliseconds
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final controller = ref.watch(videoControllerProvider);
|
||||
return Video(controller: controller);
|
||||
},
|
||||
),
|
||||
if (playback.isPlaying == false)
|
||||
const Center(
|
||||
child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70),
|
||||
),
|
||||
// Program info top-right
|
||||
Positioned(
|
||||
top: 30,
|
||||
right: 30,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
program.programName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Set ${program.currentSet}/${program.totalSets}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom overlay with title and progress
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black87],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
playback.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white24,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_formatDuration(playback.position)} / ${_formatDuration(playback.duration)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildProgramProgressBar(context, program),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramYouTube(BuildContext context, ProgramSessionState program) {
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.smart_display, size: 64, color: MirrorTheme.accent),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
program.workoutName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TimerDisplay(seconds: program.timerRemaining),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Set ${program.currentSet}/${program.totalSets}',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: MirrorTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Follow along with YouTube reference',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Program progress at bottom
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 40,
|
||||
right: 40,
|
||||
child: _buildProgramProgressBar(context, program),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramInstruction(BuildContext context, ProgramSessionState program) {
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
program.workoutName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: MirrorTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TimerDisplay(seconds: program.timerRemaining),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Set ${program.currentSet}/${program.totalSets}',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: MirrorTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${program.currentExerciseIndex + 1} of ${program.exercises.length}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Program progress at bottom
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 40,
|
||||
right: 40,
|
||||
child: _buildProgramProgressBar(context, program),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgramProgressBar(BuildContext context, ProgramSessionState program) {
|
||||
final total = program.exercises.length;
|
||||
if (total == 0) return const SizedBox.shrink();
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
child: Row(
|
||||
children: List.generate(total, (i) {
|
||||
final Color color;
|
||||
if (i < program.currentExerciseIndex) {
|
||||
color = MirrorTheme.success; // completed
|
||||
} else if (i == program.currentExerciseIndex) {
|
||||
color = MirrorTheme.accent; // current
|
||||
} else {
|
||||
color = MirrorTheme.surfaceLight; // remaining
|
||||
}
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: i < total - 1 ? 2 : 0),
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Standard workout rendering ---
|
||||
|
||||
Widget _buildIdle(BuildContext context) {
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.fitness_center, size: 64, color: MirrorTheme.textMuted),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Browse workouts on your phone',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'mirror.local:9090',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrowsing(BuildContext context) {
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.phone_android, size: 48, color: MirrorTheme.accent),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Select a workout on your phone',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaying(BuildContext context, WorkoutSessionState workout, PlaybackState? playback) {
|
||||
if (playback == null) return _buildIdle(context);
|
||||
|
||||
final progress = playback.duration.inMilliseconds > 0
|
||||
? playback.position.inMilliseconds / playback.duration.inMilliseconds
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Real video output
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final controller = ref.watch(videoControllerProvider);
|
||||
return Video(controller: controller);
|
||||
},
|
||||
),
|
||||
|
||||
// Paused indicator
|
||||
if (!playback.isPlaying)
|
||||
const Center(
|
||||
child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70),
|
||||
),
|
||||
|
||||
// Queue indicator
|
||||
if (workout.queue.length > 1)
|
||||
Positioned(
|
||||
top: 30,
|
||||
right: 30,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${workout.currentIndex + 1} / ${workout.queue.length}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom overlay
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black87],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
playback.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white24,
|
||||
color: MirrorTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_formatDuration(playback.position)} / ${_formatDuration(playback.duration)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: MirrorTheme.fontMono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResting(BuildContext context, WorkoutSessionState workout) {
|
||||
final nextTitle = workout.currentIndex < workout.queue.length
|
||||
? workout.queue[workout.currentIndex].title
|
||||
: '';
|
||||
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'REST',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: MirrorTheme.accent,
|
||||
letterSpacing: 8,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TimerDisplay(seconds: workout.restRemaining),
|
||||
const SizedBox(height: 40),
|
||||
if (nextTitle.isNotEmpty)
|
||||
Text(
|
||||
'Up next: $nextTitle',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompleted(BuildContext context) {
|
||||
return Container(
|
||||
color: MirrorTheme.background,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 96, color: MirrorTheme.success),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'DONE',
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: MirrorTheme.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Session complete',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: MirrorTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes;
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
|
||||
class LayoutPersistence {
|
||||
final String _path;
|
||||
|
||||
LayoutPersistence({String? path})
|
||||
: _path = path ??
|
||||
'${Platform.environment['HOME']}/.config/mirror-os/dashboard_layout.json';
|
||||
|
||||
Future<DashboardLayoutConfig> load() async {
|
||||
final file = File(_path);
|
||||
if (!await file.exists()) {
|
||||
return DashboardLayoutConfig.defaultLayout();
|
||||
}
|
||||
try {
|
||||
final content = await file.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
return DashboardLayoutConfig.fromJson(json);
|
||||
} catch (_) {
|
||||
return DashboardLayoutConfig.defaultLayout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(DashboardLayoutConfig layout) async {
|
||||
final file = File(_path);
|
||||
final dir = file.parent;
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
final content = const JsonEncoder.withIndent(' ').convert(layout.toJson());
|
||||
await file.writeAsString(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_web_socket/shelf_web_socket.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:mirror_protocol/mirror_protocol.dart';
|
||||
import '../providers/display_state_provider.dart';
|
||||
import '../providers/layout_provider.dart';
|
||||
import 'layout_persistence.dart';
|
||||
|
||||
final wsServerProvider = Provider<WsServer>((ref) => WsServer(ref));
|
||||
|
||||
class WsServer {
|
||||
final Ref _ref;
|
||||
final Set<WebSocketChannel> _clients = {};
|
||||
HttpServer? _server;
|
||||
final int port;
|
||||
final LayoutPersistence _layoutPersistence = LayoutPersistence();
|
||||
DashboardLayoutConfig? _currentLayout;
|
||||
|
||||
WsServer(this._ref, {this.port = 9090});
|
||||
|
||||
Future<void> start() async {
|
||||
_currentLayout = await _layoutPersistence.load();
|
||||
|
||||
final handler = webSocketHandler((WebSocketChannel channel) {
|
||||
_clients.add(channel);
|
||||
|
||||
// Send current state on connect
|
||||
final state = _ref.read(displayStateProvider);
|
||||
final sync = StateSync(state: state);
|
||||
channel.sink.add(sync.encode());
|
||||
|
||||
channel.stream.listen(
|
||||
(data) => _handleMessage(channel, data as String),
|
||||
onDone: () => _clients.remove(channel),
|
||||
onError: (_) => _clients.remove(channel),
|
||||
);
|
||||
});
|
||||
|
||||
_server = await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
|
||||
print('WebSocket server running on :$port');
|
||||
}
|
||||
|
||||
void _handleMessage(WebSocketChannel sender, String raw) {
|
||||
try {
|
||||
final message = MirrorMessage.decode(raw);
|
||||
_routeMessage(sender, message);
|
||||
} catch (e) {
|
||||
print('Invalid message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _routeMessage(WebSocketChannel sender, MirrorMessage message) {
|
||||
final notifier = _ref.read(displayStateProvider.notifier);
|
||||
|
||||
switch (message) {
|
||||
case NavigateCommand(:final target):
|
||||
notifier.navigate(target);
|
||||
case PlaybackCommand(:final action, :final value):
|
||||
notifier.handlePlayback(action, value);
|
||||
case WorkoutCommand(:final action, :final payload):
|
||||
notifier.handleWorkout(action, payload);
|
||||
case ProgramCommand(:final action, :final payload):
|
||||
notifier.handleProgram(action, payload);
|
||||
case MediaCommand(:final action, :final payload):
|
||||
notifier.handleMedia(action, payload);
|
||||
case RegisterMessage():
|
||||
// Already handled on connect
|
||||
break;
|
||||
case DashboardConfigRequest():
|
||||
_handleConfigRequest(sender);
|
||||
case DashboardConfigUpdate(:final layout):
|
||||
_handleConfigUpdate(layout);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConfigRequest(WebSocketChannel sender) {
|
||||
final layout = _currentLayout ?? DashboardLayoutConfig.defaultLayout();
|
||||
final response = DashboardConfigResponse(layout: layout);
|
||||
try {
|
||||
sender.sink.add(response.encode());
|
||||
} catch (_) {
|
||||
_clients.remove(sender);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConfigUpdate(DashboardLayoutConfig layout) {
|
||||
_currentLayout = layout;
|
||||
_layoutPersistence.save(layout);
|
||||
// Update the layout provider so dashboard screen rebuilds
|
||||
_ref.read(layoutProvider.notifier).updateLayout(layout);
|
||||
}
|
||||
|
||||
void broadcast(MirrorMessage message) {
|
||||
final encoded = message.encode();
|
||||
for (final client in _clients.toList()) {
|
||||
try {
|
||||
client.sink.add(encoded);
|
||||
} catch (_) {
|
||||
_clients.remove(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void broadcastState(DisplayState state) {
|
||||
broadcast(StateSync(state: state));
|
||||
}
|
||||
|
||||
DashboardLayoutConfig get currentLayout =>
|
||||
_currentLayout ?? DashboardLayoutConfig.defaultLayout();
|
||||
|
||||
Future<void> stop() async {
|
||||
for (final client in _clients) {
|
||||
await client.sink.close();
|
||||
}
|
||||
_clients.clear();
|
||||
await _server?.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user