diff --git a/lib/common/data/audio.dart b/lib/common/data/audio.dart index 005ed68d6..5f4e434cd 100644 --- a/lib/common/data/audio.dart +++ b/lib/common/data/audio.dart @@ -36,7 +36,7 @@ class Audio { /// The duration of the audio file or stream. It can be null if was not set final double? durationMs; - /// The artist(s) of the audio file or stream. + /// The artist(s) of the audio file or stream, for radio stations this is the language. final String? artist; /// The album of the audio file or stream. @@ -310,11 +310,14 @@ class Audio { ); } + String? get uuid => description; + String? get language => artist; + factory Audio.fromStation(Station station) { return Audio( url: station.urlResolved, title: station.name, - artist: station.language ?? station.name, + artist: station.language, album: station.tags ?? '', audioType: AudioType.radio, imageUrl: station.favicon, diff --git a/lib/common/view/audio_tile.dart b/lib/common/view/audio_tile.dart index 14677757f..3fe668735 100644 --- a/lib/common/view/audio_tile.dart +++ b/lib/common/view/audio_tile.dart @@ -57,7 +57,7 @@ class _AudioTileState extends State { final playerModel = di(); final liked = watchPropertyValue((LibraryModel m) => m.liked(widget.audio)); final starred = watchPropertyValue( - (LibraryModel m) => m.isStarredStation(widget.audio.description), + (LibraryModel m) => m.isStarredStation(widget.audio.uuid), ); final selectedColor = widget.selectedColor ?? theme.contrastyPrimary; final subTitle = switch (widget.audioPageType) { diff --git a/lib/common/view/like_icon.dart b/lib/common/view/like_icon.dart index e32dbf4c3..25e54d3d6 100644 --- a/lib/common/view/like_icon.dart +++ b/lib/common/view/like_icon.dart @@ -75,17 +75,17 @@ class RadioLikeIcon extends StatelessWidget with WatchItMixin { watchPropertyValue((LibraryModel m) => m.starredStations.length); - final isStarredStation = libraryModel.isStarredStation(audio?.description); + final isStarredStation = libraryModel.isStarredStation(audio?.uuid); final void Function()? onLike; - if (audio == null && audio?.description == null) { + if (audio == null && audio?.uuid == null) { onLike = null; } else { onLike = () { isStarredStation - ? libraryModel.unStarStation(audio!.description!) + ? libraryModel.unStarStation(audio!.uuid!) : libraryModel.addStarredStation( - audio!.description!, + audio!.uuid!, [audio!], ); }; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8e15cc3da..d37692b09 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -344,6 +344,8 @@ "closeMusicPod": "MusicPod schließen?", "confirmCloseOrHideTip": "Bitte bestätigen, ob Sie die Anwendung schließen oder ausblenden möchten.", "doNotAskAgain": "Nicht mehr fragen", + "skipToLivStream": "Zur Live-Übertragung springen", + "searchSimilarStation": "Ähnlichen Sender suchen", "regionNone": "Keine", "regionAfghanistan": "Afghanistan", "regionAlandislands": "Ålandinseln", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e1511143f..16126686b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -342,6 +342,8 @@ "closeMusicPod": "Close MusicPod?", "confirmCloseOrHideTip": "Please confirm if you need to close the application or hide it?", "doNotAskAgain": "Do not ask again", + "skipToLivStream": "Skip to live stream", + "searchSimilarStation": "Search similar station", "regionNone": "None", "regionAfghanistan": "Afghanistan", "regionAlandislands": "Alandislands", diff --git a/lib/player/view/player_main_controls.dart b/lib/player/view/player_main_controls.dart index 003702792..d97ae01bb 100644 --- a/lib/player/view/player_main_controls.dart +++ b/lib/player/view/player_main_controls.dart @@ -8,6 +8,7 @@ import '../../common/view/theme.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/theme_data_x.dart'; import '../../l10n/l10n.dart'; +import '../../radio/view/next_station_button.dart'; import '../player_model.dart'; import 'play_button.dart'; import 'repeat_button.dart'; @@ -31,24 +32,29 @@ class PlayerMainControls extends StatelessWidget with WatchItMixin { final defaultColor = iconColor ?? theme.colorScheme.onSurface; final queueLength = watchPropertyValue((PlayerModel m) => m.queue.length); final audio = watchPropertyValue((PlayerModel m) => m.audio); - final showShuffleAndRepeat = audio?.audioType == AudioType.local; final showSkipButtons = queueLength > 1 || audio?.audioType == AudioType.local; final isOnline = watchPropertyValue((ConnectivityModel m) => m.isOnline); final active = audio?.path != null || isOnline; final children = [ - if (showShuffleAndRepeat) - ShuffleButton( - active: active, - iconColor: defaultColor, - ) - else if (audio?.audioType == AudioType.podcast) - SeekButton( - active: active, - forward: false, - iconColor: defaultColor, - ), + switch (audio?.audioType) { + AudioType.local => ShuffleButton( + active: active, + iconColor: defaultColor, + ), + AudioType.podcast => SeekButton( + active: active, + forward: false, + iconColor: defaultColor, + ), + AudioType.radio => IconButton( + tooltip: context.l10n.skipToLivStream, + onPressed: di().playNext, + icon: Icon(Iconz().refresh), + ), + _ => const SizedBox.shrink() + }, _flex, if (showSkipButtons) IconButton( @@ -84,16 +90,18 @@ class PlayerMainControls extends StatelessWidget with WatchItMixin { ), ), _flex, - if (showShuffleAndRepeat) - RepeatButton( - active: active, - iconColor: defaultColor, - ) - else if (audio?.audioType == AudioType.podcast) - SeekButton( - active: active, - iconColor: defaultColor, - ), + switch (audio?.audioType) { + AudioType.local => RepeatButton( + active: active, + iconColor: defaultColor, + ), + AudioType.podcast => SeekButton( + active: active, + iconColor: defaultColor, + ), + AudioType.radio => const NextStationButton(), + _ => const SizedBox.shrink(), + }, ]; return Row( diff --git a/lib/radio/view/next_station_button.dart b/lib/radio/view/next_station_button.dart new file mode 100644 index 000000000..5dca71f37 --- /dev/null +++ b/lib/radio/view/next_station_button.dart @@ -0,0 +1,40 @@ +import '../../common/view/icons.dart'; +import '../../l10n/l10n.dart'; +import '../../player/player_model.dart'; +import '../../search/search_model.dart'; +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../radio_model.dart'; + +class NextStationButton extends StatelessWidget with WatchItMixin { + const NextStationButton({super.key}); + + @override + Widget build(BuildContext context) { + final loading = watchPropertyValue((SearchModel m) => m.loading); + final audio = watchPropertyValue((PlayerModel m) => m.audio); + + return IconButton( + tooltip: context.l10n.searchSimilarStation, + onPressed: loading + ? null + : () { + if (audio == null) return; + di().init().then((host) { + if (host == null) return null; + return di().nextSimilarStation(audio).then( + (station) { + if (station == audio) return; + di().startPlaylist( + audios: [station], + listName: station.uuid ?? station.toString(), + ); + }, + ); + }); + }, + icon: Icon(Iconz().explore), + ); + } +} diff --git a/lib/search/search_model.dart b/lib/search/search_model.dart index 847937c14..28230927f 100644 --- a/lib/search/search_model.dart +++ b/lib/search/search_model.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -162,6 +164,67 @@ class SearchModel extends SafeChangeNotifier { notifyListeners(); } + List? searchFromLanguage; + String? lastLanguage; + Future _findSimilarStation(Audio station) async { + if (searchFromLanguage == null || station.language != lastLanguage) { + searchFromLanguage = await _radioService.search( + limit: 1000, + language: station.language, + ); + } + lastLanguage = station.language; + + final noNumbers = RegExp(r'^[^0-9]+$'); + return searchFromLanguage + ?.where( + (e) => _areTagsSimilar( + stationTags: station.tags?.where((e) => noNumbers.hasMatch(e)), + eTags: + Audio.fromStation(e).tags?.where((e) => noNumbers.hasMatch(e)), + ), + ) + .firstWhereOrNull((e) => e.stationUUID != station.uuid); + } + + bool _areTagsSimilar({ + required Iterable? stationTags, + required Iterable? eTags, + }) { + if (eTags == null || + eTags.isEmpty || + stationTags == null || + stationTags.length < 2) { + return false; + } + + final random = Random(); + final randomOne = random.nextInt(stationTags.length); + var randomTwo = random.nextInt(stationTags.length); + while (randomTwo == randomOne) { + randomTwo = random.nextInt(stationTags.length); + } + + return eTags.contains(stationTags.elementAt(randomOne)) && + eTags.contains(stationTags.elementAt(randomTwo)); + } + + Future