import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:timezone/data/latest.dart' as tz; import 'app_theme_business_object.dart'; import 'casting_business_object.dart'; import 'live_info_business_object.dart'; import 'live_info_panel.dart'; import 'streaming_audio_business_object.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); tz.initializeTimeZones(); runApp(const MyApp()); // Keep startup non-blocking so UI can render even if background media // services are initializing after an Android Auto cold start. unawaited( SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]), ); } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State { final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject(); final CastingBusinessObject _casting = CastingBusinessObject(); @override void initState() { super.initState(); unawaited(_audio.ensureInitialized()); _casting.initialize(); } @override void dispose() { _casting.dispose(); _audio.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( title: 'KRYZ Go!', theme: AppThemeBusinessObject.lightTheme(), darkTheme: AppThemeBusinessObject.darkTheme(), themeMode: ThemeMode.system, home: MainPage(audio: _audio, casting: _casting), debugShowCheckedModeBanner: false, ); } } class MainPage extends StatefulWidget { const MainPage({super.key, required this.audio, required this.casting}); final StreamingAudioBusinessObject audio; final CastingBusinessObject casting; @override State createState() => _MainPageState(); } class _MainPageState extends State { StreamingAudioBusinessObject get _audio => widget.audio; CastingBusinessObject get _casting => widget.casting; final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject(); static const List _intervalOptions = [5, 10, 15, 30, 60]; bool _castStreamStartedForActiveSession = false; bool _isStartingCastStream = false; bool _pendingRemotePlay = false; bool get _usesExternalCastPlayback => _casting.isConnected && defaultTargetPlatform == TargetPlatform.android; bool get _isIosAirPlay => defaultTargetPlatform == TargetPlatform.iOS; bool get _isUsingRemotePlayback => _usesExternalCastPlayback; bool get _isPlaybackStopped { if (_isUsingRemotePlayback) { return _casting.isStopped || _casting.playbackStatus == CastingPlaybackStatus.unknown; } return _audio.isStopped; } bool get _isPlaybackPlaying { if (_isUsingRemotePlayback) { return _casting.isPlaying; } return _audio.isPlaying; } bool get _isPlaybackBuffering { if (_isUsingRemotePlayback) { return _casting.isBuffering; } return _audio.isBuffering; } String _playbackStatusLabel() { if (_isPlaybackBuffering) { return 'Buffering...'; } if (_isPlaybackPlaying) { return 'Playing'; } return 'Stopped'; } static const _playbackChannel = MethodChannel( 'kryz_go_flutter/playback/methods', ); @override void initState() { super.initState(); _audio.addListener(_handleAudioStateChanged); _casting.addListener(_handleCastingStateChanged); _liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.start(); _playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall); } Future _handlePlaybackMethodCall(MethodCall call) async { switch (call.method) { case 'play': await _play(); case 'stop': await _stop(); } } void _handleAudioStateChanged() { if (!mounted) { return; } setState(() { // Rebuild when playback state changes from the player. }); } void _handleLiveInfoChanged() { _audio.updateMetadataFromLiveInfo(_liveInfo.snapshot); if (!mounted) { return; } setState(() { // Rebuild when live metadata updates from the polling API. }); } void _handleCastingStateChanged() { if (_usesExternalCastPlayback && (!_castStreamStartedForActiveSession || _pendingRemotePlay) && !_isStartingCastStream) { _startCastingLiveStream(); } if (!_usesExternalCastPlayback) { _castStreamStartedForActiveSession = false; _isStartingCastStream = false; _pendingRemotePlay = false; } if (!mounted) { return; } setState(() { // Rebuild when cast state changes from the native platforms. }); } Future _startCastingLiveStream() async { _isStartingCastStream = true; try { await _casting.startCastingLiveStream( streamUrl: _audio.streamUrl, title: _audio.currentTitle, subtitle: _audio.currentSubtitle, ); _castStreamStartedForActiveSession = true; _pendingRemotePlay = false; if (_usesExternalCastPlayback && !_audio.isStopped) { await _audio.stop(); } } catch (_) { _castStreamStartedForActiveSession = false; // State is already updated by the casting business object. } finally { _isStartingCastStream = false; } } Future _play() async { if (_usesExternalCastPlayback) { if (_casting.isPlaying || _isStartingCastStream) { return; } await _startCastingLiveStream(); return; } if (_usesExternalCastPlayback && _casting.status == CastingStatus.connecting) { _pendingRemotePlay = true; return; } await _audio.play(); } Future _stop() async { if (_usesExternalCastPlayback) { await _casting.stopPlayback(); return; } await _audio.stop(); } Future _toggleCasting() async { if (_casting.isConnected) { await _casting.disconnect(); return; } await _casting.showDevicePicker(); } String _castingLabel() { final idleLabel = _isIosAirPlay ? 'AirPlay' : 'Cast'; final connectingLabel = _isIosAirPlay ? 'AirPlay...' : 'Casting...'; final errorLabel = _isIosAirPlay ? 'AirPlay Error' : 'Cast Error'; final unavailableLabel = _isIosAirPlay ? 'No AirPlay' : 'No Cast'; switch (_casting.status) { case CastingStatus.idle: return idleLabel; case CastingStatus.connecting: return connectingLabel; case CastingStatus.connected: final deviceName = _casting.connectedDeviceName; if (deviceName != null && deviceName.isNotEmpty) { return 'On $deviceName'; } return _isIosAirPlay ? 'AirPlaying' : 'Casting'; case CastingStatus.error: return errorLabel; case CastingStatus.unavailable: return unavailableLabel; } } IconData _castingIcon() { if (_isIosAirPlay) { return Icons.airplay_rounded; } return _casting.isConnected ? Icons.cast_connected_rounded : Icons.cast_rounded; } @override void dispose() { _audio.removeListener(_handleAudioStateChanged); _casting.removeListener(_handleCastingStateChanged); _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: 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( 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: 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, ), ), ), const SizedBox(width: 86), ], ), ), 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, vertical: 14, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ themeColors.controlsGradientStart, themeColors.controlsGradientEnd, ], ), boxShadow: [ BoxShadow( color: themeColors.controlsShadow, blurRadius: 14, offset: const Offset(0, 6), ), ], ), child: Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, spacing: 12, runSpacing: 12, children: [ ElevatedButton.icon( onPressed: _isPlaybackStopped ? _play : null, icon: const Icon(Icons.play_arrow_rounded), label: const Text('Play'), ), ElevatedButton.icon( onPressed: _isPlaybackStopped ? null : _stop, icon: const Icon(Icons.stop_rounded), label: const Text('Stop'), ), ElevatedButton.icon( onPressed: _casting.status == CastingStatus.connecting || _casting.status == CastingStatus.unavailable ? null : _toggleCasting, icon: Icon(_castingIcon()), label: Text(_castingLabel()), ), Text( _playbackStatusLabel(), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: themeColors.statusText, ), ), ], ), ), ], ), ), ), ), ); } }