diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt index c87e912..0039438 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -68,13 +68,21 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi playerNotificationService.pause() } - override fun onSkipToPrevious() { +override fun onSkipToPrevious() { + if (playerNotificationService.isAndroidAutoPlayback()) { + playerNotificationService.skipToPreviousChapter() + } else { playerNotificationService.skipToPrevious() } +} - override fun onSkipToNext() { +override fun onSkipToNext() { + if (playerNotificationService.isAndroidAutoPlayback()) { + playerNotificationService.skipToNextChapter() + } else { playerNotificationService.skipToNext() } +} override fun onFastForward() { playerNotificationService.jumpForward() @@ -84,11 +92,41 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi playerNotificationService.jumpBackward() } - override fun onSeekTo(pos: Long) { - val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs() - playerNotificationService.seekPlayer(currentTrackStartOffset + pos) +override fun onSeekTo(pos: Long) { + if (playerNotificationService.isAndroidAutoPlayback()) { + val chapter = playerNotificationService.getCurrentBookChapter() + + if (chapter != null) { + val chapterDuration = + (chapter.endMs - chapter.startMs).coerceAtLeast(0L) + + val chapterPosition = + pos.coerceIn(0L, chapterDuration) + + val absolutePosition = + chapter.startMs + chapterPosition + + Log.d( + tag, + "Android Auto chapter seek: " + + "pos=$pos " + + "chapter=${chapter.startMs}-${chapter.endMs} " + + "absolute=$absolutePosition" + ) + + playerNotificationService.seekPlayer(absolutePosition) + return + } } + val currentTrackStartOffset = + playerNotificationService.getCurrentTrackStartOffsetMs() + + playerNotificationService.seekPlayer( + currentTrackStartOffset + pos + ) +} + private fun onChangeSpeed() { // cycle to next speed, only contains preset android app options, as each increment needs it's own icon // Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app @@ -110,6 +148,144 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") + + if ( + mediaId != null && + mediaId.startsWith( + ANDROID_AUTO_BOOKMARK_PLAY_PREFIX + ) + ) { + val bookmarkTime = + mediaId + .removePrefix( + ANDROID_AUTO_BOOKMARK_PLAY_PREFIX + ) + .toLongOrNull() + + if (bookmarkTime == null) { + Log.e( + tag, + "Invalid Android Auto bookmark play ID: " + + mediaId + ) + return + } + + Handler(Looper.getMainLooper()).post { + playerNotificationService.selectAndroidAutoBookmark( + bookmarkTime, + true + ) + } + + return + } + + if ( + mediaId != null && + mediaId.startsWith( + ANDROID_AUTO_BOOKMARK_DELETE_PREFIX + ) + ) { + val bookmarkTime = + mediaId + .removePrefix( + ANDROID_AUTO_BOOKMARK_DELETE_PREFIX + ) + .toLongOrNull() + + val libraryItemId = + playerNotificationService + .currentPlaybackSession + ?.libraryItemId + + if ( + bookmarkTime == null || + libraryItemId.isNullOrBlank() + ) { + Log.e( + tag, + "Invalid Android Auto bookmark delete ID: " + + mediaId + ) + return + } + + Log.d( + tag, + "Android Auto bookmark delete selected: " + + "libraryItemId=$libraryItemId, " + + "time=$bookmarkTime" + ) + + playerNotificationService + .clientEventEmitter + ?.onBookmarkDeleteRequested( + libraryItemId, + bookmarkTime + ) + + return + } + + // Android Auto bookmark items seek within the current audiobook. + if ( + mediaId != null && + mediaId.startsWith(ANDROID_AUTO_BOOKMARK_PREFIX) + ) { + val bookmarkTime = + mediaId + .removePrefix(ANDROID_AUTO_BOOKMARK_PREFIX) + .toLongOrNull() + + if (bookmarkTime == null) { + Log.e( + tag, + "Invalid Android Auto bookmark media ID: $mediaId" + ) + return + } + + Handler(Looper.getMainLooper()).post { + playerNotificationService.selectAndroidAutoBookmark( + bookmarkTime, + true + ) + } + + return + } + + // Android Auto chapter browser items use a dedicated media ID. + // Selecting one must seek within the current audiobook instead of + // trying to load it as a new library item. + if ( + mediaId != null && + mediaId.startsWith(ANDROID_AUTO_CHAPTER_PREFIX) + ) { + val chapterId = + mediaId + .removePrefix(ANDROID_AUTO_CHAPTER_PREFIX) + .toIntOrNull() + + if (chapterId == null) { + Log.e( + tag, + "Invalid Android Auto chapter media ID: $mediaId" + ) + return + } + + Handler(Looper.getMainLooper()).post { + playerNotificationService.selectAndroidAutoChapter( + chapterId, + true + ) + } + + return + } + val libraryItemWrapper: LibraryItemWrapper? var podcastEpisode: PodcastEpisode? = null @@ -281,6 +457,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext() CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious() CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed() + CUSTOM_ACTION_ADD_BOOKMARK -> + playerNotificationService.requestBookmarkAtCurrentPosition() } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt index bc59d04..e4f33ac 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -46,6 +46,29 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") + if (mediaId.startsWith(ANDROID_AUTO_CHAPTER_PREFIX)) { + val chapterId = + mediaId + .removePrefix(ANDROID_AUTO_CHAPTER_PREFIX) + .toIntOrNull() + + if (chapterId == null) { + Log.e( + tag, + "Invalid Android Auto chapter media ID: $mediaId" + ) + return + } + + Handler(Looper.getMainLooper()).post { + playerNotificationService.selectAndroidAutoChapter( + chapterId, + playWhenReady + ) + } + return + } + val libraryItemWrapper: LibraryItemWrapper? var podcastEpisode: PodcastEpisode? = null diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt index 35d787a..50465bc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt @@ -5,6 +5,7 @@ const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_ba const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward"; const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward"; const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed"; +const val CUSTOM_ACTION_ADD_BOOKMARK = "com.audiobookshelf.customAction.add_bookmark"; const val PLAYMETHOD_DIRECTPLAY = 0 const val PLAYMETHOD_DIRECTSTREAM = 1 @@ -14,6 +15,17 @@ const val PLAYMETHOD_LOCAL = 3 // // MEDIA BROWSER STUFF (ANDROID AUTO) // +const val ANDROID_AUTO_CHAPTERS_ROOT = "android-auto-chapters" +const val ANDROID_AUTO_BOOKMARKS_ROOT = "android-auto-bookmarks" +const val ANDROID_AUTO_BOOKMARK_PREFIX = "android-auto-bookmark:" +const val ANDROID_AUTO_BOOKMARK_MENU_PREFIX = "android-auto-bookmark-menu:" +const val ANDROID_AUTO_BOOKMARK_PLAY_PREFIX = "android-auto-bookmark-play:" +const val ANDROID_AUTO_BOOKMARK_DELETE_PREFIX = "android-auto-bookmark-delete:" + + +const val ANDROID_AUTO_ACTION_DELETE_BOOKMARK = "android-auto-action-delete-bookmark" +const val ANDROID_AUTO_CHAPTER_PREFIX = "android-auto-chapter:" + const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt index 8d28b63..be00694 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -158,6 +158,11 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") } + +if (playerNotificationService.isAndroidAutoPlayback()) { + playerNotificationService.updateAndroidAutoMediaSessionState() +} + } private fun calcPauseSeekBackTime() : Long { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 86d4bdc..76d2e47 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -65,6 +65,11 @@ const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val PLAYER_CAST = "cast-player" const val PLAYER_EXO = "exo-player" +data class AndroidAutoBookmark( + val title: String, + val timeSeconds: Long +) + class PlayerNotificationService : MediaBrowserServiceCompat() { companion object { @@ -92,6 +97,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun onNetworkMeteredChanged(isUnmetered: Boolean) fun onMediaItemHistoryUpdated(mediaItemHistory: MediaItemHistory) fun onPlaybackSpeedChanged(playbackSpeed: Float) + fun onBookmarkRequested( + libraryItemId: String, + timeSeconds: Long + ) + + fun onBookmarkDeleteRequested( + libraryItemId: String, + timeSeconds: Long + ) } private val binder = LocalBinder() @@ -119,6 +133,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var channelName = "Audiobookshelf Channel" var currentPlaybackSession: PlaybackSession? = null + private var androidAutoBookmarks: + List = emptyList() private var initialPlaybackRate: Float? = null private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -126,6 +142,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var isAndroidAuto = false +// DEBUG ONLY: simulates Android Auto MediaSession while using the app normally. +private val debugAndroidAutoSimulation = BuildConfig.DEBUG + + // The following are used for the shake detection private var isShakeSensorRegistered: Boolean = false private var mSensorManager: SensorManager? = null @@ -145,6 +165,43 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var cachedSearch: String = "" private var cachedSearchResults: MutableList = mutableListOf() +private val androidAutoMediaSessionHandler = Handler(Looper.getMainLooper()) + +private var lastAndroidAutoChapterStartMs: Long? = null + +private val androidAutoMediaSessionRunnable = object : Runnable { + override fun run() { + if (isAndroidAutoPlayback() && currentPlaybackSession != null && !isClosed) { + updateAndroidAutoMediaSessionState() + androidAutoMediaSessionHandler.postDelayed(this, 500L) + } + } +} + +private fun startAndroidAutoMediaSessionUpdates() { + +Log.d( + tag, + "ANDROID_AUTO_DEBUG: starting simulated Android Auto MediaSession updates" +) + + androidAutoMediaSessionHandler.removeCallbacks( + androidAutoMediaSessionRunnable + ) + + if (isAndroidAutoPlayback()) { + androidAutoMediaSessionHandler.post( + androidAutoMediaSessionRunnable + ) + } +} + +private fun stopAndroidAutoMediaSessionUpdates() { + androidAutoMediaSessionHandler.removeCallbacks( + androidAutoMediaSessionRunnable + ) +} + /* Service related stuff */ @@ -188,6 +245,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // detach player override fun onDestroy() { + stopAndroidAutoMediaSessionUpdates() try { val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager @@ -307,9 +365,92 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // Without this, the connector's default metadata provider rebuilds metadata from the // player's own state on media item transitions/timeline changes, dropping the cover art // bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately. - mediaSessionConnector.setMediaMetadataProvider { _ -> - currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build() + +mediaSessionConnector.setMediaMetadataProvider { _ -> + val metadata = + currentPlaybackSession?.getMediaMetadataCompat(ctx) + ?: MediaMetadataCompat.Builder().build() + + if (isAndroidAutoPlayback()) { + val chapter = getCurrentBookChapter() + val session = currentPlaybackSession + + if (chapter != null && session != null) { + val chapterDuration = + (chapter.endMs - chapter.startMs).coerceAtLeast(0L) + + val chapterIndex = + session.chapters.indexOfFirst { it.id == chapter.id } + + val chapterNumber = + if (chapterIndex >= 0) chapterIndex + 1 else null + + val chapterCount = session.chapters.size + + val chapterTitle = + chapter.title?.takeIf { it.isNotBlank() } + + val chapterDescription = + when { + chapterNumber != null && chapterTitle != null -> + "Capitolo $chapterNumber/$chapterCount — $chapterTitle" + + chapterNumber != null -> + "Capitolo $chapterNumber/$chapterCount" + + chapterTitle != null -> + chapterTitle + + else -> + null + } + + val builder = + MediaMetadataCompat.Builder(metadata) + .putLong( + MediaMetadataCompat.METADATA_KEY_DURATION, + chapterDuration + ) + + if (chapterDescription != null) { + val bookTitle = + session.displayTitle?.takeIf { it.isNotBlank() } + ?: metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE) + ?: chapterDescription + + builder + // Fields normally used by Android Auto's Now Playing screen. + .putString( + MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, + bookTitle + ) + .putString( + MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, + chapterDescription + ) + .putString( + MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, + chapterDescription + ) + + // Legacy fields retained for hosts that don't use DISPLAY_*. + .putString( + MediaMetadataCompat.METADATA_KEY_TITLE, + bookTitle + ) + .putString( + MediaMetadataCompat.METADATA_KEY_ARTIST, + chapterDescription + ) + } + + return@setMediaMetadataProvider builder.build() } + } + + metadata +} + val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { override fun getSupportedQueueNavigatorActions(player: Player): Long { @@ -376,7 +517,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } setMediaSessionConnectorPlaybackActions() - mediaSessionConnector.setQueueNavigator(queueNavigator) + // Android Auto uses a manual chapter queue. + // Do not attach TimelineQueueNavigator because it would replace the + // chapter queue with the ExoPlayer physical media-item queue. mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSession.setCallback(MediaSessionCallback(this)) @@ -468,6 +611,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } currentPlaybackSession = playbackSession + + // The "Capitoli" browser entry depends on the active playback session. + // Notify Android Auto so it reloads the application root. + notifyChildrenChanged(AUTO_MEDIA_ROOT) + +lastAndroidAutoChapterStartMs = null + +if (isAndroidAutoPlayback()) { + startAndroidAutoMediaSessionUpdates() + updateAndroidAutoMediaSessionState() +} + DeviceManager.setLastPlaybackSession( playbackSession ) // Save playback session to use when app is closed @@ -586,6 +741,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mutableListOf( JumpBackwardCustomActionProvider(), JumpForwardCustomActionProvider(), + AddBookmarkCustomActionProvider(), ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left ) if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) { @@ -611,6 +767,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { if (deviceSettings.allowSeekingOnMediaControls) { playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO } + + playbackActions = + playbackActions or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT + mediaSessionConnector.setEnabledPlaybackActions(playbackActions) } @@ -826,6 +988,506 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { return currentPlaybackSession?.getChapterForTime(this.getCurrentTime()) } +fun isAndroidAutoPlayback(): Boolean { + val result = isAndroidAuto || debugAndroidAutoSimulation + + Log.d( + tag, + "ANDROID_AUTO_DEBUG: isAndroidAuto=$isAndroidAuto " + + "debug=$debugAndroidAutoSimulation result=$result" + ) + + return result +} + +fun getCurrentChapterStartTime(): Long { + return getCurrentBookChapter()?.startMs ?: 0L +} + +fun getCurrentChapterDuration(): Long { + val chapter = getCurrentBookChapter() ?: return 0L + + return (chapter.endMs - chapter.startMs).coerceAtLeast(0L) +} + +fun getCurrentChapterPosition(): Long { + val chapter = getCurrentBookChapter() ?: return 0L + + val position = getCurrentTime() - chapter.startMs + val duration = (chapter.endMs - chapter.startMs).coerceAtLeast(0L) + + return position.coerceIn(0L, duration) +} + +fun getCurrentChapterBufferedPosition(): Long { + val chapter = getCurrentBookChapter() ?: return 0L + + val position = getBufferedTime() - chapter.startMs + val duration = (chapter.endMs - chapter.startMs).coerceAtLeast(0L) + + return position.coerceIn(0L, duration) +} + +fun updateAndroidAutoMediaSessionState() { + Log.d( + tag, + "ANDROID_AUTO_DEBUG: update ENTER " + + "androidAuto=${isAndroidAutoPlayback()} " + + "session=${currentPlaybackSession != null} " + + "closed=$isClosed" + ) + + if (!isAndroidAutoPlayback() || currentPlaybackSession == null || isClosed) { + Log.d( + tag, + "ANDROID_AUTO_DEBUG: update RETURN" + ) + return + } + + val chapter = getCurrentBookChapter() + + Log.d( + tag, + "ANDROID_AUTO_DEBUG: chapter=$chapter" + ) + + if (chapter == null) { + Log.d( + tag, + "ANDROID_AUTO_DEBUG: NO CHAPTER" + ) + return + } + + val chapterDuration = + (chapter.endMs - chapter.startMs).coerceAtLeast(0L) + + if (chapterDuration <= 0L) { + return + } + + val chapterPosition = + (getCurrentTime() - chapter.startMs) + .coerceIn(0L, chapterDuration) + +Log.d( + tag, + "ANDROID_AUTO_DEBUG: " + + "time=${getCurrentTime()} " + + "chapter=${chapter.startMs}-${chapter.endMs} " + + "duration=$chapterDuration " + + "position=$chapterPosition" +) + + val chapterBufferedPosition = + (getBufferedTime() - chapter.startMs) + .coerceIn(0L, chapterDuration) + + // If we entered a different chapter, force Android Auto to rebuild + // metadata so METADATA_KEY_DURATION changes to the new chapter duration. + if (lastAndroidAutoChapterStartMs != chapter.startMs) { + lastAndroidAutoChapterStartMs = chapter.startMs + mediaSessionConnector.invalidateMediaSessionMetadata() + } + + val state = + when { + currentPlayer.playbackState == Player.STATE_BUFFERING -> + PlaybackStateCompat.STATE_BUFFERING + + currentPlayer.isPlaying -> + PlaybackStateCompat.STATE_PLAYING + + currentPlayer.playbackState == Player.STATE_ENDED -> + PlaybackStateCompat.STATE_STOPPED + + else -> + PlaybackStateCompat.STATE_PAUSED + } + +val actions = + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_STOP or + PlaybackStateCompat.ACTION_SEEK_TO + + val playbackStateBuilder = + PlaybackStateCompat.Builder() + .setActions(actions) + .setState( + state, + chapterPosition, + currentPlayer.playbackParameters.speed + ) + .setBufferedPosition(chapterBufferedPosition) + + // The chapter-specific PlaybackState replaces the state generated by + // MediaSessionConnector. Add the Android Auto custom actions again so + // rewind and fast-forward controls remain visible. + JumpBackwardCustomActionProvider() + .getCustomAction(currentPlayer) + ?.let { playbackStateBuilder.addCustomAction(it) } + + JumpForwardCustomActionProvider() + .getCustomAction(currentPlayer) + ?.let { playbackStateBuilder.addCustomAction(it) } + + AddBookmarkCustomActionProvider() + .getCustomAction(currentPlayer) + ?.let { playbackStateBuilder.addCustomAction(it) } + + val playbackState = playbackStateBuilder.build() + + // Do not invalidate the connector state here. + // This method runs periodically and invalidation causes Android Auto + // to alternate between the connector state and the chapter state. + androidAutoMediaSessionHandler.post { + if (isAndroidAutoPlayback() && currentPlaybackSession != null && !isClosed) { + mediaSession.setPlaybackState(playbackState) + } + } +} + + + /** + * Returns the browsable Android Auto entry used to open the current + * audiobook's chapter list. + */ + fun setAndroidAutoBookmarks( + bookmarks: List + ) { + androidAutoBookmarks = + bookmarks + .distinctBy { it.timeSeconds } + .sortedBy { it.timeSeconds } + + notifyChildrenChanged(AUTO_MEDIA_ROOT) + notifyChildrenChanged(ANDROID_AUTO_BOOKMARKS_ROOT) + + Log.d( + tag, + "Android Auto bookmarks updated: " + + "${androidAutoBookmarks.size}" + ) + } + + private fun getAndroidAutoBookmarksRootItem(): + MediaBrowserCompat.MediaItem? { + if (currentPlaybackSession == null) { + return null + } + + val iconUri = + Uri.parse( + "android.resource://$packageName/drawable/" + + "ic_android_auto_bookmarks" + ) + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(ANDROID_AUTO_BOOKMARKS_ROOT) + .setTitle("Segnalibri") + .setSubtitle(currentPlaybackSession?.displayTitle) + .setDescription( + "${androidAutoBookmarks.size} segnalibri" + ) + .setIconUri(iconUri) + .build() + + return MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + private fun getAndroidAutoBookmarkItems(): + MutableList { + val session = currentPlaybackSession + ?: return mutableListOf() + + val coverUri = session.getCoverUri(ctx) + + return androidAutoBookmarks.map { bookmark -> + val mediaId = + ANDROID_AUTO_BOOKMARK_MENU_PREFIX + + bookmark.timeSeconds + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(bookmark.title) + .setSubtitle( + formatAndroidAutoBookmarkTime( + bookmark.timeSeconds + ) + ) + .setDescription(session.displayTitle) + .setIconUri(coverUri) + .build() + + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + }.toMutableList() + } + + private fun getAndroidAutoBookmarkActionItems( + timeSeconds: Long + ): MutableList { + val bookmark = + androidAutoBookmarks.firstOrNull { + it.timeSeconds == timeSeconds + } ?: return mutableListOf() + + val playIconUri = + Uri.parse( + "android.resource://$packageName/drawable/" + + "ic_android_auto_bookmark_play" + ) + + val deleteIconUri = + Uri.parse( + "android.resource://$packageName/drawable/" + + "ic_android_auto_bookmark_delete" + ) + + val playDescription = + MediaDescriptionCompat.Builder() + .setMediaId( + ANDROID_AUTO_BOOKMARK_PLAY_PREFIX + + bookmark.timeSeconds + ) + .setTitle("Riproduci da qui") + .setSubtitle(bookmark.title) + .setDescription( + formatAndroidAutoBookmarkTime( + bookmark.timeSeconds + ) + ) + .setIconUri(playIconUri) + .build() + + val deleteDescription = + MediaDescriptionCompat.Builder() + .setMediaId( + ANDROID_AUTO_BOOKMARK_DELETE_PREFIX + + bookmark.timeSeconds + ) + .setTitle("Elimina segnalibro") + .setSubtitle(bookmark.title) + .setDescription( + formatAndroidAutoBookmarkTime( + bookmark.timeSeconds + ) + ) + .setIconUri(deleteIconUri) + .build() + + return mutableListOf( + MediaBrowserCompat.MediaItem( + playDescription, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ), + MediaBrowserCompat.MediaItem( + deleteDescription, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + + private fun formatAndroidAutoBookmarkTime( + timeSeconds: Long + ): String { + val hours = timeSeconds / 3600L + val minutes = (timeSeconds % 3600L) / 60L + val seconds = timeSeconds % 60L + + return if (hours > 0L) { + String.format( + Locale.getDefault(), + "%d:%02d:%02d", + hours, + minutes, + seconds + ) + } else { + String.format( + Locale.getDefault(), + "%d:%02d", + minutes, + seconds + ) + } + } + + fun selectAndroidAutoBookmark( + timeSeconds: Long, + playWhenReady: Boolean + ) { + if (currentPlaybackSession == null) { + Log.w( + tag, + "Cannot select Android Auto bookmark: " + + "no playback session" + ) + return + } + + Log.d( + tag, + "Android Auto bookmark selected: " + + "time=$timeSeconds" + ) + + seekPlayer( + timeSeconds + .coerceAtLeast(0L) * + 1000L + ) + + if (playWhenReady || currentPlayer.isPlaying) { + play() + } + + lastAndroidAutoChapterStartMs = null + mediaSessionConnector.invalidateMediaSessionMetadata() + updateAndroidAutoMediaSessionState() + } + + private fun getAndroidAutoChaptersRootItem(): + MediaBrowserCompat.MediaItem? { + val session = currentPlaybackSession ?: return null + + if (session.chapters.isEmpty()) { + return null + } + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(ANDROID_AUTO_CHAPTERS_ROOT) + .setTitle("Capitoli") + .setSubtitle(session.displayTitle) + .setDescription( + "${session.chapters.size} capitoli" + ) + .setIconUri( + Uri.parse( + "android.resource://$packageName/drawable/" + + "ic_android_auto_chapters" + ) + ) + .build() + + return MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + /** + * Builds the playable chapter list exposed through MediaBrowserService. + */ + private fun getAndroidAutoChapterItems(): + MutableList { + val session = currentPlaybackSession + ?: return mutableListOf() + + val chapters = session.chapters + val chapterCount = chapters.size + val coverUri = session.getCoverUri(ctx) + + return chapters.mapIndexed { index, chapter -> + val chapterNumber = index + 1 + val chapterTitle = + chapter.title?.takeIf { it.isNotBlank() } + + val isCurrentChapter = + getCurrentBookChapter()?.id == chapter.id + + val chapterPrefix = + if (isCurrentChapter) "▶ " else "" + + val displayTitle = + if (chapterTitle != null) { + "${chapterPrefix}Capitolo $chapterNumber/$chapterCount - $chapterTitle" + } else { + "${chapterPrefix}Capitolo $chapterNumber/$chapterCount" + } + + val mediaId = + ANDROID_AUTO_CHAPTER_PREFIX + chapter.id + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(displayTitle) + .setSubtitle(session.displayTitle) + .setDescription(session.displayAuthor) + .setIconUri(coverUri) + .build() + + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + }.toMutableList() + } + + /** + * Moves playback directly to a chapter selected from Android Auto. + * It does not add the chapter to a playback queue. + */ + fun selectAndroidAutoChapter( + chapterId: Int, + playWhenReady: Boolean + ) { + val session = currentPlaybackSession + + if (session == null) { + Log.w( + tag, + "Cannot select Android Auto chapter: no playback session" + ) + return + } + + val chapter = + session.chapters.firstOrNull { it.id == chapterId } + + if (chapter == null) { + Log.w( + tag, + "Android Auto chapter not found: $chapterId" + ) + return + } + + Log.d( + tag, + "Android Auto chapter selected: " + + "id=${chapter.id}, " + + "title=${chapter.title}, " + + "start=${chapter.startMs}" + ) + + seekPlayer(chapter.startMs) + + if (playWhenReady) { + play() + } + + lastAndroidAutoChapterStartMs = null + mediaSessionConnector.invalidateMediaSessionMetadata() + updateAndroidAutoMediaSessionState() + } + fun getEndTimeOfChapterOrTrack(): Long? { return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime() } @@ -1000,6 +1662,46 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { currentPlayer.seekToNext() } +fun skipToPreviousChapter() { + val session = currentPlaybackSession ?: return + val currentChapter = getCurrentBookChapter() ?: return + + val previousChapter = + session.chapters + .lastOrNull { it.endMs <= currentChapter.startMs } + + if (previousChapter != null) { + Log.d( + tag, + "Android Auto previous chapter: " + + "current=${currentChapter.id} " + + "previous=${previousChapter.id} " + + "seek=${previousChapter.startMs}" + ) + + seekPlayer(previousChapter.startMs) + } +} + +fun skipToNextChapter() { + val session = currentPlaybackSession ?: return + val currentChapter = getCurrentBookChapter() ?: return + + val nextChapter = session.getNextChapterForTime(currentChapter.startMs) + + if (nextChapter != null) { + Log.d( + tag, + "Android Auto next chapter: " + + "current=${currentChapter.id} " + + "next=${nextChapter.id} " + + "seek=${nextChapter.startMs}" + ) + + seekPlayer(nextChapter.startMs) + } +} + fun jumpForward() { seekForward(deviceSettings.jumpForwardTimeMs) } @@ -1016,6 +1718,41 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { seekPlayer(getCurrentTime() - amount) } + /** + * Requests creation of a synchronized bookmark through the Vue client. + */ + fun requestBookmarkAtCurrentPosition() { + val session = currentPlaybackSession + + if (session == null) { + Log.w(tag, "Cannot add bookmark: no playback session") + return + } + + val libraryItemId = session.libraryItemId + + if (libraryItemId.isNullOrBlank()) { + Log.w(tag, "Cannot add bookmark: library item ID missing") + return + } + + val timeSeconds = + getCurrentTimeSeconds() + .toLong() + .coerceAtLeast(0L) + + Log.d( + tag, + "Android Auto bookmark requested: " + + "libraryItemId=$libraryItemId, time=$timeSeconds" + ) + + clientEventEmitter?.onBookmarkRequested( + libraryItemId, + timeSeconds + ) + } + fun setPlaybackSpeed(speed: Float) { mediaManager.userSettingsPlaybackRate = speed currentPlayer.setPlaybackSpeed(speed) @@ -1061,6 +1798,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } currentPlaybackSession = null + +stopAndroidAutoMediaSessionUpdates() +lastAndroidAutoChapterStartMs = null + mediaProgressSyncer.reset() clientEventEmitter?.onPlaybackClosed() @@ -1197,7 +1938,32 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { isAndroidAuto = true val extras = Bundle() - extras.putBoolean(MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, + true + ) + + val deleteBookmarkAction = Bundle().apply { + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, + ANDROID_AUTO_ACTION_DELETE_BOOKMARK + ) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, + "Elimina segnalibro" + ) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI, + "android.resource://$packageName/drawable/" + + "ic_android_auto_bookmark_delete" + ) + } + + extras.putParcelableArrayList( + MediaConstants + .BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, + arrayListOf(deleteBookmarkAction) + ) extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM @@ -1211,6 +1977,178 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } + override fun onCustomAction( + action: String, + extras: Bundle, + result: Result + ) { + if (action != ANDROID_AUTO_ACTION_DELETE_BOOKMARK) { + super.onCustomAction(action, extras, result) + return + } + + val mediaItemId = + extras.getString( + MediaConstants + .EXTRAS_KEY_CUSTOM_BROWSER_ACTION_MEDIA_ITEM_ID + ) + + val timeSeconds = + mediaItemId + ?.takeIf { + it.startsWith( + ANDROID_AUTO_BOOKMARK_PREFIX + ) + } + ?.removePrefix( + ANDROID_AUTO_BOOKMARK_PREFIX + ) + ?.toLongOrNull() + + val libraryItemId = + currentPlaybackSession?.libraryItemId + + if ( + timeSeconds == null || + libraryItemId.isNullOrBlank() + ) { + Log.e( + tag, + "Invalid bookmark delete action: " + + "mediaItemId=$mediaItemId" + ) + + result.sendError(Bundle()) + return + } + + Log.d( + tag, + "Android Auto bookmark delete requested: " + + "libraryItemId=$libraryItemId, " + + "time=$timeSeconds" + ) + + clientEventEmitter?.onBookmarkDeleteRequested( + libraryItemId, + timeSeconds + ) + + result.sendResult(Bundle()) + } + + override fun onLoadItem( + itemId: String, + result: Result + ) { + if ( + itemId.startsWith( + ANDROID_AUTO_BOOKMARK_MENU_PREFIX + ) + ) { + val timeSeconds = + itemId + .removePrefix( + ANDROID_AUTO_BOOKMARK_MENU_PREFIX + ) + .toLongOrNull() + + val bookmark = + androidAutoBookmarks.firstOrNull { + it.timeSeconds == timeSeconds + } + + if (bookmark == null) { + result.sendResult(null) + return + } + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(itemId) + .setTitle(bookmark.title) + .setSubtitle( + formatAndroidAutoBookmarkTime( + bookmark.timeSeconds + ) + ) + .setDescription( + currentPlaybackSession?.displayTitle + ) + .setIconUri( + currentPlaybackSession + ?.getCoverUri(ctx) + ) + .build() + + result.sendResult( + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + return + } + + if (itemId.startsWith(ANDROID_AUTO_BOOKMARK_PREFIX)) { + val timeSeconds = + itemId + .removePrefix(ANDROID_AUTO_BOOKMARK_PREFIX) + .toLongOrNull() + + val bookmark = + androidAutoBookmarks.firstOrNull { + it.timeSeconds == timeSeconds + } + + if (bookmark == null) { + result.sendResult(null) + return + } + + val session = currentPlaybackSession + + if (session == null) { + result.sendResult(null) + return + } + + val bookmarkExtras = Bundle().apply { + putStringArrayList( + MediaConstants + .DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, + arrayListOf( + ANDROID_AUTO_ACTION_DELETE_BOOKMARK + ) + ) + } + + val description = + MediaDescriptionCompat.Builder() + .setMediaId(itemId) + .setTitle(bookmark.title) + .setSubtitle( + formatAndroidAutoBookmarkTime( + bookmark.timeSeconds + ) + ) + .setDescription(session.displayTitle) + .setIconUri(session.getCoverUri(ctx)) + .setExtras(bookmarkExtras) + .build() + + result.sendResult( + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + return + } + + super.onLoadItem(itemId, result) + } + override fun onLoadChildren( parentMediaId: String, result: Result> @@ -1220,12 +2158,74 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { result.detach() // Prevent crashing if app is restarted while browsing - if ((parentMediaId != DOWNLOADS_ROOT && parentMediaId != AUTO_MEDIA_ROOT) && !firstLoadDone) { + if ( + parentMediaId != DOWNLOADS_ROOT && + parentMediaId != AUTO_MEDIA_ROOT && + parentMediaId != ANDROID_AUTO_CHAPTERS_ROOT && + !firstLoadDone + ) { result.sendResult(null) return } - if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads + if ( + parentMediaId.startsWith( + ANDROID_AUTO_BOOKMARK_MENU_PREFIX + ) + ) { + val timeSeconds = + parentMediaId + .removePrefix( + ANDROID_AUTO_BOOKMARK_MENU_PREFIX + ) + .toLongOrNull() + + if (timeSeconds == null) { + Log.e( + tag, + "Invalid Android Auto bookmark menu ID: " + + parentMediaId + ) + result.sendResult(mutableListOf()) + return + } + + val actionItems = + getAndroidAutoBookmarkActionItems( + timeSeconds + ) + + Log.d( + tag, + "Android Auto bookmark submenu loaded: " + + "time=$timeSeconds, " + + "items=${actionItems.size}" + ) + + result.sendResult(actionItems) + } else if ( + parentMediaId == ANDROID_AUTO_BOOKMARKS_ROOT + ) { + val bookmarkItems = getAndroidAutoBookmarkItems() + + Log.d( + tag, + "Android Auto bookmark browser loaded: " + + "${bookmarkItems.size} bookmarks" + ) + + result.sendResult(bookmarkItems) + } else if (parentMediaId == ANDROID_AUTO_CHAPTERS_ROOT) { + val chapterItems = getAndroidAutoChapterItems() + + Log.d( + tag, + "Android Auto chapter browser loaded: " + + "${chapterItems.size} chapters" + ) + + result.sendResult(chapterItems) + } else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localBrowseItems: MutableList = mutableListOf() @@ -1335,15 +2335,29 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { ) onBrowseTreeInitialized() val children = - browseTree[parentMediaId]?.map { item -> - Log.d(tag, "Found top menu item: ${item.description.title}") - MediaBrowserCompat.MediaItem( + browseTree[parentMediaId] + ?.map { item -> + Log.d( + tag, + "Found top menu item: ${item.description.title}" + ) + MediaBrowserCompat.MediaItem( item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } + ) + } + ?.toMutableList() + ?: mutableListOf() - result.sendResult(children as MutableList?) + getAndroidAutoChaptersRootItem()?.let { + children.add(0, it) + } + + getAndroidAutoBookmarksRootItem()?.let { + children.add(1.coerceAtMost(children.size), it) + } + + result.sendResult(children) firstLoadDone = true if (mediaManager.serverLibraries.isNotEmpty()) { AbsLogger.info(tag, "onLoadChildren: Android Auto fetching personalized data for all libraries") @@ -1370,16 +2384,36 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { ) onBrowseTreeInitialized() val children = - browseTree[parentMediaId]?.map { item -> - Log.d(tag, "Found top menu item: ${item.description.title}") - MediaBrowserCompat.MediaItem( + browseTree[parentMediaId] + ?.map { item -> + Log.d( + tag, + "Found top menu item: ${item.description.title}" + ) + MediaBrowserCompat.MediaItem( item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } + ) + } + ?.toMutableList() + ?: mutableListOf() - AbsLogger.info(tag, "onLoadChildren: Android auto data loaded") - result.sendResult(children as MutableList?) + getAndroidAutoChaptersRootItem()?.let { + children.add(0, it) + } + + AbsLogger.info( + tag, + "onLoadChildren: Android auto data loaded" + ) + + getAndroidAutoBookmarksRootItem()?.let { + children.add( + 1.coerceAtMost(children.size), + it + ) + } + result.sendResult(children) } } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { @@ -2183,6 +3217,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } + inner class AddBookmarkCustomActionProvider : + CustomActionProvider { + + override fun onCustomAction( + player: Player, + action: String, + extras: Bundle? + ) { + requestBookmarkAtCurrentPosition() + } + + override fun getCustomAction( + player: Player + ): PlaybackStateCompat.CustomAction { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_ADD_BOOKMARK, + "Aggiungi segnalibro", + R.drawable.ic_android_auto_bookmark_add + ).build() + } + } + inner class ChangePlaybackSpeedCustomActionProvider : CustomActionProvider { override fun onCustomAction(player: Player, action: String, extras: Bundle?) { /* diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 6e76637..7b7bf49 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -7,6 +7,7 @@ import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.player.AndroidAutoBookmark import com.audiobookshelf.app.player.CastManager import com.audiobookshelf.app.player.PlayerListener import com.audiobookshelf.app.player.PlayerNotificationService @@ -114,6 +115,31 @@ class AbsAudioPlayer : Plugin() { override fun onPlaybackSpeedChanged(playbackSpeed:Float) { emit("onPlaybackSpeedChanged", playbackSpeed) } + + override fun onBookmarkRequested( + libraryItemId: String, + timeSeconds: Long + ) { + val ret = JSObject() + ret.put("libraryItemId", libraryItemId) + ret.put("time", timeSeconds) + + notifyListeners("onBookmarkRequested", ret) + } + + override fun onBookmarkDeleteRequested( + libraryItemId: String, + timeSeconds: Long + ) { + val ret = JSObject() + ret.put("libraryItemId", libraryItemId) + ret.put("time", timeSeconds) + + notifyListeners( + "onBookmarkDeleteRequested", + ret + ) + } }) MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter @@ -292,6 +318,46 @@ class AbsAudioPlayer : Plugin() { } } + @PluginMethod + fun setAndroidAutoBookmarks(call: PluginCall) { + val bookmarksJson = call.getString("value") + + if (bookmarksJson == null) { + call.reject("Missing bookmarks JSON") + return + } + + try { + val bookmarksType = + jacksonMapper.typeFactory.constructCollectionType( + ArrayList::class.java, + AndroidAutoBookmark::class.java + ) + + val bookmarks: List = + jacksonMapper.readValue( + bookmarksJson, + bookmarksType + ) + + playerNotificationService.setAndroidAutoBookmarks( + bookmarks + ) + + call.resolve() + } catch (error: Exception) { + Log.e( + tag, + "setAndroidAutoBookmarks failed", + error + ) + call.reject( + "Invalid bookmarks JSON", + error + ) + } + } + @PluginMethod fun getCurrentTime(call: PluginCall) { Handler(Looper.getMainLooper()).post { diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index c9d2f63..d886dfb 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -1,85 +1,280 @@ @@ -155,6 +521,7 @@ export default { playbackSession: null, showChapterModal: false, showFullscreen: false, + showAndroidAutoDebug: false, totalDuration: 0, currentPlaybackRate: 1, currentTime: 0, @@ -448,6 +815,104 @@ export default { this.showFullscreen = false } }, +toggleAndroidAutoDebug() { + this.showAndroidAutoDebug = true + this.showFullscreen = true +}, +closeAndroidAutoDebug() { + this.showAndroidAutoDebug = false +}, + async onBookmarkDeleteRequested(payload) { + const libraryItemId = + payload?.libraryItemId || this.serverLibraryItemId + + const time = + Math.floor(Number(payload?.time)) + + if (!libraryItemId || !Number.isFinite(time)) { + console.error( + 'Invalid Android Auto bookmark delete request', + payload + ) + return + } + + try { + await this.$nativeHttp.delete( + `/api/me/item/${libraryItemId}/bookmark/${time}` + ) + + this.$store.commit( + 'user/deleteBookmark', + { + libraryItemId, + time + } + ) + + this.$toast.success('Bookmark removed') + } catch (error) { + this.$toast.error( + this.$strings.ToastBookmarkRemoveFailed + ) + console.error(error) + } + }, + + async onBookmarkRequested(payload) { + const libraryItemId = + payload?.libraryItemId || this.serverLibraryItemId + + const time = + Math.floor( + Number( + payload?.time !== undefined + ? payload.time + : this.currentTime + ) + ) + + if (!libraryItemId || !Number.isFinite(time)) { + console.error( + 'Invalid Android Auto bookmark request', + payload + ) + return + } + + const existingBookmark = + this.bookmarks.find( + (bookmark) => Number(bookmark.time) === time + ) + + if (existingBookmark) { + this.$toast.success('Bookmark already exists') + return + } + + const bookmark = { + title: `Android Auto - ${this.$formatDate( + Date.now(), + 'MMM dd, yyyy HH:mm' + )}`, + time + } + + try { + await this.$nativeHttp.post( + `/api/me/item/${libraryItemId}/bookmark`, + bookmark + ) + + this.$toast.success('Bookmark added') + } catch (error) { + this.$toast.error( + this.$strings.ToastBookmarkCreateFailed + ) + console.error(error) + } + }, + async selectChapter(chapter) { await this.$hapticsImpact() this.seek(chapter.start) @@ -580,20 +1045,26 @@ export default { ts.innerText = this.$secondsToTimestamp(currentTime) }, timeupdate() { - if (!this.$refs.playedTrack) { - console.error('Invalid no played track ref') - return - } this.$emit('updateTime', this.currentTime) + // A native time update confirms that the seek completed. + // Clear the loading state even when the normal player UI is hidden, + // as happens while the Android Auto DEBUG panel is displayed. if (this.seekLoading) { this.seekLoading = false + if (this.$refs.playedTrack) { this.$refs.playedTrack.classList.remove('bg-yellow-300') this.$refs.playedTrack.classList.add('bg-gray-200') } } + // The normal progress-bar refs do not exist in Android Auto DEBUG. + // The debug panel uses reactive values and needs no direct DOM update. + if (!this.$refs.playedTrack) { + return + } + this.updateTimestamp() this.updateTrack() }, @@ -921,6 +1392,14 @@ export default { AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) + AbsAudioPlayer.addListener( + 'onBookmarkRequested', + this.onBookmarkRequested + ) + AbsAudioPlayer.addListener( + 'onBookmarkDeleteRequested', + this.onBookmarkDeleteRequested + ) }, async screenOrientationChange() { if (this.isRefreshingUI) return diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index f9f8404..28f3700 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -41,6 +41,15 @@ export default { } }, mixins: [CellularPermissionHelpers], + watch: { + bookmarks: { + deep: true, + immediate: true, + handler() { + this.syncAndroidAutoBookmarks() + } + } + }, computed: { bookmarks() { if (!this.serverLibraryItemId) return [] @@ -54,6 +63,32 @@ export default { } }, methods: { + async syncAndroidAutoBookmarks() { + const payload = + this.bookmarks.map((bookmark) => ({ + title: + bookmark.title || + this.$formatDate( + Number(bookmark.time) * 1000, + 'MMM dd, yyyy HH:mm' + ), + timeSeconds: Math.floor( + Number(bookmark.time) + ) + })) + + try { + await AbsAudioPlayer.setAndroidAutoBookmarks({ + value: JSON.stringify(payload) + }) + } catch (error) { + console.error( + 'Failed to sync Android Auto bookmarks', + error + ) + } + }, + showBookmarks() { this.showBookmarksModal = true },