From 1947225a6ebd1856fcdbae7122d62c5bd8dc1ea0 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Mon, 6 Jul 2026 20:03:34 +1000 Subject: [PATCH 01/12] Add shuffle, favourite, and mix buttons to CarPlay - Point flutter_carplay at our fork (upstream v1.6.3 plus Now Playing buttons, sfsymbol, and a shuffle state shim) - Drop sectionIndexEnabled from the Home template, the only 1.2.11 to 1.6.3 API break (param moved to CPListSection) - Hide favourite and mix while offline or without a current track Closes finamp-app/finamp#1588 --- lib/services/carplay_helper.dart | 126 ++++++++++++++++++++++++++++++- pubspec.lock | 8 +- pubspec.yaml | 4 +- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index 7fe728e2a..a77c5b045 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -18,11 +18,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; +import 'favorite_provider.dart'; import 'finamp_settings_helper.dart'; import 'finamp_user_helper.dart'; import 'audio_service_helper.dart'; import 'queue_service.dart'; import 'item_helper.dart'; +import 'radio_service_helper.dart' as radio; final _carPlayLogger = Logger("CarPlay"); @@ -53,6 +55,11 @@ class CarPlayHelper { final providerRef = GetIt.instance(); ProviderSubscription? _userSubscription; + ProviderSubscription? _favoriteSubscription; + ProviderSubscription? _offlineSubscription; + StreamSubscription? _currentTrackSubscription; + StreamSubscription? _playbackOrderSubscription; + BaseItemId? _nowPlayingButtonsTrackId; bool get isUserLoggedIn => _finampUserHelper.currentUser != null; @@ -77,10 +84,37 @@ class CarPlayHelper { void setupCarplay() { _flutterCarplay.addListenerOnConnectionChange(onConnectionChange); - // Listen for user login/logout changes and refresh CarPlay template _userSubscription = providerRef.listen(FinampUserHelper.finampCurrentUserProvider, (previous, next) { _carPlayLogger.info("User state changed, refreshing CarPlay template"); setCarplayRootTemplate(); + _updateNowPlayingButtons(); + }); + + // Keep the Now Playing buttons in sync with the current track (also + // re-subscribes the favourite-status listener below to the new track). + // Subscribed to the queue's own current-track stream rather than + // audioHandler.mediaItem, since that emits on metadata-only updates + // (e.g. artwork loading) and never emits once the queue empties. + _currentTrackSubscription = _queueService.getCurrentTrackStream().listen((track) { + final trackId = track?.baseItem.id; + if (trackId == _nowPlayingButtonsTrackId) return; + _nowPlayingButtonsTrackId = trackId; + _subscribeToCurrentTrackFavorite(); + _updateNowPlayingButtons(); + }); + _subscribeToCurrentTrackFavorite(); + _updateNowPlayingButtons(); + + // Favourite/start-mix are unavailable offline, so refresh the buttons + // whenever offline mode is toggled. + _offlineSubscription = providerRef.listen(finampSettingsProvider.isOffline, (previous, next) { + _updateNowPlayingButtons(); + }); + + // Keep the shuffle button's icon state in sync with the queue's playback + // order. The state is also synced on CarPlay connect. + _playbackOrderSubscription = _queueService.getPlaybackOrderStream().listen((order) { + FlutterCarplay.updateNowPlayingShuffleState(isShuffled: order == FinampPlaybackOrder.shuffled); }); // Defer initial template setup until after the first frame is rendered. @@ -93,12 +127,25 @@ class CarPlayHelper { void disposeCarplay() { _userSubscription?.close(); _closeTemplateSubscriptions(); + _favoriteSubscription?.close(); + _offlineSubscription?.close(); + _currentTrackSubscription?.cancel(); + _playbackOrderSubscription?.cancel(); _flutterCarplay.removeListenerOnConnectionChange(); } void onConnectionChange(ConnectionStatusTypes status) { connectionStatus = status; if (status == ConnectionStatusTypes.connected) { + // The Now Playing template is a system-owned singleton that can be + // presented unprompted on connect, so make sure its buttons and + // shuffle state are configured immediately rather than waiting for the + // next track/order change. + _updateNowPlayingButtons(); + FlutterCarplay.updateNowPlayingShuffleState( + isShuffled: _queueService.playbackOrder == FinampPlaybackOrder.shuffled, + ); + // Resume playback if there's a loaded queue that's paused final audioHandler = GetIt.instance(); if (_queueService.getCurrentTrack() != null && audioHandler.paused && isUserLoggedIn) { @@ -113,6 +160,69 @@ class CarPlayHelper { } } + /// (Re-)subscribes to favourite-status changes for the current track so the + /// Now Playing heart button stays in sync when the track is favourited or + /// unfavourited (from the phone UI, another button press, etc). + void _subscribeToCurrentTrackFavorite() { + _favoriteSubscription?.close(); + final currentTrack = _queueService.getCurrentTrack()?.baseItem; + if (currentTrack == null) { + _favoriteSubscription = null; + return; + } + _favoriteSubscription = providerRef.listen(isFavoriteProvider(currentTrack), (previous, next) { + _updateNowPlayingButtons(); + }); + } + + /// Builds and sends the CarPlay Now Playing screen buttons: shuffle + /// toggle, favourite, and start instant mix (leading to trailing). Shows + /// no buttons when logged out and hides favourite/mix when there is no + /// current track or while offline. + Future _updateNowPlayingButtons() async { + if (!isUserLoggedIn) { + await FlutterCarplay.setNowPlayingButtons([]); + return; + } + + final currentTrack = _queueService.getCurrentTrack()?.baseItem; + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final buttons = [CPNowPlayingShuffleButton(onPress: () => _queueService.togglePlaybackOrder())]; + + if (currentTrack != null && !isOffline) { + final isFavorite = providerRef.read(isFavoriteProvider(currentTrack)); + buttons.add( + CPNowPlayingImageButton( + image: isFavorite ? 'sfsymbol:heart.fill' : 'sfsymbol:heart', + onPress: () => GetIt.instance().toggleFavoriteStatusOfCurrentTrack(), + ), + ); + + buttons.add( + CPNowPlayingImageButton( + image: 'sfsymbol:radio', + onPress: () async { + // Read the track at press time. The plugin keeps earlier + // callbacks alive when a button update is skipped as redundant. + final track = _queueService.getCurrentTrack()?.baseItem; + if (track == null) return; + try { + _carPlayLogger.info("Mix button pressed, starting an instant mix from '${track.name}'"); + FinampSetters.setRadioMode(RadioMode.similar); + await radio.startRadioPlayback(track); + } catch (e) { + _carPlayLogger.severe("Starting instant mix failed: $e"); + GlobalSnackbar.error(e); + } + }, + ), + ); + } + + await FlutterCarplay.setNowPlayingButtons(buttons); + } + List _groupItemsIntoSections( List items, CPListItem Function(BaseItemDto item, int index) itemBuilder, @@ -298,6 +408,7 @@ class CarPlayHelper { List sections = []; CPListSection quickActionsSection = CPListSection( + sectionIndexEnabled: false, items: [ CPListItem( text: GlobalSnackbar.requireL10n.shuffleAll, @@ -329,7 +440,11 @@ class CarPlayHelper { ]); if (recentPlays.isNotEmpty) { - CPListSection recentPlaysSection = CPListSection(header: GlobalSnackbar.requireL10n.recentlyPlayed, items: []); + CPListSection recentPlaysSection = CPListSection( + header: GlobalSnackbar.requireL10n.recentlyPlayed, + sectionIndexEnabled: false, + items: [], + ); for (final baseItem in recentPlays) { recentPlaysSection.items.add( @@ -370,7 +485,11 @@ class CarPlayHelper { _carPlayLogger.info("Got ${recentlyAdded.length} recently added albums"); if (recentlyAdded.isNotEmpty) { - CPListSection recentlyAddedSection = CPListSection(header: GlobalSnackbar.requireL10n.recentlyAdded, items: []); + CPListSection recentlyAddedSection = CPListSection( + header: GlobalSnackbar.requireL10n.recentlyAdded, + sectionIndexEnabled: false, + items: [], + ); for (final album in recentlyAdded) { recentlyAddedSection.items.add( @@ -455,7 +574,6 @@ class CarPlayHelper { emptyViewTitleVariants: [GlobalSnackbar.requireL10n.home], emptyViewSubtitleVariants: [GlobalSnackbar.requireL10n.notAvailable], systemIcon: 'music.note.house', - sectionIndexEnabled: false, ), CPListTemplate( sections: [], diff --git a/pubspec.lock b/pubspec.lock index 10f4de322..e2633c417 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -603,11 +603,11 @@ packages: dependency: "direct main" description: path: "." - ref: "017ce2e" - resolved-ref: "017ce2e19711d3e043ed86ade25f941f96ebb7ba" - url: "https://github.com/oguzhnatly/flutter_carplay.git" + ref: "cf997048d5b51fbb60bf68bc53538e01a2378f55" + resolved-ref: "cf997048d5b51fbb60bf68bc53538e01a2378f55" + url: "https://github.com/finamp-app/flutter_carplay.git" source: git - version: "1.2.11" + version: "1.6.3" flutter_discord_rpc: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1ddeea928..2283a32c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -154,8 +154,8 @@ dependencies: bits: ^1.4.0 flutter_carplay: git: - url: https://github.com/oguzhnatly/flutter_carplay.git - ref: 017ce2e + url: https://github.com/finamp-app/flutter_carplay.git + ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 diacritic: ^0.1.6 mini_music_visualizer: ^1.1.4 From 2b2818f371fc7a175fafde214fb5e6ebc079c96a Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Mon, 6 Jul 2026 20:28:55 +1000 Subject: [PATCH 02/12] Add recent queues to carplay homescreen - Fetch through the same home screen path as the main UI - Show a collage of covers from each queue's next albums, falling back to the current track's artwork then a placeholder so tap indices stay aligned - Hide the row when there is no queue history --- lib/screens/queue_restore_screen.dart | 10 +- lib/services/carplay_helper.dart | 409 +++++++++++++++++++++++++- lib/services/queue_service.dart | 10 + 3 files changed, 411 insertions(+), 18 deletions(-) diff --git a/lib/screens/queue_restore_screen.dart b/lib/screens/queue_restore_screen.dart index 3d30ec607..776359854 100644 --- a/lib/screens/queue_restore_screen.dart +++ b/lib/screens/queue_restore_screen.dart @@ -1,10 +1,10 @@ import 'package:finamp/components/finamp_app_bar_back_button.dart'; import 'package:finamp/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:hive_ce/hive.dart'; +import 'package:get_it/get_it.dart'; import '../components/QueueRestoreScreen/queue_restore_tile.dart'; -import '../models/finamp_models.dart'; +import '../services/queue_service.dart'; class QueueRestoreScreen extends StatelessWidget { const QueueRestoreScreen({super.key}); @@ -13,11 +13,7 @@ class QueueRestoreScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final queuesBox = Hive.box("Queues"); - var queueMap = queuesBox.toMap(); - queueMap.remove("latest"); - var queueList = queueMap.values.toList(); - queueList.sort((x, y) => y.creation - x.creation); + final queueList = GetIt.instance().getRecentQueueHistory(); return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.queuesScreen), leading: FinampAppBarBackButton()), diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index a77c5b045..b87213908 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -1,5 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:finamp/components/MusicScreen/sort_and_filter_row.dart'; import 'package:finamp/components/global_snackbar.dart'; @@ -8,6 +11,7 @@ import 'package:finamp/services/album_image_provider.dart'; import 'package:finamp/services/music_player_background_task.dart'; import 'package:finamp/services/music_providers.dart'; import 'package:finamp/services/music_screen_provider.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_carplay/flutter_carplay.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; @@ -16,7 +20,10 @@ import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; +import 'package:hive_ce/hive.dart'; import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path_helper; +import 'package:path_provider/path_provider.dart'; import 'favorite_provider.dart'; import 'finamp_settings_helper.dart'; @@ -25,6 +32,7 @@ import 'audio_service_helper.dart'; import 'queue_service.dart'; import 'item_helper.dart'; import 'radio_service_helper.dart' as radio; +import 'item_by_id_provider.dart'; final _carPlayLogger = Logger("CarPlay"); @@ -46,6 +54,25 @@ const _carPlayRecentlyAddedLimit = 3; /// Tracks shown in the CarPlay home Recently Played row. const _carPlayRecentlyPlayedLimit = 5; +/// Maximum number of queues to show in the CarPlay home "Recent Queues" art +/// row, before clamping to the plugin's runtime grid-image limit. +const _maxRecentQueues = 6; + +/// Placeholder image for a CarPlay art-row entry whose artwork couldn't be +/// resolved, so the row keeps one image per entry and indices stay aligned +/// with the underlying list. +const _carPlayFallbackImage = 'sfsymbol:music.note.list'; + +/// Number of distinct albums composed into a Recent Queues collage cover, +/// and the side length in pixels of each tile within it. +const _collageTileCount = 4; +const _collageTileSize = 100; + +/// Maximum number of upcoming tracks to resolve while hunting for +/// [_collageTileCount] distinct albums for a queue's collage cover, so a +/// huge queue doesn't spam the server with lookups. +const _maxCollageTrackScan = 20; + class CarPlayHelper { ConnectionStatusTypes connectionStatus = ConnectionStatusTypes.unknown; final FlutterCarplay _flutterCarplay = FlutterCarplay(); @@ -59,6 +86,11 @@ class CarPlayHelper { ProviderSubscription? _offlineSubscription; StreamSubscription? _currentTrackSubscription; StreamSubscription? _playbackOrderSubscription; + StreamSubscription? _queueHistorySubscription; + Timer? _homeRefreshTimer; + CPListTemplate? _homeTemplate; + bool _isSettingRootTemplate = false; + int _recentQueueImageFillRun = 0; BaseItemId? _nowPlayingButtonsTrackId; bool get isUserLoggedIn => _finampUserHelper.currentUser != null; @@ -117,6 +149,19 @@ class CarPlayHelper { FlutterCarplay.updateNowPlayingShuffleState(isShuffled: order == FinampPlaybackOrder.shuffled); }); + // Rebuild the home tab when a queue is archived into history so the + // Recent Queues row appears without reopening the app. The live queue + // saves constantly under the "latest" key, so only react to other keys. + _queueHistorySubscription = Hive.box("Queues").watch().listen((event) { + if (event.key == "latest") { + return; + } + _homeRefreshTimer?.cancel(); + _homeRefreshTimer = Timer(const Duration(seconds: 2), () { + _refreshHomeSections(); + }); + }); + // Defer initial template setup until after the first frame is rendered. // This ensures GlobalSnackbar's context is available for localization. SchedulerBinding.instance.addPostFrameCallback((_) { @@ -131,6 +176,8 @@ class CarPlayHelper { _offlineSubscription?.close(); _currentTrackSubscription?.cancel(); _playbackOrderSubscription?.cancel(); + _queueHistorySubscription?.cancel(); + _homeRefreshTimer?.cancel(); _flutterCarplay.removeListenerOnConnectionChange(); } @@ -404,6 +451,273 @@ class CarPlayHelper { return _loadPagedItems(displayable as FinampPagedPlayable, limit); } + /// Fetches Recent Queues through the same provider path as the main UI home screen. + Future> _loadRecentQueueHistory() async { + final section = HomeScreenSectionConfiguration.fromPreset(HomeScreenSectionPresetType.recentQueues); + final displayable = await providerRef.read(resolveSectionProvider(section).future); + final children = await providerRef.read(getChildrenProvider(item: displayable as LatestQueues).future); + return children.map((child) => (child as PlayableQueue).queue).toList(); + } + + /// Resolves the art-row image for a saved queue: a 2x2 collage of covers + /// from the next [_collageTileCount] distinct albums coming up in the + /// queue, falling back to the current track's own artwork, then to a + /// placeholder icon, so a missing track or missing artwork doesn't shift + /// indices out of alignment with the queue list. + Future _getRecentQueueImage(FinampStorableQueueInfo info) async { + try { + final collage = await _buildRecentQueueCollage(info); + if (collage != null) { + return collage; + } + } catch (e) { + _carPlayLogger.warning("Failed to build collage for recent queue: $e"); + } + return _getRecentQueueCoverImage(info); + } + + /// Resolves the current track's own artwork for a saved queue, falling + /// back to a placeholder icon. Used when a collage can't be built. + Future _getRecentQueueCoverImage(FinampStorableQueueInfo info) async { + final currentTrackId = info.currentTrack; + if (currentTrackId == null) { + return _carPlayFallbackImage; + } + try { + final track = await providerRef.read(itemByIdProvider(currentTrackId).future); + if (track == null) { + return _carPlayFallbackImage; + } + return _getCarPlayImageUri(track) ?? _carPlayFallbackImage; + } catch (e) { + _carPlayLogger.warning("Failed to resolve artwork for recent queue: $e"); + return _carPlayFallbackImage; + } + } + + /// Finds up to [_collageTileCount] distinct albums among the tracks + /// coming up in [info] (current track, then queue), resolving each + /// candidate's cover as it's found so a single failed cover doesn't sink + /// the whole collage, then composes the resolved covers into a PNG cached + /// under the temp directory and returns a `file://` URI. Returns null if + /// no cover resolves at all. + Future _buildRecentQueueCollage(FinampStorableQueueInfo info) async { + // Prefer albums still coming up, then pad with the most recently played + // ones so a queue archived near its end can still fill the collage. + final upcomingIds = [ + if (info.currentTrack != null) info.currentTrack!, + ...info.nextUp, + ...info.queue, + ...info.previousTracks.reversed, + ]; + + final albumImages = []; + final usedAlbumIds = []; + final seenAlbumIds = {}; + var scanned = 0; + for (final id in upcomingIds) { + if (albumImages.length >= _collageTileCount || scanned >= _maxCollageTrackScan) { + break; + } + scanned++; + final track = await providerRef.read(itemByIdProvider(id).future); + final albumId = track?.albumId?.raw; + if (albumId == null || !seenAlbumIds.add(albumId)) { + continue; + } + final image = await _resolveCollageTileImage(track!); + if (image == null) { + // Cover failed to resolve or decode. Keep scanning for a + // replacement instead of failing the whole collage. + continue; + } + albumImages.add(image); + usedAlbumIds.add(albumId); + } + + if (albumImages.isEmpty) { + return null; + } + + // Anything short of a full 2x2 grid falls back to the best single + // cover scaled across the whole canvas, so every tile in the Recent + // Queues row stays the same size. + final tiles = albumImages.length == _collageTileCount ? albumImages : [albumImages.first]; + final tileIdsKey = albumImages.length == _collageTileCount ? usedAlbumIds : [usedAlbumIds.first]; + + final cacheFile = File( + path_helper.join( + (await getTemporaryDirectory()).path, + 'carplay_queue_collage_${info.creation}_${tileIdsKey.join(',').hashCode}.png', + ), + ); + if (await cacheFile.exists()) { + return Uri.file(cacheFile.path).toString(); + } + + final bytes = await _composeCollage(tiles); + if (bytes == null) { + return null; + } + await cacheFile.writeAsBytes(bytes, flush: true); + return Uri.file(cacheFile.path).toString(); + } + + /// Resolves a track's album cover as a decoded [ui.Image] via + /// [albumImageProvider], reusing Finamp's image cache and auth. Returns + /// null if the artwork can't be resolved or decoded. + Future _resolveCollageTileImage(BaseItemDto track) async { + final imageProvider = providerRef + .read( + albumImageProvider(AlbumImageRequest(item: track, maxWidth: _collageTileSize, maxHeight: _collageTileSize)), + ) + .image; + if (imageProvider == null) { + return null; + } + + final completer = Completer(); + final stream = imageProvider.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + listener = ImageStreamListener( + (image, synchronousCall) { + stream.removeListener(listener); + completer.complete(image.image); + }, + onError: (error, stackTrace) { + stream.removeListener(listener); + completer.complete(null); + }, + ); + stream.addListener(listener); + return completer.future; + } + + /// Composes [images] into a square collage PNG the same size regardless + /// of tile count, returning the encoded bytes, or null if encoding fails. + /// A single image fills the whole canvas. [_collageTileCount] images are + /// drawn as 2x2 quadrants. + Future _composeCollage(List images) async { + final tileSize = _collageTileSize.toDouble(); + final collageSize = tileSize * 2; + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, collageSize, collageSize)); + if (images.length == 1) { + final image = images.first; + canvas.drawImageRect( + image, + ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + ui.Rect.fromLTWH(0, 0, collageSize, collageSize), + ui.Paint(), + ); + } else { + for (var i = 0; i < images.length; i++) { + final image = images[i]; + final dx = (i % 2) * tileSize; + final dy = (i ~/ 2) * tileSize; + canvas.drawImageRect( + image, + ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + ui.Rect.fromLTWH(dx, dy, tileSize, tileSize), + ui.Paint(), + ); + } + } + final picture = recorder.endRecording(); + final collageImage = await picture.toImage(collageSize.round(), collageSize.round()); + final byteData = await collageImage.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } + + /// Archives the live queue, restores [info] at its saved track and seek + /// position, then shows CarPlay's Now Playing screen. Shared by the + /// Recent Queues art row's per-image tap and its pushed full-history list. + Future _resumeSavedQueue(FinampStorableQueueInfo info) async { + // The cold-launch startup restore commonly hasn't settled yet, which + // would otherwise error as "already loading". Its own failure is + // unrelated to this queue, so ignore it. + try { + await _queueService.performInitialQueueLoad(); + } catch (_) {} + _queueService.archiveSavedQueue(); + await _queueService.loadSavedQueue(info); + await FlutterCarplay.showSharedNowPlaying(); + } + + /// Pushes the full saved-queue history as a scrollable list, so tapping + /// the Recent Queues art row itself (CarPlay always renders a '>' chevron + /// on an image row) leads to more than the handful shown as art. + Future _showRecentQueuesTemplate(List queueHistory) async { + if (_isPushingPageUpdate) { + _carPlayLogger.warning("Navigation dropped: already pushing page update"); + return; + } + _isPushingPageUpdate = true; + try { + final l10n = GlobalSnackbar.requireL10n; + final items = List.generate(queueHistory.length, (index) { + final info = queueHistory[index]; + final remaining = info.trackCount - info.previousTracks.length; + return CPListItem( + text: info.source.name.getLocalized(l10n), + detailText: l10n.queueRestoreSubtitle2(info.trackCount, remaining), + image: _carPlayFallbackImage, + onPress: (complete, self) async { + try { + await _resumeSavedQueue(info); + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + ); + }); + + await FlutterCarplay.push( + template: CPListTemplate( + sections: [CPListSection(items: items)], + title: l10n.recentQueues, + systemIcon: 'clock.arrow.circlepath', + ), + ); + unawaited(_fillRecentQueueImages(queueHistory, items)); + } finally { + _isPushingPageUpdate = false; + } + } + + /// Streams the pushed Recent Queues list's collage covers in one queue at + /// a time via [CPListItem.setImage], so the list opens instantly and + /// building covers never blocks CarPlay navigation. A newer run abandons + /// any older one still going. + Future _fillRecentQueueImages(List queueHistory, List items) async { + final run = ++_recentQueueImageFillRun; + try { + for (var i = 0; i < items.length; i++) { + final image = await _getRecentQueueImage(queueHistory[i]); + if (run != _recentQueueImageFillRun) { + return; + } + if (image != _carPlayFallbackImage) { + items[i].setImage(image); + } + } + } catch (e) { + _carPlayLogger.warning("Failed to fill recent queue covers: $e"); + } + } + + /// Clamps [desired] to the CarPlay image row's runtime grid-image limit + /// when the plugin reports one smaller than [desired]. + Future _clampToGridImageLimit(int desired) async { + final maxGridImages = await CPListImageRowItem.getMaximumNumberOfGridImages(); + if (maxGridImages != null && maxGridImages < desired) { + return maxGridImages; + } + return desired; + } + Future> _buildHomeSections() async { List sections = []; @@ -483,6 +797,45 @@ class CarPlayHelper { } } + final recentQueueHistory = await _loadRecentQueueHistory(); + if (recentQueueHistory.isNotEmpty) { + final queueLimit = await _clampToGridImageLimit(_maxRecentQueues); + final recentQueues = recentQueueHistory.take(queueLimit).toList(); + + final queueImages = await Future.wait(recentQueues.map(_getRecentQueueImage)); + + sections.add( + CPListSection( + items: [ + CPListImageRowItem( + text: GlobalSnackbar.requireL10n.recentQueues, + gridImages: queueImages, + onPress: (complete, self) async { + try { + await _showRecentQueuesTemplate(recentQueueHistory); + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + onItemPress: (complete, self, index) async { + try { + if (index != null && index >= 0 && index < recentQueues.length) { + await _resumeSavedQueue(recentQueues[index]); + } + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + ), + ], + ), + ); + } + _carPlayLogger.info("Got ${recentlyAdded.length} recently added albums"); if (recentlyAdded.isNotEmpty) { CPListSection recentlyAddedSection = CPListSection( @@ -512,11 +865,25 @@ class CarPlayHelper { } Future setCarplayRootTemplate() async { - // A root rebuild discards the navigation stack, so release its paged - // requests and clear any push guard left set by an abandoned load. - _closeTemplateSubscriptions(); - _isPushingPageUpdate = false; + // Replacing the root template resets CarPlay navigation, so drop a + // rebuild that overlaps one already running. + if (_isSettingRootTemplate) { + _carPlayLogger.info("Root template rebuild dropped: already in progress"); + return; + } + _isSettingRootTemplate = true; + try { + // A root rebuild discards the navigation stack, so release its paged + // requests and clear any push guard left set by an abandoned load. + _closeTemplateSubscriptions(); + _isPushingPageUpdate = false; + await _setCarplayRootTemplate(); + } finally { + _isSettingRootTemplate = false; + } + } + Future _setCarplayRootTemplate() async { // Check if user is logged in first if (!isUserLoggedIn) { _carPlayLogger.info("User not logged in, showing login prompt on CarPlay"); @@ -565,16 +932,19 @@ class CarPlayHelper { ); } + final homeTemplate = CPListTemplate( + sections: homeSections, + title: GlobalSnackbar.requireL10n.home, + emptyViewTitleVariants: [GlobalSnackbar.requireL10n.home], + emptyViewSubtitleVariants: [GlobalSnackbar.requireL10n.notAvailable], + systemIcon: 'music.note.house', + ); + _homeTemplate = homeTemplate; + await FlutterCarplay.setRootTemplate( rootTemplate: CPTabBarTemplate( templates: [ - CPListTemplate( - sections: homeSections, - title: GlobalSnackbar.requireL10n.home, - emptyViewTitleVariants: [GlobalSnackbar.requireL10n.home], - emptyViewSubtitleVariants: [GlobalSnackbar.requireL10n.notAvailable], - systemIcon: 'music.note.house', - ), + homeTemplate, CPListTemplate( sections: [], title: GlobalSnackbar.requireL10n.search, @@ -596,6 +966,23 @@ class CarPlayHelper { await _flutterCarplay.forceUpdateRootTemplate(); } + /// Rebuilds the home tab's sections in place. Setting a new root template + /// tears down CarPlay's navigation stack and dismisses the Now Playing + /// screen, so avoid it once the root exists. + Future _refreshHomeSections() async { + final homeTemplate = _homeTemplate; + if (homeTemplate == null) { + await setCarplayRootTemplate(); + return; + } + try { + final sections = await _buildHomeSections(); + await _flutterCarplay.updateListTemplateSections(elementId: homeTemplate.uniqueId, sections: sections); + } catch (e) { + _carPlayLogger.warning("Failed to refresh CarPlay home sections: $e"); + } + } + /// Shows a template prompting the user to log in via the Finamp app Future _showLoginRequiredTemplate() async { await FlutterCarplay.setRootTemplate( diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 353c445d7..2806ac75d 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -382,6 +382,16 @@ class QueueService { return info; } + /// Returns the saved queue history (excluding the live "latest" queue), + /// newest first. + List getRecentQueueHistory() { + final queueMap = _queuesBox.toMap(); + queueMap.remove("latest"); + final queueList = queueMap.values.toList(); + queueList.sort((x, y) => y.creation - x.creation); + return queueList; + } + Future performInitialQueueLoad() async { if (_savedQueueState == SavedQueueState.preInit) { try { From 09d3c41e0cbfef59f9a3cc3ae1ad80e579a3148b Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Mon, 6 Jul 2026 20:31:37 +1000 Subject: [PATCH 03/12] Convert CarPlay Recently Added to an art row Replace the Recently Added list rows with a single art row of album covers, matching the Recent Queues presentation. The fetch limit rises from 3 to 6 albums to fill the wider row, clamped to the runtime image limit. --- lib/services/carplay_helper.dart | 98 ++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index b87213908..03eee203f 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -48,8 +48,8 @@ const _carPlayOfflineLimit = 1000; /// and transfers much faster than 200x200. const _carPlayImageSize = 100; -/// Albums shown in the CarPlay home Recently Added row. -const _carPlayRecentlyAddedLimit = 3; +/// Albums shown in the CarPlay home Recently Added art row. +const _carPlayRecentlyAddedLimit = 6; /// Tracks shown in the CarPlay home Recently Played row. const _carPlayRecentlyPlayedLimit = 5; @@ -459,6 +459,42 @@ class CarPlayHelper { return children.map((child) => (child as PlayableQueue).queue).toList(); } + /// Pushes the full recently-added albums list, so tapping the Recently + /// Added art row itself leads to more than the handful shown as art. + Future _showRecentlyAddedTemplate() async { + if (_isPushingPageUpdate) { + _carPlayLogger.warning("Navigation dropped: already pushing page update"); + return; + } + _isPushingPageUpdate = true; + try { + final albums = await _loadHomeSectionItems(HomeScreenSectionPresetType.recentlyAddedAlbums, 24); + final section = CPListSection( + items: albums.map((album) { + return CPListItem( + text: album.name ?? GlobalSnackbar.requireL10n.unknownName, + detailText: album.albumArtist, + image: _getCarPlayImageUri(album), + onPress: (complete, self) async { + await showCollectionTracksTemplate(album); + complete(); + }, + ); + }).toList(), + ); + + await FlutterCarplay.push( + template: CPListTemplate( + sections: [section], + title: GlobalSnackbar.requireL10n.recentlyAdded, + systemIcon: 'clock.arrow.circlepath', + ), + ); + } finally { + _isPushingPageUpdate = false; + } + } + /// Resolves the art-row image for a saved queue: a 2x2 collage of covers /// from the next [_collageTileCount] distinct albums coming up in the /// queue, falling back to the current track's own artwork, then to a @@ -748,7 +784,7 @@ class CarPlayHelper { ); sections.add(quickActionsSection); - final [recentPlays, recentlyAdded] = await Future.wait([ + final [recentPlays, recentlyAddedFetched] = await Future.wait([ _loadHomeSectionItems(HomeScreenSectionPresetType.recentlyPlayedTracks, _carPlayRecentlyPlayedLimit), _loadHomeSectionItems(HomeScreenSectionPresetType.recentlyAddedAlbums, _carPlayRecentlyAddedLimit), ]); @@ -836,29 +872,41 @@ class CarPlayHelper { ); } - _carPlayLogger.info("Got ${recentlyAdded.length} recently added albums"); - if (recentlyAdded.isNotEmpty) { - CPListSection recentlyAddedSection = CPListSection( - header: GlobalSnackbar.requireL10n.recentlyAdded, - sectionIndexEnabled: false, - items: [], - ); + _carPlayLogger.info("Got ${recentlyAddedFetched.length} recently added albums"); + if (recentlyAddedFetched.isNotEmpty) { + final recentlyAddedLimit = await _clampToGridImageLimit(recentlyAddedFetched.length); + final recentlyAdded = recentlyAddedFetched.take(recentlyAddedLimit).toList(); - for (final album in recentlyAdded) { - recentlyAddedSection.items.add( - CPListItem( - text: album.name ?? GlobalSnackbar.requireL10n.unknownName, - detailText: album.albumArtist, - image: _getCarPlayImageUri(album), - onPress: (complete, self) async { - await showCollectionTracksTemplate(album); - complete(); - }, - ), - ); - } - - sections.add(recentlyAddedSection); + sections.add( + CPListSection( + items: [ + CPListImageRowItem( + text: GlobalSnackbar.requireL10n.recentlyAdded, + gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? _carPlayFallbackImage).toList(), + onPress: (complete, self) async { + try { + await _showRecentlyAddedTemplate(); + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + onItemPress: (complete, self, index) async { + try { + if (index != null && index >= 0 && index < recentlyAdded.length) { + await showCollectionTracksTemplate(recentlyAdded[index]); + } + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + ), + ], + ), + ); } return sections; From 9dd05ca8e3e94122498cf2e08255e1c7a3f052f3 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Mon, 6 Jul 2026 21:59:28 +1000 Subject: [PATCH 04/12] Fix Finamp losing last-played status on CarPlay Finamp would lose last-playing status to other applications such as Apple Podcasts even when it was the last playing application. Set that it was last playing in more spots and handle play requests earlier in app lifecycle. --- lib/main.dart | 7 ++ .../music_player_background_task.dart | 42 +++++++++++ lib/services/queue_service.dart | 71 +++++++++++++------ pubspec.lock | 20 +++--- pubspec.yaml | 15 ++++ 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 1a4c06390..344b0fd9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -446,6 +446,13 @@ Future _setupPlaybackServices() async { // notificationColor: TODO use the theme color for older versions of Android, // We will handle preloading artwork ourselves preloadArtwork: false, + // Keep iOS now playing info + remote command handlers registered when + // the service stops, so Finamp stays the system's resume candidate for + // CarPlay/Bluetooth reconnects instead of losing now-playing status to + // whichever app had it before. Requires the audio_service fork + // overrides in pubspec.yaml (the flag spans audio_service and + // audio_service_platform_interface). + iosKeepNowPlayingOnStop: true, androidBrowsableRootExtras: { // support showing search button on Android Auto as well as alternative search results on the player screen after voice search "android.media.browse.SEARCH_SUPPORTED": true, diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 5eb6d9ffe..3a320c8eb 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -616,6 +616,48 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler with SeekHandler, Queue _audioServiceBackgroundTaskLogger.info( "play() start: disableFade=$disableFade, playing=${_player.playing}, fadeDirection=${fadeState.value.fadeDirection}, currentIndex=${_player.currentIndex}, position=${_player.position}", ); + if (GetIt.instance().getCurrentTrack() == null) { + // A remote play command can arrive before a queue has been loaded in + // this process, such as CarPlay background-launching Finamp on + // reconnect. Await the memoised startup restore rather than dropping + // the command. + final queueService = GetIt.instance(); + _audioServiceBackgroundTaskLogger.info( + "play() received with no current item; awaiting saved-queue restore before starting playback", + ); + try { + await queueService.performInitialQueueLoad(); + } catch (e) { + _audioServiceBackgroundTaskLogger.warning("Saved-queue restore failed while handling remote play command: $e"); + } + + if (queueService.getCurrentTrack() == null && queueService.savedQueueState == SavedQueueState.pendingSave) { + // Nothing was restored because autoloadLastQueueOnStartup is disabled. + // An explicit play command still expresses intent to resume. + _audioServiceBackgroundTaskLogger.info( + "No auto-loaded queue; loading the latest saved queue on demand for remote play command", + ); + try { + await queueService.loadLatestSavedQueueOnDemand(); + } catch (e) { + _audioServiceBackgroundTaskLogger.warning("On-demand saved-queue load failed: $e"); + } + } + + if (queueService.getCurrentTrack() == null) { + // _replaceWholeQueue nulls out the current track for the duration of + // a queue rebuild, so a load that's still settling shouldn't be + // treated the same as there being no saved queue at all. + final queueLoadSettling = queueService.savedQueueState == SavedQueueState.loading || audioSources.isNotEmpty; + if (!queueLoadSettling) { + _audioServiceBackgroundTaskLogger.info("No saved queue available to resume; ignoring play() command"); + return; + } + _audioServiceBackgroundTaskLogger.info("Queue load in progress; playing despite no current track yet"); + } else { + _audioServiceBackgroundTaskLogger.info("Saved queue restored; resuming playback at its saved position"); + } + } if (_shouldIgnorePlayPauseAfterRecentSkip) { return; } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 2806ac75d..bcecf5068 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -98,6 +98,10 @@ class QueueService { FinampStorableQueueInfo? _failedSavedQueue; static const int _maxSavedQueues = 60; + /// Memoised [Future] for [performInitialQueueLoad] so every caller awaits + /// the same restore. + Future? _initialQueueLoadFuture; + static int get maxInitialQueueItems => Platform.isIOS || Platform.isMacOS ? 1000 : Platform.isAndroid @@ -392,32 +396,51 @@ class QueueService { return queueList; } - Future performInitialQueueLoad() async { - if (_savedQueueState == SavedQueueState.preInit) { - try { - _savedQueueState = SavedQueueState.init; - archiveSavedQueue(inInit: true); - var info = _queuesBox.get("latest"); - if (info != null) { - var keys = _queuesBox.values.map((x) => DateTime.fromMillisecondsSinceEpoch(x.creation)).toList(); - keys.sort(); - _queueServiceLogger.finest("Stored queue dates: $keys"); - if (keys.length > _maxSavedQueues) { - var extra = keys.getRange(0, keys.length - _maxSavedQueues).map((e) => e.millisecondsSinceEpoch.toString()); - _queueServiceLogger.finest("Deleting stored queues: $extra"); - unawaited(_queuesBox.deleteAll(extra)); - } + /// Performs the one-time startup queue restore, loading the last "latest" + /// queue into the player, paused, per [FinampSettings.autoloadLastQueueOnStartup]. + /// Every caller awaits the same [Future]. + Future performInitialQueueLoad() { + return _initialQueueLoadFuture ??= _performInitialQueueLoad(); + } - if (FinampSettingsHelper.finampSettings.autoloadLastQueueOnStartup && !await _hasInitialPlayLink()) { - await loadSavedQueue(info); - } else { - _savedQueueState = SavedQueueState.pendingSave; - } + Future _performInitialQueueLoad() async { + try { + _savedQueueState = SavedQueueState.init; + archiveSavedQueue(inInit: true); + var info = _queuesBox.get("latest"); + if (info != null) { + var keys = _queuesBox.values.map((x) => DateTime.fromMillisecondsSinceEpoch(x.creation)).toList(); + keys.sort(); + _queueServiceLogger.finest("Stored queue dates: $keys"); + if (keys.length > _maxSavedQueues) { + var extra = keys.getRange(0, keys.length - _maxSavedQueues).map((e) => e.millisecondsSinceEpoch.toString()); + _queueServiceLogger.finest("Deleting stored queues: $extra"); + unawaited(_queuesBox.deleteAll(extra)); + } + + if (FinampSettingsHelper.finampSettings.autoloadLastQueueOnStartup && !await _hasInitialPlayLink()) { + await loadSavedQueue(info); + } else { + _savedQueueState = SavedQueueState.pendingSave; } - } catch (e) { - _queueServiceLogger.severe(e); - rethrow; } + } catch (e) { + _queueServiceLogger.severe(e); + // Don't memoise a failed restore, so a later caller (e.g. a remote + // play command) can retry it instead of being stuck forever. + _initialQueueLoadFuture = null; + rethrow; + } + } + + /// Loads the latest saved queue on demand, for callers where + /// [performInitialQueueLoad] skipped loading it (e.g. + /// [FinampSettings.autoloadLastQueueOnStartup] disabled) but an explicit + /// play command expresses intent to resume anyway. + Future loadLatestSavedQueueOnDemand() async { + var info = _queuesBox.get("latest"); + if (info != null) { + await loadSavedQueue(info); } } @@ -1284,6 +1307,8 @@ class QueueService { return _currentTrack; } + SavedQueueState get savedQueueState => _savedQueueState; + set playbackSpeed(double speed) { _playbackSpeed = speed; _playbackSpeedStream.add(speed); diff --git a/pubspec.lock b/pubspec.lock index e2633c417..c2ecc4bd7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -92,11 +92,12 @@ packages: audio_service: dependency: "direct main" description: - name: audio_service - sha256: "95f3267f3449eb5cf71c8fcf1d556f57af1e898e2dc5815fb168d1843653edb7" - url: "https://pub.dev" - source: hosted - version: "0.18.19" + path: audio_service + ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + resolved-ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + url: "https://github.com/finamp-app/audio_service.git" + source: git + version: "0.18.18" audio_service_mpris: dependency: "direct main" description: @@ -108,10 +109,11 @@ packages: audio_service_platform_interface: dependency: "direct main" description: - name: audio_service_platform_interface - sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" - url: "https://pub.dev" - source: hosted + path: audio_service_platform_interface + ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + resolved-ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + url: "https://github.com/finamp-app/audio_service.git" + source: git version: "0.1.3" audio_service_web: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 2283a32c2..f90476bac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -193,6 +193,21 @@ dependency_overrides: url: https://github.com/Komodo5197/isar-community.git ref: a602cd8999048faba043f7e1ce7ba92f4d812762 path: packages/isar + # Fork adds eager iOS MPRemoteCommandCenter registration, an + # iosKeepNowPlayingOnStop config flag, and an iOS playing-state fix, so + # that Finamp stays the system's resume candidate for CarPlay/Bluetooth + # reconnects. The new flag is part of audio_service_platform_interface as + # well, so both packages must be overridden together or the build fails. + audio_service: + git: + url: https://github.com/finamp-app/audio_service.git + ref: 32216a99e8359e7cd21282c72f1986fb7f9a5a4d + path: audio_service + audio_service_platform_interface: + git: + url: https://github.com/finamp-app/audio_service.git + ref: 32216a99e8359e7cd21282c72f1986fb7f9a5a4d + path: audio_service_platform_interface # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec From 916f367f19705b154dda421034fcffa847123b1f Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Thu, 9 Jul 2026 21:14:37 +1000 Subject: [PATCH 05/12] Use Tabler icons for CarPlay Now Playing - Render icon font glyphs to cached PNGs so CarPlay buttons can show the same Tabler icons as the main UI - Use the main UI's radio, heart, and playback order glyphs for the mix, favourite, and shuffle buttons, with SF Symbols fallbacks - Replace the native shuffle button with an image button so the icon matches the main UI's linear and shuffled states --- lib/services/carplay_helper.dart | 80 +++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index 03eee203f..cecf022fe 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -13,7 +13,9 @@ import 'package:finamp/services/music_providers.dart'; import 'package:finamp/services/music_screen_provider.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart' show IconData; import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:audio_service/audio_service.dart'; import 'package:finamp/models/finamp_models.dart'; @@ -90,6 +92,7 @@ class CarPlayHelper { Timer? _homeRefreshTimer; CPListTemplate? _homeTemplate; bool _isSettingRootTemplate = false; + bool _isUpdatingNowPlayingButtons = false; int _recentQueueImageFillRun = 0; BaseItemId? _nowPlayingButtonsTrackId; @@ -143,10 +146,10 @@ class CarPlayHelper { _updateNowPlayingButtons(); }); - // Keep the shuffle button's icon state in sync with the queue's playback - // order. The state is also synced on CarPlay connect. + // Keep the shuffle button's glyph in sync with the queue's playback + // order. _playbackOrderSubscription = _queueService.getPlaybackOrderStream().listen((order) { - FlutterCarplay.updateNowPlayingShuffleState(isShuffled: order == FinampPlaybackOrder.shuffled); + _updateNowPlayingButtons(); }); // Rebuild the home tab when a queue is archived into history so the @@ -185,13 +188,9 @@ class CarPlayHelper { connectionStatus = status; if (status == ConnectionStatusTypes.connected) { // The Now Playing template is a system-owned singleton that can be - // presented unprompted on connect, so make sure its buttons and - // shuffle state are configured immediately rather than waiting for the - // next track/order change. + // presented unprompted on connect, so its buttons can't wait for the next + // track or order change. _updateNowPlayingButtons(); - FlutterCarplay.updateNowPlayingShuffleState( - isShuffled: _queueService.playbackOrder == FinampPlaybackOrder.shuffled, - ); // Resume playback if there's a loaded queue that's paused final audioHandler = GetIt.instance(); @@ -226,7 +225,21 @@ class CarPlayHelper { /// toggle, favourite, and start instant mix (leading to trailing). Shows /// no buttons when logged out and hides favourite/mix when there is no /// current track or while offline. + /// + /// Overlapping calls are ignored. Future _updateNowPlayingButtons() async { + if (_isUpdatingNowPlayingButtons) { + return; + } + _isUpdatingNowPlayingButtons = true; + try { + await _sendNowPlayingButtons(); + } finally { + _isUpdatingNowPlayingButtons = false; + } + } + + Future _sendNowPlayingButtons() async { if (!isUserLoggedIn) { await FlutterCarplay.setNowPlayingButtons([]); return; @@ -235,20 +248,30 @@ class CarPlayHelper { final currentTrack = _queueService.getCurrentTrack()?.baseItem; final isOffline = FinampSettingsHelper.finampSettings.isOffline; - final buttons = [CPNowPlayingShuffleButton(onPress: () => _queueService.togglePlaybackOrder())]; + final isShuffled = _queueService.playbackOrder == FinampPlaybackOrder.shuffled; + final shuffleIcon = + await _getIconFontImageUri(isShuffled ? TablerIcons.arrows_shuffle : TablerIcons.arrows_right, 40) ?? + 'sfsymbol:shuffle'; + final buttons = [ + CPNowPlayingImageButton(image: shuffleIcon, onPress: () => _queueService.togglePlaybackOrder()), + ]; if (currentTrack != null && !isOffline) { final isFavorite = providerRef.read(isFavoriteProvider(currentTrack)); + final heartIcon = + await _getIconFontImageUri(isFavorite ? TablerIcons.heart_filled : TablerIcons.heart, 40) ?? + (isFavorite ? 'sfsymbol:heart.fill' : 'sfsymbol:heart'); buttons.add( CPNowPlayingImageButton( - image: isFavorite ? 'sfsymbol:heart.fill' : 'sfsymbol:heart', + image: heartIcon, onPress: () => GetIt.instance().toggleFavoriteStatusOfCurrentTrack(), ), ); + final mixIcon = await _getIconFontImageUri(TablerIcons.radio, 40) ?? 'sfsymbol:radio'; buttons.add( CPNowPlayingImageButton( - image: 'sfsymbol:radio', + image: mixIcon, onPress: () async { // Read the track at press time. The plugin keeps earlier // callbacks alive when a button update is skipped as redundant. @@ -665,6 +688,39 @@ class CarPlayHelper { return byteData?.buffer.asUint8List(); } + /// Renders an icon font glyph to a PNG in the temp directory and returns + /// its file URI, so CarPlay buttons can show the same icons as the phone + /// UI. Only the glyph's alpha matters, CarPlay tints button images itself. + Future _getIconFontImageUri(IconData icon, double size) async { + final cacheFile = File( + path_helper.join((await getTemporaryDirectory()).path, 'carplay_icon_${icon.codePoint}_${size.round()}.png'), + ); + if (!await cacheFile.exists()) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(icon.codePoint), + style: TextStyle( + fontFamily: icon.fontFamily, + package: icon.fontPackage, + fontSize: size, + color: const ui.Color(0xFFFFFFFF), + ), + ), + textDirection: ui.TextDirection.ltr, + )..layout(); + painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); + final image = await recorder.endRecording().toImage(size.round(), size.round()); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + return null; + } + await cacheFile.writeAsBytes(byteData.buffer.asUint8List(), flush: true); + } + return Uri.file(cacheFile.path).toString(); + } + /// Archives the live queue, restores [info] at its saved track and seek /// position, then shows CarPlay's Now Playing screen. Shared by the /// Recent Queues art row's per-image tap and its pushed full-history list. From 247319bda01c125327d27736127e4de2183ed448 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Thu, 9 Jul 2026 21:14:48 +1000 Subject: [PATCH 06/12] Match main UI home order and labels in CarPlay Show Recently Added, then Recently Played, then Recent Queues below the quick actions, and reuse the main UI home's Shuffle Tracks and Surprise Me translation strings for the quick actions. --- lib/services/carplay_helper.dart | 78 ++++++++++++++++---------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index cecf022fe..5ac5b2b12 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -817,14 +817,14 @@ class CarPlayHelper { sectionIndexEnabled: false, items: [ CPListItem( - text: GlobalSnackbar.requireL10n.shuffleAll, + text: GlobalSnackbar.requireL10n.shuffleTracksAction, onPress: (complete, self) async { await shuffleAllTracks(); complete(); }, ), CPListItem( - text: GlobalSnackbar.requireL10n.startRadio, + text: GlobalSnackbar.requireL10n.surpriseMeAction, onPress: (complete, self) async { if (FinampSettingsHelper.finampSettings.isOffline) { // Offline: instant mix not available, fallback to shuffle. @@ -845,6 +845,43 @@ class CarPlayHelper { _loadHomeSectionItems(HomeScreenSectionPresetType.recentlyAddedAlbums, _carPlayRecentlyAddedLimit), ]); + _carPlayLogger.info("Got ${recentlyAddedFetched.length} recently added albums"); + if (recentlyAddedFetched.isNotEmpty) { + final recentlyAddedLimit = await _clampToGridImageLimit(recentlyAddedFetched.length); + final recentlyAdded = recentlyAddedFetched.take(recentlyAddedLimit).toList(); + + sections.add( + CPListSection( + items: [ + CPListImageRowItem( + text: GlobalSnackbar.requireL10n.recentlyAdded, + gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? _carPlayFallbackImage).toList(), + onPress: (complete, self) async { + try { + await _showRecentlyAddedTemplate(); + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + onItemPress: (complete, self, index) async { + try { + if (index != null && index >= 0 && index < recentlyAdded.length) { + await showCollectionTracksTemplate(recentlyAdded[index]); + } + } catch (e) { + GlobalSnackbar.error(e); + } finally { + complete(); + } + }, + ), + ], + ), + ); + } + if (recentPlays.isNotEmpty) { CPListSection recentPlaysSection = CPListSection( header: GlobalSnackbar.requireL10n.recentlyPlayed, @@ -928,43 +965,6 @@ class CarPlayHelper { ); } - _carPlayLogger.info("Got ${recentlyAddedFetched.length} recently added albums"); - if (recentlyAddedFetched.isNotEmpty) { - final recentlyAddedLimit = await _clampToGridImageLimit(recentlyAddedFetched.length); - final recentlyAdded = recentlyAddedFetched.take(recentlyAddedLimit).toList(); - - sections.add( - CPListSection( - items: [ - CPListImageRowItem( - text: GlobalSnackbar.requireL10n.recentlyAdded, - gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? _carPlayFallbackImage).toList(), - onPress: (complete, self) async { - try { - await _showRecentlyAddedTemplate(); - } catch (e) { - GlobalSnackbar.error(e); - } finally { - complete(); - } - }, - onItemPress: (complete, self, index) async { - try { - if (index != null && index >= 0 && index < recentlyAdded.length) { - await showCollectionTracksTemplate(recentlyAdded[index]); - } - } catch (e) { - GlobalSnackbar.error(e); - } finally { - complete(); - } - }, - ), - ], - ), - ); - } - return sections; } From e84bb096234e2a03a14726bb31fe887153d6c791 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Fri, 10 Jul 2026 20:17:41 +1000 Subject: [PATCH 07/12] Remove iOS playing-state workaround - audio_service now sets MPNowPlayingInfoCenter.playbackState on iOS, merged upstream via ryanheise/audio_service#1140 (0.18.20) - Rebase the audio_service fork onto the latest upstream, dropping our copy of that fix and keeping the command registration and iosKeepNowPlayingOnStop commits - Remove the playback_state method channel and its Dart caller Closes finamp-app/finamp#1590 --- ios/Runner/AppDelegate.swift | 40 ------------------- lib/services/ios_helpers.dart | 26 +----------- .../music_player_background_task.dart | 4 -- pubspec.lock | 2 +- pubspec.yaml | 13 +++--- 5 files changed, 10 insertions(+), 75 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1c52a0846..be5b43274 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,6 @@ import app_links import UIKit import Flutter -import MediaPlayer import Intents import AVFoundation @@ -18,11 +17,6 @@ let flutterEngine = FlutterEngine(name: "SharedEngine", project: nil, allowHeadl flutterEngine.run() GeneratedPluginRegistrant.register(with: flutterEngine) - // Set up method channel for playback state sync to MPNowPlayingInfoCenter - // TODO: This is a workaround because audio_service doesn't set playbackState on iOS. - // Consider contributing a fix to audio_service to set MPNowPlayingInfoCenter.playbackState on iOS. - setupPlaybackStateChannel() - // Set up method channel for Siri media intent handling setupSiriIntentChannel() @@ -91,40 +85,6 @@ private func setExcludeFromiCloudBackup(_ dir: URL, isExcluded: Bool) throws { try mutableDir.setResourceValues(values) } -// TODO: This is a workaround because audio_service doesn't set MPNowPlayingInfoCenter.playbackState on iOS. -// The audio_service plugin only sets playbackState on macOS (see AudioServicePlugin.m line 293-295). -// This causes CarPlay's Now Playing screen to not reflect the correct play/pause state when -// playback is started from the phone. Consider contributing a fix upstream to audio_service. - -extension AppDelegate { - func setupPlaybackStateChannel() { - let channel = FlutterMethodChannel( - name: "\(Bundle.main.bundleIdentifier!)/playback_state", - binaryMessenger: flutterEngine.binaryMessenger - ) - - channel.setMethodCallHandler { [weak self] (call, result) in - switch call.method { - case "setPlaybackState": - guard let args = call.arguments as? [String: Any], - let isPlaying = args["isPlaying"] as? Bool else { - result(FlutterError(code: "INVALID_ARGS", message: "Missing isPlaying argument", details: nil)) - return - } - - if #available(iOS 13.0, *) { - let center = MPNowPlayingInfoCenter.default() - center.playbackState = isPlaying ? .playing : .paused - } - result(nil) - - default: - result(FlutterMethodNotImplemented) - } - } - } -} - // Handles voice commands like "Hey Siri, play [track/artist] on Finamp" private var siriIntentChannel: FlutterMethodChannel? diff --git a/lib/services/ios_helpers.dart b/lib/services/ios_helpers.dart index f4c7f543e..9d70973d9 100644 --- a/lib/services/ios_helpers.dart +++ b/lib/services/ios_helpers.dart @@ -8,34 +8,10 @@ import '../models/finamp_models.dart'; import 'android_auto_helper.dart'; import 'audio_service_helper.dart'; -/// iOS-specific helpers for playback state sync and Siri media intents. +/// iOS-specific helpers for Siri media intents. final _logger = Logger('IosHelpers'); -/// Syncs playback state to iOS's MPNowPlayingInfoCenter. -/// -/// TODO: This is a workaround because audio_service doesn't set -/// MPNowPlayingInfoCenter.playbackState on iOS (only on macOS). -/// This causes CarPlay's Now Playing screen to not reflect the correct -/// play/pause state when playback is started from the phone. -/// Consider contributing a fix upstream to audio_service. -class IosPlaybackStateSync { - static const _channel = MethodChannel('com.unicornsonlsd.finamp-ios/playback_state'); - - /// Sets the playback state on iOS's MPNowPlayingInfoCenter. - /// This is needed for CarPlay to show the correct play/pause state. - static Future setPlaybackState({required bool isPlaying}) async { - if (!Platform.isIOS) return; - - try { - await _channel.invokeMethod('setPlaybackState', {'isPlaying': isPlaying}); - _logger.fine('Set iOS playback state to ${isPlaying ? "playing" : "paused"}'); - } catch (e) { - _logger.warning('Failed to set iOS playback state: $e'); - } - } -} - /// Handles Siri media intent commands from iOS. /// /// This enables voice commands like "Hey Siri, play [track/artist] on Finamp" diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 3a320c8eb..8a92c1167 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -27,7 +27,6 @@ import 'package:rxdart/rxdart.dart'; import 'android_auto_helper.dart'; import 'finamp_settings_helper.dart'; -import 'ios_helpers.dart'; import 'metadata_provider.dart'; enum FadeDirection { fadeIn, fadeOut, none } @@ -1287,9 +1286,6 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler with SeekHandler, Queue jellyfin_models.BaseItemDto? currentItem; bool isFavorite = false; - // Sync playback state to iOS for CarPlay Now Playing screen - IosPlaybackStateSync.setPlaybackState(isPlaying: _player.playing); - if (mediaItem.valueOrNull?.extras?["itemJson"] != null) { currentItem = jellyfin_models.BaseItemDto.fromJson( mediaItem.valueOrNull?.extras!["itemJson"] as Map, diff --git a/pubspec.lock b/pubspec.lock index c2ecc4bd7..443038434 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,7 +97,7 @@ packages: resolved-ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" url: "https://github.com/finamp-app/audio_service.git" source: git - version: "0.18.18" + version: "0.18.19" audio_service_mpris: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f90476bac..4f3484eb1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -193,11 +193,14 @@ dependency_overrides: url: https://github.com/Komodo5197/isar-community.git ref: a602cd8999048faba043f7e1ce7ba92f4d812762 path: packages/isar - # Fork adds eager iOS MPRemoteCommandCenter registration, an - # iosKeepNowPlayingOnStop config flag, and an iOS playing-state fix, so - # that Finamp stays the system's resume candidate for CarPlay/Bluetooth - # reconnects. The new flag is part of audio_service_platform_interface as - # well, so both packages must be overridden together or the build fails. + # Forked audio_service to add: + # - iOS MPRemoteCommandCenter registration at configure time + # - An iosKeepNowPlayingOnStop config flag + # - The iOS playing-state fix from 0.18.20, not yet on pub.dev + # These keep Finamp as the system's resume candidate for CarPlay and + # Bluetooth reconnects. The flag is part of + # audio_service_platform_interface too, so both packages must be + # overridden together or the build fails. audio_service: git: url: https://github.com/finamp-app/audio_service.git From cfddf2e1e9845ab3c8d0ff415e971c6a2228a589 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Sun, 12 Jul 2026 20:18:30 +1000 Subject: [PATCH 08/12] Match main UI artwork placeholder in CarPlay Albums and queues without artwork showed a bare SF symbol, which CarPlay renders as a tiny glyph in the corner of the tile. Render the main UI's album glyph placeholder onto a card coloured tile instead. --- lib/services/carplay_helper.dart | 66 +++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index 5ac5b2b12..21132fe83 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -13,6 +13,7 @@ import 'package:finamp/services/music_providers.dart'; import 'package:finamp/services/music_screen_provider.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart' show IconData; import 'package:flutter_carplay/flutter_carplay.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; @@ -60,9 +61,7 @@ const _carPlayRecentlyPlayedLimit = 5; /// row, before clamping to the plugin's runtime grid-image limit. const _maxRecentQueues = 6; -/// Placeholder image for a CarPlay art-row entry whose artwork couldn't be -/// resolved, so the row keeps one image per entry and indices stay aligned -/// with the underlying list. +/// Last resort artwork stand-in when the rendered placeholder tile is unavailable. const _carPlayFallbackImage = 'sfsymbol:music.note.list'; /// Number of distinct albums composed into a Recent Queues collage cover, @@ -540,17 +539,17 @@ class CarPlayHelper { Future _getRecentQueueCoverImage(FinampStorableQueueInfo info) async { final currentTrackId = info.currentTrack; if (currentTrackId == null) { - return _carPlayFallbackImage; + return _getPlaceholderImageUri(); } try { final track = await providerRef.read(itemByIdProvider(currentTrackId).future); if (track == null) { - return _carPlayFallbackImage; + return _getPlaceholderImageUri(); } - return _getCarPlayImageUri(track) ?? _carPlayFallbackImage; + return _getCarPlayImageUri(track) ?? await _getPlaceholderImageUri(); } catch (e) { _carPlayLogger.warning("Failed to resolve artwork for recent queue: $e"); - return _carPlayFallbackImage; + return _getPlaceholderImageUri(); } } @@ -688,6 +687,50 @@ class CarPlayHelper { return byteData?.buffer.asUint8List(); } + String? _placeholderImage; + + /// Renders the main UI's artwork placeholder, the album glyph on a card + /// coloured tile, to a cached PNG and returns its file URI. + Future _getPlaceholderImageUri() async { + if (_placeholderImage != null) { + return _placeholderImage!; + } + try { + const size = 100.0; + final cacheFile = File( + path_helper.join((await getTemporaryDirectory()).path, 'carplay_placeholder_${size.round()}.png'), + ); + if (!await cacheFile.exists()) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); + canvas.drawRect(ui.Rect.fromLTWH(0, 0, size, size), ui.Paint()..color = const ui.Color(0xFF424242)); + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(Icons.album.codePoint), + style: TextStyle( + fontFamily: Icons.album.fontFamily, + fontSize: size * 0.4, + color: const ui.Color(0xB3FFFFFF), + ), + ), + textDirection: ui.TextDirection.ltr, + )..layout(); + painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); + final image = await recorder.endRecording().toImage(size.round(), size.round()); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + return _carPlayFallbackImage; + } + await cacheFile.writeAsBytes(byteData.buffer.asUint8List(), flush: true); + } + _placeholderImage = Uri.file(cacheFile.path).toString(); + } catch (e) { + _carPlayLogger.warning("Failed to render artwork placeholder: $e"); + _placeholderImage = _carPlayFallbackImage; + } + return _placeholderImage!; + } + /// Renders an icon font glyph to a PNG in the temp directory and returns /// its file URI, so CarPlay buttons can show the same icons as the phone /// UI. Only the glyph's alpha matters, CarPlay tints button images itself. @@ -747,13 +790,14 @@ class CarPlayHelper { _isPushingPageUpdate = true; try { final l10n = GlobalSnackbar.requireL10n; + final placeholderImage = await _getPlaceholderImageUri(); final items = List.generate(queueHistory.length, (index) { final info = queueHistory[index]; final remaining = info.trackCount - info.previousTracks.length; return CPListItem( text: info.source.name.getLocalized(l10n), detailText: l10n.queueRestoreSubtitle2(info.trackCount, remaining), - image: _carPlayFallbackImage, + image: placeholderImage, onPress: (complete, self) async { try { await _resumeSavedQueue(info); @@ -786,12 +830,13 @@ class CarPlayHelper { Future _fillRecentQueueImages(List queueHistory, List items) async { final run = ++_recentQueueImageFillRun; try { + final placeholderImage = await _getPlaceholderImageUri(); for (var i = 0; i < items.length; i++) { final image = await _getRecentQueueImage(queueHistory[i]); if (run != _recentQueueImageFillRun) { return; } - if (image != _carPlayFallbackImage) { + if (image != placeholderImage) { items[i].setImage(image); } } @@ -849,13 +894,14 @@ class CarPlayHelper { if (recentlyAddedFetched.isNotEmpty) { final recentlyAddedLimit = await _clampToGridImageLimit(recentlyAddedFetched.length); final recentlyAdded = recentlyAddedFetched.take(recentlyAddedLimit).toList(); + final placeholderImage = await _getPlaceholderImageUri(); sections.add( CPListSection( items: [ CPListImageRowItem( text: GlobalSnackbar.requireL10n.recentlyAdded, - gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? _carPlayFallbackImage).toList(), + gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? placeholderImage).toList(), onPress: (complete, self) async { try { await _showRecentlyAddedTemplate(); From 17aa8ee64699230ec270961c8c047659367d4841 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Mon, 13 Jul 2026 20:31:40 +1000 Subject: [PATCH 09/12] Lift CarPlay library limits with progressive loading and a letter picker Lists push 30 items straight away then fill in the background up to the head unit's cap, replacing one blocking 250-item fetch. Oversized views get a letter picker, except online Tracks, see _enableOnlineTracksLetterPicker. Needs flutter_carplay cf997048. --- .../MusicScreen/sort_and_filter_row.dart | 10 + lib/services/carplay_helper.dart | 572 +++++++++++++++--- lib/services/jellyfin_api.chopper.dart | 8 + lib/services/jellyfin_api.dart | 12 + lib/services/jellyfin_api_helper.dart | 8 + lib/services/music_screen_provider.dart | 90 ++- lib/services/music_screen_provider.g.dart | 146 ++++- pubspec.lock | 6 +- 8 files changed, 748 insertions(+), 104 deletions(-) diff --git a/lib/components/MusicScreen/sort_and_filter_row.dart b/lib/components/MusicScreen/sort_and_filter_row.dart index 29b9f5349..4162428d8 100644 --- a/lib/components/MusicScreen/sort_and_filter_row.dart +++ b/lib/components/MusicScreen/sort_and_filter_row.dart @@ -90,6 +90,16 @@ extension type const ResolvedSortConfig._(SortAndFilterConfiguration config) imp return ResolvedSortConfig._(config.copyWith(genreFilter: genre)); } + /// Replaces any existing letter filter with [letter] ("A".."Z" or "#") and forces ascending sort-name order. + ResolvedSortConfig copyWithLetter(String letter) { + final processedFilters = config.filters.toSet(); + processedFilters.removeWhere((x) => x.type == ItemFilterType.startsWithCharacter); + processedFilters.add(ItemFilter(type: ItemFilterType.startsWithCharacter, extras: letter)); + return ResolvedSortConfig._( + config.copyWith(sortBy: SortBy.sortName, sortOrder: SortOrder.ascending, filters: processedFilters), + ); + } + ResolvedSortConfig.skipResolving(this.config); static const defaultSort = ResolvedSortConfig._( diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index 21132fe83..d9a59ddff 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -39,18 +40,40 @@ import 'item_by_id_provider.dart'; final _carPlayLogger = Logger("CarPlay"); -/// Maximum items to fetch from server for CarPlay lists. -/// Keeps UI responsive and avoids memory issues on car displays. -const _carPlayOnlineLimit = 250; +/// Fallback item cap used when CarPlay's runtime `maximumItemCount` can't be +/// queried (older head units, or the query failing outright). +const _fallbackMaxListItems = 250; -/// Maximum items to show in offline mode for CarPlay lists. -/// Higher than online since no network latency, but still limited for performance. -const _carPlayOfflineLimit = 1000; +/// Sections the letter picker occupies: one per letter, plus "#". +const _letterSectionCount = 27; + +/// Fallback section cap, same reasoning as [_fallbackMaxListItems]. Covers the +/// letter picker plus a leading action row. +const _fallbackMaxListSections = _letterSectionCount + 1; + +/// Debug override for testing progressive loading and the letter picker +/// without a huge library, e.g. `--dart-define=CARPLAY_ITEM_CAP=30`. 0 means +/// "use the head unit's real cap". +const _itemCapOverride = int.fromEnvironment('CARPLAY_ITEM_CAP', defaultValue: 0); + +/// Debug override for the section cap, same convention as [_itemCapOverride]. +/// Below [_letterSectionCount] it exercises the flat-list fallback. +const _sectionCapOverride = int.fromEnvironment('CARPLAY_SECTION_CAP', defaultValue: 0); + +/// Online Tracks skips the letter picker: Jellyfin bakes track numbers into +/// each track's SortName, which the NameStartsWith letter filter compares +/// against, so every track lands under "#". Flipping this to true is the +/// only change needed once the server fixes Audio SortName. +const _enableOnlineTracksLetterPicker = false; /// Image size for CarPlay artwork. 100x100 is plenty for car displays /// and transfers much faster than 200x200. const _carPlayImageSize = 100; +/// First page size for CarPlay lists, kept small so lists appear quickly. +/// The background fill catches up in [musicScreenPageSize] chunks. +const _carPlayFirstPageSize = 30; + /// Albums shown in the CarPlay home Recently Added art row. const _carPlayRecentlyAddedLimit = 6; @@ -97,11 +120,28 @@ class CarPlayHelper { bool get isUserLoggedIn => _finampUserHelper.currentUser != null; - int get _carPlayItemLimit => - FinampSettingsHelper.finampSettings.isOffline ? _carPlayOfflineLimit : _carPlayOnlineLimit; - final _queueService = GetIt.instance(); + /// Runtime caps from `CPListTemplate.getMaximum*Count()`, reset on every + /// CarPlay connect since different head units allow different caps. A + /// failed query falls back to the consts above and is re-queried. + int? _cachedMaxListItems; + int? _cachedMaxListSections; + + Future _getMaxListItems() async { + if (_itemCapOverride > 0) return _itemCapOverride; + final reported = _cachedMaxListItems ??= await CPListTemplate.getMaximumItemCount() ?? _fallbackMaxListItems; + // Guard against a head unit reporting a nonsense cap of 0. + return reported > 0 ? reported : _fallbackMaxListItems; + } + + Future _getMaxListSections() async { + if (_sectionCapOverride > 0) return _sectionCapOverride; + final reported = _cachedMaxListSections ??= + await CPListTemplate.getMaximumSectionCount() ?? _fallbackMaxListSections; + return reported > 0 ? reported : _fallbackMaxListSections; + } + /// Resolves the image URI for a CarPlay list item via [albumImageProvider], /// so CarPlay shares Finamp's image cache. Returns a `file://` URI for /// downloaded images and a network URL otherwise. @@ -186,6 +226,11 @@ class CarPlayHelper { void onConnectionChange(ConnectionStatusTypes status) { connectionStatus = status; if (status == ConnectionStatusTypes.connected) { + // Different head units allow different caps, so don't carry over a + // previous connection's cached values. + _cachedMaxListItems = null; + _cachedMaxListSections = null; + // The Now Playing template is a system-owned singleton that can be // presented unprompted on connect, so its buttons can't wait for the next // track or order change. @@ -300,14 +345,10 @@ class CarPlayHelper { for (int i = 0; i < items.length; i++) { final item = items[i]; - // Use nameForSorting for bucketing so diacritic items (e.g. "Ärzte") - // land under their base letter — Jellyfin strips diacritics server-side - // when computing sortName. - final name = item.nameForSorting ?? item.name ?? ""; - String letter = name.isNotEmpty ? name[0].toUpperCase() : "#"; - if (!RegExp(r'[A-Z]').hasMatch(letter)) { - letter = "#"; - } + // Buckets on nameForSorting, so diacritic items (e.g. "Ärzte") land under + // their base letter like Jellyfin's server-side sortName. Must stay in + // step with the offline letter filter in music_screen_provider.dart. + final letter = letterBucketOf(item); grouped.putIfAbsent(letter, () => []); grouped[letter]!.add(itemBuilder(item, i)); @@ -409,7 +450,7 @@ class CarPlayHelper { } driver?.close(); }); - // The immediate fire can finish before `driver` is assigned + // listen() delivers its first value before `driver` is assigned, so close that subscription here if (completer.isCompleted) { driver.close(); } @@ -432,6 +473,377 @@ class CarPlayHelper { } } + /// Reads the sort config off a library tab request, regardless of whether + /// it's a top-level [MusicScreenPlayable] or a [Genre] drill-down. + ResolvedSortConfig _sortConfigOf(FinampPagedPlayable request) => switch (request) { + Genre() => request.sortConfig, + MusicScreenPlayable() => request.sortConfig, + }; + + /// Converts [request] to the equivalent [MusicScreenPlayable], so + /// `musicScreenItemCountProvider` has a single request shape to key off. + MusicScreenPlayable _asMusicScreenRequest(FinampPagedPlayable request) { + return switch (request) { + Genre() => request.getMusicScreenRequest(), + MusicScreenPlayable() => request, + }; + } + + /// Replaces [request]'s letter filter, forcing ascending sort-name order + /// (see [ResolvedSortConfig.copyWithLetter]). The distinct filter set + /// gives each letter its own `pagedContentProvider` cache entry. + FinampPagedPlayable _withLetter(FinampPagedPlayable request, String letter) { + return switch (request) { + Genre() => request.copyWith(request.sortConfig.copyWithLetter(letter)), + MusicScreenPlayable() => request.copyWith(request.sortConfig.copyWithLetter(letter)), + }; + } + + /// Whether [tab] can show the letter picker. See + /// [_enableOnlineTracksLetterPicker] for why online Tracks is excluded. + bool _letterLayerSupported(ContentType tab) => + FinampSettingsHelper.finampSettings.isOffline || tab != ContentType.tracks || _enableOnlineTracksLetterPicker; + + /// Groups [items] into A-Z/# sections via [_groupItemsIntoSections], + /// degrading to one header-less section if that would exceed + /// [maxSections]. [itemCache] memoises built [CPListItem]s by item id + /// across repeated calls for the same view, so a tap landing between + /// background page appends still resolves against a stable element id. + /// [leadingItem], if given, is (re-)inserted at the very top every time. + List _buildProgressiveSections( + List items, + int maxSections, + CPListItem Function(BaseItemDto item, int index) itemBuilder, + Map itemCache, { + CPListItem? leadingItem, + bool groupByLetter = true, + }) { + // A letter-filtered list is a single bucket already, so letter headers + // and a one-letter scrubber would be noise. + if (!groupByLetter) { + final flatItems = [ + for (final (index, item) in items.indexed) itemCache.putIfAbsent(item.id.raw, () => itemBuilder(item, index)), + ]; + final section = CPListSection(items: flatItems, sectionIndexEnabled: false); + if (leadingItem != null) { + section.items.insert(0, leadingItem); + } + return [section]; + } + + final sections = _groupItemsIntoSections( + items, + (item, index) => itemCache.putIfAbsent(item.id.raw, () => itemBuilder(item, index)), + ); + + final flatSections = sections.length > maxSections + ? [CPListSection(items: sections.expand((section) => section.items).toList())] + : sections; + + if (leadingItem != null && flatSections.isNotEmpty) { + flatSections.first.items.insert(0, leadingItem); + } + return flatSections; + } + + /// Chooses between a flat progressive list and the letter picker for a + /// library tab view, then pushes whichever applies. A failed item-count + /// check falls back to the flat list. + /// + /// [itemBuilderFor] is invoked with the exact request a list is pushed for + /// (the tab request, or its letter-filtered variant), so tap handlers that + /// replay the request by index always match the displayed list. + Future _showLibraryTemplate({ + required FinampPagedPlayable request, + required CPListItem Function(BaseItemDto item, int index) Function(FinampPagedPlayable) + itemBuilderFor, + required String systemIcon, + String? title, + CPListItem Function()? leadingItemBuilder, + }) async { + final [maxItems, maxSections] = await Future.wait([_getMaxListItems(), _getMaxListSections()]); + final musicRequest = _asMusicScreenRequest(request); + final sortBy = _sortConfigOf(request).sortBy; + + // The picker needs one section per letter, plus one for a leading action + // row (e.g. Shuffle All) on the views that have one. + final requiredSections = _letterSectionCount + (leadingItemBuilder != null ? 1 : 0); + + final String? letterLayerRefusal; + if (sortBy == SortBy.random) { + letterLayerRefusal = "random sort has no letter order"; + } else if (!_letterLayerSupported(musicRequest.tab)) { + letterLayerRefusal = "tab does not support letter filtering"; + } else if (maxSections < requiredSections) { + letterLayerRefusal = "head unit allows $maxSections sections, picker needs $requiredSections"; + } else { + letterLayerRefusal = null; + } + + var useLetterLayer = false; + int? total; + if (letterLayerRefusal == null) { + try { + final count = await providerRef.read(musicScreenItemCountProvider(musicRequest).future); + total = count; + useLetterLayer = count > maxItems; + } catch (e) { + _carPlayLogger.warning("Failed to check CarPlay library size, falling back to a flat list: $e"); + } + } + + _carPlayLogger.info( + "CarPlay ${musicRequest.tab}: ${total ?? '?'} items, caps $maxItems items / $maxSections sections, " + "letters: $useLetterLayer${letterLayerRefusal == null ? '' : ' ($letterLayerRefusal)'}", + ); + + if (useLetterLayer) { + await _showLetterPickerTemplate( + request: request, + itemBuilderFor: itemBuilderFor, + systemIcon: systemIcon, + title: title, + maxItems: maxItems, + maxSections: maxSections, + leadingItemBuilder: leadingItemBuilder, + ); + } else { + await _pushProgressiveListTemplate( + request: request, + itemBuilderFor: itemBuilderFor, + systemIcon: systemIcon, + title: title, + leadingItemBuilder: leadingItemBuilder, + maxItems: maxItems, + maxSections: maxSections, + ); + } + } + + /// Pushes the [_letterSectionCount]-section letter picker. Tapping a letter + /// pushes the matching filtered list via [_pushProgressiveListTemplate]. + /// Each section carries an explicit `sectionIndexTitle` but no visible + /// header, so the letter isn't rendered twice, and CarPlay's side scrubber + /// then pops the native full-screen letter grid for these sections. + Future _showLetterPickerTemplate({ + required FinampPagedPlayable request, + required CPListItem Function(BaseItemDto item, int index) Function(FinampPagedPlayable) + itemBuilderFor, + required String systemIcon, + required int maxItems, + required int maxSections, + String? title, + CPListItem Function()? leadingItemBuilder, + }) async { + final letters = [for (var i = 0; i < 26; i++) String.fromCharCode(65 + i), "#"]; + + final sections = letters.map((letter) { + return CPListSection( + sectionIndexTitle: letter, + items: [ + CPListItem( + text: letter, + onPress: (complete, self) async { + if (_isPushingPageUpdate) { + _carPlayLogger.warning("Navigation dropped: already pushing page update"); + complete(); + return; + } + _isPushingPageUpdate = true; + try { + await _pushProgressiveListTemplate( + request: _withLetter(request, letter), + itemBuilderFor: itemBuilderFor, + systemIcon: systemIcon, + title: letter, + maxItems: maxItems, + maxSections: maxSections, + groupByLetter: false, + ); + } catch (e) { + GlobalSnackbar.error(e); + } finally { + _isPushingPageUpdate = false; + complete(); + } + }, + ), + ], + ); + }).toList(); + + // Shuffle All applies to the whole library, so it belongs on the picker + // rather than inside any single letter's list. + final leadingItem = leadingItemBuilder?.call(); + final letterPickerTemplate = CPListTemplate( + title: title, + sections: [ + if (leadingItem != null) CPListSection(items: [leadingItem], sectionIndexEnabled: false), + ...sections, + ], + systemIcon: systemIcon, + emptyViewTitleVariants: [GlobalSnackbar.requireL10n.emptyFilteredListTitle], + ); + + await FlutterCarplay.push(template: letterPickerTemplate); + } + + /// Pushes [request] as a list template as soon as its first page loads, then + /// keeps appending further pages up to [maxItems] by observing the paged + /// provider. Random sort is the exception: it forces `startIndex=0` + /// server-side so appending would just duplicate items, so it shows one + /// capped page instead. + /// + /// [itemBuilderFor], bound to this exact [request], builds a [CPListItem] + /// for a playable item at its index within the full (appended) list, + /// matching what [_startSliceFromPlayable] expects. [leadingItemBuilder], + /// if given, adds one extra item (e.g. Shuffle All) at the very top of the + /// first section. + Future _pushProgressiveListTemplate({ + required FinampPagedPlayable request, + required CPListItem Function(BaseItemDto item, int index) Function(FinampPagedPlayable) + itemBuilderFor, + required String systemIcon, + required int maxItems, + required int maxSections, + String? title, + CPListItem Function()? leadingItemBuilder, + bool groupByLetter = true, + }) async { + // Bind tap handlers to this exact request (which may be letter-filtered) + // so replaying it by index resolves to the tapped item. + final itemBuilder = itemBuilderFor(request); + final itemCache = {}; + final leadingItem = leadingItemBuilder?.call(); + var cancelled = false; + + final provider = pagedContentProvider(request); + final isRandom = _sortConfigOf(request).sortBy == SortBy.random; + // Random loads its whole capped page in one request + final firstPageTarget = isRandom ? maxItems : min(_carPlayFirstPageSize, maxItems); + + if (providerRef.read(provider).error != null) { + providerRef.read(provider.notifier).retry(); + } + + // Retain the paged data so later taps resolve, until the next root rebuild. + _templateSubscriptions.add(providerRef.listen(provider, (_, _) {})); + + final pushed = Completer(); + CPListTemplate? template; + ProviderSubscription? driver; + + // Requests the next page while more items are wanted and available, and + // closes the driver once the list is complete, cancelled, or errored. + void requestNextPage() { + if (cancelled) { + driver?.close(); + return; + } + final state = providerRef.read(provider); + // A settling load emits and drives the next request + if (state.isLoading) return; + if (state.error != null) { + // The error resets when the next load starts + _carPlayLogger.warning("Stopped filling CarPlay list '$title' early: ${state.error}"); + driver?.close(); + return; + } + final loaded = (state.items ?? []).length; + if (!isRandom && loaded < maxItems && state.hasNextPage) { + providerRef.read(provider.notifier).newPage(pageSize: min(musicScreenPageSize, maxItems - loaded)); + } else { + driver?.close(); + _carPlayLogger.info("CarPlay list '$title' complete at $loaded items"); + } + } + + // Repaints the on-screen template as pages arrive, driving new page requests and the first push. + driver = providerRef.listen>(provider, fireImmediately: true, ( + _, + next, + ) { + if (cancelled) { + driver?.close(); + if (!pushed.isCompleted) pushed.complete(); + return; + } + if (next.isLoading) return; + + final loaded = (next.items ?? []).length; + final items = (next.items ?? []).take(maxItems).map((x) => (x as FinampPlayableDto).item).toList(); + + // A first-load error with nothing cached leaves no list to show. + if (template == null && next.error != null && items.isEmpty) { + driver?.close(); + if (!pushed.isCompleted) pushed.completeError(next.error!); + return; + } + + // Keep assembling the first page before the list appears on screen. + if (template == null && loaded < firstPageTarget && next.hasNextPage && next.error == null) { + providerRef.read(provider.notifier).newPage(pageSize: firstPageTarget - loaded); + return; + } + + final sections = _buildProgressiveSections( + items, + maxSections, + itemBuilder, + itemCache, + leadingItem: leadingItem, + groupByLetter: groupByLetter, + ); + + if (template == null) { + final pushTemplate = CPListTemplate( + title: title, + sections: sections, + systemIcon: systemIcon, + emptyViewTitleVariants: [GlobalSnackbar.requireL10n.emptyFilteredListTitle], + onPop: () => cancelled = true, + ); + template = pushTemplate; + // Push first, then start the fill once the template is on screen so no + // section update can race ahead of the push. + unawaited( + FlutterCarplay.push(template: pushTemplate).then((_) { + _carPlayLogger.info("Pushed CarPlay list '$title' with ${items.length} items (cap $maxItems)"); + if (!pushed.isCompleted) pushed.complete(); + requestNextPage(); + }), + ); + return; + } + + // Repaint the pushed template with the newly arrived page, then request + // the next one only after the update settles. + unawaited(() async { + try { + await _flutterCarplay.updateListTemplateSections(elementId: template!.uniqueId, sections: sections); + } catch (e) { + _carPlayLogger.warning("Failed to append CarPlay list page: $e"); + driver?.close(); + return; + } + requestNextPage(); + }()); + }); + // listen() delivers its first value before `driver` is assigned, so close that subscription here + if (pushed.isCompleted) { + driver.close(); + } + + void cancel() { + cancelled = true; + if (!pushed.isCompleted) pushed.complete(); + driver?.close(); + } + + _pendingLoadCancellers.add(cancel); + await pushed.future; + } + Future _startSliceFromPlayable(FinampPlayable playable, {int index = 0, bool shuffled = false}) async { var slice = await providerRef.read( getPlayableSliceProvider(item: playable, startingOffset: shuffled ? 0 : index).future, @@ -1207,39 +1619,38 @@ class CarPlayHelper { } _isPushingPageUpdate = true; try { - List mediaItems; + final FinampPagedPlayable request; if (genreFilter != null) { - final genre = Genre( + request = Genre( genreFilter, source: QueueItemSource.fromBaseItem(genreFilter), sortConfig: SortAndFilterConfiguration.defaultSort, type: GenreChildType.albums, library: currentLibraryPlaceholder, ); - mediaItems = await _loadPagedItems(genre, _carPlayItemLimit); } else { - mediaItems = await _loadPagedItems(_tabPlayable(tabType), _carPlayItemLimit); + request = _tabPlayable(tabType); } - final sections = _groupItemsIntoSections(mediaItems, (item, index) { - return CPListItem( - text: item.name ?? GlobalSnackbar.requireL10n.unknown, - detailText: item.artists?.join(", ") ?? item.albumArtist, - image: _getCarPlayImageUri(item), - onPress: (complete, self) async { - if (tabType == ContentType.genres && genreFilter == null) { - await showBrowsableListTemplate(tabType: tabType, genreFilter: item); - } else { - await showCollectionTracksTemplate(item); - } - complete(); - }, - ); - }); - - CPListTemplate albumsTemplate = CPListTemplate(sections: sections, systemIcon: 'square.stack'); - - await FlutterCarplay.push(template: albumsTemplate); + await _showLibraryTemplate( + request: request, + systemIcon: 'square.stack', + title: genreFilter?.name ?? tabType.toLocalisedString(GlobalSnackbar.requireL10n), + itemBuilderFor: (_) => + (item, index) => CPListItem( + text: item.name ?? GlobalSnackbar.requireL10n.unknown, + detailText: item.artists?.join(", ") ?? item.albumArtist, + image: _getCarPlayImageUri(item), + onPress: (complete, self) async { + if (tabType == ContentType.genres && genreFilter == null) { + await showBrowsableListTemplate(tabType: tabType, genreFilter: item); + } else { + await showCollectionTracksTemplate(item); + } + complete(); + }, + ), + ); } finally { _isPushingPageUpdate = false; } @@ -1252,39 +1663,30 @@ class CarPlayHelper { } _isPushingPageUpdate = true; try { - // Taps replay this exact request so the index resolves against the displayed pages. - final request = _tabPlayable(ContentType.tracks); - final tracks = await _loadPagedItems(request, _carPlayItemLimit); - - final sections = _groupItemsIntoSections(tracks, (item, index) { - return CPListItem( - text: item.name ?? GlobalSnackbar.requireL10n.unknownName, - detailText: item.artists?.join(", ") ?? item.albumArtist, - image: _getCarPlayImageUri(item), + await _showLibraryTemplate( + request: _tabPlayable(ContentType.tracks), + systemIcon: 'music.note', + title: ContentType.tracks.toLocalisedString(GlobalSnackbar.requireL10n), + leadingItemBuilder: () => CPListItem( + text: GlobalSnackbar.requireL10n.shuffleAll, onPress: (complete, self) async { - await _startSliceFromPlayable(request, index: index); + await shuffleAllTracks(); complete(); }, - ); - }); - - // Add shuffle button at the beginning - if (sections.isNotEmpty) { - sections.first.items.insert( - 0, - CPListItem( - text: GlobalSnackbar.requireL10n.shuffleAll, - onPress: (complete, self) async { - await shuffleAllTracks(); - complete(); - }, - ), - ); - } - - CPListTemplate tracksTemplate = CPListTemplate(sections: sections, systemIcon: 'music.note'); - - await FlutterCarplay.push(template: tracksTemplate); + ), + // Taps replay the pushed list's request (possibly letter-filtered) so + // the index resolves against the displayed pages. + itemBuilderFor: (request) => + (item, index) => CPListItem( + text: item.name ?? GlobalSnackbar.requireL10n.unknownName, + detailText: item.artists?.join(", ") ?? item.albumArtist, + image: _getCarPlayImageUri(item), + onPress: (complete, self) async { + await _startSliceFromPlayable(request, index: index); + complete(); + }, + ), + ); } finally { _isPushingPageUpdate = false; } @@ -1297,21 +1699,19 @@ class CarPlayHelper { } _isPushingPageUpdate = true; try { - final artists = await _loadPagedItems(_tabPlayable(ContentType.albumArtists), _carPlayItemLimit); - - final sections = _groupItemsIntoSections(artists, (item, index) { - return CPListItem( - text: item.name ?? GlobalSnackbar.requireL10n.unknownName, - onPress: (complete, self) async { - await showArtistTemplate(item); - complete(); - }, - ); - }); - - CPListTemplate artistsTemplate = CPListTemplate(sections: sections, systemIcon: 'person.2'); - - await FlutterCarplay.push(template: artistsTemplate); + await _showLibraryTemplate( + request: _tabPlayable(ContentType.albumArtists), + systemIcon: 'person.2', + title: ContentType.albumArtists.toLocalisedString(GlobalSnackbar.requireL10n), + itemBuilderFor: (_) => + (item, index) => CPListItem( + text: item.name ?? GlobalSnackbar.requireL10n.unknownName, + onPress: (complete, self) async { + await showArtistTemplate(item); + complete(); + }, + ), + ); } finally { _isPushingPageUpdate = false; } diff --git a/lib/services/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart index b0892469f..adb2e08f3 100644 --- a/lib/services/jellyfin_api.chopper.dart +++ b/lib/services/jellyfin_api.chopper.dart @@ -686,6 +686,7 @@ final class _$JellyfinApi extends JellyfinApi { int? limit, bool? isFavorite, String? nameStartsWith, + String? nameLessThan, }) async { final Uri $url = Uri.parse('/Artists'); final Map $params = { @@ -700,6 +701,7 @@ final class _$JellyfinApi extends JellyfinApi { 'Limit': limit, 'isFavorite': isFavorite, 'NameStartsWith': nameStartsWith, + 'NameLessThan': nameLessThan, }; final Request $request = Request( 'GET', @@ -732,6 +734,7 @@ final class _$JellyfinApi extends JellyfinApi { required String userId, bool? isFavorite, String? nameStartsWith, + String? nameLessThan, }) async { final Uri $url = Uri.parse('/Artists/AlbumArtists'); final Map $params = { @@ -750,6 +753,7 @@ final class _$JellyfinApi extends JellyfinApi { 'UserId': userId, 'isFavorite': isFavorite, 'NameStartsWith': nameStartsWith, + 'NameLessThan': nameLessThan, }; final Request $request = Request( 'GET', @@ -776,6 +780,8 @@ final class _$JellyfinApi extends JellyfinApi { String? searchTerm, int? startIndex, int? limit, + String? nameStartsWith, + String? nameLessThan, }) async { final Uri $url = Uri.parse('/Genres'); final Map $params = { @@ -788,6 +794,8 @@ final class _$JellyfinApi extends JellyfinApi { 'SearchTerm': searchTerm, 'StartIndex': startIndex, 'Limit': limit, + 'NameStartsWith': nameStartsWith, + 'NameLessThan': nameLessThan, }; final Request $request = Request( 'GET', diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index 638786b72..f336418d7 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -435,6 +435,9 @@ abstract class JellyfinApi extends ChopperService { /// Optional. Filter by items whose name is sorted equally than a given input string. @Query("NameStartsWith") String? nameStartsWith, + + /// Optional. Filter by items whose name is sorted less than a given input string. + @Query("NameLessThan") String? nameLessThan, }); @FactoryConverter(request: JsonConverter.requestFactory, response: JsonConverter.responseFactory) @@ -482,6 +485,9 @@ abstract class JellyfinApi extends ChopperService { /// Optional. Filter by items whose name is sorted equally than a given input string. @Query("NameStartsWith") String? nameStartsWith, + + /// Optional. Filter by items whose name is sorted less than a given input string. + @Query("NameLessThan") String? nameLessThan, }); /// Gets all genres from a given item, folder, or the entire library. @@ -534,6 +540,12 @@ abstract class JellyfinApi extends ChopperService { /// Optional. The maximum number of records to return. @Query("Limit") int? limit, + + /// Optional. Filter by items whose name is sorted equally than a given input string. + @Query("NameStartsWith") String? nameStartsWith, + + /// Optional. Filter by items whose name is sorted less than a given input string. + @Query("NameLessThan") String? nameLessThan, }); /// Marks an item as a favorite. diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index 02110d66a..b9f48d1eb 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -245,6 +245,8 @@ class JellyfinApiHelper { ArtistType? artistType, BaseItemId? genreFilter, bool? isFavorite, + String? nameStartsWith, + String? nameLessThan, int? startIndex, int? limit, }) async { @@ -263,6 +265,8 @@ class JellyfinApiHelper { artistType: artistType, genreFilter: genreFilter, isFavorite: isFavorite, + nameStartsWith: nameStartsWith, + nameLessThan: nameLessThan, startIndex: startIndex, limit: limit, ); @@ -345,6 +349,7 @@ class JellyfinApiHelper { fields: fields, isFavorite: isFavorite, nameStartsWith: nameStartsWith, + nameLessThan: nameLessThan, ); } else { //artistType == ArtistType.artist @@ -361,6 +366,7 @@ class JellyfinApiHelper { fields: fields, isFavorite: isFavorite, nameStartsWith: nameStartsWith, + nameLessThan: nameLessThan, ); } } else if (parentItem?.type == "MusicArtist") { @@ -419,6 +425,8 @@ class JellyfinApiHelper { startIndex: startIndex, limit: limit, fields: fields, + nameStartsWith: nameStartsWith, + nameLessThan: nameLessThan, ); } else if (parentItem?.type == "MusicGenre") { response = await api.getItems( diff --git a/lib/services/music_screen_provider.dart b/lib/services/music_screen_provider.dart index 13dda7722..894057c6f 100644 --- a/lib/services/music_screen_provider.dart +++ b/lib/services/music_screen_provider.dart @@ -271,14 +271,41 @@ Future?> loadHomeSectionItems( required int startIndex, required int limit, }) async { - final jellyfinApiHelper = GetIt.instance(); - // If the fully downloaded filter is active, just use the offline items. if (ref.watch(finampSettingsProvider.isOffline) || request.sortConfig.filters.where((x) => x.type == ItemFilterType.isFullyDownloaded).isNotEmpty) { return loadHomeSectionItemsOffline(ref: ref, request: request, startIndex: startIndex, limit: limit); } + final result = await _fetchMusicScreenPageOnline(ref, request: request, startIndex: startIndex, limit: limit); + return result.items; +} + +/// Total item count for [request], ignoring pagination. CarPlay uses this to +/// decide whether a view needs the letter picker. +@riverpod +Future musicScreenItemCount(Ref ref, MusicScreenPlayable request) async { + // Fully-downloaded-filtered requests serve offline items, so count those + if (ref.watch(finampSettingsProvider.isOffline) || + request.sortConfig.filters.where((x) => x.type == ItemFilterType.isFullyDownloaded).isNotEmpty) { + final items = await _buildHomeSectionItemsOffline(ref: ref, request: request); + return items?.length ?? 0; + } + + final result = await _fetchMusicScreenPageOnline(ref, request: request, startIndex: 0, limit: 1); + return result.totalRecordCount; +} + +/// Shared online fetch backing both [loadHomeSectionItems] and +/// [musicScreenItemCount] - same request, same behaviour as the main UI. +Future _fetchMusicScreenPageOnline( + Ref ref, { + required MusicScreenPlayable request, + required int startIndex, + required int limit, +}) async { + final jellyfinApiHelper = GetIt.instance(); + final BaseItemId? libraryId; if (request.library == allLibraryPlaceholder) { libraryId = null; @@ -287,7 +314,7 @@ Future?> loadHomeSectionItems( FinampUserHelper.finampCurrentUserProvider.select((value) => value?.currentView?.id), ); if (nullableLibraryId == null) { - return []; + return QueryResult_BaseItemDto(totalRecordCount: 0, startIndex: 0, items: []); } else { libraryId = nullableLibraryId; } @@ -300,7 +327,7 @@ Future?> loadHomeSectionItems( if (libraryId != null) { library = await ref.watch(itemByIdProvider(libraryId).future); if (library == null) { - return []; + return QueryResult_BaseItemDto(totalRecordCount: 0, startIndex: 0, items: []); } } @@ -316,7 +343,9 @@ Future?> loadHomeSectionItems( final artistType = artistFilter != null ? ref.watch(finampSettingsProvider.defaultArtistType) : tabArtistType; - return jellyfinApiHelper.getItems( + final letterFilter = request.sortConfig.filters.firstWhereOrNull((x) => x.type == ItemFilterType.startsWithCharacter); + final letter = letterFilter?.extraString; + return jellyfinApiHelper.getItemsWithTotalRecordCount( libraryFilter: library?.id, parentItem: request.tab == ContentType.playlists ? null : (artistFilter?.extraBaseItem ?? library), includeItemTypes: [request.tab.itemType?.jellyfinName].join(","), @@ -328,9 +357,7 @@ Future?> loadHomeSectionItems( (filter) => switch (filter.type) { ItemFilterType.isFavorite => "IsFavorite", ItemFilterType.isFullyDownloaded => null, // only applicable for offline mode - // ItemFilterType.startsWithCharacter => "NameStartsWith: ${filter.value}", - ItemFilterType.startsWithCharacter => - throw UnimplementedError(), //TODO properly handle the "NameStartsWith" filter in the API helper + ItemFilterType.startsWithCharacter => null, // handled via nameStartsWith/nameLessThan below ItemFilterType.genreFilter => null, ItemFilterType.artistFilter => null, ItemFilterType.searchTerm => null, @@ -348,6 +375,9 @@ Future?> loadHomeSectionItems( // : null, artistType: artistType, genreFilter: genreFilter?.extraBaseItem.id, + // "#" follows jellyfin-web and maps to NameLessThan=A, so names sorting after "z" are missed. + nameStartsWith: letter == null || letter == "#" ? null : letter, + nameLessThan: letter == "#" ? "A" : null, ); } @@ -356,6 +386,18 @@ Future?> loadHomeSectionItemsOffline({ required MusicScreenPlayable request, int startIndex = 0, int limit = 10, +}) async { + final items = await _buildHomeSectionItemsOffline(ref: ref, request: request); + if (items == null) return null; + return items.skip(startIndex).take(limit).toList(); +} + +/// Builds the full (unpaginated) offline item list for [request], including +/// in-memory letter filtering. Shared by [loadHomeSectionItemsOffline] and +/// [musicScreenItemCount]. +Future?> _buildHomeSectionItemsOffline({ + required Ref ref, + required MusicScreenPlayable request, }) async { final downloadsService = GetIt.instance(); @@ -424,6 +466,11 @@ Future?> loadHomeSectionItemsOffline({ items = offlineItems.map((e) => e.baseItem).nonNulls.toList(); } + final letterFilter = request.sortConfig.filters.firstWhereOrNull((x) => x.type == ItemFilterType.startsWithCharacter); + if (letterFilter != null) { + items = items.where((item) => letterBucketOf(item) == letterFilter.extraString).toList(); + } + var sortBy = request.sortConfig.sortBy; // PlayCount and Last Played are not representative in Offline Mode // so we disable it and overwrite it with the Sort Name if it was selected @@ -442,7 +489,16 @@ Future?> loadHomeSectionItemsOffline({ items = filterItemsByGenreName(items, genreFilter.extraBaseItem); } - return items.skip(startIndex).take(limit).toList(); + return items; +} + +/// Buckets [item] to "A".."Z" or "#" by the first character of its +/// [BaseItemDto.nameForSorting]. The letters CarPlay shows come from +/// `_groupItemsIntoSections`, so the two must agree on every item. +String letterBucketOf(BaseItemDto item) { + final name = item.nameForSorting ?? item.name ?? ""; + final letter = name.isNotEmpty ? name[0].toUpperCase() : "#"; + return RegExp(r'[A-Z]').hasMatch(letter) ? letter : "#"; } List sortItems(List itemsToSort, SortBy? sortBy, SortOrder? sortOrder) { @@ -752,8 +808,15 @@ Future?> getJellyfinCollection( sortConfig.filters.any((filter) => filter.type == ItemFilterType.isFavorite) && ref.watch(finampSettingsProvider.trackOfflineFavorites), ); - return stubs.map((x) => x.baseItem).nonNulls.toList(); + var items = stubs.map((x) => x.baseItem).nonNulls.toList(); + final letterFilter = sortConfig.filters.firstWhereOrNull((x) => x.type == ItemFilterType.startsWithCharacter); + if (letterFilter != null) { + items = items.where((item) => letterBucketOf(item) == letterFilter.extraString).toList(); + } + return items; } else { + final letterFilter = sortConfig.filters.firstWhereOrNull((x) => x.type == ItemFilterType.startsWithCharacter); + final letter = letterFilter?.extraString; return GetIt.instance().getItems( parentItem: collection, recursive: false, //!!! prevent loading tracks and albums from inside the collection items @@ -764,9 +827,7 @@ Future?> getJellyfinCollection( (filter) => switch (filter.type) { ItemFilterType.isFavorite => "IsFavorite", ItemFilterType.isFullyDownloaded => null, // only applicable for offline mode - // ItemFilterType.startsWithCharacter => "NameStartsWith: ${filter.value}", - ItemFilterType.startsWithCharacter => - throw UnimplementedError(), //TODO properly handle the "NameStartsWith" filter in the API helper + ItemFilterType.startsWithCharacter => null, // handled via nameStartsWith/nameLessThan below ItemFilterType.genreFilter => throw UnimplementedError(), ItemFilterType.artistFilter => throw UnimplementedError(), ItemFilterType.searchTerm => throw UnimplementedError(), @@ -778,6 +839,9 @@ Future?> getJellyfinCollection( isFavorite: JellyfinApiHelper.getIsFavoriteFilter(ContentType.mixed, sortConfig.filters), // TODO allow filtering collection child types? //includeItemTypes: sectionInfo.contentType.itemType?.jellyfinName, + // "#" follows jellyfin-web and maps to NameLessThan=A. + nameStartsWith: letter == null || letter == "#" ? null : letter, + nameLessThan: letter == "#" ? "A" : null, ); } } diff --git a/lib/services/music_screen_provider.g.dart b/lib/services/music_screen_provider.g.dart index 6f96d7340..a5446a3af 100644 --- a/lib/services/music_screen_provider.g.dart +++ b/lib/services/music_screen_provider.g.dart @@ -12,7 +12,7 @@ part of 'music_screen_provider.dart'; // ************************************************************************** String _$loadHomeSectionItemsHash() => - r'03d5a2113df428ecafabb89f08f4b1fa56f90de0'; + r'79de212cae057856d0c2c10ef7dea9dc84ede6d4'; /// Copied from Dart SDK class _SystemHash { @@ -201,8 +201,150 @@ class _LoadHomeSectionItemsProviderElement int get limit => (origin as LoadHomeSectionItemsProvider).limit; } +String _$musicScreenItemCountHash() => + r'c49c43e47599e8b6f4f7bc6280fa844767f0eee8'; + +/// Total item count for [request], ignoring pagination. CarPlay uses this to +/// decide whether a view needs the letter picker. +/// +/// Copied from [musicScreenItemCount]. +@ProviderFor(musicScreenItemCount) +const musicScreenItemCountProvider = MusicScreenItemCountFamily(); + +/// Total item count for [request], ignoring pagination. CarPlay uses this to +/// decide whether a view needs the letter picker. +/// +/// Copied from [musicScreenItemCount]. +class MusicScreenItemCountFamily extends Family> { + /// Total item count for [request], ignoring pagination. CarPlay uses this to + /// decide whether a view needs the letter picker. + /// + /// Copied from [musicScreenItemCount]. + const MusicScreenItemCountFamily(); + + /// Total item count for [request], ignoring pagination. CarPlay uses this to + /// decide whether a view needs the letter picker. + /// + /// Copied from [musicScreenItemCount]. + MusicScreenItemCountProvider call( + MusicScreenPlayable request, + ) { + return MusicScreenItemCountProvider(request); + } + + @override + MusicScreenItemCountProvider getProviderOverride( + covariant MusicScreenItemCountProvider provider, + ) { + return call(provider.request); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'musicScreenItemCountProvider'; +} + +/// Total item count for [request], ignoring pagination. CarPlay uses this to +/// decide whether a view needs the letter picker. +/// +/// Copied from [musicScreenItemCount]. +class MusicScreenItemCountProvider extends AutoDisposeFutureProvider { + /// Total item count for [request], ignoring pagination. CarPlay uses this to + /// decide whether a view needs the letter picker. + /// + /// Copied from [musicScreenItemCount]. + MusicScreenItemCountProvider(MusicScreenPlayable request) + : this._internal( + (ref) => musicScreenItemCount(ref as MusicScreenItemCountRef, request), + from: musicScreenItemCountProvider, + name: r'musicScreenItemCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$musicScreenItemCountHash, + dependencies: MusicScreenItemCountFamily._dependencies, + allTransitiveDependencies: + MusicScreenItemCountFamily._allTransitiveDependencies, + request: request, + ); + + MusicScreenItemCountProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.request, + }) : super.internal(); + + final MusicScreenPlayable request; + + @override + Override overrideWith( + FutureOr Function(MusicScreenItemCountRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: MusicScreenItemCountProvider._internal( + (ref) => create(ref as MusicScreenItemCountRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + request: request, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _MusicScreenItemCountProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MusicScreenItemCountProvider && other.request == request; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, request.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MusicScreenItemCountRef on AutoDisposeFutureProviderRef { + /// The parameter `request` of this provider. + MusicScreenPlayable get request; +} + +class _MusicScreenItemCountProviderElement + extends AutoDisposeFutureProviderElement + with MusicScreenItemCountRef { + _MusicScreenItemCountProviderElement(super.provider); + + @override + MusicScreenPlayable get request => + (origin as MusicScreenItemCountProvider).request; +} + String _$getJellyfinCollectionHash() => - r'a0f53ba1d31000864d5a81b22ecc132f0bd99934'; + r'1c7ade2240687f4de0fb3a93b51f27a97fb80e8a'; /// See also [getJellyfinCollection]. @ProviderFor(getJellyfinCollection) diff --git a/pubspec.lock b/pubspec.lock index 443038434..15c46c4b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -605,11 +605,11 @@ packages: dependency: "direct main" description: path: "." - ref: "cf997048d5b51fbb60bf68bc53538e01a2378f55" - resolved-ref: "cf997048d5b51fbb60bf68bc53538e01a2378f55" + ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 + resolved-ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 url: "https://github.com/finamp-app/flutter_carplay.git" source: git - version: "1.6.3" + version: "1.6.4" flutter_discord_rpc: dependency: "direct main" description: From 5287a1f4972c264172df97d84e90a627c058abf7 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Thu, 17 Sep 2026 17:08:22 +1000 Subject: [PATCH 10/12] Preview suggested tracks before starting CarPlay radio The radio button replaced the queue at once, so the user never saw the tracks first. Now it shows the suggested tracks in a list. Start Radio replaces the queue with them. Back keeps the queue and the radio mode setting unchanged. If CarPlay refuses the push because its template stack is full, the radio starts at once with the same tracks. --- lib/services/carplay_helper.dart | 140 +++++++++++++++++++++++-- lib/services/radio_service_helper.dart | 73 ++++++++----- pubspec.lock | 14 +-- pubspec.yaml | 6 +- 4 files changed, 190 insertions(+), 43 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index d9a59ddff..b92ca1cf4 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -117,6 +117,7 @@ class CarPlayHelper { bool _isUpdatingNowPlayingButtons = false; int _recentQueueImageFillRun = 0; BaseItemId? _nowPlayingButtonsTrackId; + void Function()? _cancelRadioPreview; bool get isUserLoggedIn => _finampUserHelper.currentUser != null; @@ -225,6 +226,9 @@ class CarPlayHelper { void onConnectionChange(ConnectionStatusTypes status) { connectionStatus = status; + if (status == ConnectionStatusTypes.disconnected) { + _cancelRadioPreview?.call(); + } if (status == ConnectionStatusTypes.connected) { // Different head units allow different caps, so don't carry over a // previous connection's cached values. @@ -266,8 +270,8 @@ class CarPlayHelper { } /// Builds and sends the CarPlay Now Playing screen buttons: shuffle - /// toggle, favourite, and start instant mix (leading to trailing). Shows - /// no buttons when logged out and hides favourite/mix when there is no + /// toggle, favourite, and start radio (leading to trailing). Shows + /// no buttons when logged out and hides favourite/radio when there is no /// current track or while offline. /// /// Overlapping calls are ignored. @@ -312,21 +316,20 @@ class CarPlayHelper { ), ); - final mixIcon = await _getIconFontImageUri(TablerIcons.radio, 40) ?? 'sfsymbol:radio'; + final radioIcon = await _getIconFontImageUri(TablerIcons.radio, 40) ?? 'sfsymbol:radio'; buttons.add( CPNowPlayingImageButton( - image: mixIcon, + image: radioIcon, onPress: () async { // Read the track at press time. The plugin keeps earlier // callbacks alive when a button update is skipped as redundant. final track = _queueService.getCurrentTrack()?.baseItem; if (track == null) return; try { - _carPlayLogger.info("Mix button pressed, starting an instant mix from '${track.name}'"); - FinampSetters.setRadioMode(RadioMode.similar); - await radio.startRadioPlayback(track); + _carPlayLogger.info("Radio button pressed, previewing a radio from '${track.name}'"); + await _showRadioPreview(track); } catch (e) { - _carPlayLogger.severe("Starting instant mix failed: $e"); + _carPlayLogger.severe("Starting radio failed: $e"); GlobalSnackbar.error(e); } }, @@ -337,6 +340,127 @@ class CarPlayHelper { await FlutterCarplay.setNowPlayingButtons(buttons); } + /// Shows a list of the tracks a radio seeded from [track] would play - the queue changes only when the user confirms. + Future _showRadioPreview(BaseItemDto track) async { + if (_isPushingPageUpdate) { + _carPlayLogger.warning("Navigation dropped: already pushing page update"); + return; + } + + final generation = radio.generateRadioPreview(track, radioMode: RadioMode.similar).catchError((Object e) { + _carPlayLogger.severe("Radio preview generation failed: $e"); + return (radioMode: RadioMode.similar, tracks: []); + }); + final l10n = GlobalSnackbar.requireL10n; + + var cancelled = false; + var starting = false; + var started = false; + var popped = false; + + void endPreview() { + popped = true; + _cancelRadioPreview = null; + if (started) return; + cancelled = true; + radio.invalidateRadioCache(); + } + + Future startRadio(BaseItemDto? firstTrack) async { + if (starting || started) return; + starting = true; + try { + final preview = await generation; + if (cancelled || preview.tracks.isEmpty) return; + final tracks = List.of(preview.tracks); + if (firstTrack != null) { + tracks.remove(firstTrack); + tracks.insert(0, firstTrack); + } + started = true; + await radio.startRadioPlaybackWithTracks(track, preview.radioMode, tracks); + _cancelRadioPreview = null; + } catch (e) { + started = false; + radio.invalidateRadioCache(); + _carPlayLogger.severe("Starting radio failed: $e"); + GlobalSnackbar.error(e); + return; + } finally { + starting = false; + } + if (popped) return; + try { + await FlutterCarplay.pop(); + } catch (e) { + _carPlayLogger.warning("Failed to pop the radio preview: $e"); + } + } + + final template = CPListTemplate( + title: l10n.radioForItem(track.name ?? ""), + sections: [], + emptyViewTitleVariants: [l10n.loading], + trailingNavigationBarButtons: [CPBarButton(title: l10n.startRadio, onPress: () => unawaited(startRadio(null)))], + onPop: endPreview, + ); + + _cancelRadioPreview = endPreview; + var pushed = false; + _isPushingPageUpdate = true; + try { + pushed = await FlutterCarplay.push(template: template); + if (!pushed) { + // CarPlay allows five screens on the stack, so make room under Now Playing + _carPlayLogger.info("Radio preview refused, showing it above the root instead"); + await FlutterCarplay.popToRoot(animated: false); + await FlutterCarplay.showSharedNowPlaying(animated: false); + pushed = await FlutterCarplay.push(template: template); + } + } finally { + _isPushingPageUpdate = false; + } + + if (!pushed) { + _carPlayLogger.warning("Couldn't push the radio preview, starting radio from '${track.name}' directly"); + _cancelRadioPreview = null; + final preview = await generation; + if (cancelled) return; + await radio.startRadioPlaybackWithTracks(track, preview.radioMode, preview.tracks); + return; + } + + final preview = await generation; + if (cancelled) return; + + final items = []; + if (preview.tracks.isEmpty) { + _carPlayLogger.warning("Radio preview from '${track.name}' generated no tracks"); + items.add(CPListItem(text: l10n.radioNoTracksFound)); + } else { + for (final previewTrack in preview.tracks) { + items.add( + CPListItem( + text: previewTrack.name ?? l10n.unknown, + detailText: previewTrack.artists?.join(", ") ?? previewTrack.albumArtist, + image: _getCarPlayImageUri(previewTrack), + onPress: (complete, self) async { + try { + await startRadio(previewTrack); + } finally { + complete(); + } + }, + ), + ); + } + } + await _flutterCarplay.updateListTemplateSections( + elementId: template.uniqueId, + sections: [CPListSection(items: items)], + ); + } + List _groupItemsIntoSections( List items, CPListItem Function(BaseItemDto item, int index) itemBuilder, diff --git a/lib/services/radio_service_helper.dart b/lib/services/radio_service_helper.dart index a98a055ae..08f8aa156 100644 --- a/lib/services/radio_service_helper.dart +++ b/lib/services/radio_service_helper.dart @@ -161,20 +161,31 @@ Future maybeAddRadioTracks() async { } } +int _radioTracksNeededForInitialQueue(RadioMode radioMode) => switch (radioMode) { + // continuous mode requires successive requests, so reduce the amount of tracks so the queue starts faster + // additional tracks will be loaded after the queue has started + RadioMode.continuous => 3, + // if we find true albums right away, this threshold should be easily surpassed + // if not, searching for fallbacks could take a few requests, so accept fewer tracks to start the queue, then delegate further loading + RadioMode.albumMix => 7, + _ => 30, +}; + Future startRadioPlayback(BaseItemDto source) async { - final currentRadioMode = FinampSettingsHelper.finampSettings.radioMode; - final int radioTracksNeededForInitialQueue = switch (currentRadioMode) { - // continuous mode requires successive requests, so reduce the amount of tracks so the queue starts faster - // additional tracks will be loaded after the queue has started - RadioMode.continuous => 3, - // if we find true albums right away, this threshold should be easily surpassed - // if not, searching for fallbacks could take a few requests, so accept fewer tracks to start the queue, then delegate further loading - RadioMode.albumMix => 7, - _ => 30, - }; + final preview = await generateRadioPreview(source); + await startRadioPlaybackWithTracks(source, preview.radioMode, preview.tracks); +} + +/// Generates the tracks for a new radio queue without touching playback. +Future<({RadioMode radioMode, List tracks})> generateRadioPreview( + BaseItemDto source, { + RadioMode? radioMode, +}) async { + final selectedRadioMode = radioMode ?? FinampSettingsHelper.finampSettings.radioMode; + final radioTracksNeededForInitialQueue = _radioTracksNeededForInitialQueue(selectedRadioMode); invalidateRadioCache(); // we're starting a new queue, any older state is invalid now - var localResult = _radioCacheStateStream.value!.copyWith(generating: true, failed: false); + final localResult = _radioCacheStateStream.value!.copyWith(generating: true, failed: false); _radioCacheStateStream.add(localResult); List generatedTracks = []; @@ -183,18 +194,29 @@ Future startRadioPlayback(BaseItemDto source) async { radioTracksNeededForInitialQueue, overrideSeedItem: source, forNewQueue: true, + radioMode: selectedRadioMode, ); } catch (e) { _radioLogger.warning("Couldn't generate radio tracks: $e"); } - final tracksToAddCount = min(switch (localResult.radioMode) { - RadioMode.albumMix => generatedTracks.length, // album mix returns full albums, and those should stay together - RadioMode.reshuffle => generatedTracks.length, // we append the full shuffled source at once + if (identical(localResult, _radioCacheStateStream.value)) { + _radioCacheStateStream.add(localResult.copyWith(generating: false)); + } + return (radioMode: selectedRadioMode, tracks: generatedTracks); +} + +/// Starts playback of a radio queue from tracks already produced by [generateRadioPreview]. +Future startRadioPlaybackWithTracks(BaseItemDto source, RadioMode radioMode, List tracks) async { + final radioTracksNeededForInitialQueue = _radioTracksNeededForInitialQueue(radioMode); + + final tracksToAddCount = min(switch (radioMode) { + RadioMode.albumMix => tracks.length, // album mix returns full albums, and those should stay together + RadioMode.reshuffle => tracks.length, // we append the full shuffled source at once _ => radioTracksNeededForInitialQueue, - }, generatedTracks.length); - final tracksToAdd = generatedTracks.take(tracksToAddCount); - final tracksToCache = generatedTracks.skip(tracksToAddCount); + }, tracks.length); + final tracksToAdd = tracks.take(tracksToAddCount); + final tracksToCache = tracks.skip(tracksToAddCount); if (tracksToAdd.isEmpty) { _radioLogger.warning("No tracks generated for radio playback from source '${source.name}'. Aborting."); @@ -202,13 +224,13 @@ Future startRadioPlayback(BaseItemDto source) async { return; } - FinampSetters.setRadioMode(currentRadioMode); + FinampSetters.setRadioMode(radioMode); toggleRadio(true); invalidateRadioCache(); // we're still starting a new queue, and acquire a new lock here - localResult = _radioCacheStateStream.value!.copyWith( + final localResult = _radioCacheStateStream.value!.copyWith( generating: false, queueing: true, - seedItem: currentRadioMode == RadioMode.continuous ? tracksToAdd.lastOrNull ?? source : source, + seedItem: radioMode == RadioMode.continuous ? tracksToAdd.lastOrNull ?? source : source, tracks: tracksToCache.toList(), ); _radioCacheStateStream.add(localResult); @@ -282,7 +304,9 @@ Future> generateRadioTracks( BaseItemDto? overrideSeedItem, List cachedTracks = const [], bool forNewQueue = false, + RadioMode? radioMode, }) async { + final selectedRadioMode = radioMode ?? FinampSettingsHelper.finampSettings.radioMode; final jellyfinApiHelper = GetIt.instance(); final downloadsService = GetIt.instance(); final finampUserHelper = GetIt.instance(); @@ -296,11 +320,10 @@ Future> generateRadioTracks( "overrideSeedItem must be provided if the queue is empty.", ); - final actualSeed = - overrideSeedItem ?? providers.read(getActiveRadioSeedProvider(FinampSettingsHelper.finampSettings.radioMode)); + final actualSeed = overrideSeedItem ?? providers.read(getActiveRadioSeedProvider(selectedRadioMode)); _radioLogger.info( - "Generating $minNumTracks radio tracks from ${overrideSeedItem == null ? "queue" : "override"} item '${actualSeed?.name}' using '${FinampSettingsHelper.finampSettings.radioMode.name}' mode.", + "Generating $minNumTracks radio tracks from ${overrideSeedItem == null ? "queue" : "override"} item '${actualSeed?.name}' using '${selectedRadioMode.name}' mode.", ); /// Adds tracks in such a manner to simulate "shuffle-repeat all", @@ -761,7 +784,7 @@ Future> generateRadioTracks( } try { - tracksOut = switch (FinampSettingsHelper.finampSettings.radioMode) { + tracksOut = switch (selectedRadioMode) { RadioMode.reshuffle => await reshuffleMode(), RadioMode.random => await randomMode(), RadioMode.similar => await similarMode(), @@ -772,7 +795,7 @@ Future> generateRadioTracks( _radioLogger.warning(e); } _radioLogger.info( - "Selected ${tracksOut.length} tracks for '${FinampSettingsHelper.finampSettings.radioMode.name}' mode: ${tracksOut.map((e) => "'${e.artists?.firstOrNull} - ${e.name}'").join(", ")}", + "Selected ${tracksOut.length} tracks for '${selectedRadioMode.name}' mode: ${tracksOut.map((e) => "'${e.artists?.firstOrNull} - ${e.name}'").join(", ")}", ); return tracksOut; } diff --git a/pubspec.lock b/pubspec.lock index 15c46c4b6..9a8bd3dbe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -93,8 +93,8 @@ packages: dependency: "direct main" description: path: audio_service - ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" - resolved-ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + ref: "77d8a1253cb34ab398e3e7bc364629f46089a897" + resolved-ref: "77d8a1253cb34ab398e3e7bc364629f46089a897" url: "https://github.com/finamp-app/audio_service.git" source: git version: "0.18.19" @@ -110,8 +110,8 @@ packages: dependency: "direct main" description: path: audio_service_platform_interface - ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" - resolved-ref: "32216a99e8359e7cd21282c72f1986fb7f9a5a4d" + ref: "77d8a1253cb34ab398e3e7bc364629f46089a897" + resolved-ref: "77d8a1253cb34ab398e3e7bc364629f46089a897" url: "https://github.com/finamp-app/audio_service.git" source: git version: "0.1.3" @@ -605,11 +605,11 @@ packages: dependency: "direct main" description: path: "." - ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 - resolved-ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 + ref: 8d243ea855bd40787a55b4492cd2ff6c6b8835f4 + resolved-ref: 8d243ea855bd40787a55b4492cd2ff6c6b8835f4 url: "https://github.com/finamp-app/flutter_carplay.git" source: git - version: "1.6.4" + version: "1.6.5" flutter_discord_rpc: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f3484eb1..58a7d88b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -155,7 +155,7 @@ dependencies: flutter_carplay: git: url: https://github.com/finamp-app/flutter_carplay.git - ref: cf997048d5b51fbb60bf68bc53538e01a2378f55 + ref: 8d243ea855bd40787a55b4492cd2ff6c6b8835f4 diacritic: ^0.1.6 mini_music_visualizer: ^1.1.4 @@ -204,12 +204,12 @@ dependency_overrides: audio_service: git: url: https://github.com/finamp-app/audio_service.git - ref: 32216a99e8359e7cd21282c72f1986fb7f9a5a4d + ref: 77d8a1253cb34ab398e3e7bc364629f46089a897 path: audio_service audio_service_platform_interface: git: url: https://github.com/finamp-app/audio_service.git - ref: 32216a99e8359e7cd21282c72f1986fb7f9a5a4d + ref: 77d8a1253cb34ab398e3e7bc364629f46089a897 path: audio_service_platform_interface # For information on the generic Dart part of this file, see the From d79c1a8e66cbcbb75a9730ad0a474ec62d23bf74 Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Thu, 17 Sep 2026 18:13:15 +1000 Subject: [PATCH 11/12] Keep the current track playing when CarPlay radio starts Start Radio replaced the whole queue, so the playing track restarted. Now the radio tracks replace only the tracks after the current one. The old queue is saved to Recent Queues first. The queue source becomes the radio, and later refills use the track as the seed. --- lib/services/carplay_helper.dart | 4 +-- lib/services/queue_service.dart | 41 ++++++++++++++++++++++++ lib/services/radio_service_helper.dart | 43 +++++++++++++++++++------- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index b92ca1cf4..76d5dae17 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -378,7 +378,7 @@ class CarPlayHelper { tracks.insert(0, firstTrack); } started = true; - await radio.startRadioPlaybackWithTracks(track, preview.radioMode, tracks); + await radio.startRadioPlaybackWithTracks(track, preview.radioMode, tracks, keepCurrentTrack: true); _cancelRadioPreview = null; } catch (e) { started = false; @@ -426,7 +426,7 @@ class CarPlayHelper { _cancelRadioPreview = null; final preview = await generation; if (cancelled) return; - await radio.startRadioPlaybackWithTracks(track, preview.radioMode, preview.tracks); + await radio.startRadioPlaybackWithTracks(track, preview.radioMode, preview.tracks, keepCurrentTrack: true); return; } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index bcecf5068..972dd8bcb 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1176,6 +1176,47 @@ class QueueService { }); } + /// Replaces the upcoming tracks with [slice] without interrupting the current track. + Future replaceUpcoming(PlayableSlice slice) async { + if (_audioHandler.audioSources.isEmpty || _currentTrack == null) { + return _startSlicePlayback(slice: slice); + } + + final upcomingCount = _queueNextUp.length + _queue.length; + + archiveSavedQueue(); + + _order.originalSource = slice.source; + + await addToQueue(slice); + + final adjustedIndicesToRemove = List.generate( + upcomingCount, + (index) => getActualIndexByLinearIndex(_currentQueueIndex + index + 1), + )..sort(); + + if (upcomingCount > 0) { + int currentRangeEnd = adjustedIndicesToRemove.last; + int currentRangeStart = currentRangeEnd; + // remove from the back to avoid index shifting + for (final adjustedIndex in adjustedIndicesToRemove.reversed.skip(1)) { + if (adjustedIndex == currentRangeStart - 1) { + currentRangeStart = adjustedIndex; + } else { + // remove in batches to improve performance + await _audioHandler.removeFinampQueueItemRange(currentRangeStart, currentRangeEnd + 1); + currentRangeStart = adjustedIndex; + currentRangeEnd = adjustedIndex; + } + } + await _audioHandler.removeFinampQueueItemRange(currentRangeStart, currentRangeEnd + 1); + } + + _buildQueueFromNativePlayerQueue(); + + _queueServiceLogger.fine("Replaced $upcomingCount upcoming items with items from '${slice.source.name}'"); + } + Future removeQueueItem(FinampQueueItem queueItem) async { int? offset = getQueue().getOffsetForQueueItem(queueItem); if (offset == null) { diff --git a/lib/services/radio_service_helper.dart b/lib/services/radio_service_helper.dart index 08f8aa156..820bbf4e4 100644 --- a/lib/services/radio_service_helper.dart +++ b/lib/services/radio_service_helper.dart @@ -206,8 +206,13 @@ Future<({RadioMode radioMode, List tracks})> generateRadioPreview( return (radioMode: selectedRadioMode, tracks: generatedTracks); } -/// Starts playback of a radio queue from tracks already produced by [generateRadioPreview]. -Future startRadioPlaybackWithTracks(BaseItemDto source, RadioMode radioMode, List tracks) async { +/// Starts a radio queue from preview tracks, after the current track when [keepCurrentTrack]. +Future startRadioPlaybackWithTracks( + BaseItemDto source, + RadioMode radioMode, + List tracks, { + bool keepCurrentTrack = false, +}) async { final radioTracksNeededForInitialQueue = _radioTracksNeededForInitialQueue(radioMode); final tracksToAddCount = min(switch (radioMode) { @@ -235,18 +240,32 @@ Future startRadioPlaybackWithTracks(BaseItemDto source, RadioMode radioMod ); _radioCacheStateStream.add(localResult); - await GetIt.instance().startPlayback( - items: tracksToAdd.toList(), - source: QueueItemSource( - type: QueueItemSourceType.radio, - name: QueueItemSourceName(type: QueueItemSourceNameType.radio, localizationParameter: source.name ?? ""), - id: source.id, - item: source, - library: GetIt.instance().currentUser?.currentViewId, - ), - skipRadioCacheInvalidation: true, + final queueService = GetIt.instance(); + final radioSource = QueueItemSource( + type: QueueItemSourceType.radio, + name: QueueItemSourceName(type: QueueItemSourceNameType.radio, localizationParameter: source.name ?? ""), + id: source.id, + item: source, + library: GetIt.instance().currentUser?.currentViewId, ); + if (keepCurrentTrack && queueService.getCurrentTrack() != null) { + await queueService.replaceUpcoming( + BasePlayableSlice( + items: tracksToAdd.toList(), + startingIndex: 0, + source: radioSource, + shuffleState: SliceShuffleState.linear, + ), + ); + } else { + await queueService.startPlayback( + items: tracksToAdd.toList(), + source: radioSource, + skipRadioCacheInvalidation: true, + ); + } + if (identical(localResult, _radioCacheStateStream.value)) { _radioCacheStateStream.add(localResult.copyWith(queueing: false)); } From 7b7dbcb94c01ca60d6aba67f64a2453d51244f9a Mon Sep 17 00:00:00 2001 From: Algy Tynan Date: Thu, 17 Sep 2026 18:30:06 +1000 Subject: [PATCH 12/12] Address review comments Make the queue service the only owner of the startup queue restore. Other callers wait for it with ensureQueueLoaded or initialQueueLoaded. Fetch the collage tracks in one request and read the cached collage before the covers are decoded. Release each decoded image, picture and text painter after use. Move the CarPlay image code to carplay_image_helper.dart. --- lib/services/carplay_helper.dart | 343 ++-------------- lib/services/carplay_image_helper.dart | 366 ++++++++++++++++++ .../music_player_background_task.dart | 42 +- lib/services/queue_service.dart | 49 +-- 4 files changed, 425 insertions(+), 375 deletions(-) create mode 100644 lib/services/carplay_image_helper.dart diff --git a/lib/services/carplay_helper.dart b/lib/services/carplay_helper.dart index 76d5dae17..e5391382e 100644 --- a/lib/services/carplay_helper.dart +++ b/lib/services/carplay_helper.dart @@ -1,21 +1,14 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:math'; -import 'dart:typed_data'; -import 'dart:ui' as ui; import 'package:finamp/components/MusicScreen/sort_and_filter_row.dart'; import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/models/music_models.dart'; -import 'package:finamp/services/album_image_provider.dart'; import 'package:finamp/services/music_player_background_task.dart'; import 'package:finamp/services/music_providers.dart'; import 'package:finamp/services/music_screen_provider.dart'; -import 'package:flutter/painting.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/material.dart' show Icons; -import 'package:flutter/widgets.dart' show IconData; import 'package:flutter_carplay/flutter_carplay.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; @@ -26,8 +19,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:hive_ce/hive.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path_helper; -import 'package:path_provider/path_provider.dart'; import 'favorite_provider.dart'; import 'finamp_settings_helper.dart'; @@ -36,7 +27,7 @@ import 'audio_service_helper.dart'; import 'queue_service.dart'; import 'item_helper.dart'; import 'radio_service_helper.dart' as radio; -import 'item_by_id_provider.dart'; +import 'carplay_image_helper.dart'; final _carPlayLogger = Logger("CarPlay"); @@ -66,10 +57,6 @@ const _sectionCapOverride = int.fromEnvironment('CARPLAY_SECTION_CAP', defaultVa /// only change needed once the server fixes Audio SortName. const _enableOnlineTracksLetterPicker = false; -/// Image size for CarPlay artwork. 100x100 is plenty for car displays -/// and transfers much faster than 200x200. -const _carPlayImageSize = 100; - /// First page size for CarPlay lists, kept small so lists appear quickly. /// The background fill catches up in [musicScreenPageSize] chunks. const _carPlayFirstPageSize = 30; @@ -84,19 +71,6 @@ const _carPlayRecentlyPlayedLimit = 5; /// row, before clamping to the plugin's runtime grid-image limit. const _maxRecentQueues = 6; -/// Last resort artwork stand-in when the rendered placeholder tile is unavailable. -const _carPlayFallbackImage = 'sfsymbol:music.note.list'; - -/// Number of distinct albums composed into a Recent Queues collage cover, -/// and the side length in pixels of each tile within it. -const _collageTileCount = 4; -const _collageTileSize = 100; - -/// Maximum number of upcoming tracks to resolve while hunting for -/// [_collageTileCount] distinct albums for a queue's collage cover, so a -/// huge queue doesn't spam the server with lookups. -const _maxCollageTrackScan = 20; - class CarPlayHelper { ConnectionStatusTypes connectionStatus = ConnectionStatusTypes.unknown; final FlutterCarplay _flutterCarplay = FlutterCarplay(); @@ -115,13 +89,14 @@ class CarPlayHelper { CPListTemplate? _homeTemplate; bool _isSettingRootTemplate = false; bool _isUpdatingNowPlayingButtons = false; - int _recentQueueImageFillRun = 0; + int _recentQueuesListImageRun = 0; BaseItemId? _nowPlayingButtonsTrackId; void Function()? _cancelRadioPreview; bool get isUserLoggedIn => _finampUserHelper.currentUser != null; final _queueService = GetIt.instance(); + final _images = CarPlayImageHelper(); /// Runtime caps from `CPListTemplate.getMaximum*Count()`, reset on every /// CarPlay connect since different head units allow different caps. A @@ -143,19 +118,6 @@ class CarPlayHelper { return reported > 0 ? reported : _fallbackMaxListSections; } - /// Resolves the image URI for a CarPlay list item via [albumImageProvider], - /// so CarPlay shares Finamp's image cache. Returns a `file://` URI for - /// downloaded images and a network URL otherwise. - String? _getCarPlayImageUri(BaseItemDto item) { - if (item.imageId == null) return null; - return providerRef - .read( - albumImageProvider(AlbumImageRequest(item: item, maxHeight: _carPlayImageSize, maxWidth: _carPlayImageSize)), - ) - .uri - ?.toString(); - } - void setupCarplay() { _flutterCarplay.addListenerOnConnectionChange(onConnectionChange); @@ -298,7 +260,7 @@ class CarPlayHelper { final isShuffled = _queueService.playbackOrder == FinampPlaybackOrder.shuffled; final shuffleIcon = - await _getIconFontImageUri(isShuffled ? TablerIcons.arrows_shuffle : TablerIcons.arrows_right, 40) ?? + await _images.iconFontImageUri(isShuffled ? TablerIcons.arrows_shuffle : TablerIcons.arrows_right, 40) ?? 'sfsymbol:shuffle'; final buttons = [ CPNowPlayingImageButton(image: shuffleIcon, onPress: () => _queueService.togglePlaybackOrder()), @@ -307,7 +269,7 @@ class CarPlayHelper { if (currentTrack != null && !isOffline) { final isFavorite = providerRef.read(isFavoriteProvider(currentTrack)); final heartIcon = - await _getIconFontImageUri(isFavorite ? TablerIcons.heart_filled : TablerIcons.heart, 40) ?? + await _images.iconFontImageUri(isFavorite ? TablerIcons.heart_filled : TablerIcons.heart, 40) ?? (isFavorite ? 'sfsymbol:heart.fill' : 'sfsymbol:heart'); buttons.add( CPNowPlayingImageButton( @@ -316,7 +278,7 @@ class CarPlayHelper { ), ); - final radioIcon = await _getIconFontImageUri(TablerIcons.radio, 40) ?? 'sfsymbol:radio'; + final radioIcon = await _images.iconFontImageUri(TablerIcons.radio, 40) ?? 'sfsymbol:radio'; buttons.add( CPNowPlayingImageButton( image: radioIcon, @@ -443,7 +405,7 @@ class CarPlayHelper { CPListItem( text: previewTrack.name ?? l10n.unknown, detailText: previewTrack.artists?.join(", ") ?? previewTrack.albumArtist, - image: _getCarPlayImageUri(previewTrack), + image: _images.imageUri(previewTrack), onPress: (complete, self) async { try { await startRadio(previewTrack); @@ -1032,7 +994,7 @@ class CarPlayHelper { return CPListItem( text: album.name ?? GlobalSnackbar.requireL10n.unknownName, detailText: album.albumArtist, - image: _getCarPlayImageUri(album), + image: _images.imageUri(album), onPress: (complete, self) async { await showCollectionTracksTemplate(album); complete(); @@ -1053,263 +1015,11 @@ class CarPlayHelper { } } - /// Resolves the art-row image for a saved queue: a 2x2 collage of covers - /// from the next [_collageTileCount] distinct albums coming up in the - /// queue, falling back to the current track's own artwork, then to a - /// placeholder icon, so a missing track or missing artwork doesn't shift - /// indices out of alignment with the queue list. - Future _getRecentQueueImage(FinampStorableQueueInfo info) async { - try { - final collage = await _buildRecentQueueCollage(info); - if (collage != null) { - return collage; - } - } catch (e) { - _carPlayLogger.warning("Failed to build collage for recent queue: $e"); - } - return _getRecentQueueCoverImage(info); - } - - /// Resolves the current track's own artwork for a saved queue, falling - /// back to a placeholder icon. Used when a collage can't be built. - Future _getRecentQueueCoverImage(FinampStorableQueueInfo info) async { - final currentTrackId = info.currentTrack; - if (currentTrackId == null) { - return _getPlaceholderImageUri(); - } - try { - final track = await providerRef.read(itemByIdProvider(currentTrackId).future); - if (track == null) { - return _getPlaceholderImageUri(); - } - return _getCarPlayImageUri(track) ?? await _getPlaceholderImageUri(); - } catch (e) { - _carPlayLogger.warning("Failed to resolve artwork for recent queue: $e"); - return _getPlaceholderImageUri(); - } - } - - /// Finds up to [_collageTileCount] distinct albums among the tracks - /// coming up in [info] (current track, then queue), resolving each - /// candidate's cover as it's found so a single failed cover doesn't sink - /// the whole collage, then composes the resolved covers into a PNG cached - /// under the temp directory and returns a `file://` URI. Returns null if - /// no cover resolves at all. - Future _buildRecentQueueCollage(FinampStorableQueueInfo info) async { - // Prefer albums still coming up, then pad with the most recently played - // ones so a queue archived near its end can still fill the collage. - final upcomingIds = [ - if (info.currentTrack != null) info.currentTrack!, - ...info.nextUp, - ...info.queue, - ...info.previousTracks.reversed, - ]; - - final albumImages = []; - final usedAlbumIds = []; - final seenAlbumIds = {}; - var scanned = 0; - for (final id in upcomingIds) { - if (albumImages.length >= _collageTileCount || scanned >= _maxCollageTrackScan) { - break; - } - scanned++; - final track = await providerRef.read(itemByIdProvider(id).future); - final albumId = track?.albumId?.raw; - if (albumId == null || !seenAlbumIds.add(albumId)) { - continue; - } - final image = await _resolveCollageTileImage(track!); - if (image == null) { - // Cover failed to resolve or decode. Keep scanning for a - // replacement instead of failing the whole collage. - continue; - } - albumImages.add(image); - usedAlbumIds.add(albumId); - } - - if (albumImages.isEmpty) { - return null; - } - - // Anything short of a full 2x2 grid falls back to the best single - // cover scaled across the whole canvas, so every tile in the Recent - // Queues row stays the same size. - final tiles = albumImages.length == _collageTileCount ? albumImages : [albumImages.first]; - final tileIdsKey = albumImages.length == _collageTileCount ? usedAlbumIds : [usedAlbumIds.first]; - - final cacheFile = File( - path_helper.join( - (await getTemporaryDirectory()).path, - 'carplay_queue_collage_${info.creation}_${tileIdsKey.join(',').hashCode}.png', - ), - ); - if (await cacheFile.exists()) { - return Uri.file(cacheFile.path).toString(); - } - - final bytes = await _composeCollage(tiles); - if (bytes == null) { - return null; - } - await cacheFile.writeAsBytes(bytes, flush: true); - return Uri.file(cacheFile.path).toString(); - } - - /// Resolves a track's album cover as a decoded [ui.Image] via - /// [albumImageProvider], reusing Finamp's image cache and auth. Returns - /// null if the artwork can't be resolved or decoded. - Future _resolveCollageTileImage(BaseItemDto track) async { - final imageProvider = providerRef - .read( - albumImageProvider(AlbumImageRequest(item: track, maxWidth: _collageTileSize, maxHeight: _collageTileSize)), - ) - .image; - if (imageProvider == null) { - return null; - } - - final completer = Completer(); - final stream = imageProvider.resolve(ImageConfiguration.empty); - late ImageStreamListener listener; - listener = ImageStreamListener( - (image, synchronousCall) { - stream.removeListener(listener); - completer.complete(image.image); - }, - onError: (error, stackTrace) { - stream.removeListener(listener); - completer.complete(null); - }, - ); - stream.addListener(listener); - return completer.future; - } - - /// Composes [images] into a square collage PNG the same size regardless - /// of tile count, returning the encoded bytes, or null if encoding fails. - /// A single image fills the whole canvas. [_collageTileCount] images are - /// drawn as 2x2 quadrants. - Future _composeCollage(List images) async { - final tileSize = _collageTileSize.toDouble(); - final collageSize = tileSize * 2; - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, collageSize, collageSize)); - if (images.length == 1) { - final image = images.first; - canvas.drawImageRect( - image, - ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), - ui.Rect.fromLTWH(0, 0, collageSize, collageSize), - ui.Paint(), - ); - } else { - for (var i = 0; i < images.length; i++) { - final image = images[i]; - final dx = (i % 2) * tileSize; - final dy = (i ~/ 2) * tileSize; - canvas.drawImageRect( - image, - ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), - ui.Rect.fromLTWH(dx, dy, tileSize, tileSize), - ui.Paint(), - ); - } - } - final picture = recorder.endRecording(); - final collageImage = await picture.toImage(collageSize.round(), collageSize.round()); - final byteData = await collageImage.toByteData(format: ui.ImageByteFormat.png); - return byteData?.buffer.asUint8List(); - } - - String? _placeholderImage; - - /// Renders the main UI's artwork placeholder, the album glyph on a card - /// coloured tile, to a cached PNG and returns its file URI. - Future _getPlaceholderImageUri() async { - if (_placeholderImage != null) { - return _placeholderImage!; - } - try { - const size = 100.0; - final cacheFile = File( - path_helper.join((await getTemporaryDirectory()).path, 'carplay_placeholder_${size.round()}.png'), - ); - if (!await cacheFile.exists()) { - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); - canvas.drawRect(ui.Rect.fromLTWH(0, 0, size, size), ui.Paint()..color = const ui.Color(0xFF424242)); - final painter = TextPainter( - text: TextSpan( - text: String.fromCharCode(Icons.album.codePoint), - style: TextStyle( - fontFamily: Icons.album.fontFamily, - fontSize: size * 0.4, - color: const ui.Color(0xB3FFFFFF), - ), - ), - textDirection: ui.TextDirection.ltr, - )..layout(); - painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); - final image = await recorder.endRecording().toImage(size.round(), size.round()); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) { - return _carPlayFallbackImage; - } - await cacheFile.writeAsBytes(byteData.buffer.asUint8List(), flush: true); - } - _placeholderImage = Uri.file(cacheFile.path).toString(); - } catch (e) { - _carPlayLogger.warning("Failed to render artwork placeholder: $e"); - _placeholderImage = _carPlayFallbackImage; - } - return _placeholderImage!; - } - - /// Renders an icon font glyph to a PNG in the temp directory and returns - /// its file URI, so CarPlay buttons can show the same icons as the phone - /// UI. Only the glyph's alpha matters, CarPlay tints button images itself. - Future _getIconFontImageUri(IconData icon, double size) async { - final cacheFile = File( - path_helper.join((await getTemporaryDirectory()).path, 'carplay_icon_${icon.codePoint}_${size.round()}.png'), - ); - if (!await cacheFile.exists()) { - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); - final painter = TextPainter( - text: TextSpan( - text: String.fromCharCode(icon.codePoint), - style: TextStyle( - fontFamily: icon.fontFamily, - package: icon.fontPackage, - fontSize: size, - color: const ui.Color(0xFFFFFFFF), - ), - ), - textDirection: ui.TextDirection.ltr, - )..layout(); - painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); - final image = await recorder.endRecording().toImage(size.round(), size.round()); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) { - return null; - } - await cacheFile.writeAsBytes(byteData.buffer.asUint8List(), flush: true); - } - return Uri.file(cacheFile.path).toString(); - } - /// Archives the live queue, restores [info] at its saved track and seek /// position, then shows CarPlay's Now Playing screen. Shared by the /// Recent Queues art row's per-image tap and its pushed full-history list. Future _resumeSavedQueue(FinampStorableQueueInfo info) async { - // The cold-launch startup restore commonly hasn't settled yet, which - // would otherwise error as "already loading". Its own failure is - // unrelated to this queue, so ignore it. - try { - await _queueService.performInitialQueueLoad(); - } catch (_) {} + await _queueService.initialQueueLoaded; _queueService.archiveSavedQueue(); await _queueService.loadSavedQueue(info); await FlutterCarplay.showSharedNowPlaying(); @@ -1326,7 +1036,7 @@ class CarPlayHelper { _isPushingPageUpdate = true; try { final l10n = GlobalSnackbar.requireL10n; - final placeholderImage = await _getPlaceholderImageUri(); + final placeholderImage = await _images.placeholderImageUri(); final items = List.generate(queueHistory.length, (index) { final info = queueHistory[index]; final remaining = info.trackCount - info.previousTracks.length; @@ -1353,23 +1063,20 @@ class CarPlayHelper { systemIcon: 'clock.arrow.circlepath', ), ); - unawaited(_fillRecentQueueImages(queueHistory, items)); + unawaited(_fillRecentQueuesListImages(queueHistory, items)); } finally { _isPushingPageUpdate = false; } } - /// Streams the pushed Recent Queues list's collage covers in one queue at - /// a time via [CPListItem.setImage], so the list opens instantly and - /// building covers never blocks CarPlay navigation. A newer run abandons - /// any older one still going. - Future _fillRecentQueueImages(List queueHistory, List items) async { - final run = ++_recentQueueImageFillRun; + /// Fills the pushed Recent Queues list covers after it is on screen. + Future _fillRecentQueuesListImages(List queueHistory, List items) async { + final run = ++_recentQueuesListImageRun; try { - final placeholderImage = await _getPlaceholderImageUri(); + final placeholderImage = await _images.placeholderImageUri(); for (var i = 0; i < items.length; i++) { - final image = await _getRecentQueueImage(queueHistory[i]); - if (run != _recentQueueImageFillRun) { + final image = await _images.recentQueueImage(queueHistory[i]); + if (run != _recentQueuesListImageRun) { return; } if (image != placeholderImage) { @@ -1430,14 +1137,14 @@ class CarPlayHelper { if (recentlyAddedFetched.isNotEmpty) { final recentlyAddedLimit = await _clampToGridImageLimit(recentlyAddedFetched.length); final recentlyAdded = recentlyAddedFetched.take(recentlyAddedLimit).toList(); - final placeholderImage = await _getPlaceholderImageUri(); + final placeholderImage = await _images.placeholderImageUri(); sections.add( CPListSection( items: [ CPListImageRowItem( text: GlobalSnackbar.requireL10n.recentlyAdded, - gridImages: recentlyAdded.map((album) => _getCarPlayImageUri(album) ?? placeholderImage).toList(), + gridImages: recentlyAdded.map((album) => _images.imageUri(album) ?? placeholderImage).toList(), onPress: (complete, self) async { try { await _showRecentlyAddedTemplate(); @@ -1476,7 +1183,7 @@ class CarPlayHelper { CPListItem( text: baseItem.name ?? GlobalSnackbar.requireL10n.unknown, detailText: baseItem.artists?.join(", ") ?? baseItem.albumArtist, - image: _getCarPlayImageUri(baseItem), + image: _images.imageUri(baseItem), onPress: (complete, self) async { if (!FinampSettingsHelper.finampSettings.isOffline) { final audioServiceHelper = GetIt.instance(); @@ -1513,7 +1220,7 @@ class CarPlayHelper { final queueLimit = await _clampToGridImageLimit(_maxRecentQueues); final recentQueues = recentQueueHistory.take(queueLimit).toList(); - final queueImages = await Future.wait(recentQueues.map(_getRecentQueueImage)); + final queueImages = await Future.wait(recentQueues.map(_images.recentQueueImage)); sections.add( CPListSection( @@ -1716,7 +1423,7 @@ class CarPlayHelper { CPListItem( text: item.name ?? GlobalSnackbar.requireL10n.unknownName, detailText: item.artists?.join(", ") ?? item.albumArtist, - image: _getCarPlayImageUri(item), + image: _images.imageUri(item), onPress: (complete, self) async { await playItem(parent, index: index); complete(); @@ -1764,7 +1471,7 @@ class CarPlayHelper { (item, index) => CPListItem( text: item.name ?? GlobalSnackbar.requireL10n.unknown, detailText: item.artists?.join(", ") ?? item.albumArtist, - image: _getCarPlayImageUri(item), + image: _images.imageUri(item), onPress: (complete, self) async { if (tabType == ContentType.genres && genreFilter == null) { await showBrowsableListTemplate(tabType: tabType, genreFilter: item); @@ -1804,7 +1511,7 @@ class CarPlayHelper { (item, index) => CPListItem( text: item.name ?? GlobalSnackbar.requireL10n.unknownName, detailText: item.artists?.join(", ") ?? item.albumArtist, - image: _getCarPlayImageUri(item), + image: _images.imageUri(item), onPress: (complete, self) async { await _startSliceFromPlayable(request, index: index); complete(); @@ -1880,7 +1587,7 @@ class CarPlayHelper { artistAlbums.items.add( CPListItem( text: item.name ?? GlobalSnackbar.requireL10n.unknownName, - image: _getCarPlayImageUri(item), + image: _images.imageUri(item), onPress: (complete, self) async { await showCollectionTracksTemplate(item); complete(); diff --git a/lib/services/carplay_image_helper.dart b/lib/services/carplay_image_helper.dart new file mode 100644 index 000000000..2ab4b819b --- /dev/null +++ b/lib/services/carplay_image_helper.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/album_image_provider.dart'; +import 'package:finamp/services/downloads_service.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:finamp/services/item_by_id_provider.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart' show IconData; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path_helper; +import 'package:path_provider/path_provider.dart'; + +final _carPlayImageLogger = Logger("CarPlayImages"); + +/// Image size for CarPlay artwork. 100x100 is plenty for car displays +/// and transfers much faster than 200x200. +const _carPlayImageSize = 100; + +/// Last resort artwork stand-in when the rendered placeholder tile is unavailable. +const _carPlayFallbackImage = 'sfsymbol:music.note.list'; + +/// Number of distinct albums composed into a Recent Queues collage cover, +/// and the side length in pixels of each tile within it. +const _collageTileCount = 4; +const _collageTileSize = 100; + +/// Upper bound on tracks looked up for one collage. +const _maxCollageTrackScan = 20; + +/// Resolves and renders every image CarPlay shows. +class CarPlayImageHelper { + final _providers = GetIt.instance(); + + /// Resolves the image URI for a CarPlay list item via [albumImageProvider], + /// so CarPlay shares Finamp's image cache. Returns a `file://` URI for + /// downloaded images and a network URL otherwise. + String? imageUri(BaseItemDto item) { + if (item.imageId == null) return null; + return _providers + .read( + albumImageProvider(AlbumImageRequest(item: item, maxHeight: _carPlayImageSize, maxWidth: _carPlayImageSize)), + ) + .uri + ?.toString(); + } + + /// Resolves the art-row image for a saved queue: a 2x2 collage of covers + /// from the next [_collageTileCount] distinct albums coming up in the + /// queue, falling back to the current track's own artwork, then to a + /// placeholder icon, so a missing track or missing artwork doesn't shift + /// indices out of alignment with the queue list. + Future recentQueueImage(FinampStorableQueueInfo info) async { + try { + final collage = await _buildRecentQueueCollage(info); + if (collage != null) { + return collage; + } + } catch (e) { + _carPlayImageLogger.warning("Failed to build collage for recent queue: $e"); + } + return _getRecentQueueCoverImage(info); + } + + /// Resolves the current track's own artwork for a saved queue, falling + /// back to a placeholder icon. Used when a collage can't be built. + Future _getRecentQueueCoverImage(FinampStorableQueueInfo info) async { + final currentTrackId = info.currentTrack; + if (currentTrackId == null) { + return placeholderImageUri(); + } + try { + final track = await _providers.read(itemByIdProvider(currentTrackId).future); + if (track == null) { + return placeholderImageUri(); + } + return imageUri(track) ?? await placeholderImageUri(); + } catch (e) { + _carPlayImageLogger.warning("Failed to resolve artwork for recent queue: $e"); + return placeholderImageUri(); + } + } + + /// Looks up [ids] in one request, or from the downloads database when offline. + Future> _lookupTracks(List ids) async { + if (ids.isEmpty) { + return {}; + } + final tracks = {}; + if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsService = GetIt.instance(); + for (final id in ids) { + final track = (await downloadsService.getTrackInfo(id: id))?.baseItem; + if (track != null) { + tracks[id] = track; + } + } + } else { + final items = await GetIt.instance().getItems(itemIds: ids); + for (final item in items ?? const []) { + tracks[item.id] = item; + } + } + return tracks; + } + + /// Composes the first distinct album covers of the queue into a cached PNG. + Future _buildRecentQueueCollage(FinampStorableQueueInfo info) async { + // Prefer albums still coming up, then pad with the most recently played + // ones so a queue archived near its end can still fill the collage. + final upcomingIds = [ + if (info.currentTrack != null) info.currentTrack!, + ...info.nextUp, + ...info.queue, + ...info.previousTracks.reversed, + ]; + + final candidateIds = upcomingIds.take(_maxCollageTrackScan).toList(); + final tracks = await _lookupTracks(candidateIds); + + final albumTracks = []; + final albumIds = []; + final seenAlbumIds = {}; + for (final id in candidateIds) { + final track = tracks[id]; + final albumId = track?.albumId?.raw; + if (albumId == null || !seenAlbumIds.add(albumId)) { + continue; + } + albumTracks.add(track!); + albumIds.add(albumId); + } + + if (albumTracks.isEmpty) { + return null; + } + + final tempPath = (await getTemporaryDirectory()).path; + File collageFile(List ids) => + File(path_helper.join(tempPath, 'carplay_queue_collage_${info.creation}_${ids.join(',').hashCode}.png')); + + // Anything short of a full 2x2 grid falls back to the best single + // cover scaled across the whole canvas, so every tile in the Recent + // Queues row stays the same size. + final expectedIds = albumIds.length >= _collageTileCount + ? albumIds.take(_collageTileCount).toList() + : [albumIds.first]; + final expectedFile = collageFile(expectedIds); + if (await expectedFile.exists()) { + return Uri.file(expectedFile.path).toString(); + } + + final tiles = []; + final usedAlbumIds = []; + try { + for (var i = 0; i < albumTracks.length && tiles.length < _collageTileCount; i++) { + final tile = await _resolveCollageTileImage(albumTracks[i]); + if (tile == null) { + // Cover failed to resolve or decode. Keep scanning for a + // replacement instead of failing the whole collage. + continue; + } + tiles.add(tile); + usedAlbumIds.add(albumIds[i]); + } + + if (tiles.isEmpty) { + return null; + } + + final drawnTiles = tiles.length == _collageTileCount ? tiles : [tiles.first]; + final drawnIds = tiles.length == _collageTileCount ? usedAlbumIds : [usedAlbumIds.first]; + final cacheFile = collageFile(drawnIds); + if (await cacheFile.exists()) { + return Uri.file(cacheFile.path).toString(); + } + + final bytes = await _composeCollage(drawnTiles.map((tile) => tile.image).toList()); + if (bytes == null) { + return null; + } + await cacheFile.writeAsBytes(bytes, flush: true); + return Uri.file(cacheFile.path).toString(); + } finally { + for (final tile in tiles) { + tile.dispose(); + } + } + } + + /// The caller owns the returned [ImageInfo] and must dispose it. + Future _resolveCollageTileImage(BaseItemDto track) async { + final imageProvider = _providers + .read( + albumImageProvider(AlbumImageRequest(item: track, maxWidth: _collageTileSize, maxHeight: _collageTileSize)), + ) + .image; + if (imageProvider == null) { + return null; + } + + final completer = Completer(); + final stream = imageProvider.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + listener = ImageStreamListener( + (imageInfo, synchronousCall) { + stream.removeListener(listener); + if (completer.isCompleted) { + imageInfo.dispose(); + return; + } + completer.complete(imageInfo); + }, + onError: (error, stackTrace) { + if (completer.isCompleted) { + return; + } + stream.removeListener(listener); + completer.complete(null); + }, + ); + stream.addListener(listener); + return completer.future; + } + + /// Composes [images] into a square collage PNG the same size regardless + /// of tile count, returning the encoded bytes, or null if encoding fails. + /// A single image fills the whole canvas. [_collageTileCount] images are + /// drawn as 2x2 quadrants. + Future _composeCollage(List images) async { + final tileSize = _collageTileSize.toDouble(); + final collageSize = tileSize * 2; + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, collageSize, collageSize)); + if (images.length == 1) { + final image = images.first; + canvas.drawImageRect( + image, + ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + ui.Rect.fromLTWH(0, 0, collageSize, collageSize), + ui.Paint(), + ); + } else { + for (var i = 0; i < images.length; i++) { + final image = images[i]; + final dx = (i % 2) * tileSize; + final dy = (i ~/ 2) * tileSize; + canvas.drawImageRect( + image, + ui.Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + ui.Rect.fromLTWH(dx, dy, tileSize, tileSize), + ui.Paint(), + ); + } + } + return _encodePng(recorder.endRecording(), collageSize.round()); + } + + Future _encodePng(ui.Picture picture, int size) async { + final ui.Image image; + try { + image = await picture.toImage(size, size); + } finally { + picture.dispose(); + } + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } finally { + image.dispose(); + } + } + + String? _placeholderImage; + + /// Renders the main UI's artwork placeholder, the album glyph on a card + /// coloured tile, to a cached PNG and returns its file URI. + Future placeholderImageUri() async { + if (_placeholderImage != null) { + return _placeholderImage!; + } + try { + const size = 100.0; + final cacheFile = File( + path_helper.join((await getTemporaryDirectory()).path, 'carplay_placeholder_${size.round()}.png'), + ); + if (!await cacheFile.exists()) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); + canvas.drawRect(ui.Rect.fromLTWH(0, 0, size, size), ui.Paint()..color = const ui.Color(0xFF424242)); + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(Icons.album.codePoint), + style: TextStyle( + fontFamily: Icons.album.fontFamily, + fontSize: size * 0.4, + color: const ui.Color(0xB3FFFFFF), + ), + ), + textDirection: ui.TextDirection.ltr, + ); + try { + painter.layout(); + painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); + } finally { + painter.dispose(); + } + final bytes = await _encodePng(recorder.endRecording(), size.round()); + if (bytes == null) { + return _carPlayFallbackImage; + } + await cacheFile.writeAsBytes(bytes, flush: true); + } + _placeholderImage = Uri.file(cacheFile.path).toString(); + } catch (e) { + _carPlayImageLogger.warning("Failed to render artwork placeholder: $e"); + _placeholderImage = _carPlayFallbackImage; + } + return _placeholderImage!; + } + + /// Renders an icon font glyph to a PNG in the temp directory and returns + /// its file URI, so CarPlay buttons can show the same icons as the phone + /// UI. Only the glyph's alpha matters, CarPlay tints button images itself. + Future iconFontImageUri(IconData icon, double size) async { + final cacheFile = File( + path_helper.join((await getTemporaryDirectory()).path, 'carplay_icon_${icon.codePoint}_${size.round()}.png'), + ); + if (!await cacheFile.exists()) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, size, size)); + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(icon.codePoint), + style: TextStyle( + fontFamily: icon.fontFamily, + package: icon.fontPackage, + fontSize: size, + color: const ui.Color(0xFFFFFFFF), + ), + ), + textDirection: ui.TextDirection.ltr, + ); + try { + painter.layout(); + painter.paint(canvas, ui.Offset((size - painter.width) / 2, (size - painter.height) / 2)); + } finally { + painter.dispose(); + } + final bytes = await _encodePng(recorder.endRecording(), size.round()); + if (bytes == null) { + return null; + } + await cacheFile.writeAsBytes(bytes, flush: true); + } + return Uri.file(cacheFile.path).toString(); + } +} diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 8a92c1167..34a6a1d1a 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -615,46 +615,22 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler with SeekHandler, Queue _audioServiceBackgroundTaskLogger.info( "play() start: disableFade=$disableFade, playing=${_player.playing}, fadeDirection=${fadeState.value.fadeDirection}, currentIndex=${_player.currentIndex}, position=${_player.position}", ); - if (GetIt.instance().getCurrentTrack() == null) { - // A remote play command can arrive before a queue has been loaded in - // this process, such as CarPlay background-launching Finamp on - // reconnect. Await the memoised startup restore rather than dropping - // the command. - final queueService = GetIt.instance(); + final queueService = GetIt.instance(); + if (queueService.getCurrentTrack() == null) { _audioServiceBackgroundTaskLogger.info( - "play() received with no current item; awaiting saved-queue restore before starting playback", + "play() received with no current item, awaiting saved-queue restore before starting playback", ); + bool queueAvailable; try { - await queueService.performInitialQueueLoad(); + queueAvailable = await queueService.ensureQueueLoaded(); } catch (e) { _audioServiceBackgroundTaskLogger.warning("Saved-queue restore failed while handling remote play command: $e"); + queueAvailable = audioSources.isNotEmpty; } - if (queueService.getCurrentTrack() == null && queueService.savedQueueState == SavedQueueState.pendingSave) { - // Nothing was restored because autoloadLastQueueOnStartup is disabled. - // An explicit play command still expresses intent to resume. - _audioServiceBackgroundTaskLogger.info( - "No auto-loaded queue; loading the latest saved queue on demand for remote play command", - ); - try { - await queueService.loadLatestSavedQueueOnDemand(); - } catch (e) { - _audioServiceBackgroundTaskLogger.warning("On-demand saved-queue load failed: $e"); - } - } - - if (queueService.getCurrentTrack() == null) { - // _replaceWholeQueue nulls out the current track for the duration of - // a queue rebuild, so a load that's still settling shouldn't be - // treated the same as there being no saved queue at all. - final queueLoadSettling = queueService.savedQueueState == SavedQueueState.loading || audioSources.isNotEmpty; - if (!queueLoadSettling) { - _audioServiceBackgroundTaskLogger.info("No saved queue available to resume; ignoring play() command"); - return; - } - _audioServiceBackgroundTaskLogger.info("Queue load in progress; playing despite no current track yet"); - } else { - _audioServiceBackgroundTaskLogger.info("Saved queue restored; resuming playback at its saved position"); + if (!queueAvailable) { + _audioServiceBackgroundTaskLogger.info("No saved queue available to resume, ignoring play() command"); + return; } } if (_shouldIgnorePlayPauseAfterRecentSkip) { diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 972dd8bcb..1288cb99a 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -98,9 +98,7 @@ class QueueService { FinampStorableQueueInfo? _failedSavedQueue; static const int _maxSavedQueues = 60; - /// Memoised [Future] for [performInitialQueueLoad] so every caller awaits - /// the same restore. - Future? _initialQueueLoadFuture; + final _initialQueueLoad = Completer(); static int get maxInitialQueueItems => Platform.isIOS || Platform.isMacOS ? 1000 @@ -396,14 +394,8 @@ class QueueService { return queueList; } - /// Performs the one-time startup queue restore, loading the last "latest" - /// queue into the player, paused, per [FinampSettings.autoloadLastQueueOnStartup]. - /// Every caller awaits the same [Future]. - Future performInitialQueueLoad() { - return _initialQueueLoadFuture ??= _performInitialQueueLoad(); - } - - Future _performInitialQueueLoad() async { + /// Startup queue restore, called once from main(). + Future performInitialQueueLoad() async { try { _savedQueueState = SavedQueueState.init; archiveSavedQueue(inInit: true); @@ -426,22 +418,33 @@ class QueueService { } } catch (e) { _queueServiceLogger.severe(e); - // Don't memoise a failed restore, so a later caller (e.g. a remote - // play command) can retry it instead of being stuck forever. - _initialQueueLoadFuture = null; rethrow; + } finally { + if (!_initialQueueLoad.isCompleted) { + _initialQueueLoad.complete(); + } } } - /// Loads the latest saved queue on demand, for callers where - /// [performInitialQueueLoad] skipped loading it (e.g. - /// [FinampSettings.autoloadLastQueueOnStartup] disabled) but an explicit - /// play command expresses intent to resume anyway. - Future loadLatestSavedQueueOnDemand() async { - var info = _queuesBox.get("latest"); - if (info != null) { - await loadSavedQueue(info); + /// Completes when the startup restore finishes, and never errors. + Future get initialQueueLoaded => _initialQueueLoad.future; + + /// Waits for the startup restore, then loads the latest saved queue if that restore skipped it. + Future ensureQueueLoaded() async { + await _initialQueueLoad.future; + if (_currentTrack == null && _audioHandler.audioSources.isEmpty) { + if (_savedQueueState == SavedQueueState.failed) { + await retryQueueLoad(); + } else if (_savedQueueState == SavedQueueState.pendingSave) { + final info = _queuesBox.get("latest"); + if (info != null) { + await loadSavedQueue(info); + } + } } + return _currentTrack != null || + _audioHandler.audioSources.isNotEmpty || + _savedQueueState == SavedQueueState.loading; } Future _hasInitialPlayLink() async { @@ -1348,8 +1351,6 @@ class QueueService { return _currentTrack; } - SavedQueueState get savedQueueState => _savedQueueState; - set playbackSpeed(double speed) { _playbackSpeed = speed; _playbackSpeedStream.add(speed);