375 lines
9.7 KiB
Dart
Executable File
375 lines
9.7 KiB
Dart
Executable File
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'generated/app_config.dart';
|
|
|
|
class LiveInfoBusinessObject extends ChangeNotifier {
|
|
LiveInfoBusinessObject({
|
|
String? endpoint,
|
|
int refreshIntervalSeconds = 20,
|
|
this.requestTimeout = const Duration(seconds: 8),
|
|
}) : _endpointOverride = endpoint,
|
|
_refreshIntervalSeconds = refreshIntervalSeconds;
|
|
|
|
/// Explicit endpoint passed at construction, if any.
|
|
final String? _endpointOverride;
|
|
|
|
/// The effective polling endpoint: construction override > env var > AppConfig.
|
|
String get endpoint {
|
|
final override = _endpointOverride;
|
|
if (override != null && override.isNotEmpty) {
|
|
return override;
|
|
}
|
|
const envUrl = String.fromEnvironment('KRYZ_LIVE_INFO_URL', defaultValue: '');
|
|
if (envUrl.isNotEmpty) {
|
|
return envUrl;
|
|
}
|
|
return AppConfig.metadataUrl ?? '';
|
|
}
|
|
final Duration requestTimeout;
|
|
|
|
Timer? _pollTimer;
|
|
LiveInfoSnapshot? _snapshot;
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
DateTime? _lastUpdatedAt;
|
|
int _refreshIntervalSeconds;
|
|
final List<RecentPlayedItem> _recentPlayed = <RecentPlayedItem>[];
|
|
final Set<String> _recentKeys = <String>{};
|
|
|
|
LiveInfoSnapshot? get snapshot => _snapshot;
|
|
bool get isLoading => _isLoading;
|
|
String? get errorMessage => _errorMessage;
|
|
DateTime? get lastUpdatedAt => _lastUpdatedAt;
|
|
int get refreshIntervalSeconds => _refreshIntervalSeconds;
|
|
List<RecentPlayedItem> get recentPlayed => List.unmodifiable(_recentPlayed);
|
|
|
|
Future<void> start() async {
|
|
// Skip polling if no endpoint is configured
|
|
if (endpoint.isEmpty && (AppConfig.metadataUrl ?? '').isEmpty) {
|
|
return;
|
|
}
|
|
await refresh();
|
|
_restartTimer();
|
|
}
|
|
|
|
void setRefreshIntervalSeconds(int seconds) {
|
|
if (seconds <= 0 || seconds == _refreshIntervalSeconds) {
|
|
return;
|
|
}
|
|
|
|
_refreshIntervalSeconds = seconds;
|
|
_restartTimer();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final response = await http
|
|
.get(Uri.parse(endpoint))
|
|
.timeout(requestTimeout);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw StateError('HTTP ${response.statusCode} from live-info API');
|
|
}
|
|
|
|
final dynamic decoded = jsonDecode(response.body);
|
|
if (decoded is! Map<String, dynamic>) {
|
|
throw StateError('Unexpected live-info payload shape');
|
|
}
|
|
|
|
final LiveInfoSnapshot next = LiveInfoSnapshot.fromJson(decoded);
|
|
_snapshot = next;
|
|
_lastUpdatedAt = DateTime.now();
|
|
_errorMessage = null;
|
|
_updateRecentPlayed(next);
|
|
} catch (error) {
|
|
_errorMessage = error.toString();
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _restartTimer() {
|
|
_pollTimer?.cancel();
|
|
_pollTimer = Timer.periodic(
|
|
Duration(seconds: _refreshIntervalSeconds),
|
|
(_) => refresh(),
|
|
);
|
|
}
|
|
|
|
void _updateRecentPlayed(LiveInfoSnapshot snapshot) {
|
|
void addIfTrack(LiveInfoTrackInfo? track, {required String source}) {
|
|
if (track == null || track.type != 'track') {
|
|
return;
|
|
}
|
|
|
|
final String key =
|
|
'$source:${track.metadata.id ?? 'none'}:${track.starts ?? 'unknown'}';
|
|
if (_recentKeys.contains(key)) {
|
|
return;
|
|
}
|
|
|
|
_recentKeys.add(key);
|
|
_recentPlayed.insert(
|
|
0,
|
|
RecentPlayedItem(
|
|
title: track.displayTitle,
|
|
subtitle: track.displaySubtitle,
|
|
starts: track.starts,
|
|
ends: track.ends,
|
|
source: source,
|
|
),
|
|
);
|
|
}
|
|
|
|
addIfTrack(snapshot.current, source: 'current');
|
|
addIfTrack(snapshot.previous, source: 'previous');
|
|
|
|
const int maxItems = 40;
|
|
if (_recentPlayed.length > maxItems) {
|
|
final List<RecentPlayedItem> removed = _recentPlayed
|
|
.sublist(maxItems)
|
|
.toList(growable: false);
|
|
_recentPlayed.removeRange(maxItems, _recentPlayed.length);
|
|
for (final RecentPlayedItem item in removed) {
|
|
_recentKeys.remove(item.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pollTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class LiveInfoSnapshot {
|
|
LiveInfoSnapshot({
|
|
required this.env,
|
|
required this.schedulerTime,
|
|
required this.previous,
|
|
required this.current,
|
|
required this.next,
|
|
required this.currentShow,
|
|
required this.nextShow,
|
|
required this.timezone,
|
|
required this.timezoneOffset,
|
|
required this.sourceEnabled,
|
|
});
|
|
|
|
final String? env;
|
|
final String? schedulerTime;
|
|
final LiveInfoTrackInfo? previous;
|
|
final LiveInfoTrackInfo? current;
|
|
final LiveInfoTrackInfo? next;
|
|
final List<LiveInfoShowInfo> currentShow;
|
|
final List<LiveInfoShowInfo> nextShow;
|
|
final String? timezone;
|
|
final String? timezoneOffset;
|
|
final String? sourceEnabled;
|
|
|
|
factory LiveInfoSnapshot.fromJson(Map<String, dynamic> json) {
|
|
List<LiveInfoShowInfo> parseShows(dynamic value) {
|
|
if (value is! List) {
|
|
return const <LiveInfoShowInfo>[];
|
|
}
|
|
|
|
return value
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(LiveInfoShowInfo.fromJson)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
return LiveInfoSnapshot(
|
|
env: json['env'] as String?,
|
|
schedulerTime: json['schedulerTime'] as String?,
|
|
previous: LiveInfoTrackInfo.fromUnknown(json['previous']),
|
|
current: LiveInfoTrackInfo.fromUnknown(json['current']),
|
|
next: LiveInfoTrackInfo.fromUnknown(json['next']),
|
|
currentShow: parseShows(json['currentShow']),
|
|
nextShow: parseShows(json['nextShow']),
|
|
timezone: json['timezone'] as String?,
|
|
timezoneOffset: json['timezoneOffset']?.toString(),
|
|
sourceEnabled: json['source_enabled'] as String?,
|
|
);
|
|
}
|
|
}
|
|
|
|
class LiveInfoTrackInfo {
|
|
LiveInfoTrackInfo({
|
|
required this.starts,
|
|
required this.ends,
|
|
required this.type,
|
|
required this.name,
|
|
required this.metadata,
|
|
});
|
|
|
|
final String? starts;
|
|
final String? ends;
|
|
final String? type;
|
|
final String? name;
|
|
final LiveInfoTrackMetadata metadata;
|
|
|
|
String get displayTitle {
|
|
final String candidate = _decodeHtml(
|
|
metadata.trackTitle ??
|
|
metadata.filepath ??
|
|
metadata.name ??
|
|
name ??
|
|
'Unknown Track',
|
|
);
|
|
return candidate.trim().isEmpty ? 'Unknown Track' : candidate;
|
|
}
|
|
|
|
String get displaySubtitle {
|
|
final String artist = _decodeHtml(metadata.artistName ?? '').trim();
|
|
final String album = _decodeHtml(metadata.albumTitle ?? '').trim();
|
|
|
|
if (artist.isNotEmpty && album.isNotEmpty) {
|
|
return '$artist • $album';
|
|
}
|
|
if (artist.isNotEmpty) {
|
|
return artist;
|
|
}
|
|
if (album.isNotEmpty) {
|
|
return album;
|
|
}
|
|
return _decodeHtml(name ?? '').trim();
|
|
}
|
|
|
|
factory LiveInfoTrackInfo.fromUnknown(dynamic value) {
|
|
if (value is! Map<String, dynamic>) {
|
|
return LiveInfoTrackInfo(
|
|
starts: null,
|
|
ends: null,
|
|
type: null,
|
|
name: null,
|
|
metadata: LiveInfoTrackMetadata.empty(),
|
|
);
|
|
}
|
|
|
|
final Map<String, dynamic> metadataMap =
|
|
value['metadata'] is Map<String, dynamic>
|
|
? value['metadata'] as Map<String, dynamic>
|
|
: <String, dynamic>{};
|
|
|
|
return LiveInfoTrackInfo(
|
|
starts: value['starts']?.toString(),
|
|
ends: value['ends']?.toString(),
|
|
type: value['type']?.toString(),
|
|
name: value['name']?.toString(),
|
|
metadata: LiveInfoTrackMetadata.fromJson(metadataMap),
|
|
);
|
|
}
|
|
}
|
|
|
|
class LiveInfoTrackMetadata {
|
|
LiveInfoTrackMetadata({
|
|
required this.id,
|
|
required this.name,
|
|
required this.filepath,
|
|
required this.trackTitle,
|
|
required this.artistName,
|
|
required this.albumTitle,
|
|
required this.genre,
|
|
required this.year,
|
|
});
|
|
|
|
final int? id;
|
|
final String? name;
|
|
final String? filepath;
|
|
final String? trackTitle;
|
|
final String? artistName;
|
|
final String? albumTitle;
|
|
final String? genre;
|
|
final String? year;
|
|
|
|
factory LiveInfoTrackMetadata.fromJson(Map<String, dynamic> json) {
|
|
return LiveInfoTrackMetadata(
|
|
id: json['id'] as int?,
|
|
name: json['name']?.toString(),
|
|
filepath: json['filepath']?.toString(),
|
|
trackTitle: json['track_title']?.toString(),
|
|
artistName: json['artist_name']?.toString(),
|
|
albumTitle: json['album_title']?.toString(),
|
|
genre: json['genre']?.toString(),
|
|
year: json['year']?.toString(),
|
|
);
|
|
}
|
|
|
|
factory LiveInfoTrackMetadata.empty() {
|
|
return LiveInfoTrackMetadata(
|
|
id: null,
|
|
name: null,
|
|
filepath: null,
|
|
trackTitle: null,
|
|
artistName: null,
|
|
albumTitle: null,
|
|
genre: null,
|
|
year: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class LiveInfoShowInfo {
|
|
LiveInfoShowInfo({
|
|
required this.name,
|
|
required this.description,
|
|
required this.starts,
|
|
required this.ends,
|
|
});
|
|
|
|
final String? name;
|
|
final String? description;
|
|
final String? starts;
|
|
final String? ends;
|
|
|
|
factory LiveInfoShowInfo.fromJson(Map<String, dynamic> json) {
|
|
return LiveInfoShowInfo(
|
|
name: json['name']?.toString(),
|
|
description: json['description']?.toString(),
|
|
starts: json['starts']?.toString(),
|
|
ends: json['ends']?.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RecentPlayedItem {
|
|
RecentPlayedItem({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.starts,
|
|
required this.ends,
|
|
required this.source,
|
|
});
|
|
|
|
final String title;
|
|
final String subtitle;
|
|
final String? starts;
|
|
final String? ends;
|
|
final String source;
|
|
|
|
String get key => '$source:$starts:$title';
|
|
}
|
|
|
|
String _decodeHtml(String raw) {
|
|
if (raw.isEmpty) {
|
|
return raw;
|
|
}
|
|
|
|
return raw
|
|
.replaceAll('&', '&')
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'")
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>');
|
|
}
|