corrected icon text. created info panel to replace web view with station stream info from airtime #1

Merged
kfj001 merged 3 commits from info_at_top into main 2026-04-30 19:30:20 -07:00
47 changed files with 924 additions and 114 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+11 -11
View File
@@ -25,9 +25,9 @@
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.44427667" inkscape:zoom="0.68550502"
inkscape:cx="707.89222" inkscape:cx="485.04386"
inkscape:cy="284.73248" inkscape:cy="511.30187"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="962" inkscape:window-height="962"
inkscape:window-x="0" inkscape:window-x="0"
@@ -132,32 +132,32 @@
xml:space="preserve" 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" 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" x="22.209259"
y="505.82516" y="501.82516"
id="text8"><tspan id="text8"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan7" id="tspan7"
x="22.209259" x="22.209259"
y="505.82516" y="501.82516"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:100.118px;font-family:C059;-inkscape-font-specification:'C059, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke:none;stroke-width:1.65284">98.5 LPFM</tspan></text> style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:100.118px;font-family:C059;-inkscape-font-specification:'C059, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke:none;stroke-width:1.65284">98.5 LPFM</tspan></text>
<text <text
xml:space="preserve" xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:169.76px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:2.80256" style="font-style:italic;font-variant:normal;font-weight:500;font-stretch:normal;font-size:169.76px;line-height:0.8;font-family:Z003;-inkscape-font-specification:'Z003, Medium Italic';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;writing-mode:lr-tb;direction:ltr;fill:#ffffff;stroke:none;stroke-width:2.80256"
x="25.539324" x="25.539324"
y="677.90143" y="669.90143"
id="text10"><tspan id="text10"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan10" id="tspan10"
x="25.539324" x="25.539324"
y="677.90143" y="669.90143"
style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan style="line-height:0.8;stroke-width:2.80256">Mariposa</tspan><tspan
sodipodi:role="line" sodipodi:role="line"
x="25.539324" x="25.539324"
y="813.70941" y="805.70941"
id="tspan11" id="tspan11"
style="line-height:0.8;stroke-width:2.80256">Public</tspan><tspan style="line-height:0.8;stroke-width:2.80256">Community</tspan><tspan
sodipodi:role="line" sodipodi:role="line"
x="25.539324" x="25.539324"
y="949.5174" y="941.5174"
style="line-height:0.8;stroke-width:2.80256" style="line-height:0.8;stroke-width:2.80256"
id="tspan13">Radio</tspan></text> id="tspan13">Radio</tspan></text>
<text <text
@@ -167,5 +167,5 @@
y="762.51642" y="762.51642"
id="text12"><tspan id="text12"><tspan
sodipodi:role="line" sodipodi:role="line"
id="tspan12"></tspan></text> id="tspan12" /></text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+83 -16
View File
@@ -3,6 +3,12 @@ import 'package:flutter/material.dart';
class AppThemeColors { class AppThemeColors {
const AppThemeColors({ const AppThemeColors({
required this.primarySeed, 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.webViewBorder,
required this.webViewBackground, required this.webViewBackground,
required this.controlsGradientStart, required this.controlsGradientStart,
@@ -12,6 +18,12 @@ class AppThemeColors {
}); });
final Color primarySeed; 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 webViewBorder;
final Color webViewBackground; final Color webViewBackground;
final Color controlsGradientStart; final Color controlsGradientStart;
@@ -22,23 +34,35 @@ class AppThemeColors {
class AppThemeBusinessObject { class AppThemeBusinessObject {
static const AppThemeColors lightColors = AppThemeColors( static const AppThemeColors lightColors = AppThemeColors(
primarySeed: Color(0xFF607D8B), primarySeed: Color(0xFFE36A18),
webViewBorder: Color(0x42000000), secondarySeed: Color(0xFF1663C7),
scaffoldGradientStart: Color(0xFFFFF3E3),
scaffoldGradientEnd: Color(0xFFE6F2FF),
shellSurface: Color(0xF7FFFFFF),
shellBorder: Color(0x1A1A4D86),
accent: Color(0xFF0F5DB6),
webViewBorder: Color(0x261A4D86),
webViewBackground: Color(0xFFFFFFFF), webViewBackground: Color(0xFFFFFFFF),
controlsGradientStart: Color(0xFFE1F5FE), controlsGradientStart: Color(0xFFFFB465),
controlsGradientEnd: Color(0xFFB3E5FC), controlsGradientEnd: Color(0xFF69ADFF),
controlsShadow: Color(0x22000000), controlsShadow: Color(0x24114B7A),
statusText: Color(0xDD000000), statusText: Color(0xFF13243D),
); );
static const AppThemeColors darkColors = AppThemeColors( static const AppThemeColors darkColors = AppThemeColors(
primarySeed: Color(0xFF37474F), primarySeed: Color(0xFFFF9D59),
webViewBorder: Color(0x66FFFFFF), secondarySeed: Color(0xFF76B8FF),
webViewBackground: Color(0xFF0F1417), scaffoldGradientStart: Color(0xFF0F1C30),
controlsGradientStart: Color(0xFF1B2731), scaffoldGradientEnd: Color(0xFF31190C),
controlsGradientEnd: Color(0xFF233744), shellSurface: Color(0xD9162438),
shellBorder: Color(0x3366AAFF),
accent: Color(0xFFFFBC7E),
webViewBorder: Color(0x3378B7FF),
webViewBackground: Color(0xFF0C1420),
controlsGradientStart: Color(0xFF275792),
controlsGradientEnd: Color(0xFF9A4F1F),
controlsShadow: Color(0x44000000), controlsShadow: Color(0x44000000),
statusText: Color(0xE6FFFFFF), statusText: Color(0xFFF4F7FF),
); );
static bool isDarkMode(BuildContext context) { static bool isDarkMode(BuildContext context) {
@@ -50,19 +74,62 @@ class AppThemeBusinessObject {
} }
static ThemeData lightTheme() { 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( return ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: lightColors.primarySeed),
useMaterial3: true, 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() { static ThemeData darkTheme() {
return ThemeData( final ColorScheme scheme =
colorScheme: ColorScheme.fromSeed( ColorScheme.fromSeed(
seedColor: darkColors.primarySeed, seedColor: darkColors.primarySeed,
brightness: Brightness.dark, brightness: Brightness.dark,
), ).copyWith(
primary: darkColors.primarySeed,
secondary: darkColors.secondarySeed,
tertiary: const Color(0xFF4E97E8),
surface: const Color(0xFF111A27),
onSurface: darkColors.statusText,
);
return ThemeData(
useMaterial3: true, 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),
),
),
),
); );
} }
} }
+356
View File
@@ -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<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 {
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('&#39;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
+366
View File
@@ -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<int> 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<int>(
tooltip: 'Set refresh interval',
initialValue: liveInfo.refreshIntervalSeconds,
onSelected: liveInfo.setRefreshIntervalSeconds,
itemBuilder: (BuildContext context) {
return intervalOptions
.map(
(int seconds) => PopupMenuItem<int>(
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)}';
}
+91 -73
View File
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'app_theme_business_object.dart'; import 'app_theme_business_object.dart';
import 'live_info_business_object.dart';
import 'live_info_panel.dart';
import 'streaming_audio_business_object.dart'; import 'streaming_audio_business_object.dart';
Future<void> main() async { Future<void> main() async {
@@ -56,7 +57,8 @@ class MainPage extends StatefulWidget {
class _MainPageState extends State<MainPage> { class _MainPageState extends State<MainPage> {
StreamingAudioBusinessObject get _audio => widget.audio; StreamingAudioBusinessObject get _audio => widget.audio;
late final WebViewController _webViewController; final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
String _playbackStatusLabel() { String _playbackStatusLabel() {
switch (_audio.playbackStatus) { switch (_audio.playbackStatus) {
@@ -72,10 +74,9 @@ class _MainPageState extends State<MainPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse('https://kryzradio.org'));
_audio.addListener(_handleAudioStateChanged); _audio.addListener(_handleAudioStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged);
_liveInfo.start();
} }
void _handleAudioStateChanged() { void _handleAudioStateChanged() {
@@ -88,6 +89,16 @@ class _MainPageState extends State<MainPage> {
}); });
} }
void _handleLiveInfoChanged() {
if (!mounted) {
return;
}
setState(() {
// Rebuild when live metadata updates from the polling API.
});
}
Future<void> _play() async { Future<void> _play() async {
await _audio.play(); await _audio.play();
} }
@@ -99,91 +110,92 @@ class _MainPageState extends State<MainPage> {
@override @override
void dispose() { void dispose() {
_audio.removeListener(_handleAudioStateChanged); _audio.removeListener(_handleAudioStateChanged);
_liveInfo.removeListener(_handleLiveInfoChanged);
_liveInfo.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeColors = AppThemeBusinessObject.colorsFor(context); final themeColors = AppThemeBusinessObject.colorsFor(context);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: SafeArea( body: DecoratedBox(
child: Column( decoration: BoxDecoration(
children: [ gradient: LinearGradient(
Expanded( begin: Alignment.topLeft,
flex: 9, end: Alignment.bottomRight,
colors: [
themeColors.scaffoldGradientStart,
themeColors.scaffoldGradientEnd,
],
),
),
child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(
color: themeColors.webViewBorder,
width: 2,
),
borderRadius: BorderRadius.circular(8),
color: themeColors.webViewBackground,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Column( child: Column(
children: [ children: [
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.all(14),
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( color: themeColors.shellSurface,
bottom: BorderSide( borderRadius: BorderRadius.circular(24),
color: themeColors.webViewBorder, border: Border.all(color: themeColors.shellBorder),
boxShadow: [
BoxShadow(
color: themeColors.controlsShadow,
blurRadius: 18,
offset: const Offset(0, 10),
), ),
],
), ),
), child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( ClipRRect(
'Station Information', borderRadius: BorderRadius.circular(18),
style: Theme.of(context).textTheme.labelMedium child: Image.asset(
?.copyWith( '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, 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,
),
), ),
const SizedBox(width: 86),
], ],
), ),
), ),
const SizedBox(height: 12),
Expanded( Expanded(
child: WebViewWidget(controller: _webViewController), child: LiveInfoPanel(
), liveInfo: _liveInfo,
], themeColors: themeColors,
intervalOptions: _intervalOptions,
), ),
), ),
), const SizedBox(height: 12),
), Container(
),
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(24),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
@@ -196,35 +208,41 @@ class _MainPageState extends State<MainPage> {
BoxShadow( BoxShadow(
color: themeColors.controlsShadow, color: themeColors.controlsShadow,
blurRadius: 14, blurRadius: 14,
offset: Offset(0, 6), offset: const Offset(0, 6),
), ),
], ],
), ),
child: Row( child: Wrap(
mainAxisAlignment: MainAxisAlignment.center, alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 12,
children: [ children: [
ElevatedButton( ElevatedButton.icon(
onPressed: _audio.isStopped ? _play : null, onPressed: _audio.isStopped ? _play : null,
child: const Text('▶️'), icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
), ),
const SizedBox(width: 12), ElevatedButton.icon(
ElevatedButton(
onPressed: _audio.isStopped ? null : _stop, onPressed: _audio.isStopped ? null : _stop,
child: const Text('⏹️'), icon: const Icon(Icons.stop_rounded),
label: const Text('Stop'),
), ),
const SizedBox(width: 16),
Text( Text(
_playbackStatusLabel(), _playbackStatusLabel(),
style: TextStyle(color: themeColors.statusText), style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: themeColors.statusText,
),
),
],
),
), ),
], ],
), ),
), ),
), ),
), ),
],
),
),
); );
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.13) 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+3
View File
@@ -38,6 +38,7 @@ dependencies:
just_audio_background: ^0.0.1-beta.17 just_audio_background: ^0.0.1-beta.17
audio_session: ^0.2.2 audio_session: ^0.2.2
webview_flutter: ^4.13.0 webview_flutter: ^4.13.0
http: ^1.5.0
dev_dependencies: dev_dependencies:
flutter_name_manager: ^1.0.0 flutter_name_manager: ^1.0.0
@@ -62,6 +63,8 @@ flutter:
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/icon/app_icon.png
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB