diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 2a8d3d1..51b2b81 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 41aaeb5..d1d6a5f 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 5ae6356..651bf24 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index a81c98f..4ee9f9e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 600ec21..167958a 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png index 1844599..e5817ad 100644 Binary files a/assets/icon/app_icon.png and b/assets/icon/app_icon.png differ diff --git a/assets/icon/icon_2.svg b/assets/icon/icon_2.svg index f98fd1c..e0d79b8 100644 --- a/assets/icon/icon_2.svg +++ b/assets/icon/icon_2.svg @@ -25,9 +25,9 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="0.44427667" - inkscape:cx="707.89222" - inkscape:cy="284.73248" + inkscape:zoom="0.68550502" + inkscape:cx="485.04386" + inkscape:cy="511.30187" inkscape:window-width="1920" inkscape:window-height="962" inkscape:window-x="0" @@ -132,32 +132,32 @@ xml:space="preserve" style="font-weight:bold;font-size:100.118px;line-height:0;font-family:'Noto Sans Mono CJK KR';-inkscape-font-specification:'Noto Sans Mono CJK KR Bold';letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:#000000;stroke-width:1.65284" x="22.209259" - y="505.82516" + y="501.82516" id="text8">98.5 LPFM MariposaPublicCommunityRadio + id="tspan12" /> diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 4dd40f4..dc74b54 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index bff3b8d..0f4a6d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 95c0587..bfceeaf 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index d0d28a1..6002b16 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 964a8d1..54b4fff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 8c1aca9..394214b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index c2f30f2..8c5638f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 95c0587..bfceeaf 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 0cf4ded..4061a5f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 01d1b8c..aa462a1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index e838012..18d8937 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index d674695..d5c6d22 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index dd9dfbd..251a88c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 4d4e717..9143b86 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 01d1b8c..aa462a1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e1c10dd..b6bc3c9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 2a8d3d1..51b2b81 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index a81c98f..4ee9f9e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 467ed04..e9e7a15 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index b1213a2..d3d906b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index d04b5f7..3f62d68 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/app_theme_business_object.dart b/lib/app_theme_business_object.dart index 279462f..3341bba 100644 --- a/lib/app_theme_business_object.dart +++ b/lib/app_theme_business_object.dart @@ -3,6 +3,12 @@ import 'package:flutter/material.dart'; class AppThemeColors { const AppThemeColors({ required this.primarySeed, + required this.secondarySeed, + required this.scaffoldGradientStart, + required this.scaffoldGradientEnd, + required this.shellSurface, + required this.shellBorder, + required this.accent, required this.webViewBorder, required this.webViewBackground, required this.controlsGradientStart, @@ -12,6 +18,12 @@ class AppThemeColors { }); final Color primarySeed; + final Color secondarySeed; + final Color scaffoldGradientStart; + final Color scaffoldGradientEnd; + final Color shellSurface; + final Color shellBorder; + final Color accent; final Color webViewBorder; final Color webViewBackground; final Color controlsGradientStart; @@ -22,23 +34,35 @@ class AppThemeColors { class AppThemeBusinessObject { static const AppThemeColors lightColors = AppThemeColors( - primarySeed: Color(0xFF607D8B), - webViewBorder: Color(0x42000000), + primarySeed: Color(0xFFE36A18), + secondarySeed: Color(0xFF1663C7), + scaffoldGradientStart: Color(0xFFFFF3E3), + scaffoldGradientEnd: Color(0xFFE6F2FF), + shellSurface: Color(0xF7FFFFFF), + shellBorder: Color(0x1A1A4D86), + accent: Color(0xFF0F5DB6), + webViewBorder: Color(0x261A4D86), webViewBackground: Color(0xFFFFFFFF), - controlsGradientStart: Color(0xFFE1F5FE), - controlsGradientEnd: Color(0xFFB3E5FC), - controlsShadow: Color(0x22000000), - statusText: Color(0xDD000000), + controlsGradientStart: Color(0xFFFFB465), + controlsGradientEnd: Color(0xFF69ADFF), + controlsShadow: Color(0x24114B7A), + statusText: Color(0xFF13243D), ); static const AppThemeColors darkColors = AppThemeColors( - primarySeed: Color(0xFF37474F), - webViewBorder: Color(0x66FFFFFF), - webViewBackground: Color(0xFF0F1417), - controlsGradientStart: Color(0xFF1B2731), - controlsGradientEnd: Color(0xFF233744), + primarySeed: Color(0xFFFF9D59), + secondarySeed: Color(0xFF76B8FF), + scaffoldGradientStart: Color(0xFF0F1C30), + scaffoldGradientEnd: Color(0xFF31190C), + shellSurface: Color(0xD9162438), + shellBorder: Color(0x3366AAFF), + accent: Color(0xFFFFBC7E), + webViewBorder: Color(0x3378B7FF), + webViewBackground: Color(0xFF0C1420), + controlsGradientStart: Color(0xFF275792), + controlsGradientEnd: Color(0xFF9A4F1F), controlsShadow: Color(0x44000000), - statusText: Color(0xE6FFFFFF), + statusText: Color(0xFFF4F7FF), ); static bool isDarkMode(BuildContext context) { @@ -50,19 +74,62 @@ class AppThemeBusinessObject { } static ThemeData lightTheme() { + final ColorScheme scheme = + ColorScheme.fromSeed( + seedColor: lightColors.primarySeed, + brightness: Brightness.light, + ).copyWith( + primary: lightColors.primarySeed, + secondary: lightColors.secondarySeed, + tertiary: const Color(0xFF2C85E1), + surface: const Color(0xFFFFFBF7), + onSurface: lightColors.statusText, + ); + return ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: lightColors.primarySeed), useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), ); } static ThemeData darkTheme() { + final ColorScheme scheme = + ColorScheme.fromSeed( + seedColor: darkColors.primarySeed, + brightness: Brightness.dark, + ).copyWith( + primary: darkColors.primarySeed, + secondary: darkColors.secondarySeed, + tertiary: const Color(0xFF4E97E8), + surface: const Color(0xFF111A27), + onSurface: darkColors.statusText, + ); + return ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: darkColors.primarySeed, - brightness: Brightness.dark, - ), useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), ); } } diff --git a/lib/live_info_business_object.dart b/lib/live_info_business_object.dart new file mode 100644 index 0000000..44f1bec --- /dev/null +++ b/lib/live_info_business_object.dart @@ -0,0 +1,356 @@ +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 = 15, + 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('>', '>'); +} diff --git a/lib/live_info_panel.dart b/lib/live_info_panel.dart new file mode 100644 index 0000000..9a07581 --- /dev/null +++ b/lib/live_info_panel.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; + +import 'app_theme_business_object.dart'; +import 'live_info_business_object.dart'; + +class LiveInfoPanel extends StatelessWidget { + const LiveInfoPanel({ + super.key, + required this.liveInfo, + required this.themeColors, + required this.intervalOptions, + }); + + final LiveInfoBusinessObject liveInfo; + final AppThemeColors themeColors; + final List intervalOptions; + + @override + Widget build(BuildContext context) { + final LiveInfoSnapshot? snapshot = liveInfo.snapshot; + final LiveInfoTrackInfo? current = snapshot?.current; + final LiveInfoTrackInfo? next = snapshot?.next; + final LiveInfoShowInfo? currentShow = snapshot?.currentShow.isEmpty ?? true + ? null + : snapshot!.currentShow.first; + final LiveInfoShowInfo? nextShow = snapshot?.nextShow.isEmpty ?? true + ? null + : snapshot!.nextShow.first; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + themeColors.controlsGradientStart.withValues(alpha: 0.9), + themeColors.controlsGradientEnd.withValues(alpha: 0.95), + ], + ), + boxShadow: [ + BoxShadow( + color: themeColors.controlsShadow, + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.graphic_eq_rounded, + color: themeColors.statusText, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Live Studio Feed', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: themeColors.statusText, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: Colors.black.withValues(alpha: 0.12), + ), + child: Text( + snapshot?.sourceEnabled ?? 'Unknown Source', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + tooltip: 'Set refresh interval', + initialValue: liveInfo.refreshIntervalSeconds, + onSelected: liveInfo.setRefreshIntervalSeconds, + itemBuilder: (BuildContext context) { + return intervalOptions + .map( + (int seconds) => PopupMenuItem( + value: seconds, + child: Text('$seconds seconds'), + ), + ) + .toList(growable: false); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.black.withValues(alpha: 0.08), + ), + child: Text( + '${liveInfo.refreshIntervalSeconds}s', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + IconButton( + tooltip: 'Refresh now', + onPressed: liveInfo.isLoading + ? null + : () => liveInfo.refresh(), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + const SizedBox(height: 8), + if (liveInfo.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Live info warning: ${liveInfo.errorMessage}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: _TrackCard( + label: 'Now Playing', + track: current, + titleColor: themeColors.statusText, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _TrackCard( + label: 'Coming Next', + track: next, + titleColor: themeColors.statusText, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + _ShowCard( + label: 'On Air Show', + show: currentShow, + titleColor: themeColors.statusText, + ), + const SizedBox(height: 6), + _ShowCard( + label: 'Up Next Show', + show: nextShow, + titleColor: themeColors.statusText, + ), + const SizedBox(height: 10), + Text( + 'Recently Played (runtime)', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: liveInfo.recentPlayed.isEmpty + ? Center( + child: Text( + liveInfo.isLoading + ? 'Loading recent items...' + : 'No recent tracks captured yet', + style: Theme.of(context).textTheme.bodySmall, + ), + ) + : ListView.separated( + itemCount: liveInfo.recentPlayed.length, + separatorBuilder: (BuildContext context, int index) => + Divider( + height: 1, + color: Colors.black.withValues(alpha: 0.08), + ), + itemBuilder: (BuildContext context, int index) { + final RecentPlayedItem item = + liveInfo.recentPlayed[index]; + return ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + item.subtitle.isEmpty + ? _formatTimeWindow(item.starts, item.ends) + : '${item.subtitle} | ${_formatTimeWindow(item.starts, item.ends)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + leading: const Icon(Icons.queue_music_rounded), + ); + }, + ), + ), + ), + const SizedBox(height: 6), + Text( + liveInfo.lastUpdatedAt == null + ? 'Waiting for API response...' + : 'Last update: ${liveInfo.lastUpdatedAt!.toLocal().toString().substring(11, 19)} • ${snapshot?.timezone ?? '--'}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + } +} + +class _TrackCard extends StatelessWidget { + const _TrackCard({ + required this.label, + required this.track, + required this.titleColor, + }); + + final String label; + final LiveInfoTrackInfo? track; + final Color titleColor; + + @override + Widget build(BuildContext context) { + final String title = track?.displayTitle ?? 'Unavailable'; + final String subtitle = (track?.displaySubtitle ?? '').trim(); + final String window = _formatTimeWindow(track?.starts, track?.ends); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.black.withValues(alpha: 0.12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: titleColor, + ), + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 3), + Text( + subtitle.isEmpty ? 'No metadata provided' : subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + Text( + window, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class _ShowCard extends StatelessWidget { + const _ShowCard({ + required this.label, + required this.show, + required this.titleColor, + }); + + final String label; + final LiveInfoShowInfo? show; + final Color titleColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.black.withValues(alpha: 0.06), + border: Border.all(color: Colors.black.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + Icon(Icons.mic_external_on_rounded, color: titleColor, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + '$label: ${show?.name ?? 'No scheduled show'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Text( + _formatTimeWindow(show?.starts, show?.ends), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ); + } +} + +String _formatTimeWindow(String? starts, String? ends) { + String formatOne(String? value) { + if (value == null || value.length < 16) { + return '--:--'; + } + return value.substring(11, 16); + } + + return '${formatOne(starts)} - ${formatOne(ends)}'; +} diff --git a/lib/main.dart b/lib/main.dart index 7fb1838..e42a21a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:just_audio_background/just_audio_background.dart'; -import 'package:webview_flutter/webview_flutter.dart'; import 'app_theme_business_object.dart'; +import 'live_info_business_object.dart'; +import 'live_info_panel.dart'; import 'streaming_audio_business_object.dart'; Future main() async { @@ -56,7 +57,8 @@ class MainPage extends StatefulWidget { class _MainPageState extends State { StreamingAudioBusinessObject get _audio => widget.audio; - late final WebViewController _webViewController; + final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject(); + static const List _intervalOptions = [5, 10, 15, 30, 60]; String _playbackStatusLabel() { switch (_audio.playbackStatus) { @@ -72,10 +74,9 @@ class _MainPageState extends State { @override void initState() { super.initState(); - _webViewController = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..loadRequest(Uri.parse('https://kryzradio.org')); _audio.addListener(_handleAudioStateChanged); + _liveInfo.addListener(_handleLiveInfoChanged); + _liveInfo.start(); } void _handleAudioStateChanged() { @@ -88,6 +89,16 @@ class _MainPageState extends State { }); } + void _handleLiveInfoChanged() { + if (!mounted) { + return; + } + + setState(() { + // Rebuild when live metadata updates from the polling API. + }); + } + Future _play() async { await _audio.play(); } @@ -99,91 +110,92 @@ class _MainPageState extends State { @override void dispose() { _audio.removeListener(_handleAudioStateChanged); + _liveInfo.removeListener(_handleLiveInfoChanged); + _liveInfo.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final themeColors = AppThemeBusinessObject.colorsFor(context); + final theme = Theme.of(context); return Scaffold( - body: SafeArea( - child: Column( - children: [ - Expanded( - flex: 9, - child: Padding( - padding: const EdgeInsets.all(12), - child: Container( + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + themeColors.scaffoldGradientStart, + themeColors.scaffoldGradientEnd, + ], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Container( width: double.infinity, + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - border: Border.all( - color: themeColors.webViewBorder, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - color: themeColors.webViewBackground, + color: themeColors.shellSurface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: themeColors.shellBorder), + boxShadow: [ + BoxShadow( + color: themeColors.controlsShadow, + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: themeColors.webViewBorder, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Station Information', - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - color: themeColors.statusText, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - 'https://kryzradio.org', - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - color: Colors.blue, - decoration: TextDecoration.underline, - decorationColor: Colors.blue, - ), - ), - ], + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Image.asset( + 'assets/icon/app_icon.png', + width: 72, + height: 72, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'KRYZ Go!', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 0.4, + color: themeColors.statusText, ), ), - Expanded( - child: WebViewWidget(controller: _webViewController), - ), - ], - ), + ), + const SizedBox(width: 86), + ], ), ), - ), - ), - Expanded( - flex: 1, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: Container( + const SizedBox(height: 12), + Expanded( + child: LiveInfoPanel( + liveInfo: _liveInfo, + themeColors: themeColors, + intervalOptions: _intervalOptions, + ), + ), + const SizedBox(height: 12), + Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(24), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -196,33 +208,39 @@ class _MainPageState extends State { BoxShadow( color: themeColors.controlsShadow, blurRadius: 14, - offset: Offset(0, 6), + offset: const Offset(0, 6), ), ], ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 12, children: [ - ElevatedButton( + ElevatedButton.icon( onPressed: _audio.isStopped ? _play : null, - child: const Text('▶️'), + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Play'), ), - const SizedBox(width: 12), - ElevatedButton( + ElevatedButton.icon( onPressed: _audio.isStopped ? null : _stop, - child: const Text('⏹️'), + icon: const Icon(Icons.stop_rounded), + label: const Text('Stop'), ), - const SizedBox(width: 16), Text( _playbackStatusLabel(), - style: TextStyle(color: themeColors.statusText), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: themeColors.statusText, + ), ), ], ), ), - ), + ], ), - ], + ), ), ), ); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 99f69f2..4e46d0f 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -1,6 +1,6 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.13) -project(KRYZ Go! LANGUAGES CXX) +project(KRYZ_Go! LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 4dd40f4..dc74b54 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index a1386d1..9ecbd77 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 294aa42..c51211a 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index 687eb87..9c20ede 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index ab8cf56..4581a99 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 9b7bf86..23e7f5c 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 4ea44fc..ff588e1 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.yaml b/pubspec.yaml index 0e7844c..685e0db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: just_audio_background: ^0.0.1-beta.17 audio_session: ^0.2.2 webview_flutter: ^4.13.0 + http: ^1.5.0 dev_dependencies: flutter_name_manager: ^1.0.0 @@ -62,6 +63,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/icon/app_icon.png # To add assets to your application, add an assets section, like this: # assets: diff --git a/web/favicon.png b/web/favicon.png index 294aa42..c51211a 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 600ec21..167958a 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 9b7bf86..23e7f5c 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index 600ec21..167958a 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index 9b7bf86..23e7f5c 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index a00cd8a..b0c3ccb 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ