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,9 @@
library mirror_core;
export 'models/program.dart';
export 'services/home_assistant_service.dart';
export 'services/plex_service.dart';
export 'services/programs_api_client.dart';
export 'services/spotify_service.dart';
export 'services/workout_api_client.dart';
export 'services/youtube_service.dart';
@@ -0,0 +1,153 @@
class ProgramExercise {
final String id;
final String workoutId;
final int position;
final String type; // 'video' | 'youtube' | 'instruction'
final String? videoPath;
final String? videoId;
final String? youtubeUrl;
final String? instructionTitle;
final String? instructionText;
final int? durationSeconds;
final int restSeconds;
final int sets;
final int? setDurationSeconds;
ProgramExercise({
required this.id,
required this.workoutId,
required this.position,
required this.type,
this.videoPath,
this.videoId,
this.youtubeUrl,
this.instructionTitle,
this.instructionText,
this.durationSeconds,
required this.restSeconds,
required this.sets,
this.setDurationSeconds,
});
factory ProgramExercise.fromJson(Map<String, dynamic> json) {
return ProgramExercise(
id: json['id'] as String,
workoutId: json['workoutId'] as String,
position: (json['position'] as num).toInt(),
type: json['type'] as String,
videoPath: json['videoPath'] as String?,
videoId: json['videoId'] as String?,
youtubeUrl: json['youtubeUrl'] as String?,
instructionTitle: json['instructionTitle'] as String?,
instructionText: json['instructionText'] as String?,
durationSeconds: (json['durationSeconds'] as num?)?.toInt(),
restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0,
sets: (json['sets'] as num?)?.toInt() ?? 1,
setDurationSeconds: (json['setDurationSeconds'] as num?)?.toInt(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'workoutId': workoutId,
'position': position,
'type': type,
if (videoPath != null) 'videoPath': videoPath,
if (videoId != null) 'videoId': videoId,
if (youtubeUrl != null) 'youtubeUrl': youtubeUrl,
if (instructionTitle != null) 'instructionTitle': instructionTitle,
if (instructionText != null) 'instructionText': instructionText,
if (durationSeconds != null) 'durationSeconds': durationSeconds,
'restSeconds': restSeconds,
'sets': sets,
if (setDurationSeconds != null) 'setDurationSeconds': setDurationSeconds,
};
}
class ProgramWorkout {
final String id;
final String programId;
final String name;
final int position;
final DateTime? completedAt;
final List<ProgramExercise> exercises;
ProgramWorkout({
required this.id,
required this.programId,
required this.name,
required this.position,
this.completedAt,
required this.exercises,
});
factory ProgramWorkout.fromJson(Map<String, dynamic> json) {
return ProgramWorkout(
id: json['id'] as String,
programId: json['programId'] as String,
name: json['name'] as String,
position: (json['position'] as num).toInt(),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
exercises: (json['exercises'] as List<dynamic>?)
?.map((e) => ProgramExercise.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'programId': programId,
'name': name,
'position': position,
'completedAt': completedAt?.toIso8601String(),
'exercises': exercises.map((e) => e.toJson()).toList(),
};
}
class Program {
final String id;
final String name;
final String? description;
final int currentWorkoutIndex;
final DateTime createdAt;
final DateTime updatedAt;
final List<ProgramWorkout> workouts;
Program({
required this.id,
required this.name,
this.description,
required this.currentWorkoutIndex,
required this.createdAt,
required this.updatedAt,
required this.workouts,
});
factory Program.fromJson(Map<String, dynamic> json) {
return Program(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
currentWorkoutIndex: (json['currentWorkoutIndex'] as num?)?.toInt() ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
workouts: (json['workouts'] as List<dynamic>?)
?.map((e) => ProgramWorkout.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (description != null) 'description': description,
'currentWorkoutIndex': currentWorkoutIndex,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'workouts': workouts.map((w) => w.toJson()).toList(),
};
}
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class HaEntity {
final String entityId;
final String state;
final Map<String, dynamic> attributes;
final DateTime? lastChanged;
HaEntity({
required this.entityId,
required this.state,
required this.attributes,
this.lastChanged,
});
factory HaEntity.fromJson(Map<String, dynamic> json) => HaEntity(
entityId: json['entity_id'] as String,
state: json['state'] as String? ?? '',
attributes:
(json['attributes'] as Map<String, dynamic>?) ?? const {},
lastChanged: json['last_changed'] != null
? DateTime.tryParse(json['last_changed'] as String)
: null,
);
Map<String, dynamic> toJson() => {
'entity_id': entityId,
'state': state,
'attributes': attributes,
'last_changed': lastChanged?.toIso8601String(),
};
}
class HaForecast {
final DateTime datetime;
final double temperature;
final String condition;
HaForecast({
required this.datetime,
required this.temperature,
required this.condition,
});
factory HaForecast.fromJson(Map<String, dynamic> json) => HaForecast(
datetime: DateTime.parse(json['datetime'] as String),
temperature: (json['temperature'] as num).toDouble(),
condition: json['condition'] as String? ?? '',
);
Map<String, dynamic> toJson() => {
'datetime': datetime.toIso8601String(),
'temperature': temperature,
'condition': condition,
};
}
class HaWeather {
final double temperature;
final double humidity;
final String condition;
final List<HaForecast> forecast;
HaWeather({
required this.temperature,
required this.humidity,
required this.condition,
required this.forecast,
});
factory HaWeather.fromEntity(HaEntity entity) {
final attrs = entity.attributes;
final rawForecast = attrs['forecast'] as List<dynamic>? ?? [];
return HaWeather(
temperature: (attrs['temperature'] as num?)?.toDouble() ?? 0,
humidity: (attrs['humidity'] as num?)?.toDouble() ?? 0,
condition: entity.state,
forecast: rawForecast
.map((e) => HaForecast.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() => {
'temperature': temperature,
'humidity': humidity,
'condition': condition,
'forecast': forecast.map((f) => f.toJson()).toList(),
};
}
class HaCalendarEvent {
final String summary;
final DateTime start;
final DateTime end;
final String? description;
HaCalendarEvent({
required this.summary,
required this.start,
required this.end,
this.description,
});
factory HaCalendarEvent.fromJson(Map<String, dynamic> json) =>
HaCalendarEvent(
summary: json['summary'] as String? ?? '',
start: _parseDateTime(json['start']),
end: _parseDateTime(json['end']),
description: json['description'] as String?,
);
static DateTime _parseDateTime(dynamic value) {
if (value is Map<String, dynamic>) {
// HA returns {dateTime: "..."} or {date: "..."}
final dt = value['dateTime'] as String? ?? value['date'] as String? ?? '';
return DateTime.parse(dt);
}
return DateTime.parse(value as String);
}
Map<String, dynamic> toJson() => {
'summary': summary,
'start': start.toIso8601String(),
'end': end.toIso8601String(),
if (description != null) 'description': description,
};
}
class HomeAssistantService {
final String baseUrl;
final String token;
final http.Client _client;
HomeAssistantService({
required this.baseUrl,
required this.token,
http.Client? client,
}) : _client = client ?? http.Client();
Map<String, String> get _headers => {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
/// GET /api/states — all entity states.
Future<List<HaEntity>> getStates() async {
final response = await _client.get(
Uri.parse('$baseUrl/api/states'),
headers: _headers,
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch states: ${response.statusCode}');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((e) => HaEntity.fromJson(e as Map<String, dynamic>))
.toList();
}
/// GET /api/states/{entity_id} — single entity state.
Future<HaEntity?> getState(String entityId) async {
final response = await _client.get(
Uri.parse('$baseUrl/api/states/$entityId'),
headers: _headers,
);
if (response.statusCode == 404) return null;
if (response.statusCode != 200) {
throw Exception('Failed to fetch state: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return HaEntity.fromJson(data);
}
/// POST /api/services/{domain}/{service} — call a service.
Future<bool> callService(
String domain,
String service, {
String? entityId,
Map<String, dynamic>? data,
}) async {
final body = <String, dynamic>{
if (entityId != null) 'entity_id': entityId,
if (data != null) ...data,
};
final response = await _client.post(
Uri.parse('$baseUrl/api/services/$domain/$service'),
headers: _headers,
body: jsonEncode(body),
);
return response.statusCode == 200;
}
/// Parse a weather entity into structured weather data.
Future<HaWeather?> getWeather(String entityId) async {
final entity = await getState(entityId);
if (entity == null) return null;
return HaWeather.fromEntity(entity);
}
/// GET /api/calendars/{entity_id}?start=...&end=...
Future<List<HaCalendarEvent>> getCalendarEvents(
String entityId, {
DateTime? start,
DateTime? end,
}) async {
final now = DateTime.now();
final queryStart = start ?? now;
final queryEnd = end ?? now.add(const Duration(days: 7));
final uri =
Uri.parse('$baseUrl/api/calendars/$entityId').replace(queryParameters: {
'start': queryStart.toIso8601String(),
'end': queryEnd.toIso8601String(),
});
final response = await _client.get(uri, headers: _headers);
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch calendar events: ${response.statusCode}');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((e) => HaCalendarEvent.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Convenience: turn on an entity.
Future<bool> turnOn(String entityId) async {
final domain = entityId.split('.').first;
return callService(domain, 'turn_on', entityId: entityId);
}
/// Convenience: turn off an entity.
Future<bool> turnOff(String entityId) async {
final domain = entityId.split('.').first;
return callService(domain, 'turn_off', entityId: entityId);
}
void dispose() => _client.close();
}
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PlexService {
final String host;
final int port;
final String token;
final http.Client _client;
PlexService({
required this.host,
required this.port,
required this.token,
http.Client? client,
}) : _client = client ?? http.Client();
Uri _uri(String path, [Map<String, String>? queryParameters]) {
return Uri(
scheme: 'http',
host: host,
port: port,
path: path,
queryParameters: queryParameters,
);
}
Map<String, String> get _headers => {
'X-Plex-Token': token,
'Accept': 'application/json',
};
Future<List<PlexLibrary>> fetchLibraries() async {
final response = await _client.get(_uri('/library/sections'), headers: _headers);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch libraries: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final directories = (container['Directory'] as List?) ?? [];
return directories
.map((d) => PlexLibrary.fromJson(d as Map<String, dynamic>))
.toList();
}
Future<List<PlexItem>> fetchItems(String libraryId) async {
final response = await _client.get(
_uri('/library/sections/$libraryId/all'),
headers: _headers,
);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch items: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final metadata = (container['Metadata'] as List?) ?? [];
return metadata
.map((m) => PlexItem.fromJson(m as Map<String, dynamic>))
.toList();
}
Future<List<PlexItem>> fetchChildren(String itemId) async {
final response = await _client.get(
_uri('/library/metadata/$itemId/children'),
headers: _headers,
);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch children: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final metadata = (container['Metadata'] as List?) ?? [];
return metadata
.map((m) => PlexItem.fromJson(m as Map<String, dynamic>))
.toList();
}
String getStreamUrl(String partKey) {
return _uri(partKey, {'X-Plex-Token': token}).toString();
}
String getThumbnailUrl(String thumbPath, {int width = 200, int height = 200}) {
return _uri('/photo/:/transcode', {
'url': thumbPath,
'width': width.toString(),
'height': height.toString(),
'X-Plex-Token': token,
}).toString();
}
void dispose() => _client.close();
}
class PlexLibrary {
final String id;
final String title;
final String type;
const PlexLibrary({
required this.id,
required this.title,
required this.type,
});
factory PlexLibrary.fromJson(Map<String, dynamic> json) {
return PlexLibrary(
id: json['key'] as String,
title: json['title'] as String,
type: json['type'] as String,
);
}
Map<String, dynamic> toJson() => {
'key': id,
'title': title,
'type': type,
};
}
class PlexItem {
final String id;
final String title;
final String type;
final int? year;
final int? duration;
final String? thumbPath;
final String? partKey;
const PlexItem({
required this.id,
required this.title,
required this.type,
this.year,
this.duration,
this.thumbPath,
this.partKey,
});
factory PlexItem.fromJson(Map<String, dynamic> json) {
// Extract partKey from nested Media → Part structure
String? partKey;
final media = json['Media'] as List?;
if (media != null && media.isNotEmpty) {
final parts = (media[0] as Map<String, dynamic>)['Part'] as List?;
if (parts != null && parts.isNotEmpty) {
partKey = (parts[0] as Map<String, dynamic>)['key'] as String?;
}
}
return PlexItem(
id: json['ratingKey'] as String,
title: json['title'] as String,
type: json['type'] as String,
year: json['year'] as int?,
duration: json['duration'] as int?,
thumbPath: json['thumb'] as String?,
partKey: partKey,
);
}
Map<String, dynamic> toJson() => {
'ratingKey': id,
'title': title,
'type': type,
if (year != null) 'year': year,
if (duration != null) 'duration': duration,
if (thumbPath != null) 'thumb': thumbPath,
if (partKey != null) 'partKey': partKey,
};
}
class PlexException implements Exception {
final String message;
const PlexException(this.message);
@override
String toString() => 'PlexException: $message';
}
@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/program.dart';
class ProgramsApiClient {
final String baseUrl;
final http.Client _client;
ProgramsApiClient({
required this.baseUrl,
http.Client? client,
}) : _client = client ?? http.Client();
Future<List<Program>> fetchPrograms() async {
final response = await _client.get(Uri.parse('$baseUrl/api/programs'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch programs: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['programs'] as List<dynamic>?)
?.map((e) => Program.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
}
Future<void> markWorkoutCompleted(String programId, String workoutId) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/programs'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': programId,
'markWorkoutCompleted': workoutId,
}),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to mark workout completed: ${response.statusCode}');
}
}
Future<void> advanceWorkout(String programId) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/programs'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': programId,
'advanceWorkout': true,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to advance workout: ${response.statusCode}');
}
}
void dispose() => _client.close();
}
@@ -0,0 +1,319 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
// --- Model Classes ---
class SpotifyPlaybackState {
final bool isPlaying;
final String trackName;
final String artistName;
final String? albumArt;
final int progressMs;
final int durationMs;
final String? deviceName;
SpotifyPlaybackState({
required this.isPlaying,
required this.trackName,
required this.artistName,
this.albumArt,
required this.progressMs,
required this.durationMs,
this.deviceName,
});
factory SpotifyPlaybackState.fromJson(Map<String, dynamic> json) {
final item = json['item'] as Map<String, dynamic>?;
final artists = (item?['artists'] as List<dynamic>?)
?.map((a) => a['name'] as String)
.join(', ') ??
'';
final images = item?['album']?['images'] as List<dynamic>?;
final device = json['device'] as Map<String, dynamic>?;
return SpotifyPlaybackState(
isPlaying: json['is_playing'] as bool? ?? false,
trackName: item?['name'] as String? ?? '',
artistName: artists,
albumArt: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
progressMs: json['progress_ms'] as int? ?? 0,
durationMs: item?['duration_ms'] as int? ?? 0,
deviceName: device?['name'] as String?,
);
}
Map<String, dynamic> toJson() => {
'isPlaying': isPlaying,
'trackName': trackName,
'artistName': artistName,
'albumArt': albumArt,
'progressMs': progressMs,
'durationMs': durationMs,
'deviceName': deviceName,
};
}
class SpotifyPlaylist {
final String id;
final String name;
final String? imageUrl;
final int trackCount;
SpotifyPlaylist({
required this.id,
required this.name,
this.imageUrl,
required this.trackCount,
});
factory SpotifyPlaylist.fromJson(Map<String, dynamic> json) {
final images = json['images'] as List<dynamic>?;
return SpotifyPlaylist(
id: json['id'] as String,
name: json['name'] as String,
imageUrl: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
trackCount: (json['tracks'] as Map<String, dynamic>?)?['total'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'imageUrl': imageUrl,
'trackCount': trackCount,
};
}
class SpotifyTrack {
final String id;
final String name;
final String artist;
final String album;
final int durationMs;
final String? imageUrl;
SpotifyTrack({
required this.id,
required this.name,
required this.artist,
required this.album,
required this.durationMs,
this.imageUrl,
});
factory SpotifyTrack.fromJson(Map<String, dynamic> json) {
// Playlist track items are wrapped in a 'track' key
final track = json.containsKey('track')
? json['track'] as Map<String, dynamic>
: json;
final artists = (track['artists'] as List<dynamic>?)
?.map((a) => a['name'] as String)
.join(', ') ??
'';
final albumData = track['album'] as Map<String, dynamic>?;
final images = albumData?['images'] as List<dynamic>?;
return SpotifyTrack(
id: track['id'] as String? ?? '',
name: track['name'] as String? ?? '',
artist: artists,
album: albumData?['name'] as String? ?? '',
durationMs: track['duration_ms'] as int? ?? 0,
imageUrl: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'artist': artist,
'album': album,
'durationMs': durationMs,
'imageUrl': imageUrl,
};
}
class SpotifyDevice {
final String id;
final String name;
final bool isActive;
SpotifyDevice({
required this.id,
required this.name,
required this.isActive,
});
factory SpotifyDevice.fromJson(Map<String, dynamic> json) {
return SpotifyDevice(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
isActive: json['is_active'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'isActive': isActive,
};
}
// --- Service ---
class SpotifyService {
static const _baseUrl = 'https://api.spotify.com';
String? _accessToken;
final http.Client _client;
SpotifyService({String? accessToken, http.Client? client})
: _accessToken = accessToken,
_client = client ?? http.Client();
void setToken(String token) {
_accessToken = token;
}
Map<String, String> get _headers => {
'Authorization': 'Bearer $_accessToken',
'Content-Type': 'application/json',
};
// --- Playback ---
Future<SpotifyPlaybackState?> getCurrentPlayback() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/player'),
headers: _headers,
);
if (response.statusCode == 204 || response.body.isEmpty) {
return null; // Nothing playing
}
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
return SpotifyPlaybackState.fromJson(
json.decode(response.body) as Map<String, dynamic>,
);
}
Future<void> play({String? deviceId}) async {
final uri = Uri.parse('$_baseUrl/v1/me/player/play').replace(
queryParameters: deviceId != null ? {'device_id': deviceId} : null,
);
final response = await _client.put(uri, headers: _headers);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> pause() async {
final response = await _client.put(
Uri.parse('$_baseUrl/v1/me/player/pause'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> next() async {
final response = await _client.post(
Uri.parse('$_baseUrl/v1/me/player/next'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> previous() async {
final response = await _client.post(
Uri.parse('$_baseUrl/v1/me/player/previous'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
// --- Playlists ---
Future<List<SpotifyPlaylist>> getPlaylists() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/playlists?limit=50'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final items = data['items'] as List<dynamic>? ?? [];
return items
.map((e) => SpotifyPlaylist.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<SpotifyTrack>> getPlaylistTracks(String playlistId) async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/playlists/$playlistId/tracks?limit=100'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final items = data['items'] as List<dynamic>? ?? [];
return items
.map((e) => SpotifyTrack.fromJson(e as Map<String, dynamic>))
.toList();
}
// --- Devices ---
Future<List<SpotifyDevice>> getDevices() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/player/devices'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final devices = data['devices'] as List<dynamic>? ?? [];
return devices
.map((e) => SpotifyDevice.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> transferPlayback(String deviceId) async {
final response = await _client.put(
Uri.parse('$_baseUrl/v1/me/player'),
headers: _headers,
body: json.encode({
'device_ids': [deviceId],
'play': true,
}),
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
}
class SpotifyApiException implements Exception {
final int statusCode;
final String body;
SpotifyApiException(this.statusCode, this.body);
@override
String toString() => 'SpotifyApiException($statusCode): $body';
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:mirror_protocol/mirror_protocol.dart';
class WorkoutApiClient {
final String baseUrl;
final http.Client _client;
WorkoutApiClient({
required this.baseUrl,
http.Client? client,
}) : _client = client ?? http.Client();
Future<({List<VideoState> videos, List<String> folders})> fetchVideos({
String? folder,
WorkoutFilter filter = WorkoutFilter.all,
}) async {
final uri = Uri.parse('$baseUrl/api/videos').replace(
queryParameters: {
if (folder != null && folder.isNotEmpty) 'folder': folder,
},
);
final response = await _client.get(uri);
if (response.statusCode != 200) {
throw Exception('Failed to fetch videos: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rawVideos = (data['videos'] as List<dynamic>?)
?.map((e) => _parseVideo(e as Map<String, dynamic>))
.toList() ??
[];
final folders = (data['folders'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final videos = _applyFilter(rawVideos, filter);
return (videos: videos, folders: folders);
}
Future<WorkoutStats> fetchStats() async {
final response = await _client.get(Uri.parse('$baseUrl/api/stats'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch stats: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return WorkoutStats.fromJson(data);
}
Future<List<Map<String, dynamic>>> fetchRoutines() async {
final response = await _client.get(Uri.parse('$baseUrl/api/routines'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch routines: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['routines'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[];
}
Future<void> updateProgress({
required String videoId,
required int position,
required int percent,
}) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/videos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'videoId': videoId,
'position': position,
'percent': percent,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to save progress: ${response.statusCode}');
}
}
String streamUrl(String videoPath) =>
'$baseUrl/api/stream?path=${Uri.encodeComponent(videoPath)}';
VideoState _parseVideo(Map<String, dynamic> json) => VideoState(
id: json['id'] as String,
title: json['title'] as String? ?? json['name'] as String? ?? '',
path: json['path'] as String,
folder: json['folder'] as String?,
thumbnailPath: json['thumbnailPath'] as String?,
duration: (json['duration'] as num?)?.toInt() ?? 0,
trimStart: (json['trimStart'] as num?)?.toInt() ?? 0,
trimEnd: (json['trimEnd'] as num?)?.toInt() ?? 0,
lastPosition: (json['lastPosition'] as num?)?.toInt() ?? 0,
percentCompleted: (json['percentCompleted'] as num?)?.toInt() ?? 0,
restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0,
);
List<VideoState> _applyFilter(List<VideoState> videos, WorkoutFilter filter) =>
switch (filter) {
WorkoutFilter.all => videos,
WorkoutFilter.unwatched => videos.where((v) => v.percentCompleted == 0).toList(),
WorkoutFilter.inProgress =>
videos.where((v) => v.percentCompleted > 0 && v.percentCompleted < 95).toList(),
WorkoutFilter.completed => videos.where((v) => v.percentCompleted >= 95).toList(),
};
void dispose() => _client.close();
}
@@ -0,0 +1,67 @@
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YouTubeService {
final YoutubeExplode _yt;
YouTubeService() : _yt = YoutubeExplode();
Future<YouTubeVideo> getVideo(String urlOrId) async {
final video = await _yt.videos.get(urlOrId);
return YouTubeVideo(
id: video.id.value,
title: video.title,
author: video.author,
duration: video.duration ?? Duration.zero,
thumbnailUrl: video.thumbnails.highResUrl,
);
}
Future<String> getStreamUrl(String urlOrId) async {
final manifest = await _yt.videos.streamsClient.getManifest(urlOrId);
// Prefer muxed stream (video + audio) at highest quality available
final muxed = manifest.muxed.sortByVideoQuality();
if (muxed.isNotEmpty) {
return muxed.first.url.toString();
}
// Fallback to audio-only for music
final audio = manifest.audioOnly.sortByBitrate();
if (audio.isNotEmpty) {
return audio.first.url.toString();
}
throw Exception('No playable streams found for $urlOrId');
}
Future<List<YouTubeVideo>> search(String query, {int limit = 10}) async {
final results = await _yt.search.search(query);
return results.take(limit).map((item) {
if (item is SearchVideo) {
return YouTubeVideo(
id: item.id.value,
title: item.title,
author: item.author,
duration: item.duration ?? Duration.zero,
thumbnailUrl: item.thumbnails.highResUrl,
);
}
return null;
}).whereType<YouTubeVideo>().toList();
}
void dispose() => _yt.close();
}
class YouTubeVideo {
final String id;
final String title;
final String author;
final Duration duration;
final String? thumbnailUrl;
const YouTubeVideo({
required this.id,
required this.title,
required this.author,
required this.duration,
this.thumbnailUrl,
});
}
+13
View File
@@ -0,0 +1,13 @@
name: mirror_core
description: Shared business logic, models, and API clients for Mirror OS
version: 0.1.0
publish_to: none
resolution: workspace
environment:
sdk: ^3.5.0
dependencies:
mirror_protocol: any
http: ^1.2.0
youtube_explode_dart: ^2.2.0