diff --git a/lib/common/logging.dart b/lib/common/logging.dart new file mode 100644 index 00000000..1cbb3c50 --- /dev/null +++ b/lib/common/logging.dart @@ -0,0 +1,7 @@ +import 'package:flutter/foundation.dart'; + +void printMessageInDebugMode(String message) { + if (kDebugMode) { + print(message); + } +} diff --git a/lib/common/view/safe_network_image.dart b/lib/common/view/safe_network_image.dart index 5223581f..df1358b6 100644 --- a/lib/common/view/safe_network_image.dart +++ b/lib/common/view/safe_network_image.dart @@ -9,6 +9,7 @@ import 'package:path/path.dart' as p; import 'package:xdg_directories/xdg_directories.dart'; import '../../extensions/build_context_x.dart'; +import '../logging.dart'; import 'icons.dart'; class SafeNetworkImage extends StatelessWidget { @@ -21,6 +22,7 @@ class SafeNetworkImage extends StatelessWidget { this.errorIcon, this.height, this.width, + this.httpHeaders, }); final String? url; @@ -30,6 +32,7 @@ class SafeNetworkImage extends StatelessWidget { final Widget? errorIcon; final double? height; final double? width; + final Map? httpHeaders; @override Widget build(BuildContext context) { @@ -64,6 +67,7 @@ class SafeNetworkImage extends StatelessWidget { width: width, ), errorWidget: (context, url, _) => errorWidget, + errorListener: (e) => printMessageInDebugMode(e.toString()), ); } on Exception { return fallBack; diff --git a/lib/constants.dart b/lib/constants.dart index 74163252..e9cf0d80 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -179,11 +179,15 @@ const shops = { const kSponsorLink = 'https://github.com/sponsors/Feichtmeier'; -const kAlbumArtHeaders = { +const kMusicBrainzHeaders = { 'Accept': 'application/json', 'User-Agent': '$kAppTitle ($kRepoUrl)', }; +const kInternetArchiveHeaders = { + 'User-Agent': '$kAppTitle ($kRepoUrl)', +}; + const kAudioHeaderDescriptionWidth = 400.0; const kShowLeadingThreshold = 3000; diff --git a/lib/expose/expose_service.dart b/lib/expose/expose_service.dart index 6a384863..e43131b9 100644 --- a/lib/expose/expose_service.dart +++ b/lib/expose/expose_service.dart @@ -13,9 +13,23 @@ class ExposeService { _discordRPC?.isConnectedStream ?? Stream.value(false); Future exposeTitleOnline({ - required String line1, - required String line2, - required String line3, + required String title, + required String artist, + required String additionalInfo, + String? imageUrl, + }) async { + await _exposeTitleToDiscord( + title: title, + artist: artist, + additionalInfo: additionalInfo, + imageUrl: imageUrl, + ); + } + + Future _exposeTitleToDiscord({ + required String title, + required String artist, + required String additionalInfo, String? imageUrl, }) async { try { @@ -26,11 +40,11 @@ class ExposeService { await _discordRPC?.setActivity( activity: RPCActivity( assets: RPCAssets( - largeText: line3, + largeText: additionalInfo, largeImage: imageUrl, ), - details: line1, - state: line2, + details: title, + state: artist, activityType: ActivityType.listening, ), ); @@ -41,11 +55,7 @@ class ExposeService { } Future connect() async { - try { - await _discordRPC?.connect(); - } on Exception catch (e) { - _errorController.add(e.toString()); - } + await connectToDiscord(); } Future connectToDiscord() async { @@ -65,7 +75,7 @@ class ExposeService { } Future dispose() async { - await _discordRPC?.disconnect(); + await disconnectFromDiscord(); await _errorController.close(); } } diff --git a/lib/player/player_model.dart b/lib/player/player_model.dart index cab4879a..cba1ad65 100644 --- a/lib/player/player_model.dart +++ b/lib/player/player_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter/material.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:safe_change_notifier/safe_change_notifier.dart'; @@ -106,7 +105,6 @@ class PlayerModel extends SafeChangeNotifier { index: index, ); - Color? get color => _playerService.color; String? get remoteImageUrl => _playerService.remoteImageUrl; bool _isUpNextExpanded = false; diff --git a/lib/player/player_service.dart b/lib/player/player_service.dart index 7a1830d8..055074e1 100644 --- a/lib/player/player_service.dart +++ b/lib/player/player_service.dart @@ -4,11 +4,9 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; import 'package:html/parser.dart'; import 'package:media_kit/media_kit.dart' hide PlayerState; import 'package:media_kit_video/media_kit_video.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:path/path.dart' as p; import 'package:smtc_windows/smtc_windows.dart'; @@ -67,7 +65,7 @@ class PlayerService { MpvMetaData? _mpvMetaData; MpvMetaData? get mpvMetaData => _mpvMetaData; - void _setMpvMetaData(MpvMetaData? value) { + Future _setMpvMetaData(MpvMetaData? value) async { _mpvMetaData = value; var validHistoryElement = _mpvMetaData?.icyTitle.isNotEmpty == true; @@ -88,6 +86,8 @@ class PlayerService { icyName: audio?.title?.trim() ?? _mpvMetaData?.icyName ?? '', ), ); + + await _processParsedIcyTitle(mpvMetaData!.icyTitle.everyWordCapitalized); } _propertiesChangedController.add(true); } @@ -224,12 +224,13 @@ class PlayerService { ), ); } - _setMediaControlsMetaData(audio: audio!); - _loadColorAndSetRemoteUrl(); - _exposeService.exposeTitleOnline( - line1: audio?.title ?? '', - line2: audio?.artist ?? '', - line3: audio?.album ?? '', + _setRemoteImageUrl(_audio?.imageUrl ?? _audio?.albumArtUrl); + + await _setMediaControlsMetaData(audio: audio!); + await _exposeService.exposeTitleOnline( + title: audio?.title ?? '', + artist: audio?.artist ?? '', + additionalInfo: audio?.album ?? '', imageUrl: audio?.imageUrl ?? audio?.albumArtUrl, ); _firstPlay = false; @@ -291,32 +292,12 @@ class PlayerService { final newData = MpvMetaData.fromJson(data); final parsedIcyTitle = HtmlParser(newData.icyTitle).parseFragment().text; - if (parsedIcyTitle == _mpvMetaData?.icyTitle) return; + if (parsedIcyTitle == null || + parsedIcyTitle == _mpvMetaData?.icyTitle) { + return; + } _setMpvMetaData(newData.copyWith(icyTitle: parsedIcyTitle)); - - if (parsedIcyTitle == null) return; - - final songInfo = parsedIcyTitle.splitByDash; - _onlineArtService.fetchAlbumArt(parsedIcyTitle).then( - (albumArt) async { - final mergedAudio = - (_audio ?? const Audio(audioType: AudioType.radio)).copyWith( - imageUrl: albumArt, - title: songInfo.songName, - artist: songInfo.artist, - ); - await _setMediaControlsMetaData(audio: mergedAudio); - await _loadColorAndSetRemoteUrl(artUrl: albumArt); - - await _exposeService.exposeTitleOnline( - line1: songInfo.songName ?? '', - line2: songInfo.artist ?? '', - line3: _audio?.title ?? 'Internet Radio', - imageUrl: albumArt, - ); - }, - ); }, ); @@ -343,6 +324,27 @@ class PlayerService { await _setPlayerState(); } + Future _processParsedIcyTitle(String parsedIcyTitle) async { + final songInfo = parsedIcyTitle.splitByDash; + final albumArt = await _onlineArtService.fetchAlbumArt(parsedIcyTitle); + + final mergedAudio = + (_audio ?? const Audio(audioType: AudioType.radio)).copyWith( + imageUrl: albumArt, + title: songInfo.songName, + artist: songInfo.artist, + ); + await _setMediaControlsMetaData(audio: mergedAudio); + _setRemoteImageUrl(albumArt ?? _audio?.imageUrl ?? _audio?.albumArtUrl); + + await _exposeService.exposeTitleOnline( + title: songInfo.songName ?? '', + artist: songInfo.artist ?? '', + additionalInfo: _audio?.title ?? 'Internet Radio', + imageUrl: albumArt, + ); + } + Future playNext() async { await safeLastPosition(); if (!repeatSingle && nextAudio != null) { @@ -457,9 +459,6 @@ class PlayerService { await _play(newPosition: _position); } - Color? _color; - Color? get color => _color; - String? _remoteImageUrl; String? get remoteImageUrl => _remoteImageUrl; void _setRemoteImageUrl(String? url) { @@ -467,33 +466,6 @@ class PlayerService { _propertiesChangedController.add(true); } - Future _loadColorAndSetRemoteUrl({String? artUrl}) async { - final pic = CoverStore().get(_audio?.albumId); - if (pic == null && - audio?.imageUrl == null && - audio?.albumArtUrl == null && - artUrl == null) { - _color = null; - _setRemoteImageUrl(null); - - return; - } - - ImageProvider? image; - if (pic != null) { - _setRemoteImageUrl(null); - image = MemoryImage(pic); - } else { - final url = artUrl ?? _audio!.imageUrl ?? _audio!.albumArtUrl!; - _setRemoteImageUrl(url); - image = NetworkImage( - url, - ); - } - final generator = await PaletteGenerator.fromImageProvider(image); - _color = generator.dominantColor?.color; - } - Future _setPlayerState() async { final playerState = await _readPlayerState(); diff --git a/lib/player/view/bottom_player.dart b/lib/player/view/bottom_player.dart index 3f95dca8..8aae36e3 100644 --- a/lib/player/view/bottom_player.dart +++ b/lib/player/view/bottom_player.dart @@ -11,6 +11,7 @@ import '../../common/view/theme.dart'; import '../../extensions/build_context_x.dart'; import '../../l10n/l10n.dart'; import '../../player/player_model.dart'; +import 'blurred_full_height_player_image.dart'; import 'bottom_player_image.dart'; import 'play_button.dart'; import 'playback_rate_button.dart'; @@ -145,6 +146,17 @@ class BottomPlayer extends StatelessWidget with WatchItMixin { ); } - return player; + if (isVideo == true) { + return player; + } + + return Stack( + children: [ + BlurredFullHeightPlayerImage( + size: Size(context.mediaQuerySize.width, bottomPlayerHeight), + ), + player, + ], + ); } } diff --git a/lib/player/view/player_view.dart b/lib/player/view/player_view.dart index 21a4ca82..99d5b2d0 100644 --- a/lib/player/view/player_view.dart +++ b/lib/player/view/player_view.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:watch_it/watch_it.dart'; import '../../app/app_model.dart'; -import '../../common/view/theme.dart'; import '../../extensions/build_context_x.dart'; -import '../player_model.dart'; import 'bottom_player.dart'; import 'full_height_player.dart'; @@ -45,8 +43,6 @@ class _PlayerViewState extends State { @override Widget build(BuildContext context) { final theme = context.theme; - final c = watchPropertyValue((PlayerModel m) => m.color); - final color = getPlayerBg(c, theme.cardColor); Widget player; if (widget.position != PlayerPosition.bottom) { @@ -66,7 +62,7 @@ class _PlayerViewState extends State { // VERY important to reduce CPU usage return RepaintBoundary( child: Material( - color: color, + color: theme.cardColor, child: player, ), ); diff --git a/lib/radio/online_art_service.dart b/lib/radio/online_art_service.dart index 3e4586c6..2e0c3b7f 100644 --- a/lib/radio/online_art_service.dart +++ b/lib/radio/online_art_service.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import '../common/logging.dart'; import '../constants.dart'; import '../extensions/string_x.dart'; @@ -21,15 +23,11 @@ class OnlineArtService { url: await compute( _fetchAlbumArt, _ComputeCapsule(icyTitle: icyTitle, dio: _dio), - ).onError( - (e, s) { - if (kDebugMode) { - debugPrintStack(stackTrace: s); - } - _errorController.add('$e : $s'); - return null; - }, - ), + ).onError((e, s) { + printMessageInDebugMode(e.toString()); + _errorController.add('$e : $s'); + return null; + }), ); } @@ -60,7 +58,7 @@ class _ComputeCapsule { Future _fetchAlbumArt(_ComputeCapsule capsule) async { final dio = capsule.dio; - dio.options.headers = kAlbumArtHeaders; + dio.options.headers = kMusicBrainzHeaders; final songInfo = capsule.icyTitle.splitByDash; if (songInfo.songName == null || songInfo.artist == null) return null; @@ -80,16 +78,34 @@ Future _fetchAlbumArt(_ComputeCapsule capsule) async { final releaseId = firstRecording == null ? null : firstRecording?['releases']?[0]?['id']; - if (releaseId == null) return null; + if (releaseId == null) { + printMessageInDebugMode('${capsule.icyTitle}: No release found}'); + return null; + } + + printMessageInDebugMode( + '${capsule.icyTitle}: Release ($releaseId) found, trying to find artwork ...', + ); final albumArtUrl = await _fetchAlbumArtUrlFromReleaseId( releaseId: releaseId, dio: dio, ); + if (albumArtUrl != null) { + printMessageInDebugMode( + '${capsule.icyTitle}: Resource ($albumArtUrl) found', + ); + } else { + printMessageInDebugMode( + '${capsule.icyTitle}: No resource found for ($releaseId)!', + ); + } + return albumArtUrl; } on Exception { - rethrow; + printMessageInDebugMode('No release found!'); + return null; } } @@ -98,19 +114,39 @@ Future _fetchAlbumArtUrlFromReleaseId({ required Dio dio, }) async { try { - dio.options.headers = kAlbumArtHeaders; - final response = await dio.get('$_kCoverArtArchiveAddress$releaseId'); - - final images = response.data['images'] as List; - - if (images.isNotEmpty) { - final artwork = images[0]; - - return (artwork['image']) as String?; + dio.options.headers = kInternetArchiveHeaders; + dio.options.followRedirects = true; + dio.options.maxRedirects = 5; + dio.options.receiveTimeout = const Duration(seconds: 25); + dio.options.validateStatus = (code) { + final stringCode = code.toString(); + if (stringCode.startsWith('2') || stringCode.startsWith('3')) { + return true; + } + return false; + }; + + final path = '$_kCoverArtArchiveAddress$releaseId'; + final response = await dio.get(path); + final imagesMaps = response.data['images'] as List; + + if (imagesMaps.isNotEmpty == true) { + final imageMap = imagesMaps + .firstWhereOrNull((e) => (e['front'] as bool?) == true || e != null); + + final thumbnail = imageMap?['thumbnails'] as Map?; + + final url = thumbnail?['large'] as String? ?? + thumbnail?['small'] as String? ?? + thumbnail?['500'] as String? ?? + thumbnail?['1200'] as String? ?? + imageMap['image'] as String?; + + return url?.replaceAll('http://', 'https://'); } - } on Exception { - rethrow; + } on Exception catch (e) { + printMessageInDebugMode(e.toString()); + return null; } - return null; } diff --git a/lib/radio/view/radio_connect_snackbar.dart b/lib/radio/view/radio_connect_snackbar.dart index c6bad433..1146d284 100644 --- a/lib/radio/view/radio_connect_snackbar.dart +++ b/lib/radio/view/radio_connect_snackbar.dart @@ -5,22 +5,18 @@ import '../../common/view/snackbars.dart'; import '../../l10n/l10n.dart'; import '../radio_model.dart'; -class RadioConnectSnackbar extends StatelessWidget { - const RadioConnectSnackbar({super.key, this.connectedHost}); - - final String? connectedHost; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return SnackBar( +SnackBar buildConnectSnackBar({ + required String? connectedHost, + required BuildContext context, +}) => + SnackBar( duration: connectedHost != null ? const Duration(seconds: 1) : const Duration(seconds: 30), content: Text( connectedHost != null - ? '${l10n.connectedTo}: $connectedHost' - : l10n.noRadioServerFound, + ? '${context.l10n.connectedTo}: $connectedHost' + : context.l10n.noRadioServerFound, ), action: (connectedHost == null) ? SnackBarAction( @@ -29,13 +25,14 @@ class RadioConnectSnackbar extends StatelessWidget { if (context.mounted) { showSnackBar( context: context, - content: RadioConnectSnackbar(connectedHost: connectedHost), + content: buildConnectSnackBar( + connectedHost: connectedHost, + context: context, + ), ); } }, - label: l10n.tryReconnect, + label: context.l10n.tryReconnect, ) : null, ); - } -} diff --git a/lib/radio/view/radio_page_tag_bar.dart b/lib/radio/view/radio_page_tag_bar.dart index 75b541d0..6e54ebb0 100644 --- a/lib/radio/view/radio_page_tag_bar.dart +++ b/lib/radio/view/radio_page_tag_bar.dart @@ -88,8 +88,9 @@ class RadioPageTagBar extends StatelessWidget { if (context.mounted) { showSnackBar( context: context, - content: RadioConnectSnackbar( + content: buildConnectSnackBar( connectedHost: connectedHost, + context: context, ), duration: Duration( seconds: connectedHost == null ? 10 : 3, diff --git a/lib/radio/view/radio_reconnect_button.dart b/lib/radio/view/radio_reconnect_button.dart index 5529a03b..633ad363 100644 --- a/lib/radio/view/radio_reconnect_button.dart +++ b/lib/radio/view/radio_reconnect_button.dart @@ -23,8 +23,13 @@ class RadioReconnectButton extends StatelessWidget { if (context.mounted) { showSnackBar( context: context, - content: RadioConnectSnackbar(connectedHost: host), - duration: Duration(seconds: host == null ? 10 : 3), + content: buildConnectSnackBar( + connectedHost: host, + context: context, + ), + duration: Duration( + seconds: host == null ? 10 : 3, + ), ); } }, diff --git a/lib/search/view/sliver_radio_search_results.dart b/lib/search/view/sliver_radio_search_results.dart index c1e3517d..d917e7cf 100644 --- a/lib/search/view/sliver_radio_search_results.dart +++ b/lib/search/view/sliver_radio_search_results.dart @@ -39,8 +39,9 @@ class _SliverRadioSearchResultsState extends State { if (mounted && model.showConnectSnackBar) { showSnackBar( context: context, - content: RadioConnectSnackbar( + snackBar: buildConnectSnackBar( connectedHost: connectedHost, + context: context, ), duration: Duration( seconds: connectedHost == null ? 10 : 3,