import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; class LiveInfoBusinessObject extends ChangeNotifier { LiveInfoBusinessObject({ this.endpoint = const String.fromEnvironment( 'KRYZ_LIVE_INFO_URL', defaultValue: 'http://kryz.airtime.pro/api/live-info', ), int refreshIntervalSeconds = 20, this.requestTimeout = const Duration(seconds: 8), }) : _refreshIntervalSeconds = refreshIntervalSeconds; final String endpoint; final Duration requestTimeout; Timer? _pollTimer; LiveInfoSnapshot? _snapshot; bool _isLoading = false; String? _errorMessage; DateTime? _lastUpdatedAt; int _refreshIntervalSeconds; final List _recentPlayed = []; final Set _recentKeys = {}; LiveInfoSnapshot? get snapshot => _snapshot; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; DateTime? get lastUpdatedAt => _lastUpdatedAt; int get refreshIntervalSeconds => _refreshIntervalSeconds; List get recentPlayed => List.unmodifiable(_recentPlayed); Future start() async { await refresh(); _restartTimer(); } void setRefreshIntervalSeconds(int seconds) { if (seconds <= 0 || seconds == _refreshIntervalSeconds) { return; } _refreshIntervalSeconds = seconds; _restartTimer(); notifyListeners(); } Future 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) { 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 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 currentShow; final List nextShow; final String? timezone; final String? timezoneOffset; final String? sourceEnabled; factory LiveInfoSnapshot.fromJson(Map json) { List parseShows(dynamic value) { if (value is! List) { return const []; } return value .whereType>() .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) { return LiveInfoTrackInfo( starts: null, ends: null, type: null, name: null, metadata: LiveInfoTrackMetadata.empty(), ); } final Map metadataMap = value['metadata'] is Map ? value['metadata'] as Map : {}; 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 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 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('>', '>'); }