Preserve Flutter mirror-os codebase

This commit is contained in:
Chris
2026-07-11 21:51:34 -05:00
commit 27631ed108
142 changed files with 11896 additions and 0 deletions
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
final connectionProvider =
NotifierProvider<ConnectionNotifier, ConnectionState>(ConnectionNotifier.new);
final mirrorStateProvider = StateProvider<DisplayState?>((ref) => null);
final dashboardConfigProvider = StateProvider<DashboardLayoutConfig?>((ref) => null);
class ConnectionState {
final bool connected;
final bool connecting;
final String? host;
final String? error;
final bool manualDisconnect;
const ConnectionState({
this.connected = false,
this.connecting = false,
this.host,
this.error,
this.manualDisconnect = false,
});
ConnectionState copyWith({
bool? connected,
bool? connecting,
String? host,
String? error,
bool clearError = false,
bool? manualDisconnect,
}) =>
ConnectionState(
connected: connected ?? this.connected,
connecting: connecting ?? this.connecting,
host: host ?? this.host,
error: clearError ? null : (error ?? this.error),
manualDisconnect: manualDisconnect ?? this.manualDisconnect,
);
}
class ConnectionNotifier extends Notifier<ConnectionState> {
WebSocketChannel? _channel;
Timer? _reconnectTimer;
@override
ConnectionState build() => const ConnectionState();
Future<void> connect(String host, {int port = 9090}) async {
state = state.copyWith(connecting: true, clearError: true, manualDisconnect: false);
try {
final uri = Uri.parse('ws://$host:$port');
_channel = WebSocketChannel.connect(uri);
await _channel!.ready;
state = state.copyWith(connected: true, connecting: false, host: host);
// Send registration
_channel!.sink.add(RegisterMessage(role: 'remote').encode());
// Listen for messages
_channel!.stream.listen(
_handleMessage,
onDone: () => _onDisconnected(),
onError: (e) => _onDisconnected(error: e.toString()),
);
} catch (e) {
state = state.copyWith(
connected: false,
connecting: false,
error: 'Failed to connect: $e',
);
}
}
void _handleMessage(dynamic data) {
try {
final message = MirrorMessage.decode(data as String);
switch (message) {
case StateSync(state: final mirrorState):
ref.read(mirrorStateProvider.notifier).state = mirrorState;
case DashboardConfigResponse(layout: final layout):
ref.read(dashboardConfigProvider.notifier).state = layout;
case StatePatch():
break;
case EventMessage():
break;
default:
break;
}
} catch (e) {
print('Failed to parse message: $e');
}
}
void _onDisconnected({String? error}) {
state = state.copyWith(connected: false, error: error);
_channel = null;
// Only auto-reconnect if not intentionally disconnected
if (state.host != null && error != null) {
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 3), () {
if (state.host != null && !state.connected) {
connect(state.host!);
}
});
}
}
void send(MirrorMessage message) {
_channel?.sink.add(message.encode());
}
void disconnect() {
_reconnectTimer?.cancel();
_channel?.sink.close();
_channel = null;
state = const ConnectionState(manualDisconnect: true);
ref.read(mirrorStateProvider.notifier).state = null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final haServiceProvider = Provider<HomeAssistantService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.haBaseUrl == null || settings.haToken == null) {
return null;
}
return HomeAssistantService(
baseUrl: settings.haBaseUrl!,
token: settings.haToken!,
);
});
final haEntitiesProvider =
AsyncNotifierProvider<HaEntitiesNotifier, HaEntitiesState>(HaEntitiesNotifier.new);
class HaEntitiesState {
final List<HaEntity> entities;
final HaWeather? weather;
final List<HaCalendarEvent> calendarEvents;
final String? error;
const HaEntitiesState({
this.entities = const [],
this.weather,
this.calendarEvents = const [],
this.error,
});
}
class HaEntitiesNotifier extends AsyncNotifier<HaEntitiesState> {
@override
Future<HaEntitiesState> build() async {
final service = ref.watch(haServiceProvider);
if (service == null) {
return const HaEntitiesState(error: 'Configure Home Assistant in Settings');
}
try {
final entities = await service.getStates();
// Find weather entity
final weatherEntity = entities.firstWhere(
(e) => e.entityId.startsWith('weather.'),
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
HaWeather? weather;
if (weatherEntity.entityId.isNotEmpty) {
weather = HaWeather.fromEntity(weatherEntity);
}
return HaEntitiesState(
entities: entities,
weather: weather,
);
} catch (e) {
return HaEntitiesState(error: 'HA connection failed: $e');
}
}
Future<void> toggleEntity(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
final entity = state.valueOrNull?.entities.firstWhere(
(e) => e.entityId == entityId,
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
if (entity == null || entity.entityId.isEmpty) return;
final isOn = entity.state == 'on';
if (isOn) {
await service.turnOff(entityId);
} else {
await service.turnOn(entityId);
}
// Refresh state
ref.invalidateSelf();
}
Future<void> callScene(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
await service.callService('scene', 'turn_on', entityId: entityId);
ref.invalidateSelf();
}
void refresh() => ref.invalidateSelf();
}
class HaWeather {
final double? temperature;
final String? condition;
final double? humidity;
const HaWeather({this.temperature, this.condition, this.humidity});
factory HaWeather.fromEntity(HaEntity entity) {
final attrs = entity.attributes;
return HaWeather(
temperature: (attrs['temperature'] as num?)?.toDouble(),
condition: entity.state,
humidity: (attrs['humidity'] as num?)?.toDouble(),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final plexServiceProvider = Provider<PlexService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.plexHost == null || settings.plexToken == null) {
return null;
}
return PlexService(
host: settings.plexHost!,
port: settings.plexPort,
token: settings.plexToken!,
);
});
final plexBrowseProvider =
AsyncNotifierProvider<PlexBrowseNotifier, PlexBrowseState>(
PlexBrowseNotifier.new);
class PlexBrowseState {
final List<PlexLibrary> libraries;
final PlexLibrary? currentLibrary;
final List<PlexItem> items;
final List<String> breadcrumb;
final String? error;
const PlexBrowseState({
this.libraries = const [],
this.currentLibrary,
this.items = const [],
this.breadcrumb = const [],
this.error,
});
}
class PlexBrowseNotifier extends AsyncNotifier<PlexBrowseState> {
@override
Future<PlexBrowseState> build() async {
final service = ref.watch(plexServiceProvider);
if (service == null) {
return const PlexBrowseState(error: 'Configure Plex in Settings');
}
final libraries = await service.fetchLibraries();
return PlexBrowseState(libraries: libraries);
}
Future<void> openLibrary(PlexLibrary library) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final items = await service.fetchItems(library.id);
return PlexBrowseState(
libraries: state.valueOrNull?.libraries ?? [],
currentLibrary: library,
items: items,
breadcrumb: [library.title],
);
});
}
Future<void> openItem(PlexItem item) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
if (item.type == 'show' || item.type == 'season' || item.type == 'artist' || item.type == 'album') {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final children = await service.fetchChildren(item.id);
final prev = state.valueOrNull;
return PlexBrowseState(
libraries: prev?.libraries ?? [],
currentLibrary: prev?.currentLibrary,
items: children,
breadcrumb: [...(prev?.breadcrumb ?? []), item.title],
);
});
}
}
Future<void> goBack() async {
final prev = state.valueOrNull;
if (prev == null) return;
if (prev.breadcrumb.length <= 1) {
state = AsyncValue.data(PlexBrowseState(
libraries: prev.libraries,
));
} else {
if (prev.currentLibrary != null) {
await openLibrary(prev.currentLibrary!);
}
}
}
void refresh() {
ref.invalidateSelf();
}
}
@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final programsApiProvider = Provider<ProgramsApiClient>((ref) {
return ProgramsApiClient(baseUrl: 'http://192.168.86.172:8091');
});
final programsProvider =
AsyncNotifierProvider<ProgramsNotifier, List<Program>>(ProgramsNotifier.new);
class ProgramsNotifier extends AsyncNotifier<List<Program>> {
@override
Future<List<Program>> build() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
});
}
}
@@ -0,0 +1,109 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
final settingsProvider = AsyncNotifierProvider<SettingsNotifier, AppSettings>(SettingsNotifier.new);
class AppSettings {
final String mirrorHost;
final int mirrorPort;
final String workoutPlayerUrl;
final String? plexHost;
final int plexPort;
final String? plexToken;
final String? haBaseUrl;
final String? haToken;
final String? spotifyClientId;
final bool spotifyConnected;
const AppSettings({
this.mirrorHost = 'mirror.local',
this.mirrorPort = 9090,
this.workoutPlayerUrl = 'http://192.168.86.172:8091',
this.plexHost,
this.plexPort = 32400,
this.plexToken,
this.haBaseUrl,
this.haToken,
this.spotifyClientId,
this.spotifyConnected = false,
});
AppSettings copyWith({
String? mirrorHost,
int? mirrorPort,
String? workoutPlayerUrl,
String? plexHost,
int? plexPort,
String? plexToken,
String? haBaseUrl,
String? haToken,
String? spotifyClientId,
bool? spotifyConnected,
}) =>
AppSettings(
mirrorHost: mirrorHost ?? this.mirrorHost,
mirrorPort: mirrorPort ?? this.mirrorPort,
workoutPlayerUrl: workoutPlayerUrl ?? this.workoutPlayerUrl,
plexHost: plexHost ?? this.plexHost,
plexPort: plexPort ?? this.plexPort,
plexToken: plexToken ?? this.plexToken,
haBaseUrl: haBaseUrl ?? this.haBaseUrl,
haToken: haToken ?? this.haToken,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyConnected: spotifyConnected ?? this.spotifyConnected,
);
}
class SettingsNotifier extends AsyncNotifier<AppSettings> {
@override
Future<AppSettings> build() async {
final prefs = await SharedPreferences.getInstance();
return AppSettings(
mirrorHost: prefs.getString('mirror_host') ?? 'mirror.local',
mirrorPort: prefs.getInt('mirror_port') ?? 9090,
workoutPlayerUrl: prefs.getString('workout_player_url') ?? 'http://192.168.86.172:8091',
plexHost: prefs.getString('plex_host'),
plexPort: prefs.getInt('plex_port') ?? 32400,
plexToken: prefs.getString('plex_token'),
haBaseUrl: prefs.getString('ha_base_url'),
haToken: prefs.getString('ha_token'),
spotifyClientId: prefs.getString('spotify_client_id'),
spotifyConnected: prefs.getBool('spotify_connected') ?? false,
);
}
Future<void> save(AppSettings settings) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('mirror_host', settings.mirrorHost);
await prefs.setInt('mirror_port', settings.mirrorPort);
await prefs.setString('workout_player_url', settings.workoutPlayerUrl);
if (settings.plexHost != null) {
await prefs.setString('plex_host', settings.plexHost!);
} else {
await prefs.remove('plex_host');
}
await prefs.setInt('plex_port', settings.plexPort);
if (settings.plexToken != null) {
await prefs.setString('plex_token', settings.plexToken!);
} else {
await prefs.remove('plex_token');
}
if (settings.haBaseUrl != null) {
await prefs.setString('ha_base_url', settings.haBaseUrl!);
} else {
await prefs.remove('ha_base_url');
}
if (settings.haToken != null) {
await prefs.setString('ha_token', settings.haToken!);
} else {
await prefs.remove('ha_token');
}
if (settings.spotifyClientId != null) {
await prefs.setString('spotify_client_id', settings.spotifyClientId!);
} else {
await prefs.remove('spotify_client_id');
}
await prefs.setBool('spotify_connected', settings.spotifyConnected);
state = AsyncValue.data(settings);
}
}
@@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'settings_provider.dart';
const _currentVersion = 1;
final updateCheckProvider = FutureProvider<UpdateInfo?>((ref) async {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null) return null;
final baseUrl = settings.workoutPlayerUrl;
// Update server runs alongside workout-player
final host = Uri.parse(baseUrl).host;
try {
final response = await http.get(
Uri.parse('http://$host:9091/api/mirror-os/version'),
).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body) as Map<String, dynamic>;
final latestVersion = data['version'] as int? ?? 0;
final downloadUrl = data['downloadUrl'] as String?;
final changelog = data['changelog'] as String?;
if (latestVersion > _currentVersion && downloadUrl != null) {
return UpdateInfo(
currentVersion: _currentVersion,
latestVersion: latestVersion,
downloadUrl: downloadUrl,
changelog: changelog,
);
}
return null;
} catch (_) {
return null;
}
});
class UpdateInfo {
final int currentVersion;
final int latestVersion;
final String downloadUrl;
final String? changelog;
const UpdateInfo({
required this.currentVersion,
required this.latestVersion,
required this.downloadUrl,
this.changelog,
});
}
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final update = ref.watch(updateCheckProvider).valueOrNull;
if (update == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E3A5F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF60A5FA), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Color(0xFF60A5FA), size: 20),
const SizedBox(width: 8),
Text(
'Update available (v${update.latestVersion})',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Colors.white,
),
),
],
),
if (update.changelog != null) ...[
const SizedBox(height: 8),
Text(
update.changelog!,
style: const TextStyle(fontSize: 13, color: Colors.white70),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _downloadUpdate(context, update.downloadUrl),
icon: const Icon(Icons.download, size: 18),
label: const Text('Download & Install'),
),
),
],
),
);
}
void _downloadUpdate(BuildContext context, String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Open in browser: $url')),
);
}
}
}
}
@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'settings_provider.dart';
final workoutApiProvider = Provider<WorkoutApiClient>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
final url = settings?.workoutPlayerUrl ?? 'http://192.168.86.172:8091';
return WorkoutApiClient(baseUrl: url);
});
final workoutBrowseProvider =
AsyncNotifierProvider<WorkoutBrowseNotifier, WorkoutBrowseState>(
WorkoutBrowseNotifier.new);
class WorkoutBrowseState {
final List<VideoState> videos;
final List<String> folders;
final WorkoutStats? stats;
final String currentPath;
final WorkoutFilter filter;
const WorkoutBrowseState({
this.videos = const [],
this.folders = const [],
this.stats,
this.currentPath = '',
this.filter = WorkoutFilter.all,
});
WorkoutBrowseState copyWith({
List<VideoState>? videos,
List<String>? folders,
WorkoutStats? stats,
String? currentPath,
WorkoutFilter? filter,
}) =>
WorkoutBrowseState(
videos: videos ?? this.videos,
folders: folders ?? this.folders,
stats: stats ?? this.stats,
currentPath: currentPath ?? this.currentPath,
filter: filter ?? this.filter,
);
}
class WorkoutBrowseNotifier extends AsyncNotifier<WorkoutBrowseState> {
@override
Future<WorkoutBrowseState> build() async {
return _fetch('', WorkoutFilter.all);
}
Future<WorkoutBrowseState> _fetch(String path, WorkoutFilter filter) async {
final client = ref.read(workoutApiProvider);
final result = await client.fetchVideos(folder: path.isEmpty ? null : path, filter: filter);
final stats = await client.fetchStats();
return WorkoutBrowseState(
videos: result.videos,
folders: result.folders,
stats: stats,
currentPath: path,
filter: filter,
);
}
Future<void> browse(String path, {WorkoutFilter? filter}) async {
final currentFilter = filter ?? state.valueOrNull?.filter ?? WorkoutFilter.all;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, currentFilter));
}
Future<void> setFilter(WorkoutFilter filter) async {
final path = state.valueOrNull?.currentPath ?? '';
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, filter));
}
String streamUrl(String videoPath) {
final client = ref.read(workoutApiProvider);
return client.streamUrl(videoPath);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final youtubeServiceProvider = Provider<YouTubeService>((ref) {
return YouTubeService();
});
final youtubeSearchProvider =
AsyncNotifierProvider<YouTubeSearchNotifier, YouTubeSearchState>(
YouTubeSearchNotifier.new);
class YouTubeSearchState {
final String query;
final List<YouTubeVideo> results;
const YouTubeSearchState({this.query = '', this.results = const []});
}
class YouTubeSearchNotifier extends AsyncNotifier<YouTubeSearchState> {
@override
Future<YouTubeSearchState> build() async {
return const YouTubeSearchState();
}
Future<void> search(String query) async {
if (query.trim().isEmpty) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final service = ref.read(youtubeServiceProvider);
final results = await service.search(query, limit: 15);
return YouTubeSearchState(query: query, results: results);
});
}
void clear() {
state = const AsyncValue.data(YouTubeSearchState());
}
}