Files
kryz-go-flutter/lib/live_info_business_object.dart

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('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}