From ca569ef364c2f16fb64f50431b65c0d18b16cb4c Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Fri, 19 Dec 2025 10:55:27 +0100 Subject: [PATCH 01/22] feat: Android Auto and new audio player --- .../presentation/radio_luz_view.dart | 15 +- .../service/radio_audio_handler.dart | 364 ++++++++++++------ .../service/radio_player_controller.dart | 37 +- .../service/radio_player_provider.dart | 4 +- lib/main.dart | 13 +- pubspec.yaml | 3 +- 6 files changed, 292 insertions(+), 144 deletions(-) diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index a96fae7f7..47c883e2a 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -1,15 +1,12 @@ -import "dart:async"; - import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "../../../config/ui_config.dart"; import "../../../theme/app_theme.dart"; import "../../../utils/context_extensions.dart"; import "../../../widgets/horizontal_symmetric_safe_area.dart"; -import "../service/radio_player_controller.dart"; +import "../service/radio_player_provider.dart"; import "audio_player_widget.dart"; import "broadcasts_section.dart"; import "now_playing_section.dart"; @@ -18,20 +15,14 @@ import "radio_luz_socials_section.dart"; import "radio_luz_title.dart"; @RoutePage() -class RadioLuzView extends HookConsumerWidget { +class RadioLuzView extends ConsumerWidget { const RadioLuzView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.localize; final cappedTextScale = context.textScaler.clamp(maxScaleFactor: 1.7); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(ref.read(radioControllerProvider.notifier).preload()); - }); - return null; - }, []); + final handler = ref.watch(radioPlayerProvider); return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 813b1bba9..d8941cc78 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -1,13 +1,16 @@ + import "dart:async"; +import "dart:convert"; +import "dart:io"; import "package:audio_service/audio_service.dart"; -import "package:audio_session/audio_session.dart"; -import "package:flutter/widgets.dart"; +import "package:dio/dio.dart"; +import "package:flutter/services.dart"; import "package:just_audio/just_audio.dart"; +import "package:path/path.dart" as p; +import "package:path_provider/path_provider.dart"; import "../../../config/env.dart"; -import "../data/client/radio_luz_client.dart"; -import "../data/repository/radio_luz_repository.dart"; //Here the audio player is defined. Its created at app startup and is used for playing live stream. //This whole class describes the audio player behavior, media items and metadata. @@ -15,161 +18,298 @@ import "../data/repository/radio_luz_repository.dart"; //Thanks to that the audio player can talk to native APIs of audio services specific to the platform. //Specifically, it allows the app to be recognized as a media player, which allows integration with Android Auto, CarPlay, etc. -const radioLuzArtwork = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; -const refreshInterval = Duration(seconds: 15); -const staleStreamThreshold = Duration(seconds: 30); +const RADIO_LUZ_ARTWORK = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; +const REFRESH_INTERVAL = Duration(minutes: 1); + +//used for AA and CP to display folders and media items +class _MediaIds { + static const liveRadioFolder = "radio_luz_folder"; + static const liveRadioPlayable = "radio_luz_station"; + static const recentlyPlayed = "recently_played_folder"; + static const schedule = "schedule_folder"; +} -class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, WidgetsBindingObserver { +class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final _player = AudioPlayer(); - //used for fetching now playing metadata - final RadioLuzRepository _repository; + //used for fetching recently played and schedule + final _dio = Dio(BaseOptions( + baseUrl: Env.radioLuzApiUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + "User-Agent": "RadioLuzApp/1.0 (Dart Dio)", + "Accept": "*/*", + "Content-Type": "application/x-www-form-urlencoded", + }, + )); + + late MediaItem _radioLuzMediaItem; + + List? _recentlyPlayedCache; + DateTime? _recentlyPlayedLastFetch; + static const _recentlyPlayedTtl = Duration(minutes: 1); - //main media item - live radio (information source) - var _radioLuzMediaItem = MediaItem( - id: "radio_luz_station", - title: "Radio LUZ", - album: "Studenckie Radio", - artUri: Uri.parse(radioLuzArtwork), //artwork cannot be local asset - ); + List? _scheduleCache; + DateTime? _scheduleLastFetch; + static const _scheduleTtl = Duration(minutes: 5); - var _isDisposed = false; + bool _isDisposed = false; Timer? _refreshTimer; - StreamSubscription? _playbackEventSubscription; - StreamSubscription? _sequenceStateSubscription; - RadioAudioHandlerBridge() : _repository = RadioLuzRepository(createRadioLuzDio()) { - _initializeListeners(); - } + RadioAudioHandler() { + _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl)), preload: false); // Pre-configuration + _player.setAndroidAudioAttributes(const AndroidAudioAttributes( + contentType: AndroidAudioContentType.music, + usage: AndroidAudioUsage.media, + )); + + //main media item - live radio (information source) + _radioLuzMediaItem = MediaItem( + id: _MediaIds.liveRadioPlayable, + title: "Radio LUZ", + album: "Studenckie Radio", + playable: true, + artUri: Uri.parse(RADIO_LUZ_ARTWORK), //artwork cannot be local asset + ); - void _initializeListeners() { - //connect 'just_audio' (Flutter) to 'audio_service' (native) - _playbackEventSubscription = _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); + _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) + _player.sequenceStateStream.map((state) => state?.currentSource?.tag as MediaItem?).listen(mediaItem.add); //metadata bridge to audio_service - _sequenceStateSubscription = _player.sequenceStateStream.listen((state) { - mediaItem.add(_radioLuzMediaItem); - }); + _startPeriodicRefresh(); + } - //periodically refresh now playing metadata + //refreshes recently played + void _startPeriodicRefresh() { _refreshTimer?.cancel(); - _refreshTimer = Timer.periodic(refreshInterval, (_) async { - await _fetchNowPlaying(); - }); + _refreshTimer = Timer.periodic(REFRESH_INTERVAL, (_) async { + _recentlyPlayedCache = null; + _recentlyPlayedLastFetch = null; - //pre-configure audio session for iOS to reduce playback startup latency - unawaited(_initAudioSession()); + await _fetchRecentlyPlayed(); + if (_isDisposed) return; // Guard against updates after disposal - //register lifecycle observer to stop playback when app is killed - WidgetsBinding.instance.addObserver(this); + notifyChildrenChanged(_MediaIds.recentlyPlayed); + }); } @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // Stop playback when app is detached (killed) - if (state == AppLifecycleState.detached) { - unawaited(stop()); + Future play() async { + if (_player.processingState == ProcessingState.idle) { + await _player.setAudioSource( + AudioSource.uri( + Uri.parse(Env.radioLuzStreamUrl), + tag: _radioLuzMediaItem, + ), + ); + mediaItem.add(_radioLuzMediaItem); } + await _player.play(); } - /// iOS audio session - Future _initAudioSession() async { - final session = await AudioSession.instance; - await session.configure( - const AudioSessionConfiguration( - avAudioSessionCategory: AVAudioSessionCategory.playback, - avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.allowBluetooth, - avAudioSessionMode: AVAudioSessionMode.defaultMode, - avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, - avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, - androidAudioAttributes: AndroidAudioAttributes( - contentType: AndroidAudioContentType.music, - usage: AndroidAudioUsage.media, - ), - androidWillPauseWhenDucked: false, - ), - ); + @override + Future pause() => _player.pause(); + + @override + dispose() { + _isDisposed = true; + _refreshTimer?.cancel(); + _player.dispose(); + super.dispose(); + } + + //most crucial - defines the structure of the media library that is visible in AA and CP + @override + Future> getChildren(String parentMediaId, [Map? options]) async { + switch (parentMediaId) { + case AudioService.browsableRootId: + return [ + const MediaItem( + id: _MediaIds.liveRadioFolder, + title: "Radio LUZ", + album: "Studenckie Radio", + playable: false, + ), + MediaItem( + id: _MediaIds.recentlyPlayed, + title: "Teraz gramy", + playable: false, + ), + const MediaItem( + id: _MediaIds.schedule, + title: "Audycje", + playable: false, + ), + ]; + + case _MediaIds.liveRadioFolder: + return [_radioLuzMediaItem]; + + case _MediaIds.recentlyPlayed: + return _fetchRecentlyPlayed(); + + case _MediaIds.schedule: + return _fetchSchedule(); + + default: + return []; + } } - //track when the stream was paused to detect stale buffers - DateTime? _lastPauseTime; + Future> _fetchRecentlyPlayed() async { + final now = DateTime.now(); + if (_recentlyPlayedCache != null && + _recentlyPlayedLastFetch != null && + now.difference(_recentlyPlayedLastFetch!) < _recentlyPlayedTtl) { + return _recentlyPlayedCache!; + } - Future _fetchNowPlaying() async { try { - if (_isDisposed) return; - final nowPlaying = await _repository.getNowPlaying(); + final formData = FormData.fromMap({"action": "histoprylog"}); + final response = await _dio.post("admin-ajax.php", data: formData); - if (nowPlaying == null || nowPlaying.now == null || nowPlaying.now!.isEmpty) { - return; - } + if (_isDisposed) return []; - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying.now, artist: nowPlaying.now); + if (response.data == null) return _recentlyPlayedCache ?? []; - mediaItem.add(_radioLuzMediaItem); - } on Exception catch (_) { - //keep the old metadata - } - } + final dynamic decoded = jsonDecode(response.data!); + if (decoded is! List) return _recentlyPlayedCache ?? []; - ///pre-loads the audio stream - Future _loadStream() async { - await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); - mediaItem.add(_radioLuzMediaItem); - } + final items = decoded.map((e) { + if (e is! List) return null; - Future preload() async { - if (_player.processingState == ProcessingState.idle) { - await _loadStream(); + final timeRaw = e[1]?.toString() ?? ""; + final artist = e[2]?.toString() ?? ""; + final title = e[3]?.toString() ?? ""; + final album = e[4]?.toString() ?? ""; + + final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; + + return MediaItem( + id: "history_${timeRaw}_$title", + title: "$time - $title", + artist: artist, + album: album, + playable: true, + ); + }).whereType().toList(); + + //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest + items.sort((a, b) => b.title.compareTo(a.title)); + + if (items.isNotEmpty) { + final latest = items.first; + final songTitle = latest.title.length >= 8 ? latest.title.substring(8) : latest.title; + final newAlbum = "${latest.artist} - $songTitle"; + + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: newAlbum); + + if (_player.playing) { + //refresh metadata + mediaItem.add(_radioLuzMediaItem); + } + } + + _recentlyPlayedCache = items; + _recentlyPlayedLastFetch = now; + + return items; + } catch (_) { + return _recentlyPlayedCache ?? []; } } - @override - Future play() async { + Future> _fetchSchedule() async { final now = DateTime.now(); - final isStale = _lastPauseTime != null && now.difference(_lastPauseTime!) > staleStreamThreshold; + if (_scheduleCache != null && + _scheduleLastFetch != null && + now.difference(_scheduleLastFetch!) < _scheduleTtl) { + return _scheduleCache!; + } - _lastPauseTime = null; + try { + final formData = FormData.fromMap({"action": "schedule"}); + final response = await _dio.post("admin-ajax.php", data: formData); - //if stream is stale or player is idle, reload the audio source - if (isStale || _player.processingState == ProcessingState.idle) { - await _loadStream(); - } + if (response.data == null) return _scheduleCache ?? []; - unawaited(_player.play()); - } + final dynamic jsonMap = jsonDecode(response.data!); + if (jsonMap is! Map) return _scheduleCache ?? []; - @override - Future pause() async { - _lastPauseTime = DateTime.now(); - await _player.pause(); - } + final broadcasts = jsonMap["broadcasts"]; + if (broadcasts is! List) return _scheduleCache ?? []; - @override - Future stop() async { - _isDisposed = true; - _refreshTimer?.cancel(); - await _playbackEventSubscription?.cancel(); - await _sequenceStateSubscription?.cancel(); - WidgetsBinding.instance.removeObserver(this); - await _player.stop(); - return super.stop(); + final items = []; + + for (final block in broadcasts) { + if (block is! Map) continue; + + final isNow = block["isNow"] as bool? ?? false; + final broadcastList = block["broadcasts"]; + + if (broadcastList is! List) continue; + + for (final broadcast in broadcastList) { + if (broadcast is! Map) continue; + + final b = broadcast; + final time = b["time"]?.toString() ?? ""; + final title = b["title"]?.toString() ?? ""; + final authors = b["authors"]?.toString() ?? ""; + final thumbnail = b["thumbnail"]; + + Uri? artUri; + if (thumbnail is String && thumbnail.isNotEmpty) { + artUri = Uri.tryParse(thumbnail); + } + + items.add(MediaItem( + id: "schedule_${b["id"]}", + title: isNow ? "▶ $title" : title, + artist: authors, + album: time, + playable: true, + artUri: artUri, + )); + } + } + + _scheduleCache = items; + _scheduleLastFetch = now; + + return items; + } catch (_) { + return _scheduleCache ?? []; + } } - //defines the media library visible in Android Auto and CarPlay + //mainly for getting info about recently played media item @override - Future> getChildren(String parentMediaId, [Map? options]) async { - if (parentMediaId == AudioService.browsableRootId) { - return [_radioLuzMediaItem]; + Future getItem(String mediaId) async { + if (mediaId == _radioLuzMediaItem.id) { + return _radioLuzMediaItem; } - return []; + return mediaItem.value; } + //start playing media item @override Future playFromMediaId(String mediaId, [Map? extras]) async { - if (mediaId == _radioLuzMediaItem.id) { - await _loadStream(); - unawaited(_player.play()); + //...because anything you click on should play the radio + if (mediaId == _radioLuzMediaItem.id || + mediaId.startsWith("history_") || + mediaId.startsWith("schedule_")) { + await _player.setAudioSource( + AudioSource.uri( + Uri.parse(Env.radioLuzStreamUrl), + tag: _radioLuzMediaItem, + ), + ); + mediaItem.add(_radioLuzMediaItem); + await _player.play(); } + //this if might be useless but you never know... } Future setVolume(double volume) => _player.setVolume(volume); diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index bf10367e3..527ce458b 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -1,6 +1,7 @@ import "dart:async"; import "dart:io"; +import "package:audio_service/audio_service.dart"; import "package:flutter/services.dart"; import "package:just_audio/just_audio.dart"; import "package:path/path.dart" as p; @@ -17,7 +18,8 @@ part "radio_player_controller.g.dart"; @Riverpod(keepAlive: true) class RadioController extends _$RadioController { - late final RadioAudioHandlerBridge _handler = ref.watch(radioPlayerProvider); + late final RadioAudioHandler _handler = ref.watch(radioPlayerProvider); + late final AudioPlayerStrings _audioPlayerStrings; var _initialized = false; var _prevVolume = 1.0; @@ -35,7 +37,22 @@ class RadioController extends _$RadioController { final isPlaying = (playerStateProvider.value?.playing ?? false) && processingState == ProcessingState.ready; final isLoading = processingState == ProcessingState.loading || processingState == ProcessingState.buffering; - return RadioState(isPlaying: isPlaying, isLoading: isLoading, volume: volume, isMuted: volume <= _muteThreshold); + return RadioState(isPlaying: isPlaying, isLoading: isLoading, volume: volume); + } + + Future _initPlayer() async { + final assetPath = Assets.png.radioLuz.radioLuzLogo.path; + final artUri = await assetToFileUri(assetPath); + + final audioSource = AudioSource.uri( + Uri.parse(Env.radioLuzStreamUrl), + tag: MediaItem(id: "1", title: _audioPlayerStrings.title, album: _audioPlayerStrings.album, artUri: artUri), + ); + + await _handler.setAudioSource(audioSource); + + final volume = ref.read(audioPlayerVolumeProvider).value ?? 1.0; + await _handler.setVolume(volume); } void init(AudioPlayerStrings audioPlayerStrings) { @@ -72,19 +89,11 @@ class RadioController extends _$RadioController { await _handler.pause(); } + Future stop() async { + await _handler.stop(); + } //i don't think we need this, but keeping just in case + Future setVolume(double newVolume) async { await _handler.setVolume(newVolume); } - - void rememberVolume(double newVolume) { - _prevVolume = newVolume > _muteThreshold ? newVolume : _prevVolume; - } - - Future toggleVolume() async { - if (state.volume <= _muteThreshold) { - await _handler.setVolume(_prevVolume); - } else { - await _handler.setVolume(0); - } - } } diff --git a/lib/features/radio_luz/service/radio_player_provider.dart b/lib/features/radio_luz/service/radio_player_provider.dart index b1f5846a1..d206f2a19 100644 --- a/lib/features/radio_luz/service/radio_player_provider.dart +++ b/lib/features/radio_luz/service/radio_player_provider.dart @@ -1,3 +1,5 @@ +import "package:audio_service/audio_service.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; import "radio_audio_handler.dart"; @@ -9,6 +11,6 @@ part "radio_player_provider.g.dart"; //the player is assigned in main.dart so it starts even before app is fully loaded @Riverpod(keepAlive: true) -RadioAudioHandlerBridge radioPlayer(Ref ref) { +RadioAudioHandler radioPlayer(Ref ref) { throw UnimplementedError("radioPlayer provider must be overridden in main.dart"); } diff --git a/lib/main.dart b/lib/main.dart index 5a0d0ea33..241bbd88a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,15 +50,20 @@ Future main() async { Future runToPWR() async { final data = await PlatformAssetBundle().load(Assets.certs.przewodnikPwrEduPl); SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); + // await setupParkingWidgetsWorkManager(); - final config = ClarityConfig(projectId: Env.clarityConfigId, logLevel: LogLevel.None); + final config = ClarityConfig( + projectId: Env.clarityConfigId, + logLevel: LogLevel.None, + ); final audioHandler = await AudioService.init( - builder: RadioAudioHandlerBridge.new, + builder: RadioAudioHandler.new, config: const AudioServiceConfig( androidNotificationChannelId: "com.solvro.topwr.audio", androidNotificationChannelName: "Audio playback", androidNotificationOngoing: true, + androidStopForegroundOnPause: true, ), ); @@ -72,7 +77,9 @@ Future runToPWR() async { if (retryCount > 5) return null; return Duration(seconds: retryCount * 2); }, - overrides: [radioPlayerProvider.overrideWithValue(audioHandler)], + overrides: [ + radioPlayerProvider.overrideWithValue(audioHandler), + ], child: const SplashScreen(child: MyApp()), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 9c94832d5..d6eeb853f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -118,10 +118,8 @@ dependencies: crypto: ^3.0.7 #Audio - #TODO: bump when just_audio fixes issue: https://github.com/ryanheise/just_audio/issues/1558 just_audio: ^0.9.36 audio_service: ^0.18.18 - audio_session: ^0.1.21 #Other in_app_update: ^4.2.5 @@ -133,6 +131,7 @@ dependencies: wiredash: ^2.6.0 flutter_hooks: ^0.21.3 sentry_flutter: ^9.12.0 + workmanager: ^0.8.0 #Solvro packages solvro_translator_with_drift_cache_flutter: ^0.9.4 From 0f1c02771784c4ee119a081d5b33c5d2aa1d0fba Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sat, 20 Dec 2025 01:59:21 +0100 Subject: [PATCH 02/22] fix: nowPlaying and few fixes --- .../service/radio_audio_handler.dart | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index d8941cc78..2f1ed9d93 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -19,7 +19,7 @@ import "../../../config/env.dart"; //Specifically, it allows the app to be recognized as a media player, which allows integration with Android Auto, CarPlay, etc. const RADIO_LUZ_ARTWORK = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; -const REFRESH_INTERVAL = Duration(minutes: 1); +const REFRESH_INTERVAL = Duration(seconds: 15); //used for AA and CP to display folders and media items class _MediaIds { @@ -59,12 +59,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Timer? _refreshTimer; RadioAudioHandler() { - _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl)), preload: false); // Pre-configuration - _player.setAndroidAudioAttributes(const AndroidAudioAttributes( - contentType: AndroidAudioContentType.music, - usage: AndroidAudioUsage.media, - )); - //main media item - live radio (information source) _radioLuzMediaItem = MediaItem( id: _MediaIds.liveRadioPlayable, @@ -75,28 +69,56 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ); _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) - _player.sequenceStateStream.map((state) => state?.currentSource?.tag as MediaItem?).listen(mediaItem.add); //metadata bridge to audio_service + + _player.sequenceStateStream.listen((state) { + mediaItem.add(_radioLuzMediaItem); + }); _startPeriodicRefresh(); } - //refreshes recently played + //refreshes now playing metadata void _startPeriodicRefresh() { _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(REFRESH_INTERVAL, (_) async { - _recentlyPlayedCache = null; - _recentlyPlayedLastFetch = null; + await _fetchNowPlaying(); + }); + } - await _fetchRecentlyPlayed(); - if (_isDisposed) return; // Guard against updates after disposal + // Track when the stream was paused to detect stale buffers + DateTime? _lastPauseTime; + static const _staleStreamThreshold = Duration(seconds: 30); - notifyChildrenChanged(_MediaIds.recentlyPlayed); - }); + Future _fetchNowPlaying() async { + try { + if (_isDisposed) return; + final formData = FormData.fromMap({"action": "nowPlaying"}); + final response = await _dio.post("admin-ajax.php", data: formData); + + if (response.data == null) return; + + final dynamic decoded = jsonDecode(response.data!); + if (decoded is! Map) return; + + final nowPlaying = decoded["now"]?.toString(); + if (nowPlaying == null || nowPlaying.isEmpty) return; + + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying); + + mediaItem.add(_radioLuzMediaItem); + } catch (_) { + //keep the old metadata + } } @override Future play() async { - if (_player.processingState == ProcessingState.idle) { + final now = DateTime.now(); + final isStale = _lastPauseTime != null && + now.difference(_lastPauseTime!) > _staleStreamThreshold; + + // If stream is stale or player is idle, reload the audio source + if (isStale || _player.processingState == ProcessingState.idle) { await _player.setAudioSource( AudioSource.uri( Uri.parse(Env.radioLuzStreamUrl), @@ -105,18 +127,23 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ); mediaItem.add(_radioLuzMediaItem); } + + _lastPauseTime = null; await _player.play(); } @override - Future pause() => _player.pause(); + Future pause() async { + _lastPauseTime = DateTime.now(); + await _player.pause(); + } @override - dispose() { + Future stop() async { _isDisposed = true; _refreshTimer?.cancel(); - _player.dispose(); - super.dispose(); + await _player.stop(); + return super.stop(); } //most crucial - defines the structure of the media library that is visible in AA and CP @@ -198,19 +225,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest items.sort((a, b) => b.title.compareTo(a.title)); - if (items.isNotEmpty) { - final latest = items.first; - final songTitle = latest.title.length >= 8 ? latest.title.substring(8) : latest.title; - final newAlbum = "${latest.artist} - $songTitle"; - - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: newAlbum); - - if (_player.playing) { - //refresh metadata - mediaItem.add(_radioLuzMediaItem); - } - } - _recentlyPlayedCache = items; _recentlyPlayedLastFetch = now; @@ -256,7 +270,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final b = broadcast; final time = b["time"]?.toString() ?? ""; final title = b["title"]?.toString() ?? ""; - final authors = b["authors"]?.toString() ?? ""; final thumbnail = b["thumbnail"]; Uri? artUri; @@ -267,7 +280,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { items.add(MediaItem( id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, - artist: authors, album: time, playable: true, artUri: artUri, From c07de54e5c2f3dbec712f1e6acc4467320abaabd Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Fri, 19 Dec 2025 10:55:27 +0100 Subject: [PATCH 03/22] feat: Android Auto and new audio player --- .../presentation/radio_luz_view.dart | 56 +++--- .../service/radio_audio_handler.dart | 176 +++++++++--------- .../service/radio_player_provider.dart | 3 +- lib/main.dart | 1 + pubspec.yaml | 49 +++-- 5 files changed, 135 insertions(+), 150 deletions(-) diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index 47c883e2a..e32c70b2c 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -5,7 +5,6 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "../../../config/ui_config.dart"; import "../../../theme/app_theme.dart"; import "../../../utils/context_extensions.dart"; -import "../../../widgets/horizontal_symmetric_safe_area.dart"; import "../service/radio_player_provider.dart"; import "audio_player_widget.dart"; import "broadcasts_section.dart"; @@ -23,38 +22,35 @@ class RadioLuzView extends ConsumerWidget { final l10n = context.localize; final cappedTextScale = context.textScaler.clamp(maxScaleFactor: 1.7); final handler = ref.watch(radioPlayerProvider); - + return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), - child: HorizontalSymmetricSafeAreaScaffold( - backgroundColor: context.colorScheme.primaryContainer, + child: Scaffold( + backgroundColor: context.colorScheme.surfaceTint, appBar: RadioLuzAppBar(context, logoSize: 55), - body: ColoredBox( - color: context.colorScheme.surfaceTint, - child: Stack( - children: [ - ListView( - padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), - children: [ - RadioLuzTitle(title: l10n.now_playing.toUpperCase()), - const SizedBox(height: 12), - const NowPlayingSection(), - const SizedBox(height: 24), - RadioLuzTitle(title: l10n.broadcast.toUpperCase()), - const SizedBox(height: 12), - const BroadcastsSection(), - const SizedBox(height: 20), - RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), - const SizedBox(height: 12), - const _TextSection(), - const SizedBox(height: 12), - const RadioLuzSocialsSection(), - const SizedBox(height: 80), - ], - ), - const Align(alignment: Alignment.bottomCenter, child: AudioPlayerWidget()), - ], - ), + body: Stack( + children: [ + ListView( + padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), + children: [ + RadioLuzTitle(title: l10n.now_playing.toUpperCase()), + const SizedBox(height: 12), + const NowPlayingSection(), + const SizedBox(height: 24), + RadioLuzTitle(title: l10n.broadcast.toUpperCase()), + const SizedBox(height: 12), + const BroadcastsSection(), + const SizedBox(height: 20), + RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), + const SizedBox(height: 12), + const _TextSection(), + const SizedBox(height: 12), + const RadioLuzSocialsSection(), + const SizedBox(height: 80), + ], + ), + const Align(alignment: Alignment.bottomCenter, child: AudioPlayerWidget()), + ], ), ), ); diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 2f1ed9d93..870f5e218 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -19,7 +19,7 @@ import "../../../config/env.dart"; //Specifically, it allows the app to be recognized as a media player, which allows integration with Android Auto, CarPlay, etc. const RADIO_LUZ_ARTWORK = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; -const REFRESH_INTERVAL = Duration(seconds: 15); +const REFRESH_INTERVAL = Duration(minutes: 1); //used for AA and CP to display folders and media items class _MediaIds { @@ -43,7 +43,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { "Content-Type": "application/x-www-form-urlencoded", }, )); - + late MediaItem _radioLuzMediaItem; List? _recentlyPlayedCache; @@ -59,66 +59,44 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Timer? _refreshTimer; RadioAudioHandler() { + _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl)), preload: false); // Pre-configuration + _player.setAndroidAudioAttributes(const AndroidAudioAttributes( + contentType: AndroidAudioContentType.music, + usage: AndroidAudioUsage.media, + )); + //main media item - live radio (information source) _radioLuzMediaItem = MediaItem( id: _MediaIds.liveRadioPlayable, title: "Radio LUZ", album: "Studenckie Radio", playable: true, - artUri: Uri.parse(RADIO_LUZ_ARTWORK), //artwork cannot be local asset + artUri: Uri.parse(RADIO_LUZ_ARTWORK) //artwork cannot be local asset ); - + _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) - - _player.sequenceStateStream.listen((state) { - mediaItem.add(_radioLuzMediaItem); - }); - + _player.sequenceStateStream.map((state) => state?.currentSource?.tag as MediaItem?).listen(mediaItem.add); //metadata bridge to audio_service + _startPeriodicRefresh(); } - - //refreshes now playing metadata + + //refreshes recently played void _startPeriodicRefresh() { _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(REFRESH_INTERVAL, (_) async { - await _fetchNowPlaying(); - }); - } - - // Track when the stream was paused to detect stale buffers - DateTime? _lastPauseTime; - static const _staleStreamThreshold = Duration(seconds: 30); - - Future _fetchNowPlaying() async { - try { - if (_isDisposed) return; - final formData = FormData.fromMap({"action": "nowPlaying"}); - final response = await _dio.post("admin-ajax.php", data: formData); - - if (response.data == null) return; - - final dynamic decoded = jsonDecode(response.data!); - if (decoded is! Map) return; - - final nowPlaying = decoded["now"]?.toString(); - if (nowPlaying == null || nowPlaying.isEmpty) return; + _recentlyPlayedCache = null; + _recentlyPlayedLastFetch = null; - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying); - - mediaItem.add(_radioLuzMediaItem); - } catch (_) { - //keep the old metadata - } + await _fetchRecentlyPlayed(); + if (_isDisposed) return; // Guard against updates after disposal + + notifyChildrenChanged(_MediaIds.recentlyPlayed); + }); } @override Future play() async { - final now = DateTime.now(); - final isStale = _lastPauseTime != null && - now.difference(_lastPauseTime!) > _staleStreamThreshold; - - // If stream is stale or player is idle, reload the audio source - if (isStale || _player.processingState == ProcessingState.idle) { + if (_player.processingState == ProcessingState.idle) { await _player.setAudioSource( AudioSource.uri( Uri.parse(Env.radioLuzStreamUrl), @@ -127,23 +105,18 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ); mediaItem.add(_radioLuzMediaItem); } - - _lastPauseTime = null; await _player.play(); } @override - Future pause() async { - _lastPauseTime = DateTime.now(); - await _player.pause(); - } + Future pause() => _player.pause(); @override - Future stop() async { + dispose() { _isDisposed = true; _refreshTimer?.cancel(); - await _player.stop(); - return super.stop(); + _player.dispose(); + super.dispose(); } //most crucial - defines the structure of the media library that is visible in AA and CP @@ -157,7 +130,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { title: "Radio LUZ", album: "Studenckie Radio", playable: false, - ), + ), MediaItem( id: _MediaIds.recentlyPlayed, title: "Teraz gramy", @@ -169,50 +142,50 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { playable: false, ), ]; - + case _MediaIds.liveRadioFolder: return [_radioLuzMediaItem]; - + case _MediaIds.recentlyPlayed: return _fetchRecentlyPlayed(); - + case _MediaIds.schedule: return _fetchSchedule(); - + default: return []; } } - + Future> _fetchRecentlyPlayed() async { final now = DateTime.now(); - if (_recentlyPlayedCache != null && + if (_recentlyPlayedCache != null && _recentlyPlayedLastFetch != null && now.difference(_recentlyPlayedLastFetch!) < _recentlyPlayedTtl) { return _recentlyPlayedCache!; } - + try { final formData = FormData.fromMap({"action": "histoprylog"}); final response = await _dio.post("admin-ajax.php", data: formData); - + if (_isDisposed) return []; if (response.data == null) return _recentlyPlayedCache ?? []; - + final dynamic decoded = jsonDecode(response.data!); if (decoded is! List) return _recentlyPlayedCache ?? []; - + final items = decoded.map((e) { if (e is! List) return null; - + final timeRaw = e[1]?.toString() ?? ""; final artist = e[2]?.toString() ?? ""; final title = e[3]?.toString() ?? ""; final album = e[4]?.toString() ?? ""; - + final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; - + return MediaItem( id: "history_${timeRaw}_$title", title: "$time - $title", @@ -221,41 +194,54 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { playable: true, ); }).whereType().toList(); - + //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest items.sort((a, b) => b.title.compareTo(a.title)); - + + if (items.isNotEmpty) { + final latest = items.first; + final songTitle = latest.title.length >= 8 ? latest.title.substring(8) : latest.title; + final newAlbum = "${latest.artist} - $songTitle"; + + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: newAlbum); + + if (_player.playing) { + //refresh metadata + mediaItem.add(_radioLuzMediaItem); + } + } + _recentlyPlayedCache = items; _recentlyPlayedLastFetch = now; - + return items; } catch (_) { return _recentlyPlayedCache ?? []; } } - + Future> _fetchSchedule() async { final now = DateTime.now(); - if (_scheduleCache != null && + if (_scheduleCache != null && _scheduleLastFetch != null && now.difference(_scheduleLastFetch!) < _scheduleTtl) { return _scheduleCache!; } - + try { final formData = FormData.fromMap({"action": "schedule"}); final response = await _dio.post("admin-ajax.php", data: formData); - + if (response.data == null) return _scheduleCache ?? []; - + final dynamic jsonMap = jsonDecode(response.data!); if (jsonMap is! Map) return _scheduleCache ?? []; final broadcasts = jsonMap["broadcasts"]; if (broadcasts is! List) return _scheduleCache ?? []; - + final items = []; - + for (final block in broadcasts) { if (block is! Map) continue; @@ -263,33 +249,35 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final broadcastList = block["broadcasts"]; if (broadcastList is! List) continue; - + for (final broadcast in broadcastList) { if (broadcast is! Map) continue; final b = broadcast; final time = b["time"]?.toString() ?? ""; final title = b["title"]?.toString() ?? ""; + final authors = b["authors"]?.toString() ?? ""; final thumbnail = b["thumbnail"]; - + Uri? artUri; if (thumbnail is String && thumbnail.isNotEmpty) { artUri = Uri.tryParse(thumbnail); } - + items.add(MediaItem( id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, + artist: authors, album: time, playable: true, artUri: artUri, )); } } - + _scheduleCache = items; _scheduleLastFetch = now; - + return items; } catch (_) { return _scheduleCache ?? []; @@ -329,23 +317,27 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Future setAudioSource(AudioSource source) async { await _player.setAudioSource(source); if (source is UriAudioSource && source.tag is MediaItem) { - mediaItem.add((source.tag as MediaItem).copyWith(playable: true)); + mediaItem.add((source.tag as MediaItem).copyWith(playable: true)); } } //this is where the audio player (just_audio) and audio service (audio_service) connect PlaybackState _transformEvent(PlaybackEvent event) { return PlaybackState( - controls: [if (_player.playing) MediaControl.pause else MediaControl.play], - systemActions: const {MediaAction.seek}, - androidCompactActionIndices: const [0], - processingState: switch (_player.processingState) { - ProcessingState.idle => AudioProcessingState.idle, - ProcessingState.loading => AudioProcessingState.loading, - ProcessingState.buffering => AudioProcessingState.buffering, - ProcessingState.ready => AudioProcessingState.ready, - ProcessingState.completed => AudioProcessingState.completed, + controls: [ + if (_player.playing) MediaControl.pause else MediaControl.play, + ], + systemActions: const { + MediaAction.seek, }, + androidCompactActionIndices: const [0], + processingState: const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, playing: _player.playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, diff --git a/lib/features/radio_luz/service/radio_player_provider.dart b/lib/features/radio_luz/service/radio_player_provider.dart index d206f2a19..ee9e20ed0 100644 --- a/lib/features/radio_luz/service/radio_player_provider.dart +++ b/lib/features/radio_luz/service/radio_player_provider.dart @@ -1,5 +1,4 @@ -import "package:audio_service/audio_service.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; + import "package:riverpod_annotation/riverpod_annotation.dart"; import "radio_audio_handler.dart"; diff --git a/lib/main.dart b/lib/main.dart index 241bbd88a..540107ea0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -52,6 +52,7 @@ Future runToPWR() async { SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); // await setupParkingWidgetsWorkManager(); + final config = ClarityConfig( projectId: Env.clarityConfigId, logLevel: LogLevel.None, diff --git a/pubspec.yaml b/pubspec.yaml index d6eeb853f..2033febc6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,11 +16,11 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.20+107 +version: 1.2.8+95 environment: - sdk: 3.10.8 - flutter: 3.38.9 + sdk: 3.10.4 + flutter: 3.38.5 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -35,23 +35,23 @@ dependencies: sdk: flutter #State managements - flutter_riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible - riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible - riverpod_annotation: ^4.0.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible - hooks_riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + flutter_riverpod: ^3.1.0 + riverpod: ^3.1.0 + riverpod_annotation: ^4.0.0 + hooks_riverpod: ^3.1.0 #Storage shared_preferences: ^2.5.4 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - drift: ^2.31.0 + drift: ^2.30.0 drift_flutter: ^0.2.8 dio_cache_interceptor_db_store: ^6.0.0 #Network url_launcher: ^6.3.2 html: ^0.15.6 - dio: ^5.9.1 + dio: ^5.9.0 #Widgets cupertino_icons: ^1.0.8 @@ -76,6 +76,7 @@ dependencies: dotted_border: ^3.1.0 fluttertoast: ^9.0.0 carousel_slider_plus: ^7.1.1 + home_widget: ^0.8.1 scrollable_positioned_list: ^0.3.8 scrolls_to_top: ^2.1.1 sliver_tools: ^0.2.12 @@ -101,9 +102,9 @@ dependencies: flutter_compass: ^0.8.1 #Firebase - firebase_core: ^4.4.0 + firebase_core: ^4.3.0 firebase_performance: ^0.11.1 - firebase_messaging: ^16.1.1 + firebase_messaging: ^16.1.0 #Utility intl: ^0.20.2 @@ -119,40 +120,40 @@ dependencies: #Audio just_audio: ^0.9.36 - audio_service: ^0.18.18 + audio_service: ^0.18.12 #Other in_app_update: ^4.2.5 - upgrader: ^12.5.0 + upgrader: ^12.3.0 in_app_review: ^2.0.11 protontime: ^2.0.0 logger: ^2.6.2 separate: ^1.0.3 wiredash: ^2.6.0 flutter_hooks: ^0.21.3 - sentry_flutter: ^9.12.0 - workmanager: ^0.8.0 + sentry_flutter: ^9.9.1 + workmanager: ^0.9.0 #Solvro packages - solvro_translator_with_drift_cache_flutter: ^0.9.4 + solvro_translator_with_drift_cache_flutter: ^0.8.0 solvro_translator_core: ^0.8.0 - clarity_flutter: ^1.7.1 + clarity_flutter: ^1.6.0 dev_dependencies: flutter_test: sdk: flutter #Storage - drift_dev: ^2.31.0 + drift_dev: ^2.30.0 #Lints solvro_config: ^1.3.0 #CodeGen - build_runner: ^2.10.4 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + build_runner: ^2.10.4 freezed: ^3.2.3 - json_serializable: ^6.11.2 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible - riverpod_generator: ^4.0.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + json_serializable: ^6.11.2 # todo(simon-the-shark): bump when freezed does it, https://github.com/rrousselGit/freezed/issues/1326 + riverpod_generator: ^4.0.0 # todo(simon-the-shark): this seems like a typo but it's current newest version, fix when riverpod does it envied_generator: ^1.3.2 flutter_gen_runner: ^5.12.0 auto_route_generator: ^10.4.0 @@ -163,7 +164,7 @@ dev_dependencies: test: ^1.26.3 dependency_overrides: - riverpod: 3.1.0 # todo(simon-the-shark): remove this when analyzer bump to 9 will be possible + riverpod: 3.1.0 # todo(simon-the-shark): remove this when riverpod sorts those versions out flutter: uses-material-design: true @@ -235,10 +236,6 @@ flutter: - assets/svg/streaming/youtube_music.svg - assets/svg/streaming/deezer.svg - assets/svg/streaming/tidal.svg - - assets/svg/branches_logos/logo-pwr.svg - - assets/svg/branches_logos/logo-jelenia.svg - - assets/svg/branches_logos/logo-walbrzych.svg - - assets/svg/branches_logos/logo-legnica.svg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware From 309655932f9d498ac1db8a7bc6149cb79cb4a93e Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sat, 20 Dec 2025 01:59:21 +0100 Subject: [PATCH 04/22] fix: nowPlaying and few fixes --- .../service/radio_audio_handler.dart | 84 +++++++++++-------- pubspec.yaml | 2 +- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 870f5e218..e922526e2 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -19,7 +19,7 @@ import "../../../config/env.dart"; //Specifically, it allows the app to be recognized as a media player, which allows integration with Android Auto, CarPlay, etc. const RADIO_LUZ_ARTWORK = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; -const REFRESH_INTERVAL = Duration(minutes: 1); +const REFRESH_INTERVAL = Duration(seconds: 15); //used for AA and CP to display folders and media items class _MediaIds { @@ -59,12 +59,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Timer? _refreshTimer; RadioAudioHandler() { - _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl)), preload: false); // Pre-configuration - _player.setAndroidAudioAttributes(const AndroidAudioAttributes( - contentType: AndroidAudioContentType.music, - usage: AndroidAudioUsage.media, - )); - //main media item - live radio (information source) _radioLuzMediaItem = MediaItem( id: _MediaIds.liveRadioPlayable, @@ -75,28 +69,56 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ); _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) - _player.sequenceStateStream.map((state) => state?.currentSource?.tag as MediaItem?).listen(mediaItem.add); //metadata bridge to audio_service + + _player.sequenceStateStream.listen((state) { + mediaItem.add(_radioLuzMediaItem); + }); _startPeriodicRefresh(); } - //refreshes recently played + //refreshes now playing metadata void _startPeriodicRefresh() { _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(REFRESH_INTERVAL, (_) async { - _recentlyPlayedCache = null; - _recentlyPlayedLastFetch = null; - - await _fetchRecentlyPlayed(); - if (_isDisposed) return; // Guard against updates after disposal - - notifyChildrenChanged(_MediaIds.recentlyPlayed); + await _fetchNowPlaying(); }); } + // Track when the stream was paused to detect stale buffers + DateTime? _lastPauseTime; + static const _staleStreamThreshold = Duration(seconds: 30); + + Future _fetchNowPlaying() async { + try { + if (_isDisposed) return; + final formData = FormData.fromMap({"action": "nowPlaying"}); + final response = await _dio.post("admin-ajax.php", data: formData); + + if (response.data == null) return; + + final dynamic decoded = jsonDecode(response.data!); + if (decoded is! Map) return; + + final nowPlaying = decoded["now"]?.toString(); + if (nowPlaying == null || nowPlaying.isEmpty) return; + + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying); + + mediaItem.add(_radioLuzMediaItem); + } catch (_) { + //keep the old metadata + } + } + @override Future play() async { - if (_player.processingState == ProcessingState.idle) { + final now = DateTime.now(); + final isStale = _lastPauseTime != null && + now.difference(_lastPauseTime!) > _staleStreamThreshold; + + // If stream is stale or player is idle, reload the audio source + if (isStale || _player.processingState == ProcessingState.idle) { await _player.setAudioSource( AudioSource.uri( Uri.parse(Env.radioLuzStreamUrl), @@ -105,18 +127,23 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ); mediaItem.add(_radioLuzMediaItem); } + + _lastPauseTime = null; await _player.play(); } @override - Future pause() => _player.pause(); + Future pause() async { + _lastPauseTime = DateTime.now(); + await _player.pause(); + } @override - dispose() { + Future stop() async { _isDisposed = true; _refreshTimer?.cancel(); - _player.dispose(); - super.dispose(); + await _player.stop(); + return super.stop(); } //most crucial - defines the structure of the media library that is visible in AA and CP @@ -198,19 +225,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest items.sort((a, b) => b.title.compareTo(a.title)); - if (items.isNotEmpty) { - final latest = items.first; - final songTitle = latest.title.length >= 8 ? latest.title.substring(8) : latest.title; - final newAlbum = "${latest.artist} - $songTitle"; - - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: newAlbum); - - if (_player.playing) { - //refresh metadata - mediaItem.add(_radioLuzMediaItem); - } - } - _recentlyPlayedCache = items; _recentlyPlayedLastFetch = now; @@ -256,7 +270,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final b = broadcast; final time = b["time"]?.toString() ?? ""; final title = b["title"]?.toString() ?? ""; - final authors = b["authors"]?.toString() ?? ""; final thumbnail = b["thumbnail"]; Uri? artUri; @@ -267,7 +280,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { items.add(MediaItem( id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, - artist: authors, album: time, playable: true, artUri: artUri, diff --git a/pubspec.yaml b/pubspec.yaml index 2033febc6..c50031398 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -120,7 +120,7 @@ dependencies: #Audio just_audio: ^0.9.36 - audio_service: ^0.18.12 + audio_service: ^0.18.18 #Other in_app_update: ^4.2.5 From 5330a10c72fbd9e3237eccc234743c03fbe4504b Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Fri, 2 Jan 2026 00:12:25 +0100 Subject: [PATCH 05/22] fix: ios --- .env | Bin 953 -> 954 bytes .../presentation/radio_luz_view.dart | 24 +- .../service/radio_audio_handler.dart | 257 ++++++++++-------- .../service/radio_player_controller.dart | 22 +- .../service/radio_player_provider.dart | 1 - lib/main.dart | 10 +- pubspec.yaml | 1 + 7 files changed, 164 insertions(+), 151 deletions(-) diff --git a/.env b/.env index ce76ab54d273a3df4ae6a5d394d5f289741d7a7b..8d82675d4bfdc5ae3c7d8d1d631caa349653a422 100755 GIT binary patch literal 954 zcmV;r14aA*M@dveQdv+`09!)klzcg-yS{Usc>uhxc@{M4 zn+D zzdxQe3q;{WS1&4up2nl{FbPkpy}_N2U;WLzcYGTK;{-EFoEN1?Rw{tSYrRdgvdrB` zL}5*0uP$e|H~8E^5jYHHRZR#E(jv0*E;9`b5>4VWI`stthD+#(fx!4gILC3Ezn@)d zkh!+9H!?sH^7j6NRgAcp`>RIz2q$USdIv%F! zkouD_mQ|*HRlm&+<|x{M_q(<&D=@LzF$@3NvK+(TuMP}Q#X~R|hjFJ6Cr_uSPx(D( zSNr;>PGqc^i)Ts0vN^(r+p`@&aCGtv0yQ@nv&z5v+L0@p6MNn&rnaCLsW<)N^ZU}? z#6H?dWNm{+&@VS$4NK9r9=dN1Kw)&kG>UeLf!8d=XB5AM}R5|CJ&2LQ>eS%3PU) zk|d&3Emtz{URzzklO~qAB!`OV36m#_ErS;Fgt5E=e%yger2FjQSM) z&LySHFP-V9^dG)@!39}gR&d6~>JG*fNHg}J^fe(#TEy-&&Lnn%B5vyz>dUr&h?jq_ zN!tqnF0zSNdc^G2MKf)S4Hybnd32x2K)9MUMJtI$BdYgl+lO1UV0IRo6_-}uR#AWb z;t;futWl4{9ZK(iJ{i|$As+R6AiD&kx)&L_ zJ=DEb>|BTB{ER4H@raciU3&$0zxSbzJhJ749JAd!JOVXt6}T4WFLYy5K+#+@v9^%Y zF~NR|reMaH)GizLbpC*EbEt$xB`_kgfSBd12i#0uM(j~>$wLXeA27AqOl16!0dvfF c*@ogCP(3zJ;S%(`VGa=bmd#<*wY8s8ocS{VWO_VGS3vJtP7}em13&+esVPG!ND|_Ny5s>&B<>CshoQ{-bn; zczsOsy2Tl1sF?3!C^LBi%Tm;O97LKksq)>@dYjMZz*=t=B+hQ;4(&;08erA(lAI`E z$|rk&pycOk1rfSJwX>r4M+R@xCq!9H|d7;)M*sY<|uhvEU-7?;I~zx=)6{8xA=L2t>W7+ zrP$lnr>3=Vznya>`jF0!9D`LBuzl#*{SB-^1)RFl`@X9JVR4B> z^DMl&ajszz)l>Legtgppboo8j3l{o?ShyGlVpncMUpQ8XmBR3oQK8wNfqfNJ2f72i z8@}>a0}DAHQC?2eHCZ@iH+*3g5wey6oULR6cI=4nYX;MUmKK2=f6Pr(1NsYSE!>w= zv;C&g2}+$5P%h3QLB*SQb3r9a*`Messc);WxsBv(eU)GAMrCQY9@vny+ssb(21-|Q zWjf(QlC>3z6HO~@Q9m^de28BEO>&<1TY$9 zw{cW;IXmIuC`+Ks+cGvs)pZy3&-CgC5&>oBRjxjGgyG4R?6+7w`)-}3;D=}5Gw+wX zJD=r#!9obn4%yq4<-sWi2-`1{=v$Aigyvr^9_vt<17o+Wv createState() => _RadioLuzViewState(); +} + +class _RadioLuzViewState extends ConsumerState { + @override + void initState() { + super.initState(); + // Pre-load the audio stream when the radio screen opens + // This reduces iOS startup latency by buffering before user presses play + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(radioControllerProvider.notifier).preload(); + }); + } + + @override + Widget build(BuildContext context) { final l10n = context.localize; final cappedTextScale = context.textScaler.clamp(maxScaleFactor: 1.7); - final handler = ref.watch(radioPlayerProvider); - + ref.watch(radioPlayerProvider); // Keep watching the handler + return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), child: Scaffold( diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index e922526e2..f52379674 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -1,14 +1,10 @@ - import "dart:async"; import "dart:convert"; -import "dart:io"; import "package:audio_service/audio_service.dart"; +import "package:audio_session/audio_session.dart"; import "package:dio/dio.dart"; -import "package:flutter/services.dart"; import "package:just_audio/just_audio.dart"; -import "package:path/path.dart" as p; -import "package:path_provider/path_provider.dart"; import "../../../config/env.dart"; @@ -33,17 +29,19 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final _player = AudioPlayer(); //used for fetching recently played and schedule - final _dio = Dio(BaseOptions( - baseUrl: Env.radioLuzApiUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: { - "User-Agent": "RadioLuzApp/1.0 (Dart Dio)", - "Accept": "*/*", - "Content-Type": "application/x-www-form-urlencoded", - }, - )); - + final _dio = Dio( + BaseOptions( + baseUrl: Env.radioLuzApiUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + "User-Agent": "RadioLuzApp/1.0 (Dart Dio)", + "Accept": "*/*", + "Content-Type": "application/x-www-form-urlencoded", + }, + ), + ); + late MediaItem _radioLuzMediaItem; List? _recentlyPlayedCache; @@ -64,19 +62,44 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { id: _MediaIds.liveRadioPlayable, title: "Radio LUZ", album: "Studenckie Radio", - playable: true, - artUri: Uri.parse(RADIO_LUZ_ARTWORK) //artwork cannot be local asset + artUri: Uri.parse(RADIO_LUZ_ARTWORK), //artwork cannot be local asset ); - - _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) - + + _player.playbackEventStream + .map(_transformEvent) + .listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) + _player.sequenceStateStream.listen((state) { - mediaItem.add(_radioLuzMediaItem); + mediaItem.add(_radioLuzMediaItem); }); - + _startPeriodicRefresh(); + + // Pre-configure audio session for iOS to reduce playback startup latency + unawaited(_initAudioSession()); } - + + /// Pre-configures the audio session for streaming on iOS. + /// This reduces the delay when user presses play by activating the audio pipeline early. + Future _initAudioSession() async { + final session = await AudioSession.instance; + await session.configure( + const AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.allowBluetooth, + avAudioSessionMode: AVAudioSessionMode.defaultMode, + avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, + avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, + androidAudioAttributes: AndroidAudioAttributes( + contentType: AndroidAudioContentType.music, + usage: AndroidAudioUsage.media, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + androidWillPauseWhenDucked: true, + ), + ); + } + //refreshes now playing metadata void _startPeriodicRefresh() { _refreshTimer?.cancel(); @@ -94,42 +117,54 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { if (_isDisposed) return; final formData = FormData.fromMap({"action": "nowPlaying"}); final response = await _dio.post("admin-ajax.php", data: formData); - + if (response.data == null) return; - + final dynamic decoded = jsonDecode(response.data!); if (decoded is! Map) return; - + final nowPlaying = decoded["now"]?.toString(); if (nowPlaying == null || nowPlaying.isEmpty) return; - - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying); - + + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying, artist: nowPlaying); + mediaItem.add(_radioLuzMediaItem); } catch (_) { //keep the old metadata } } + /// Pre-loads the audio stream to reduce startup latency when play() is called. + /// Call this when the radio screen is opened so buffering begins before user presses play. + Future preload() async { + if (_player.processingState == ProcessingState.idle) { + await _player.setAudioSource( + AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), + preload: true, + ); + mediaItem.add(_radioLuzMediaItem); + } + } + @override Future play() async { final now = DateTime.now(); - final isStale = _lastPauseTime != null && - now.difference(_lastPauseTime!) > _staleStreamThreshold; - + final isStale = _lastPauseTime != null && now.difference(_lastPauseTime!) > _staleStreamThreshold; + + _lastPauseTime = null; + // If stream is stale or player is idle, reload the audio source if (isStale || _player.processingState == ProcessingState.idle) { + // Use preload: true to begin buffering immediately for faster playback start await _player.setAudioSource( - AudioSource.uri( - Uri.parse(Env.radioLuzStreamUrl), - tag: _radioLuzMediaItem, - ), + AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), + preload: true, ); mediaItem.add(_radioLuzMediaItem); } - - _lastPauseTime = null; - await _player.play(); + + // Start playback - don't await to reduce perceived latency + unawaited(_player.play()); } @override @@ -157,105 +192,98 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { title: "Radio LUZ", album: "Studenckie Radio", playable: false, - ), - MediaItem( - id: _MediaIds.recentlyPlayed, - title: "Teraz gramy", - playable: false, - ), - const MediaItem( - id: _MediaIds.schedule, - title: "Audycje", - playable: false, ), + const MediaItem(id: _MediaIds.recentlyPlayed, title: "Teraz gramy", playable: false), + const MediaItem(id: _MediaIds.schedule, title: "Audycje", playable: false), ]; - + case _MediaIds.liveRadioFolder: return [_radioLuzMediaItem]; - + case _MediaIds.recentlyPlayed: return _fetchRecentlyPlayed(); - + case _MediaIds.schedule: return _fetchSchedule(); - + default: return []; } } - + Future> _fetchRecentlyPlayed() async { final now = DateTime.now(); - if (_recentlyPlayedCache != null && + if (_recentlyPlayedCache != null && _recentlyPlayedLastFetch != null && now.difference(_recentlyPlayedLastFetch!) < _recentlyPlayedTtl) { return _recentlyPlayedCache!; } - + try { final formData = FormData.fromMap({"action": "histoprylog"}); final response = await _dio.post("admin-ajax.php", data: formData); - + if (_isDisposed) return []; if (response.data == null) return _recentlyPlayedCache ?? []; - + final dynamic decoded = jsonDecode(response.data!); if (decoded is! List) return _recentlyPlayedCache ?? []; - - final items = decoded.map((e) { - if (e is! List) return null; - - final timeRaw = e[1]?.toString() ?? ""; - final artist = e[2]?.toString() ?? ""; - final title = e[3]?.toString() ?? ""; - final album = e[4]?.toString() ?? ""; - - final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; - - return MediaItem( - id: "history_${timeRaw}_$title", - title: "$time - $title", - artist: artist, - album: album, - playable: true, - ); - }).whereType().toList(); - + + final items = decoded + .map((e) { + if (e is! List) return null; + + final timeRaw = e[1]?.toString() ?? ""; + final artist = e[2]?.toString() ?? ""; + final title = e[3]?.toString() ?? ""; + final album = e[4]?.toString() ?? ""; + + final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; + + return MediaItem( + id: "history_${timeRaw}_$title", + title: "$time - $title", + artist: artist, + album: album, + playable: true, + ); + }) + .whereType() + .toList(); + //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest items.sort((a, b) => b.title.compareTo(a.title)); - + _recentlyPlayedCache = items; _recentlyPlayedLastFetch = now; - + return items; } catch (_) { return _recentlyPlayedCache ?? []; } } - + Future> _fetchSchedule() async { final now = DateTime.now(); - if (_scheduleCache != null && - _scheduleLastFetch != null && - now.difference(_scheduleLastFetch!) < _scheduleTtl) { + if (_scheduleCache != null && _scheduleLastFetch != null && now.difference(_scheduleLastFetch!) < _scheduleTtl) { return _scheduleCache!; } - + try { final formData = FormData.fromMap({"action": "schedule"}); final response = await _dio.post("admin-ajax.php", data: formData); - + if (response.data == null) return _scheduleCache ?? []; - + final dynamic jsonMap = jsonDecode(response.data!); if (jsonMap is! Map) return _scheduleCache ?? []; final broadcasts = jsonMap["broadcasts"]; if (broadcasts is! List) return _scheduleCache ?? []; - + final items = []; - + for (final block in broadcasts) { if (block is! Map) continue; @@ -263,7 +291,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final broadcastList = block["broadcasts"]; if (broadcastList is! List) continue; - + for (final broadcast in broadcastList) { if (broadcast is! Map) continue; @@ -271,25 +299,27 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final time = b["time"]?.toString() ?? ""; final title = b["title"]?.toString() ?? ""; final thumbnail = b["thumbnail"]; - + Uri? artUri; if (thumbnail is String && thumbnail.isNotEmpty) { artUri = Uri.tryParse(thumbnail); } - - items.add(MediaItem( - id: "schedule_${b["id"]}", - title: isNow ? "▶ $title" : title, - album: time, - playable: true, - artUri: artUri, - )); + + items.add( + MediaItem( + id: "schedule_${b["id"]}", + title: isNow ? "▶ $title" : title, + album: time, + playable: true, + artUri: artUri, + ), + ); } } - + _scheduleCache = items; _scheduleLastFetch = now; - + return items; } catch (_) { return _scheduleCache ?? []; @@ -297,7 +327,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { } //mainly for getting info about recently played media item - @override Future getItem(String mediaId) async { if (mediaId == _radioLuzMediaItem.id) { return _radioLuzMediaItem; @@ -309,17 +338,13 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { @override Future playFromMediaId(String mediaId, [Map? extras]) async { //...because anything you click on should play the radio - if (mediaId == _radioLuzMediaItem.id || - mediaId.startsWith("history_") || - mediaId.startsWith("schedule_")) { + if (mediaId == _radioLuzMediaItem.id || mediaId.startsWith("history_") || mediaId.startsWith("schedule_")) { await _player.setAudioSource( - AudioSource.uri( - Uri.parse(Env.radioLuzStreamUrl), - tag: _radioLuzMediaItem, - ), + AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), + preload: true, ); mediaItem.add(_radioLuzMediaItem); - await _player.play(); + unawaited(_player.play()); } //this if might be useless but you never know... } @@ -329,19 +354,15 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Future setAudioSource(AudioSource source) async { await _player.setAudioSource(source); if (source is UriAudioSource && source.tag is MediaItem) { - mediaItem.add((source.tag as MediaItem).copyWith(playable: true)); + mediaItem.add((source.tag as MediaItem).copyWith(playable: true)); } } //this is where the audio player (just_audio) and audio service (audio_service) connect PlaybackState _transformEvent(PlaybackEvent event) { return PlaybackState( - controls: [ - if (_player.playing) MediaControl.pause else MediaControl.play, - ], - systemActions: const { - MediaAction.seek, - }, + controls: [if (_player.playing) MediaControl.pause else MediaControl.play], + systemActions: const {MediaAction.seek}, androidCompactActionIndices: const [0], processingState: const { ProcessingState.idle: AudioProcessingState.idle, diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index 527ce458b..3704b5050 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -1,7 +1,6 @@ import "dart:async"; import "dart:io"; -import "package:audio_service/audio_service.dart"; import "package:flutter/services.dart"; import "package:just_audio/just_audio.dart"; import "package:path/path.dart" as p; @@ -19,11 +18,8 @@ part "radio_player_controller.g.dart"; @Riverpod(keepAlive: true) class RadioController extends _$RadioController { late final RadioAudioHandler _handler = ref.watch(radioPlayerProvider); - late final AudioPlayerStrings _audioPlayerStrings; var _initialized = false; - var _prevVolume = 1.0; - final _muteThreshold = 0.05; @override RadioState build() { @@ -40,21 +36,6 @@ class RadioController extends _$RadioController { return RadioState(isPlaying: isPlaying, isLoading: isLoading, volume: volume); } - Future _initPlayer() async { - final assetPath = Assets.png.radioLuz.radioLuzLogo.path; - final artUri = await assetToFileUri(assetPath); - - final audioSource = AudioSource.uri( - Uri.parse(Env.radioLuzStreamUrl), - tag: MediaItem(id: "1", title: _audioPlayerStrings.title, album: _audioPlayerStrings.album, artUri: artUri), - ); - - await _handler.setAudioSource(audioSource); - - final volume = ref.read(audioPlayerVolumeProvider).value ?? 1.0; - await _handler.setVolume(volume); - } - void init(AudioPlayerStrings audioPlayerStrings) { if (_initialized) return; _initialized = true; @@ -72,7 +53,8 @@ class RadioController extends _$RadioController { return file.uri; } - ///pre-loads the audio stream to reduce startup delay + /// Pre-loads the audio stream to reduce startup delay on iOS. + /// Call this when the radio screen is opened. Future preload() async { await _handler.preload(); } diff --git a/lib/features/radio_luz/service/radio_player_provider.dart b/lib/features/radio_luz/service/radio_player_provider.dart index ee9e20ed0..c6675a4ed 100644 --- a/lib/features/radio_luz/service/radio_player_provider.dart +++ b/lib/features/radio_luz/service/radio_player_provider.dart @@ -1,4 +1,3 @@ - import "package:riverpod_annotation/riverpod_annotation.dart"; import "radio_audio_handler.dart"; diff --git a/lib/main.dart b/lib/main.dart index 540107ea0..09e60e374 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -52,11 +52,7 @@ Future runToPWR() async { SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); // await setupParkingWidgetsWorkManager(); - - final config = ClarityConfig( - projectId: Env.clarityConfigId, - logLevel: LogLevel.None, - ); + final config = ClarityConfig(projectId: Env.clarityConfigId, logLevel: LogLevel.None); final audioHandler = await AudioService.init( builder: RadioAudioHandler.new, @@ -78,9 +74,7 @@ Future runToPWR() async { if (retryCount > 5) return null; return Duration(seconds: retryCount * 2); }, - overrides: [ - radioPlayerProvider.overrideWithValue(audioHandler), - ], + overrides: [radioPlayerProvider.overrideWithValue(audioHandler)], child: const SplashScreen(child: MyApp()), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index c50031398..a1e3e03cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -121,6 +121,7 @@ dependencies: #Audio just_audio: ^0.9.36 audio_service: ^0.18.18 + audio_session: ^0.1.21 #Other in_app_update: ^4.2.5 From b4ba9573c378b2ae5140f4af58a3dba98625b4e1 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 4 Jan 2026 18:36:03 +0100 Subject: [PATCH 06/22] fix: few last checks --- android/app/src/main/AndroidManifest.xml | 1 - .../presentation/radio_luz_view.dart | 8 +++--- .../service/radio_audio_handler.dart | 27 +++++++------------ .../service/radio_player_controller.dart | 3 +-- lib/main.dart | 1 - 5 files changed, 14 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 76bff5619..1907b9165 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,7 +23,6 @@ - diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index 90b0a8cf0..a164953a2 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; @@ -26,10 +28,8 @@ class _RadioLuzViewState extends ConsumerState { @override void initState() { super.initState(); - // Pre-load the audio stream when the radio screen opens - // This reduces iOS startup latency by buffering before user presses play WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(radioControllerProvider.notifier).preload(); + unawaited(ref.read(radioControllerProvider.notifier).preload()); }); } @@ -37,7 +37,7 @@ class _RadioLuzViewState extends ConsumerState { Widget build(BuildContext context) { final l10n = context.localize; final cappedTextScale = context.textScaler.clamp(maxScaleFactor: 1.7); - ref.watch(radioPlayerProvider); // Keep watching the handler + ref.watch(radioPlayerProvider); return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index f52379674..9a474774e 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -52,7 +52,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { DateTime? _scheduleLastFetch; static const _scheduleTtl = Duration(minutes: 5); - bool _isDisposed = false; + var _isDisposed = false; Timer? _refreshTimer; @@ -79,8 +79,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { unawaited(_initAudioSession()); } - /// Pre-configures the audio session for streaming on iOS. - /// This reduces the delay when user presses play by activating the audio pipeline early. + /// iOS audio session Future _initAudioSession() async { final session = await AudioSession.instance; await session.configure( @@ -94,8 +93,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { contentType: AndroidAudioContentType.music, usage: AndroidAudioUsage.media, ), - androidAudioFocusGainType: AndroidAudioFocusGainType.gain, - androidWillPauseWhenDucked: true, + androidWillPauseWhenDucked: false, ), ); } @@ -108,7 +106,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { }); } - // Track when the stream was paused to detect stale buffers + //track when the stream was paused to detect stale buffers DateTime? _lastPauseTime; static const _staleStreamThreshold = Duration(seconds: 30); @@ -129,18 +127,16 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying, artist: nowPlaying); mediaItem.add(_radioLuzMediaItem); - } catch (_) { + } on Exception catch (_) { //keep the old metadata } } - /// Pre-loads the audio stream to reduce startup latency when play() is called. - /// Call this when the radio screen is opened so buffering begins before user presses play. + ///pre-loads the audio stream Future preload() async { if (_player.processingState == ProcessingState.idle) { await _player.setAudioSource( AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - preload: true, ); mediaItem.add(_radioLuzMediaItem); } @@ -153,17 +149,14 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { _lastPauseTime = null; - // If stream is stale or player is idle, reload the audio source + //if stream is stale or player is idle, reload the audio source if (isStale || _player.processingState == ProcessingState.idle) { - // Use preload: true to begin buffering immediately for faster playback start await _player.setAudioSource( AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - preload: true, ); mediaItem.add(_radioLuzMediaItem); } - // Start playback - don't await to reduce perceived latency unawaited(_player.play()); } @@ -259,7 +252,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { _recentlyPlayedLastFetch = now; return items; - } catch (_) { + } on Exception catch (_) { return _recentlyPlayedCache ?? []; } } @@ -310,7 +303,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, album: time, - playable: true, artUri: artUri, ), ); @@ -321,7 +313,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { _scheduleLastFetch = now; return items; - } catch (_) { + } on Exception catch (_) { return _scheduleCache ?? []; } } @@ -341,7 +333,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { if (mediaId == _radioLuzMediaItem.id || mediaId.startsWith("history_") || mediaId.startsWith("schedule_")) { await _player.setAudioSource( AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - preload: true, ); mediaItem.add(_radioLuzMediaItem); unawaited(_player.play()); diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index 3704b5050..ebe2bd3d6 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -53,8 +53,7 @@ class RadioController extends _$RadioController { return file.uri; } - /// Pre-loads the audio stream to reduce startup delay on iOS. - /// Call this when the radio screen is opened. + ///pre-loads the audio stream to reduce startup delay Future preload() async { await _handler.preload(); } diff --git a/lib/main.dart b/lib/main.dart index 09e60e374..731215d70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,7 +60,6 @@ Future runToPWR() async { androidNotificationChannelId: "com.solvro.topwr.audio", androidNotificationChannelName: "Audio playback", androidNotificationOngoing: true, - androidStopForegroundOnPause: true, ), ); From 916d4f063904b4b9581583523b6b3862c01a4858 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 4 Jan 2026 20:31:34 +0100 Subject: [PATCH 07/22] fix: format --- .../service/radio_audio_handler.dart | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 9a474774e..fe1eef7a2 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -135,9 +135,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { ///pre-loads the audio stream Future preload() async { if (_player.processingState == ProcessingState.idle) { - await _player.setAudioSource( - AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - ); + await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); mediaItem.add(_radioLuzMediaItem); } } @@ -151,9 +149,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { //if stream is stale or player is idle, reload the audio source if (isStale || _player.processingState == ProcessingState.idle) { - await _player.setAudioSource( - AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - ); + await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); mediaItem.add(_radioLuzMediaItem); } @@ -299,12 +295,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { } items.add( - MediaItem( - id: "schedule_${b["id"]}", - title: isNow ? "▶ $title" : title, - album: time, - artUri: artUri, - ), + MediaItem(id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, album: time, artUri: artUri), ); } } @@ -331,9 +322,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Future playFromMediaId(String mediaId, [Map? extras]) async { //...because anything you click on should play the radio if (mediaId == _radioLuzMediaItem.id || mediaId.startsWith("history_") || mediaId.startsWith("schedule_")) { - await _player.setAudioSource( - AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem), - ); + await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); mediaItem.add(_radioLuzMediaItem); unawaited(_player.play()); } From 7c16f31178486236b2c49226024b35b2bb0d7ed5 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 4 Jan 2026 20:49:46 +0100 Subject: [PATCH 08/22] fix: typos --- .../radio_luz/service/radio_audio_handler.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index fe1eef7a2..86207b597 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -14,8 +14,9 @@ import "../../../config/env.dart"; //Thanks to that the audio player can talk to native APIs of audio services specific to the platform. //Specifically, it allows the app to be recognized as a media player, which allows integration with Android Auto, CarPlay, etc. -const RADIO_LUZ_ARTWORK = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; -const REFRESH_INTERVAL = Duration(seconds: 15); +const radioLuzArtwork = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324-9f1f-9ae594af1327.png"; +const refreshInterval = Duration(seconds: 15); +const staleStreamThreshold = Duration(seconds: 30); //used for AA and CP to display folders and media items class _MediaIds { @@ -62,7 +63,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { id: _MediaIds.liveRadioPlayable, title: "Radio LUZ", album: "Studenckie Radio", - artUri: Uri.parse(RADIO_LUZ_ARTWORK), //artwork cannot be local asset + artUri: Uri.parse(radioLuzArtwork), //artwork cannot be local asset ); _player.playbackEventStream @@ -101,14 +102,13 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { //refreshes now playing metadata void _startPeriodicRefresh() { _refreshTimer?.cancel(); - _refreshTimer = Timer.periodic(REFRESH_INTERVAL, (_) async { + _refreshTimer = Timer.periodic(refreshInterval, (_) async { await _fetchNowPlaying(); }); } //track when the stream was paused to detect stale buffers DateTime? _lastPauseTime; - static const _staleStreamThreshold = Duration(seconds: 30); Future _fetchNowPlaying() async { try { @@ -143,7 +143,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { @override Future play() async { final now = DateTime.now(); - final isStale = _lastPauseTime != null && now.difference(_lastPauseTime!) > _staleStreamThreshold; + final isStale = _lastPauseTime != null && now.difference(_lastPauseTime!) > staleStreamThreshold; _lastPauseTime = null; From 3a3a1913aea1eb13eacfb3afbb6d79f7248db282 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 4 Jan 2026 21:15:16 +0100 Subject: [PATCH 09/22] fix: mediaitem --- lib/features/radio_luz/service/radio_audio_handler.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 86207b597..dbbcf4db3 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -235,7 +235,6 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { title: "$time - $title", artist: artist, album: album, - playable: true, ); }) .whereType() From 07c9816b602cf74c32fe5f84723c7dc2dcf5b0a7 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 4 Jan 2026 21:22:54 +0100 Subject: [PATCH 10/22] fix: format... --- lib/features/radio_luz/service/radio_audio_handler.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index dbbcf4db3..55aa9ea0e 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -230,12 +230,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; - return MediaItem( - id: "history_${timeRaw}_$title", - title: "$time - $title", - artist: artist, - album: album, - ); + return MediaItem(id: "history_${timeRaw}_$title", title: "$time - $title", artist: artist, album: album); }) .whereType() .toList(); From 7d277cebef28245c0193947f7b4ab5021bd1531f Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 25 Jan 2026 19:58:46 +0100 Subject: [PATCH 11/22] fix: bridge name --- lib/features/radio_luz/service/radio_audio_handler.dart | 4 ++-- .../radio_luz/service/radio_player_controller.dart | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 55aa9ea0e..dbd14f493 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -26,7 +26,7 @@ class _MediaIds { static const schedule = "schedule_folder"; } -class RadioAudioHandler extends BaseAudioHandler with SeekHandler { +class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { final _player = AudioPlayer(); //used for fetching recently played and schedule @@ -57,7 +57,7 @@ class RadioAudioHandler extends BaseAudioHandler with SeekHandler { Timer? _refreshTimer; - RadioAudioHandler() { + RadioAudioHandlerBridge() { //main media item - live radio (information source) _radioLuzMediaItem = MediaItem( id: _MediaIds.liveRadioPlayable, diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index ebe2bd3d6..fb61c11df 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -17,7 +17,7 @@ part "radio_player_controller.g.dart"; @Riverpod(keepAlive: true) class RadioController extends _$RadioController { - late final RadioAudioHandler _handler = ref.watch(radioPlayerProvider); + late final RadioAudioHandlerBridge _handler = ref.watch(radioPlayerProvider); var _initialized = false; @@ -69,11 +69,7 @@ class RadioController extends _$RadioController { Future pause() async { await _handler.pause(); } - - Future stop() async { - await _handler.stop(); - } //i don't think we need this, but keeping just in case - + Future setVolume(double newVolume) async { await _handler.setVolume(newVolume); } From cced66cbb9b5c03ca9e380c00d514a157477f265 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 25 Jan 2026 21:04:21 +0100 Subject: [PATCH 12/22] fix: requested changes --- .../presentation/radio_luz_view.dart | 27 ++-- .../service/radio_audio_handler.dart | 148 +++++++++--------- .../service/radio_player_controller.dart | 2 +- .../service/radio_player_provider.dart | 2 +- lib/main.dart | 3 +- 5 files changed, 86 insertions(+), 96 deletions(-) diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index a164953a2..57b25a863 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -2,13 +2,13 @@ import "dart:async"; import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "../../../config/ui_config.dart"; import "../../../theme/app_theme.dart"; import "../../../utils/context_extensions.dart"; import "../service/radio_player_controller.dart"; -import "../service/radio_player_provider.dart"; import "audio_player_widget.dart"; import "broadcasts_section.dart"; import "now_playing_section.dart"; @@ -17,27 +17,20 @@ import "radio_luz_socials_section.dart"; import "radio_luz_title.dart"; @RoutePage() -class RadioLuzView extends ConsumerStatefulWidget { +class RadioLuzView extends HookConsumerWidget { const RadioLuzView({super.key}); @override - ConsumerState createState() => _RadioLuzViewState(); -} - -class _RadioLuzViewState extends ConsumerState { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(ref.read(radioControllerProvider.notifier).preload()); - }); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.localize; final cappedTextScale = context.textScaler.clamp(maxScaleFactor: 1.7); - ref.watch(radioPlayerProvider); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(ref.read(radioControllerProvider.notifier).preload()); + }); + return null; + }, []); return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index dbd14f493..587c412d1 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -4,9 +4,13 @@ import "dart:convert"; import "package:audio_service/audio_service.dart"; import "package:audio_session/audio_session.dart"; import "package:dio/dio.dart"; +import "package:flutter/widgets.dart"; import "package:just_audio/just_audio.dart"; import "../../../config/env.dart"; +import "../data/models/history_entry.dart"; +import "../data/models/now_playing.dart"; +import "../data/models/schedule.dart"; //Here the audio player is defined. Its created at app startup and is used for playing live stream. //This whole class describes the audio player behavior, media items and metadata. @@ -26,7 +30,7 @@ class _MediaIds { static const schedule = "schedule_folder"; } -class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { +class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, WidgetsBindingObserver { final _player = AudioPlayer(); //used for fetching recently played and schedule @@ -43,7 +47,13 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { ), ); - late MediaItem _radioLuzMediaItem; + //main media item - live radio (information source) + var _radioLuzMediaItem = MediaItem( + id: _MediaIds.liveRadioPlayable, + title: "Radio LUZ", + album: "Studenckie Radio", + artUri: Uri.parse(radioLuzArtwork), //artwork cannot be local asset + ); List? _recentlyPlayedCache; DateTime? _recentlyPlayedLastFetch; @@ -56,28 +66,40 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { var _isDisposed = false; Timer? _refreshTimer; + StreamSubscription? _playbackEventSubscription; + StreamSubscription? _sequenceStateSubscription; RadioAudioHandlerBridge() { - //main media item - live radio (information source) - _radioLuzMediaItem = MediaItem( - id: _MediaIds.liveRadioPlayable, - title: "Radio LUZ", - album: "Studenckie Radio", - artUri: Uri.parse(radioLuzArtwork), //artwork cannot be local asset - ); + _initializeListeners(); + } - _player.playbackEventStream - .map(_transformEvent) - .listen(playbackState.add); //connecting 'just_audio' (flutter) to 'audio_service' (native) + void _initializeListeners() { + //connect 'just_audio' (Flutter) to 'audio_service' (native) + _playbackEventSubscription = _player.playbackEventStream.map(_transformEvent).listen(playbackState.add); - _player.sequenceStateStream.listen((state) { + _sequenceStateSubscription = _player.sequenceStateStream.listen((state) { mediaItem.add(_radioLuzMediaItem); }); - _startPeriodicRefresh(); + //periodically refresh now playing metadata + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(refreshInterval, (_) async { + await _fetchNowPlaying(); + }); - // Pre-configure audio session for iOS to reduce playback startup latency + //pre-configure audio session for iOS to reduce playback startup latency unawaited(_initAudioSession()); + + //register lifecycle observer to stop playback when app is killed + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Stop playback when app is detached (killed) + if (state == AppLifecycleState.detached) { + unawaited(stop()); + } } /// iOS audio session @@ -99,14 +121,6 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { ); } - //refreshes now playing metadata - void _startPeriodicRefresh() { - _refreshTimer?.cancel(); - _refreshTimer = Timer.periodic(refreshInterval, (_) async { - await _fetchNowPlaying(); - }); - } - //track when the stream was paused to detect stale buffers DateTime? _lastPauseTime; @@ -121,10 +135,10 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { final dynamic decoded = jsonDecode(response.data!); if (decoded is! Map) return; - final nowPlaying = decoded["now"]?.toString(); - if (nowPlaying == null || nowPlaying.isEmpty) return; + final nowPlaying = NowPlaying.fromJson(decoded); + if (nowPlaying.now == null || nowPlaying.now!.isEmpty) return; - _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying, artist: nowPlaying); + _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying.now, artist: nowPlaying.now); mediaItem.add(_radioLuzMediaItem); } on Exception catch (_) { @@ -166,6 +180,9 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { Future stop() async { _isDisposed = true; _refreshTimer?.cancel(); + await _playbackEventSubscription?.cancel(); + await _sequenceStateSubscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); await _player.stop(); return super.stop(); } @@ -219,21 +236,16 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { final dynamic decoded = jsonDecode(response.data!); if (decoded is! List) return _recentlyPlayedCache ?? []; - final items = decoded - .map((e) { - if (e is! List) return null; - - final timeRaw = e[1]?.toString() ?? ""; - final artist = e[2]?.toString() ?? ""; - final title = e[3]?.toString() ?? ""; - final album = e[4]?.toString() ?? ""; - - final time = timeRaw.length >= 5 ? timeRaw.substring(0, 5) : ""; - - return MediaItem(id: "history_${timeRaw}_$title", title: "$time - $title", artist: artist, album: album); - }) - .whereType() - .toList(); + final items = decoded.whereType>().map((e) { + final entry = HistoryEntry.fromList(e); + final timeStr = "${entry.time.hour.toString().padLeft(2, '0')}:${entry.time.minute.toString().padLeft(2, '0')}"; + return MediaItem( + id: "history_${entry.date}_${entry.title}", + title: "$timeStr - ${entry.title}", + artist: entry.artist, + album: entry.album, + ); + }).toList(); //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest items.sort((a, b) => b.title.compareTo(a.title)); @@ -262,34 +274,26 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { final dynamic jsonMap = jsonDecode(response.data!); if (jsonMap is! Map) return _scheduleCache ?? []; - final broadcasts = jsonMap["broadcasts"]; - if (broadcasts is! List) return _scheduleCache ?? []; + final schedule = Schedule.fromJson(jsonMap); final items = []; - for (final block in broadcasts) { - if (block is! Map) continue; - - final isNow = block["isNow"] as bool? ?? false; - final broadcastList = block["broadcasts"]; - - if (broadcastList is! List) continue; - - for (final broadcast in broadcastList) { - if (broadcast is! Map) continue; - - final b = broadcast; - final time = b["time"]?.toString() ?? ""; - final title = b["title"]?.toString() ?? ""; - final thumbnail = b["thumbnail"]; + for (final block in schedule.broadcasts) { + final isNow = block.isNow ?? false; + for (final broadcast in block.broadcasts) { Uri? artUri; - if (thumbnail is String && thumbnail.isNotEmpty) { - artUri = Uri.tryParse(thumbnail); + if (broadcast.thumbnail != null && broadcast.thumbnail!.isNotEmpty) { + artUri = Uri.tryParse(broadcast.thumbnail!); } items.add( - MediaItem(id: "schedule_${b["id"]}", title: isNow ? "▶ $title" : title, album: time, artUri: artUri), + MediaItem( + id: "schedule_${broadcast.id}", + title: isNow ? "▶ ${broadcast.title}" : broadcast.title, + album: broadcast.time, + artUri: artUri, + ), ); } } @@ -303,14 +307,6 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { } } - //mainly for getting info about recently played media item - Future getItem(String mediaId) async { - if (mediaId == _radioLuzMediaItem.id) { - return _radioLuzMediaItem; - } - return mediaItem.value; - } - //start playing media item @override Future playFromMediaId(String mediaId, [Map? extras]) async { @@ -338,13 +334,13 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler { controls: [if (_player.playing) MediaControl.pause else MediaControl.play], systemActions: const {MediaAction.seek}, androidCompactActionIndices: const [0], - processingState: const { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[_player.processingState]!, + processingState: switch (_player.processingState) { + ProcessingState.idle => AudioProcessingState.idle, + ProcessingState.loading => AudioProcessingState.loading, + ProcessingState.buffering => AudioProcessingState.buffering, + ProcessingState.ready => AudioProcessingState.ready, + ProcessingState.completed => AudioProcessingState.completed, + }, playing: _player.playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index fb61c11df..fea9b6b54 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -69,7 +69,7 @@ class RadioController extends _$RadioController { Future pause() async { await _handler.pause(); } - + Future setVolume(double newVolume) async { await _handler.setVolume(newVolume); } diff --git a/lib/features/radio_luz/service/radio_player_provider.dart b/lib/features/radio_luz/service/radio_player_provider.dart index c6675a4ed..b1f5846a1 100644 --- a/lib/features/radio_luz/service/radio_player_provider.dart +++ b/lib/features/radio_luz/service/radio_player_provider.dart @@ -9,6 +9,6 @@ part "radio_player_provider.g.dart"; //the player is assigned in main.dart so it starts even before app is fully loaded @Riverpod(keepAlive: true) -RadioAudioHandler radioPlayer(Ref ref) { +RadioAudioHandlerBridge radioPlayer(Ref ref) { throw UnimplementedError("radioPlayer provider must be overridden in main.dart"); } diff --git a/lib/main.dart b/lib/main.dart index 731215d70..05d999bd5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -55,11 +55,12 @@ Future runToPWR() async { final config = ClarityConfig(projectId: Env.clarityConfigId, logLevel: LogLevel.None); final audioHandler = await AudioService.init( - builder: RadioAudioHandler.new, + builder: RadioAudioHandlerBridge.new, config: const AudioServiceConfig( androidNotificationChannelId: "com.solvro.topwr.audio", androidNotificationChannelName: "Audio playback", androidNotificationOngoing: true, + androidStopForegroundOnPause: true, ), ); From de56826f4e43292cd18d9a6178d4d1491254f70e Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 25 Jan 2026 21:11:50 +0100 Subject: [PATCH 13/22] fix: reuse method --- .../radio_luz/service/radio_audio_handler.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 587c412d1..f3e48efe8 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -147,10 +147,14 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets } ///pre-loads the audio stream + Future _loadStream() async { + await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); + mediaItem.add(_radioLuzMediaItem); + } + Future preload() async { if (_player.processingState == ProcessingState.idle) { - await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); - mediaItem.add(_radioLuzMediaItem); + await _loadStream(); } } @@ -163,8 +167,7 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets //if stream is stale or player is idle, reload the audio source if (isStale || _player.processingState == ProcessingState.idle) { - await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); - mediaItem.add(_radioLuzMediaItem); + await _loadStream(); } unawaited(_player.play()); @@ -312,8 +315,7 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets Future playFromMediaId(String mediaId, [Map? extras]) async { //...because anything you click on should play the radio if (mediaId == _radioLuzMediaItem.id || mediaId.startsWith("history_") || mediaId.startsWith("schedule_")) { - await _player.setAudioSource(AudioSource.uri(Uri.parse(Env.radioLuzStreamUrl), tag: _radioLuzMediaItem)); - mediaItem.add(_radioLuzMediaItem); + await _loadStream(); unawaited(_player.play()); } //this if might be useless but you never know... From f500c0a157ef932e44af26a52b1c42b7ab1dc3d5 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 25 Jan 2026 21:43:14 +0100 Subject: [PATCH 14/22] fix: build error --- .../presentation/radio_luz_view.dart | 54 ++++++++++--------- lib/main.dart | 1 - 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index 57b25a863..a96fae7f7 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -8,6 +8,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "../../../config/ui_config.dart"; import "../../../theme/app_theme.dart"; import "../../../utils/context_extensions.dart"; +import "../../../widgets/horizontal_symmetric_safe_area.dart"; import "../service/radio_player_controller.dart"; import "audio_player_widget.dart"; import "broadcasts_section.dart"; @@ -34,32 +35,35 @@ class RadioLuzView extends HookConsumerWidget { return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: cappedTextScale), - child: Scaffold( - backgroundColor: context.colorScheme.surfaceTint, + child: HorizontalSymmetricSafeAreaScaffold( + backgroundColor: context.colorScheme.primaryContainer, appBar: RadioLuzAppBar(context, logoSize: 55), - body: Stack( - children: [ - ListView( - padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), - children: [ - RadioLuzTitle(title: l10n.now_playing.toUpperCase()), - const SizedBox(height: 12), - const NowPlayingSection(), - const SizedBox(height: 24), - RadioLuzTitle(title: l10n.broadcast.toUpperCase()), - const SizedBox(height: 12), - const BroadcastsSection(), - const SizedBox(height: 20), - RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), - const SizedBox(height: 12), - const _TextSection(), - const SizedBox(height: 12), - const RadioLuzSocialsSection(), - const SizedBox(height: 80), - ], - ), - const Align(alignment: Alignment.bottomCenter, child: AudioPlayerWidget()), - ], + body: ColoredBox( + color: context.colorScheme.surfaceTint, + child: Stack( + children: [ + ListView( + padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), + children: [ + RadioLuzTitle(title: l10n.now_playing.toUpperCase()), + const SizedBox(height: 12), + const NowPlayingSection(), + const SizedBox(height: 24), + RadioLuzTitle(title: l10n.broadcast.toUpperCase()), + const SizedBox(height: 12), + const BroadcastsSection(), + const SizedBox(height: 20), + RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), + const SizedBox(height: 12), + const _TextSection(), + const SizedBox(height: 12), + const RadioLuzSocialsSection(), + const SizedBox(height: 80), + ], + ), + const Align(alignment: Alignment.bottomCenter, child: AudioPlayerWidget()), + ], + ), ), ), ); diff --git a/lib/main.dart b/lib/main.dart index 05d999bd5..3e436f54d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,7 +60,6 @@ Future runToPWR() async { androidNotificationChannelId: "com.solvro.topwr.audio", androidNotificationChannelName: "Audio playback", androidNotificationOngoing: true, - androidStopForegroundOnPause: true, ), ); From 87a66cdd26491e6f3445353c408a3757b0a3bfd9 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 15 Feb 2026 21:13:26 +0100 Subject: [PATCH 15/22] chore: add bump info --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index a1e3e03cc..7fb5b4ebc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -119,6 +119,7 @@ dependencies: crypto: ^3.0.7 #Audio + #TODO: bump when just_audio fixes issue: https://github.com/ryanheise/just_audio/issues/1558 just_audio: ^0.9.36 audio_service: ^0.18.18 audio_session: ^0.1.21 From 5215317cc6eca7a1a4bb2d621bb2ba1880bab64a Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 15 Feb 2026 21:17:37 +0100 Subject: [PATCH 16/22] chore: delete comment --- lib/main.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 3e436f54d..5a0d0ea33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,7 +50,6 @@ Future main() async { Future runToPWR() async { final data = await PlatformAssetBundle().load(Assets.certs.przewodnikPwrEduPl); SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); - // await setupParkingWidgetsWorkManager(); final config = ClarityConfig(projectId: Env.clarityConfigId, logLevel: LogLevel.None); From dcee6df70765fab71b3d38fa8161c942b2d3df4b Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 15 Feb 2026 22:46:32 +0100 Subject: [PATCH 17/22] Adjust Radio Luz client setup --- .../service/radio_audio_handler.dart | 57 +++++-------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index f3e48efe8..22670dc8e 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -1,16 +1,13 @@ import "dart:async"; -import "dart:convert"; import "package:audio_service/audio_service.dart"; import "package:audio_session/audio_session.dart"; -import "package:dio/dio.dart"; import "package:flutter/widgets.dart"; import "package:just_audio/just_audio.dart"; import "../../../config/env.dart"; -import "../data/models/history_entry.dart"; -import "../data/models/now_playing.dart"; -import "../data/models/schedule.dart"; +import "../data/client/radio_luz_client.dart"; +import "../data/repository/radio_luz_repository.dart"; //Here the audio player is defined. Its created at app startup and is used for playing live stream. //This whole class describes the audio player behavior, media items and metadata. @@ -34,18 +31,7 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets final _player = AudioPlayer(); //used for fetching recently played and schedule - final _dio = Dio( - BaseOptions( - baseUrl: Env.radioLuzApiUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: { - "User-Agent": "RadioLuzApp/1.0 (Dart Dio)", - "Accept": "*/*", - "Content-Type": "application/x-www-form-urlencoded", - }, - ), - ); + final RadioLuzRepository _repository; //main media item - live radio (information source) var _radioLuzMediaItem = MediaItem( @@ -69,7 +55,7 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets StreamSubscription? _playbackEventSubscription; StreamSubscription? _sequenceStateSubscription; - RadioAudioHandlerBridge() { + RadioAudioHandlerBridge() : _repository = RadioLuzRepository(createRadioLuzDio()) { _initializeListeners(); } @@ -127,16 +113,11 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets Future _fetchNowPlaying() async { try { if (_isDisposed) return; - final formData = FormData.fromMap({"action": "nowPlaying"}); - final response = await _dio.post("admin-ajax.php", data: formData); - - if (response.data == null) return; + final nowPlaying = await _repository.getNowPlaying(); - final dynamic decoded = jsonDecode(response.data!); - if (decoded is! Map) return; - - final nowPlaying = NowPlaying.fromJson(decoded); - if (nowPlaying.now == null || nowPlaying.now!.isEmpty) return; + if (nowPlaying == null || nowPlaying.now == null || nowPlaying.now!.isEmpty) { + return; + } _radioLuzMediaItem = _radioLuzMediaItem.copyWith(album: nowPlaying.now, artist: nowPlaying.now); @@ -229,18 +210,12 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets } try { - final formData = FormData.fromMap({"action": "histoprylog"}); - final response = await _dio.post("admin-ajax.php", data: formData); - if (_isDisposed) return []; - if (response.data == null) return _recentlyPlayedCache ?? []; - - final dynamic decoded = jsonDecode(response.data!); - if (decoded is! List) return _recentlyPlayedCache ?? []; + final recentlyPlayed = await _repository.getRecentlyPlayed(); + if (recentlyPlayed == null) return _recentlyPlayedCache ?? []; - final items = decoded.whereType>().map((e) { - final entry = HistoryEntry.fromList(e); + final items = recentlyPlayed.map((entry) { final timeStr = "${entry.time.hour.toString().padLeft(2, '0')}:${entry.time.minute.toString().padLeft(2, '0')}"; return MediaItem( id: "history_${entry.date}_${entry.title}", @@ -269,15 +244,9 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets } try { - final formData = FormData.fromMap({"action": "schedule"}); - final response = await _dio.post("admin-ajax.php", data: formData); - - if (response.data == null) return _scheduleCache ?? []; - - final dynamic jsonMap = jsonDecode(response.data!); - if (jsonMap is! Map) return _scheduleCache ?? []; + final schedule = await _repository.getSchedule(); - final schedule = Schedule.fromJson(jsonMap); + if (schedule == null) return _scheduleCache ?? []; final items = []; From 4101fcfa16ea436957ca0e788e564d90c03100b3 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Sun, 22 Feb 2026 00:53:49 +0100 Subject: [PATCH 18/22] fix: changed to requested behaviour --- .../service/radio_audio_handler.dart | 132 +----------------- 1 file changed, 7 insertions(+), 125 deletions(-) diff --git a/lib/features/radio_luz/service/radio_audio_handler.dart b/lib/features/radio_luz/service/radio_audio_handler.dart index 22670dc8e..813b1bba9 100644 --- a/lib/features/radio_luz/service/radio_audio_handler.dart +++ b/lib/features/radio_luz/service/radio_audio_handler.dart @@ -19,36 +19,20 @@ const radioLuzArtwork = "https://api.topwr.solvro.pl/uploads/28ef1261-47d5-4324- const refreshInterval = Duration(seconds: 15); const staleStreamThreshold = Duration(seconds: 30); -//used for AA and CP to display folders and media items -class _MediaIds { - static const liveRadioFolder = "radio_luz_folder"; - static const liveRadioPlayable = "radio_luz_station"; - static const recentlyPlayed = "recently_played_folder"; - static const schedule = "schedule_folder"; -} - class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, WidgetsBindingObserver { final _player = AudioPlayer(); - //used for fetching recently played and schedule + //used for fetching now playing metadata final RadioLuzRepository _repository; //main media item - live radio (information source) var _radioLuzMediaItem = MediaItem( - id: _MediaIds.liveRadioPlayable, + id: "radio_luz_station", title: "Radio LUZ", album: "Studenckie Radio", artUri: Uri.parse(radioLuzArtwork), //artwork cannot be local asset ); - List? _recentlyPlayedCache; - DateTime? _recentlyPlayedLastFetch; - static const _recentlyPlayedTtl = Duration(minutes: 1); - - List? _scheduleCache; - DateTime? _scheduleLastFetch; - static const _scheduleTtl = Duration(minutes: 5); - var _isDisposed = false; Timer? _refreshTimer; @@ -171,123 +155,21 @@ class RadioAudioHandlerBridge extends BaseAudioHandler with SeekHandler, Widgets return super.stop(); } - //most crucial - defines the structure of the media library that is visible in AA and CP + //defines the media library visible in Android Auto and CarPlay @override Future> getChildren(String parentMediaId, [Map? options]) async { - switch (parentMediaId) { - case AudioService.browsableRootId: - return [ - const MediaItem( - id: _MediaIds.liveRadioFolder, - title: "Radio LUZ", - album: "Studenckie Radio", - playable: false, - ), - const MediaItem(id: _MediaIds.recentlyPlayed, title: "Teraz gramy", playable: false), - const MediaItem(id: _MediaIds.schedule, title: "Audycje", playable: false), - ]; - - case _MediaIds.liveRadioFolder: - return [_radioLuzMediaItem]; - - case _MediaIds.recentlyPlayed: - return _fetchRecentlyPlayed(); - - case _MediaIds.schedule: - return _fetchSchedule(); - - default: - return []; - } - } - - Future> _fetchRecentlyPlayed() async { - final now = DateTime.now(); - if (_recentlyPlayedCache != null && - _recentlyPlayedLastFetch != null && - now.difference(_recentlyPlayedLastFetch!) < _recentlyPlayedTtl) { - return _recentlyPlayedCache!; - } - - try { - if (_isDisposed) return []; - - final recentlyPlayed = await _repository.getRecentlyPlayed(); - if (recentlyPlayed == null) return _recentlyPlayedCache ?? []; - - final items = recentlyPlayed.map((entry) { - final timeStr = "${entry.time.hour.toString().padLeft(2, '0')}:${entry.time.minute.toString().padLeft(2, '0')}"; - return MediaItem( - id: "history_${entry.date}_${entry.title}", - title: "$timeStr - ${entry.title}", - artist: entry.artist, - album: entry.album, - ); - }).toList(); - - //because title is in format "HH:MM - Title", it is sorted by time from latest to oldest - items.sort((a, b) => b.title.compareTo(a.title)); - - _recentlyPlayedCache = items; - _recentlyPlayedLastFetch = now; - - return items; - } on Exception catch (_) { - return _recentlyPlayedCache ?? []; - } - } - - Future> _fetchSchedule() async { - final now = DateTime.now(); - if (_scheduleCache != null && _scheduleLastFetch != null && now.difference(_scheduleLastFetch!) < _scheduleTtl) { - return _scheduleCache!; - } - - try { - final schedule = await _repository.getSchedule(); - - if (schedule == null) return _scheduleCache ?? []; - - final items = []; - - for (final block in schedule.broadcasts) { - final isNow = block.isNow ?? false; - - for (final broadcast in block.broadcasts) { - Uri? artUri; - if (broadcast.thumbnail != null && broadcast.thumbnail!.isNotEmpty) { - artUri = Uri.tryParse(broadcast.thumbnail!); - } - - items.add( - MediaItem( - id: "schedule_${broadcast.id}", - title: isNow ? "▶ ${broadcast.title}" : broadcast.title, - album: broadcast.time, - artUri: artUri, - ), - ); - } - } - - _scheduleCache = items; - _scheduleLastFetch = now; - - return items; - } on Exception catch (_) { - return _scheduleCache ?? []; + if (parentMediaId == AudioService.browsableRootId) { + return [_radioLuzMediaItem]; } + return []; } - //start playing media item @override Future playFromMediaId(String mediaId, [Map? extras]) async { - //...because anything you click on should play the radio - if (mediaId == _radioLuzMediaItem.id || mediaId.startsWith("history_") || mediaId.startsWith("schedule_")) { + if (mediaId == _radioLuzMediaItem.id) { await _loadStream(); unawaited(_player.play()); } - //this if might be useless but you never know... } Future setVolume(double volume) => _player.setVolume(volume); From 31bbad1f41c5ea12b7ae7e38907ebc340e9084fb Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Fri, 27 Feb 2026 00:12:29 +0100 Subject: [PATCH 19/22] feat: auto-refresh --- .../repository/history_entry_repository.dart | 4 +- .../presentation/radio_luz_view.dart | 43 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/lib/features/radio_luz/data/repository/history_entry_repository.dart b/lib/features/radio_luz/data/repository/history_entry_repository.dart index 519458e09..bb2175a23 100644 --- a/lib/features/radio_luz/data/repository/history_entry_repository.dart +++ b/lib/features/radio_luz/data/repository/history_entry_repository.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; +import "../../../../utils/ref_extensions.dart"; import "../models/history_entry.dart"; import "radio_luz_repository.dart"; @@ -8,7 +9,8 @@ part "history_entry_repository.g.dart"; @riverpod Future?> historyEntryRepository(Ref ref) async { - final repository = ref.read(radioLuzRepositoryProvider); + ref.setRefresh(const Duration(seconds: 60)); + final repository = ref.watch(radioLuzRepositoryProvider); final history = await repository.getRecentlyPlayed(); if (history == null) { diff --git a/lib/features/radio_luz/presentation/radio_luz_view.dart b/lib/features/radio_luz/presentation/radio_luz_view.dart index a96fae7f7..7d45a39a6 100644 --- a/lib/features/radio_luz/presentation/radio_luz_view.dart +++ b/lib/features/radio_luz/presentation/radio_luz_view.dart @@ -9,6 +9,7 @@ import "../../../config/ui_config.dart"; import "../../../theme/app_theme.dart"; import "../../../utils/context_extensions.dart"; import "../../../widgets/horizontal_symmetric_safe_area.dart"; +import "../data/repository/history_entry_repository.dart"; import "../service/radio_player_controller.dart"; import "audio_player_widget.dart"; import "broadcasts_section.dart"; @@ -42,24 +43,30 @@ class RadioLuzView extends HookConsumerWidget { color: context.colorScheme.surfaceTint, child: Stack( children: [ - ListView( - padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), - children: [ - RadioLuzTitle(title: l10n.now_playing.toUpperCase()), - const SizedBox(height: 12), - const NowPlayingSection(), - const SizedBox(height: 24), - RadioLuzTitle(title: l10n.broadcast.toUpperCase()), - const SizedBox(height: 12), - const BroadcastsSection(), - const SizedBox(height: 20), - RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), - const SizedBox(height: 12), - const _TextSection(), - const SizedBox(height: 12), - const RadioLuzSocialsSection(), - const SizedBox(height: 80), - ], + RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + await ref.refresh(historyEntryRepositoryProvider.future); + }, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: RadioLuzConfig.horizontalBasePadding), + children: [ + RadioLuzTitle(title: l10n.now_playing.toUpperCase()), + const SizedBox(height: 12), + const NowPlayingSection(), + const SizedBox(height: 24), + RadioLuzTitle(title: l10n.broadcast.toUpperCase()), + const SizedBox(height: 12), + const BroadcastsSection(), + const SizedBox(height: 20), + RadioLuzTitle(title: l10n.radio_luz_info.toUpperCase()), + const SizedBox(height: 12), + const _TextSection(), + const SizedBox(height: 12), + const RadioLuzSocialsSection(), + const SizedBox(height: 80), + ], + ), ), const Align(alignment: Alignment.bottomCenter, child: AudioPlayerWidget()), ], From 831de4dbfd03c044ee9f99ff15596e33c0212703 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Tue, 3 Mar 2026 11:44:27 +0100 Subject: [PATCH 20/22] fix: rb1 --- android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1907b9165..76bff5619 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + From 77fcc29d71d7c8df6ef9e47b6c0189893a5bfde7 Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Tue, 3 Mar 2026 11:52:29 +0100 Subject: [PATCH 21/22] fix: rb2 --- .../service/radio_player_controller.dart | 16 ++++++- pubspec.yaml | 45 ++++++++++--------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/lib/features/radio_luz/service/radio_player_controller.dart b/lib/features/radio_luz/service/radio_player_controller.dart index fea9b6b54..bf10367e3 100644 --- a/lib/features/radio_luz/service/radio_player_controller.dart +++ b/lib/features/radio_luz/service/radio_player_controller.dart @@ -20,6 +20,8 @@ class RadioController extends _$RadioController { late final RadioAudioHandlerBridge _handler = ref.watch(radioPlayerProvider); var _initialized = false; + var _prevVolume = 1.0; + final _muteThreshold = 0.05; @override RadioState build() { @@ -33,7 +35,7 @@ class RadioController extends _$RadioController { final isPlaying = (playerStateProvider.value?.playing ?? false) && processingState == ProcessingState.ready; final isLoading = processingState == ProcessingState.loading || processingState == ProcessingState.buffering; - return RadioState(isPlaying: isPlaying, isLoading: isLoading, volume: volume); + return RadioState(isPlaying: isPlaying, isLoading: isLoading, volume: volume, isMuted: volume <= _muteThreshold); } void init(AudioPlayerStrings audioPlayerStrings) { @@ -73,4 +75,16 @@ class RadioController extends _$RadioController { Future setVolume(double newVolume) async { await _handler.setVolume(newVolume); } + + void rememberVolume(double newVolume) { + _prevVolume = newVolume > _muteThreshold ? newVolume : _prevVolume; + } + + Future toggleVolume() async { + if (state.volume <= _muteThreshold) { + await _handler.setVolume(_prevVolume); + } else { + await _handler.setVolume(0); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 7fb5b4ebc..47eab8f2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,11 +16,11 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.8+95 +version: 1.2.20+107 environment: - sdk: 3.10.4 - flutter: 3.38.5 + sdk: 3.10.8 + flutter: 3.38.9 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -35,23 +35,23 @@ dependencies: sdk: flutter #State managements - flutter_riverpod: ^3.1.0 - riverpod: ^3.1.0 - riverpod_annotation: ^4.0.0 - hooks_riverpod: ^3.1.0 + flutter_riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + riverpod_annotation: ^4.0.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + hooks_riverpod: ^3.1.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible #Storage shared_preferences: ^2.5.4 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - drift: ^2.30.0 + drift: ^2.31.0 drift_flutter: ^0.2.8 dio_cache_interceptor_db_store: ^6.0.0 #Network url_launcher: ^6.3.2 html: ^0.15.6 - dio: ^5.9.0 + dio: ^5.9.1 #Widgets cupertino_icons: ^1.0.8 @@ -76,7 +76,6 @@ dependencies: dotted_border: ^3.1.0 fluttertoast: ^9.0.0 carousel_slider_plus: ^7.1.1 - home_widget: ^0.8.1 scrollable_positioned_list: ^0.3.8 scrolls_to_top: ^2.1.1 sliver_tools: ^0.2.12 @@ -102,9 +101,9 @@ dependencies: flutter_compass: ^0.8.1 #Firebase - firebase_core: ^4.3.0 + firebase_core: ^4.4.0 firebase_performance: ^0.11.1 - firebase_messaging: ^16.1.0 + firebase_messaging: ^16.1.1 #Utility intl: ^0.20.2 @@ -126,36 +125,36 @@ dependencies: #Other in_app_update: ^4.2.5 - upgrader: ^12.3.0 + upgrader: ^12.5.0 in_app_review: ^2.0.11 protontime: ^2.0.0 logger: ^2.6.2 separate: ^1.0.3 wiredash: ^2.6.0 flutter_hooks: ^0.21.3 - sentry_flutter: ^9.9.1 + sentry_flutter: ^9.12.0 workmanager: ^0.9.0 #Solvro packages - solvro_translator_with_drift_cache_flutter: ^0.8.0 + solvro_translator_with_drift_cache_flutter: ^0.9.4 solvro_translator_core: ^0.8.0 - clarity_flutter: ^1.6.0 + clarity_flutter: ^1.7.1 dev_dependencies: flutter_test: sdk: flutter #Storage - drift_dev: ^2.30.0 + drift_dev: ^2.31.0 #Lints solvro_config: ^1.3.0 #CodeGen - build_runner: ^2.10.4 + build_runner: ^2.10.4 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible freezed: ^3.2.3 - json_serializable: ^6.11.2 # todo(simon-the-shark): bump when freezed does it, https://github.com/rrousselGit/freezed/issues/1326 - riverpod_generator: ^4.0.0 # todo(simon-the-shark): this seems like a typo but it's current newest version, fix when riverpod does it + json_serializable: ^6.11.2 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible + riverpod_generator: ^4.0.0 # todo(simon-the-shark): bump when analyzer bump to 9 will be possible envied_generator: ^1.3.2 flutter_gen_runner: ^5.12.0 auto_route_generator: ^10.4.0 @@ -166,7 +165,7 @@ dev_dependencies: test: ^1.26.3 dependency_overrides: - riverpod: 3.1.0 # todo(simon-the-shark): remove this when riverpod sorts those versions out + riverpod: 3.1.0 # todo(simon-the-shark): remove this when analyzer bump to 9 will be possible flutter: uses-material-design: true @@ -238,6 +237,10 @@ flutter: - assets/svg/streaming/youtube_music.svg - assets/svg/streaming/deezer.svg - assets/svg/streaming/tidal.svg + - assets/svg/branches_logos/logo-pwr.svg + - assets/svg/branches_logos/logo-jelenia.svg + - assets/svg/branches_logos/logo-walbrzych.svg + - assets/svg/branches_logos/logo-legnica.svg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware From b545a7260cb0683531738048cca97b124c3431fc Mon Sep 17 00:00:00 2001 From: MarmoPL Date: Tue, 3 Mar 2026 11:53:21 +0100 Subject: [PATCH 22/22] fix: rb3 --- pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 47eab8f2f..9c94832d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -133,7 +133,6 @@ dependencies: wiredash: ^2.6.0 flutter_hooks: ^0.21.3 sentry_flutter: ^9.12.0 - workmanager: ^0.9.0 #Solvro packages solvro_translator_with_drift_cache_flutter: ^0.9.4