diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt index 7737786..c1a01ae 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCommandService.kt @@ -116,6 +116,10 @@ class AstraCarCommandService : HeadlessJsTaskService() { ) } + fun startFavoriteAction(context: Context) { + start(context, Intent(context, AstraCarCommandService::class.java).putExtra(EXTRA_COMMAND, AstraCarFavoriteAction.COMMAND)) + } + fun startPlayFromMediaId(context: Context, mediaId: String?) { val media = AstraCarMediaIds.decode(mediaId) ?: return start( diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarFavoriteAction.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarFavoriteAction.kt new file mode 100644 index 0000000..3500817 --- /dev/null +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarFavoriteAction.kt @@ -0,0 +1,6 @@ +package expo.modules.astracar + +object AstraCarFavoriteAction { + const val TOGGLE = "astra.favorite.toggle" + const val COMMAND = "toggleFavorite" +} diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt index 56e7104..4ffd91a 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarMediaService.kt @@ -51,6 +51,14 @@ class AstraCarMediaService : MediaBrowserServiceCompat() { override fun onPlayFromSearch(query: String?, extras: Bundle?) { AstraCarCommandService.startPlayFromSearch(this@AstraCarMediaService, query, extras) } + + override fun onCustomAction(action: String?, extras: Bundle?) { + if (action == AstraCarFavoriteAction.TOGGLE) { + AstraCarCommandService.startFavoriteAction(this@AstraCarMediaService) + } else { + super.onCustomAction(action, extras) + } + } }, ) applyAstraState(AstraCarNowPlayingStore.load(this@AstraCarMediaService)) diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt index dc7f2d4..a50296d 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarModule.kt @@ -37,6 +37,12 @@ class AstraCarNowPlayingRecord : Record { @Field val position: Double? = null + + @Field + val trackPath: String? = null + + @Field + val isFavorite: Boolean = false } class AstraCarModule : Module() { @@ -56,6 +62,8 @@ class AstraCarModule : Module() { hasTrack = state.hasTrack, durationSeconds = state.duration, positionSeconds = state.position, + trackPath = state.trackPath, + isFavorite = state.isFavorite, ), ) } diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt index 452a1cd..f82c093 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarNowPlaying.kt @@ -16,6 +16,8 @@ data class AstraCarNowPlayingState( val hasTrack: Boolean, val durationSeconds: Double?, val positionSeconds: Double?, + val trackPath: String?, + val isFavorite: Boolean, ) object AstraCarNowPlayingStore { @@ -94,9 +96,19 @@ object AstraCarNowPlayingStore { ((state.positionSeconds ?: 0.0) * 1000).toLong(), if (playbackState == PlaybackStateCompat.STATE_PLAYING) 1f else 0f, ) + .apply { + favoriteAction(state)?.let(::addCustomAction) + } .build() } + private fun favoriteAction(state: AstraCarNowPlayingState): PlaybackStateCompat.CustomAction? { + if (!state.hasTrack || state.trackPath.isNullOrBlank()) return null + val label = if (state.isFavorite) "Unfavorite" else "Favorite" + val icon = if (state.isFavorite) R.drawable.ic_astra_favorite else R.drawable.ic_astra_favorite_border + return PlaybackStateCompat.CustomAction.Builder(AstraCarFavoriteAction.TOGGLE, label, icon).build() + } + private fun encode(state: AstraCarNowPlayingState): String = JSONObject() .put("title", state.title) @@ -107,6 +119,8 @@ object AstraCarNowPlayingStore { .put("hasTrack", state.hasTrack) .put("durationSeconds", state.durationSeconds) .put("positionSeconds", state.positionSeconds) + .put("trackPath", state.trackPath) + .put("isFavorite", state.isFavorite) .toString() private fun decode(value: String?): AstraCarNowPlayingState { @@ -122,6 +136,8 @@ object AstraCarNowPlayingStore { hasTrack = json.optBoolean("hasTrack", false), durationSeconds = json.optDoubleOrNull("durationSeconds"), positionSeconds = json.optDoubleOrNull("positionSeconds"), + trackPath = json.optNullableString("trackPath"), + isFavorite = json.optBoolean("isFavorite", false), ) }.getOrDefault(emptyState()) } @@ -136,6 +152,8 @@ object AstraCarNowPlayingStore { hasTrack = false, durationSeconds = null, positionSeconds = null, + trackPath = null, + isFavorite = false, ) private fun JSONObject.optNullableString(key: String): String? { diff --git a/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite.xml b/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite.xml new file mode 100644 index 0000000..34d39fe --- /dev/null +++ b/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite.xml @@ -0,0 +1,9 @@ + + + diff --git a/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite_border.xml b/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite_border.xml new file mode 100644 index 0000000..9bfc293 --- /dev/null +++ b/modules/astra-car/android/src/main/res/drawable/ic_astra_favorite_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/modules/astra-car/index.ts b/modules/astra-car/index.ts index b501a27..5f21e77 100644 --- a/modules/astra-car/index.ts +++ b/modules/astra-car/index.ts @@ -16,6 +16,8 @@ export interface AstraCarNowPlayingState { hasTrack: boolean; duration?: number | null; position?: number | null; + trackPath?: string | null; + isFavorite?: boolean; } declare class AstraCarModuleType extends NativeModule { diff --git a/src/audio/carSync.ts b/src/audio/carSync.ts index 38eafeb..235e0cd 100644 --- a/src/audio/carSync.ts +++ b/src/audio/carSync.ts @@ -2,6 +2,7 @@ import TrackPlayer, { State, type Track as RntpTrack } from 'react-native-track- import type { PlaybackState, Track } from '@/types/audio'; import { AstraCar } from '../../modules/astra-car'; +import { usePlaylistStore } from '@/stores/playlistStore'; function mapRntpState(state?: State): PlaybackState { switch (state) { @@ -20,7 +21,7 @@ function mapRntpState(state?: State): PlaybackState { type CarNowPlayingTrack = Pick< Track, - 'title' | 'artist' | 'album' | 'artworkData' | 'duration' | 'sourceType' | 'sourceId' | 'artworkSourceId' + 'path' | 'title' | 'artist' | 'album' | 'artworkData' | 'duration' | 'sourceType' | 'sourceId' | 'artworkSourceId' >; /** True when the track should use its remote server cover (no local cache to serve). */ @@ -51,6 +52,7 @@ export function setCarNowPlaying( // Android Auto loads art only from content:// URIs, so we pass structured identity // (local hash or remote source+id) and let the native module build the content URI. const remote = isRemoteArt(track); + const trackPath = track?.path ?? null; AstraCar.setNowPlaying({ title: track?.title ?? null, artist: track?.artist ?? null, @@ -62,6 +64,8 @@ export function setCarNowPlaying( hasTrack: Boolean(track), duration: duration ?? track?.duration ?? null, position: position ?? null, + trackPath, + isFavorite: trackPath ? usePlaylistStore.getState().favoritePaths.has(trackPath) : false, }); } @@ -74,6 +78,7 @@ export function setCarNowPlayingFromRntpTrack( setCarNowPlaying( track ? { + path: typeof track.astraPath === 'string' ? track.astraPath : String(track.url ?? track.id), title: track.title ?? 'Unknown title', artist: track.artist ?? 'Unknown artist', album: track.album ?? '', diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts index 8da1ef3..6d4bae8 100644 --- a/src/car/carPlayback.ts +++ b/src/car/carPlayback.ts @@ -15,9 +15,11 @@ import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping' import { dbTrackToTrack } from '@/library/trackAdapter'; import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController'; import { syncCarNowPlayingFromTrackPlayer } from '@/audio/carSync'; +import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { useEQStore } from '@/stores/eqStore'; import { useLibraryStore } from '@/stores/libraryStore'; +import { usePlaylistStore } from '@/stores/playlistStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useSettingsStore } from '@/stores/settingsStore'; import type { DbTrack } from '@/types/library'; @@ -53,6 +55,7 @@ async function initializeForCar(): Promise { initPromise = (async () => { await useSettingsStore.getState().load(); await useLibraryStore.getState().initialize(); + await usePlaylistStore.getState().refresh(); await useRemoteSourcesStore.getState().init(); await Promise.all([ useEQStore.getState().load(), @@ -92,6 +95,9 @@ export async function handleAstraCarCommand(payload: CarCommandPayload): Promise case 'seek': if (typeof payload.position === 'number') await seekTo(payload.position); break; + case 'toggleFavorite': + await handleFavoriteCommand(); + break; default: break; } @@ -102,6 +108,19 @@ export async function handleAstraCarCommand(payload: CarCommandPayload): Promise } } +async function handleFavoriteCommand(): Promise { + const activeTrack = await TrackPlayer.getActiveTrack(); + const path = rntpTrackPath(activeTrack); + if (!path) return; + await usePlaylistStore.getState().toggleFavorite({ path }); +} + +function rntpTrackPath(track: RntpTrack | null | undefined): string | null { + if (!track) return null; + if (typeof track.astraPath === 'string' && track.astraPath.length > 0) return track.astraPath; + return typeof track.url === 'string' && track.url.length > 0 ? track.url : null; +} + async function playMedia(media: CarMediaPayload): Promise { const db = await openLibraryDb(); const resolved = await resolveMediaTracks(db, media);