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