corrected icon text. created info panel to replace web view with station stream info from airtime #1
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 100 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -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() {
|
||||||
|
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(
|
return ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: darkColors.primarySeed,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>');
|
||||||
|
}
|
||||||
@@ -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)}';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
child: Padding(
|
colors: [
|
||||||
padding: const EdgeInsets.all(12),
|
themeColors.scaffoldGradientStart,
|
||||||
child: Container(
|
themeColors.scaffoldGradientEnd,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
color: themeColors.shellSurface,
|
||||||
color: themeColors.webViewBorder,
|
borderRadius: BorderRadius.circular(24),
|
||||||
width: 2,
|
border: Border.all(color: themeColors.shellBorder),
|
||||||
),
|
boxShadow: [
|
||||||
borderRadius: BorderRadius.circular(8),
|
BoxShadow(
|
||||||
color: themeColors.webViewBackground,
|
color: themeColors.controlsShadow,
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: Row(
|
||||||
borderRadius: BorderRadius.circular(6),
|
children: [
|
||||||
child: Column(
|
ClipRRect(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(18),
|
||||||
Container(
|
child: Image.asset(
|
||||||
width: double.infinity,
|
'assets/icon/app_icon.png',
|
||||||
padding: const EdgeInsets.symmetric(
|
width: 72,
|
||||||
horizontal: 12,
|
height: 72,
|
||||||
vertical: 8,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
border: Border(
|
const SizedBox(width: 14),
|
||||||
bottom: BorderSide(
|
Expanded(
|
||||||
color: themeColors.webViewBorder,
|
child: Text(
|
||||||
),
|
'KRYZ Go!',
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
),
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
child: Column(
|
fontWeight: FontWeight.w900,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
letterSpacing: 0.4,
|
||||||
mainAxisSize: MainAxisSize.min,
|
color: themeColors.statusText,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: WebViewWidget(controller: _webViewController),
|
const SizedBox(width: 86),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: LiveInfoPanel(
|
||||||
flex: 1,
|
liveInfo: _liveInfo,
|
||||||
child: Padding(
|
themeColors: themeColors,
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
intervalOptions: _intervalOptions,
|
||||||
child: Container(
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
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,33 +208,39 @@ 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,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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 717 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -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:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 717 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.1 KiB |