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