Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ List<SingleChildWidget> _providers = [
),
),
ChangeNotifierProvider(create: (context) => PodcastProvider()),
ChangeNotifierProvider(create: (context) => RadioStationProvider()),
ChangeNotifierProvider(create: (context) => RadioPlayerProvider()),
ChangeNotifierProvider(
create: (context) => PlayableListScreenProvider(
playableProvider: context.read<PlayableProvider>(),
Expand Down
1 change: 1 addition & 0 deletions lib/models/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export 'playable.dart';
export 'playlist.dart';
export 'playlist_folder.dart';
export 'podcast.dart';
export 'radio_station.dart';
export 'song.dart';
export 'user.dart';
43 changes: 43 additions & 0 deletions lib/models/radio_station.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:faker/faker.dart';

class RadioStation {
final String id;
String name;
String url;
String? logo;
String? description;
bool isPublic;

RadioStation({
required this.id,
required this.name,
required this.url,
this.logo,
this.description,
this.isPublic = false,
});

factory RadioStation.fromJson(Map<String, dynamic> json) {
return RadioStation(
id: json['id'],
name: json['name'],
url: json['url'],
logo: json['logo'],
description: json['description'],
isPublic: json['is_public'] ?? false,
);
}

factory RadioStation.fake({
String? id,
String? name,
String? url,
}) {
final faker = Faker();
return RadioStation(
id: id ?? faker.guid.guid(),
name: name ?? '${faker.address.city()} FM',
url: url ?? 'https://stream.example.com/live',
);
}
}
2 changes: 2 additions & 0 deletions lib/providers/providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ export 'playable_provider.dart';
export 'playlist_folder_provider.dart';
export 'playlist_provider.dart';
export 'podcast_provider.dart';
export 'radio_player_provider.dart';
export 'radio_station_provider.dart';
export 'recently_played_provider.dart';
export 'search_provider.dart';
105 changes: 105 additions & 0 deletions lib/providers/radio_player_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'dart:async';

import 'package:app/main.dart';
import 'package:app/models/models.dart';
import 'package:app/utils/preferences.dart' as preferences;
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';

class RadioPlayerProvider with ChangeNotifier {
final _player = AudioPlayer();
RadioStation? _currentStation;
var _playing = false;
var _loading = false;
String? _streamTitle;

StreamSubscription? _playingSubscription;
StreamSubscription? _processingSubscription;
StreamSubscription? _queuePlaybackSubscription;

RadioStation? get currentStation => _currentStation;
bool get playing => _playing;
bool get loading => _loading;
bool get active => _currentStation != null;
String? get streamTitle => _streamTitle;

RadioPlayerProvider() {
_playingSubscription = _player.playingStream.listen((playing) {
_playing = playing;
notifyListeners();
});

_processingSubscription =
_player.processingStateStream.listen((state) {
_loading = state == ProcessingState.loading ||
state == ProcessingState.buffering;
notifyListeners();
});

// When queue playback starts, stop radio
_queuePlaybackSubscription =
audioHandler.playbackState.listen((state) {
if (state.playing && active) {
stop();
}
});
}

Future<void> play(RadioStation station) async {
// Pause the main queue player when radio starts
if (audioHandler.playbackState.value.playing) {
await audioHandler.pause();
}

_currentStation = station;
_streamTitle = null;
_loading = true;
notifyListeners();

final streamUrl =
'${preferences.host}/radio/stream/${station.id}?t=${preferences.audioToken}';

try {
await _player.setUrl(streamUrl);
await _player.play();
} catch (e) {
_currentStation = null;
_loading = false;
notifyListeners();
rethrow;
}
}

Future<void> stop() async {
await _player.stop();
_currentStation = null;
_playing = false;
_loading = false;
_streamTitle = null;
notifyListeners();
}

Future<void> togglePlayPause() async {
if (_playing) {
await _player.pause();
} else if (_currentStation != null) {
await _player.play();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

void updateStreamTitle(String? title) {
if (title != _streamTitle) {
_streamTitle = title;
notifyListeners();
}
}

@override
void dispose() {
_playingSubscription?.cancel();
_processingSubscription?.cancel();
_queuePlaybackSubscription?.cancel();
_player.dispose();
super.dispose();
}
}
79 changes: 79 additions & 0 deletions lib/providers/radio_station_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'package:app/mixins/stream_subscriber.dart';
import 'package:app/models/models.dart';
import 'package:app/providers/auth_provider.dart';
import 'package:app/utils/api_request.dart';
import 'package:flutter/foundation.dart';

class RadioStationProvider with ChangeNotifier, StreamSubscriber {
var _stations = <RadioStation>[];

List<RadioStation> get stations => _stations.toList();

RadioStationProvider() {
subscribe(AuthProvider.userLoggedOutStream.listen((_) {
_stations.clear();
notifyListeners();
}));
Comment on lines +13 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop late fetchAll() results after logout.

The logout listener clears _stations, but an in-flight fetchAll() still writes its response back afterward. That can repopulate radio stations after logout and leak stale/private data into the next session.

One way to guard late responses
 class RadioStationProvider with ChangeNotifier, StreamSubscriber {
   var _stations = <RadioStation>[];
+  var _generation = 0;
@@
   RadioStationProvider() {
     subscribe(AuthProvider.userLoggedOutStream.listen((_) {
+      _generation++;
       _stations.clear();
       notifyListeners();
     }));
   }
@@
   Future<void> fetchAll() async {
+    final generation = _generation;
     final res = await get('radio/stations');
+    if (generation != _generation) return;
+
     _stations = (res as List)
         .map<RadioStation>((j) => RadioStation.fromJson(j))
         .toList();
     notifyListeners();
   }

Also applies to: 19-24

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/providers/radio_station_provider.dart` around lines 13 - 16, The logout
listener currently clears _stations but doesn't prevent an in-flight fetchAll()
from writing results afterward; add a simple session/request token (e.g. an int
_fetchToken) on the provider, increment it inside the
AuthProvider.userLoggedOutStream.listen handler (where you call
_stations.clear(); notifyListeners();), and in fetchAll() capture the current
token at start and before mutating _stations check that the token still matches
— if it doesn't, drop the response. This keeps the
subscribe(AuthProvider.userLoggedOutStream.listen(...)) logic and
notifyListeners() intact while ensuring late fetchAll() responses are ignored
and cannot repopulate stations after logout.

}
Comment on lines +12 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n lib/providers/radio_station_provider.dart

Repository: koel/player

Length of output: 2461


🏁 Script executed:

find . -name "stream_subscriber.dart" -o -name "*stream_subscriber*"

Repository: koel/player

Length of output: 90


🏁 Script executed:

cat -n lib/mixins/stream_subscriber.dart

Repository: koel/player

Length of output: 365


🏁 Script executed:

rg -l "StreamSubscriber" --type dart | head -10

Repository: koel/player

Length of output: 454


🏁 Script executed:

for file in lib/providers/playlist_provider.dart lib/providers/podcast_provider.dart lib/providers/recently_played_provider.dart; do
  echo "=== $file ===";
  grep -A 10 "dispose()" "$file" 2>/dev/null || echo "No dispose() found";
done

Repository: koel/player

Length of output: 252


🏁 Script executed:

rg "void dispose\(\)" lib/providers/ --type dart

Repository: koel/player

Length of output: 175


🏁 Script executed:

for file in lib/providers/radio_player_provider.dart lib/providers/download_sync_provider.dart; do
  echo "=== $file ===";
  head -50 "$file";
done

Repository: koel/player

Length of output: 3105


🏁 Script executed:

for file in lib/providers/radio_player_provider.dart lib/providers/download_sync_provider.dart; do
  echo "=== $file ===";
  grep -A 10 "void dispose()" "$file";
done

Repository: koel/player

Length of output: 386


🏁 Script executed:

rg "class RadioStationProvider" -A 2 lib/providers/radio_station_provider.dart

Repository: koel/player

Length of output: 158


🏁 Script executed:

rg "RadioStationProvider" --type dart -B 2 -A 5 | grep -A 7 "dispose\|RadioStationProvider"

Repository: koel/player

Length of output: 3744


🏁 Script executed:

rg "ChangeNotifier" lib/mixins/stream_subscriber.dart

Repository: koel/player

Length of output: 37


🏁 Script executed:

rg "class StreamSubscriber" -A 20 lib/mixins/stream_subscriber.dart

Repository: koel/player

Length of output: 37


🏁 Script executed:

cat -n lib/mixins/stream_subscriber.dart

Repository: koel/player

Length of output: 365


🏁 Script executed:

cd lib && find . -name "*.dart" -exec grep -l "mixin.*ChangeNotifier.*StreamSubscriber\|with ChangeNotifier.*StreamSubscriber\|with StreamSubscriber.*ChangeNotifier" {} \;

Repository: koel/player

Length of output: 502


🏁 Script executed:

for file in lib/providers/album_provider.dart lib/providers/favorite_provider.dart lib/providers/interaction_provider.dart; do
  echo "=== $file ===";
  tail -20 "$file" | head -15;
done

Repository: koel/player

Length of output: 1402


🏁 Script executed:

for file in lib/providers/interaction_provider.dart lib/providers/overview_provider.dart lib/providers/playable_provider.dart; do
  echo "=== $file ===";
  grep -c "void dispose()" "$file" && echo "Has dispose()" || echo "No dispose()";
done

Repository: koel/player

Length of output: 237


Override dispose() to clean up the logout subscription.

The RadioStationProvider subscribes to AuthProvider.userLoggedOutStream in the constructor but never unsubscribes. When the provider is disposed, this subscription remains active and will trigger notifyListeners() on logout, causing a memory leak and potential errors on a disposed provider.

Suggested fix
   RadioStationProvider() {
     subscribe(AuthProvider.userLoggedOutStream.listen((_) {
       _stations.clear();
       notifyListeners();
     }));
   }
+
+  `@override`
+  void dispose() {
+    unsubscribeAll();
+    super.dispose();
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RadioStationProvider() {
subscribe(AuthProvider.userLoggedOutStream.listen((_) {
_stations.clear();
notifyListeners();
}));
}
RadioStationProvider() {
subscribe(AuthProvider.userLoggedOutStream.listen((_) {
_stations.clear();
notifyListeners();
}));
}
`@override`
void dispose() {
unsubscribeAll();
super.dispose();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/providers/radio_station_provider.dart` around lines 12 - 17,
RadioStationProvider subscribes to AuthProvider.userLoggedOutStream in the
constructor but never cancels it; add a private StreamSubscription field (e.g.
_logoutSub) to store the result of
subscribe(AuthProvider.userLoggedOutStream.listen(...)) inside the
RadioStationProvider constructor, then override dispose() in
RadioStationProvider to call await or _logoutSub?.cancel() (and set it to null)
before calling super.dispose(), ensuring the subscription is cancelled so
_stations.clear() and notifyListeners() won't run after disposal.


Future<void> fetchAll() async {
final res = await get('radio/stations');
_stations = (res as List)
.map<RadioStation>((j) => RadioStation.fromJson(j))
.toList();
notifyListeners();
}

Future<RadioStation> create({
required String name,
required String url,
String? description,
bool isPublic = false,
}) async {
final json = await post('radio/stations', data: {
'name': name,
'url': url,
'is_public': isPublic,
if (description != null && description.isNotEmpty)
'description': description,
});

final station = RadioStation.fromJson(json);
_stations.add(station);
notifyListeners();
return station;
}

Future<void> update(
RadioStation station, {
required String name,
required String url,
String? description,
bool isPublic = false,
}) async {
await put('radio/stations/${station.id}', data: {
'name': name,
'url': url,
'description': description ?? '',
'is_public': isPublic,
});

station
..name = name
..url = url
..description = description
..isPublic = isPublic;

notifyListeners();
}

Future<void> remove(RadioStation station) async {
delete('radio/stations/${station.id}');
_stations.remove(station);
notifyListeners();
Comment on lines +70 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Await delete before mutating local state.

Line 66 currently fire-and-forgets the server delete, then removes locally. If the request fails, UI and server diverge.

Proposed fix
   Future<void> remove(RadioStation station) async {
-    delete('radio/stations/${station.id}');
-    _stations.remove(station);
+    await delete('radio/stations/${station.id}');
+    _stations.removeWhere((s) => s.id == station.id);
     notifyListeners();
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Future<void> remove(RadioStation station) async {
delete('radio/stations/${station.id}');
_stations.remove(station);
notifyListeners();
Future<void> remove(RadioStation station) async {
await delete('radio/stations/${station.id}');
_stations.removeWhere((s) => s.id == station.id);
notifyListeners();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/providers/radio_station_provider.dart` around lines 65 - 68, The
remove(RadioStation station) function currently fire-and-forgets the HTTP delete
call (delete('radio/stations/${station.id}')) then immediately mutates local
state (_stations.remove(station); notifyListeners()), causing possible UI/server
divergence; change it to await the delete call and only remove from _stations
and call notifyListeners() after a successful response, wrapping the await in
try/catch to handle and surface errors (log/rethrow or show user feedback) so
failures do not remove the station locally.

}

Future<Map<String, dynamic>> getNowPlaying(RadioStation station) async {
return await get('radio/stations/${station.id}/now-playing');
}
}
7 changes: 7 additions & 0 deletions lib/ui/screens/library.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ class LibraryScreen extends StatelessWidget {
CupertinoPageRoute(builder: (_) => const PodcastsScreen()),
),
),
LibraryMenuItem(
icon: CupertinoIcons.antenna_radiowaves_left_right,
label: 'Radio',
onTap: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => const RadioStationsScreen()),
),
),
LibraryMenuItem(
icon: CupertinoIcons.cloud_download_fill,
label: 'Downloaded',
Expand Down
Loading
Loading