Update Android Auto chapters and bookmarks
Some checks are pending
Build APK / main (push) Waiting to run
Build iOS / Build iOS simulator app (push) Waiting to run
Verify all i18n files are alphabetized / update_translations (push) Waiting to run

This commit is contained in:
FabioMich66 2026-10-05 20:58:02 +02:00
parent fc946702c1
commit 1cccb8efe9
8 changed files with 1926 additions and 72 deletions

View file

@ -68,13 +68,21 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.pause() playerNotificationService.pause()
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
if (playerNotificationService.isAndroidAutoPlayback()) {
playerNotificationService.skipToPreviousChapter()
} else {
playerNotificationService.skipToPrevious() playerNotificationService.skipToPrevious()
} }
}
override fun onSkipToNext() { override fun onSkipToNext() {
if (playerNotificationService.isAndroidAutoPlayback()) {
playerNotificationService.skipToNextChapter()
} else {
playerNotificationService.skipToNext() playerNotificationService.skipToNext()
} }
}
override fun onFastForward() { override fun onFastForward() {
playerNotificationService.jumpForward() playerNotificationService.jumpForward()
@ -84,11 +92,41 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.jumpBackward() playerNotificationService.jumpBackward()
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs() if (playerNotificationService.isAndroidAutoPlayback()) {
playerNotificationService.seekPlayer(currentTrackStartOffset + pos) 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() { private fun onChangeSpeed() {
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon // 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 // 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?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") 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? val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null
@ -281,6 +457,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext() CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious() CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed() CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
CUSTOM_ACTION_ADD_BOOKMARK ->
playerNotificationService.requestBookmarkAtCurrentPosition()
} }
} }
} }

View file

@ -46,6 +46,29 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") 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? val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null

View file

@ -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_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward"; 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_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_DIRECTPLAY = 0
const val PLAYMETHOD_DIRECTSTREAM = 1 const val PLAYMETHOD_DIRECTSTREAM = 1
@ -14,6 +15,17 @@ const val PLAYMETHOD_LOCAL = 3
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // 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_PKG_NAME = "com.google.android.projection.gearhead"
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"

View file

@ -158,6 +158,11 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
} }
if (playerNotificationService.isAndroidAutoPlayback()) {
playerNotificationService.updateAndroidAutoMediaSessionState()
}
} }
private fun calcPauseSeekBackTime() : Long { private fun calcPauseSeekBackTime() : Long {

View file

@ -7,6 +7,7 @@ import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.AndroidAutoBookmark
import com.audiobookshelf.app.player.CastManager import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerListener import com.audiobookshelf.app.player.PlayerListener
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
@ -114,6 +115,31 @@ class AbsAudioPlayer : Plugin() {
override fun onPlaybackSpeedChanged(playbackSpeed:Float) { override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
emit("onPlaybackSpeedChanged", playbackSpeed) 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 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<AndroidAutoBookmark> =
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 @PluginMethod
fun getCurrentTime(call: PluginCall) { fun getCurrentTime(call: PluginCall) {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {

View file

@ -1,85 +1,280 @@
<template> <template>
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes --> <div class="audio-player-root">
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }"> <!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
<div
v-if="playbackSession && !showAndroidAutoDebug"
id="streamContainer"
class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none"
:class="{
fullscreen: showFullscreen,
'ios-player': $platform === 'ios',
'web-player': $platform === 'web'
}"
>
<!-- Fullscreen overlays: colored background and menu overlays --> <!-- Fullscreen overlays: colored background and menu overlays -->
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }"> <div
v-if="showFullscreen && !showAndroidAutoDebug"
class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto"
:style="{ backgroundColor: coverRgb }"
>
<!-- Background gradient for player --> <!-- Background gradient for player -->
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" /> <div
class="w-full h-full absolute top-0 left-0 pointer-events-none"
style="background: var(--gradient-audio-player)"
/>
<!-- Collapse button - minimizes player --> <!-- Collapse button - minimizes player -->
<div class="top-4 left-4 absolute cursor-pointer"> <div class="top-4 left-4 absolute cursor-pointer">
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span> <span
class="material-symbols text-5xl"
:class="{
'text-black text-opacity-75':
coverBgIsLight && theme !== 'black'
}"
@click="collapseFullscreen"
>
keyboard_arrow_down
</span>
</div> </div>
<!-- Cast button - Chromecast toggle --> <!-- Top right controls -->
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer"> <div class="top-6 right-4 absolute flex items-center gap-5 z-50">
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
<!-- Cast -->
<span
v-if="showCastBtn"
class="material-symbols text-3xl cursor-pointer"
:class="
coverBgIsLight && theme !== 'black'
? 'text-black'
: ''
"
@click.stop="castClick"
>
{{ isCasting ? 'cast_connected' : 'cast' }}
</span>
<!-- Android Auto DEBUG -->
<span
class="material-symbols text-3xl cursor-pointer"
:class="{
'text-black text-opacity-75':
coverBgIsLight && theme !== 'black'
}"
@click.stop="toggleAndroidAutoDebug"
>
directions_car
</span>
<!-- More menu -->
<span
class="material-symbols text-3xl cursor-pointer"
:class="{
'text-black text-opacity-75':
coverBgIsLight && theme !== 'black'
}"
@click.stop="showMoreMenuDialog = true"
>
more_vert
</span>
</div> </div>
<!-- Player options menu button --> <!-- Playback method indicator -->
<div class="top-6 right-4 absolute cursor-pointer"> <p
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span> class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50"
</div> :class="{
'text-black text-opacity-75':
<!-- Playback method indicator (Direct/Local/Transcode) --> coverBgIsLight && theme !== 'black'
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p> }"
style="font-size: 10px"
>
{{
isDirectPlayMethod
? $strings.LabelPlaybackDirect
: isLocalPlayMethod
? $strings.LabelPlaybackLocal
: $strings.LabelPlaybackTranscode
}}
</p>
</div> </div>
<!-- Overall book progress bar --> <!-- Overall book progress bar -->
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6"> <div
v-if="
playerSettings.useChapterTrack &&
playerSettings.useTotalTrack &&
showFullscreen &&
!showAndroidAutoDebug
"
class="absolute total-track w-full z-30 px-6"
>
<div class="flex"> <div class="flex">
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p> <p class="font-mono text-fg" style="font-size: 0.8rem">
{{ currentTimePretty }}
</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
<p class="font-mono text-fg" style="font-size: 0.8rem">
{{ totalTimeRemainingPretty }}
</p>
</div> </div>
<div class="w-full"> <div class="w-full">
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden"> <div
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" /> class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden"
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" /> >
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" /> <div
ref="totalReadyTrack"
class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full"
/>
<div
ref="totalBufferedTrack"
class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full"
/>
<div
ref="totalPlayedTrack"
class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- Book cover wrapper --> <!-- Book cover wrapper -->
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer"> <div
class="cover-wrapper absolute z-30 pointer-events-auto"
@click="clickContainer"
>
<div class="w-full h-full flex justify-center"> <div class="w-full h-full flex justify-center">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" /> <covers-book-cover
v-if="libraryItem || localLibraryItemCoverSrc"
ref="cover"
:library-item="libraryItem"
:download-cover="localLibraryItemCoverSrc"
:width="bookCoverWidth"
:book-cover-aspect-ratio="bookCoverAspectRatio"
raw
@imageLoaded="coverImageLoaded"
/>
</div> </div>
<!-- Sync failed indicator - shows error icon if progress sync failed --> <!-- Sync failed indicator -->
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog"> <div
<span class="material-symbols text-error text-3xl">error</span> v-if="syncStatus === $constants.SyncStatus.FAILED"
class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30"
@click.stop="showSyncsFailedDialog"
>
<span class="material-symbols text-error text-3xl">
error
</span>
</div> </div>
</div> </div>
<!-- Title and author text --> <!-- Title and author text -->
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor"> <div
<div ref="titlewrapper" class="overflow-hidden relative"> class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden"
@click="clickTitleAndAuthor"
>
<div
ref="titlewrapper"
class="overflow-hidden relative"
>
<p class="title-text whitespace-nowrap"></p> <p class="title-text whitespace-nowrap"></p>
</div> </div>
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
<p class="author-text text-fg text-opacity-75 truncate">
{{ authorName }}
</p>
</div> </div>
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer"> <!-- Main player content -->
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters --> <div
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px"> v-if="!showAndroidAutoDebug"
<div class="flex items-center justify-between pointer-events-auto"> id="playerContent"
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span> class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all"
<!-- hidden for podcasts but still using this as a placeholder --> :style="{ backgroundColor: showFullscreen ? '' : coverRgb }"
<span v-else class="material-symbols text-3xl text-white text-opacity-0">bookmark</span> @click="clickContainer"
>
<span class="font-mono text-fg-muted cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span> <!-- Top controls bar - fullscreen only -->
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')"> <div
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> v-if="showFullscreen"
class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6"
style="max-width: 414px"
>
<div class="flex items-center justify-between pointer-events-auto">
<span
v-if="!isPodcast && serverLibraryItemId && socketConnected"
class="material-symbols text-3xl text-fg-muted cursor-pointer"
:class="{ fill: bookmarks.length }"
@click="$emit('showBookmarks')"
>
bookmark
</span>
<!-- hidden for podcasts but still using this as a placeholder -->
<span
v-else
class="material-symbols text-3xl text-white text-opacity-0"
>
bookmark
</span>
<span
class="font-mono text-fg-muted cursor-pointer"
style="font-size: 1.35rem"
@click="$emit('selectPlaybackSpeed')"
>
{{ currentPlaybackRate }}x
</span>
<svg
v-if="!sleepTimerRunning"
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7 text-fg-muted cursor-pointer"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@click.stop="$emit('showSleepTimer')"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg> </svg>
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p> <div
v-else
class="h-7 w-7 flex items-center justify-around cursor-pointer"
@click.stop="$emit('showSleepTimer')"
>
<p class="text-xl font-mono text-success">
{{ sleepTimeRemainingPretty }}
</p>
</div> </div>
<span class="material-symbols text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span> <span
class="material-symbols text-3xl text-fg cursor-pointer"
:class="
chapters.length
? 'text-opacity-75'
: 'text-opacity-10'
"
@click="clickChaptersBtn"
>
format_list_bulleted
</span>
</div> </div>
</div> </div>
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" /> <div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
<!-- Playback controls - jump buttons, play/pause, chapter navigation --> <!-- Playback controls - jump buttons, play/pause, chapter navigation -->
@ -126,6 +321,177 @@
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
<!-- More menu dialog - player settings and options --> <!-- More menu dialog - player settings and options -->
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" /> <modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
</div>
<!-- Android Auto DEBUG panel -->
<div
v-if="showFullscreen && showAndroidAutoDebug"
class="fixed left-0 top-0 w-full h-full z-[2147483647] flex items-center justify-center bg-transparent pointer-events-auto"
>
<div class="w-full max-w-md mx-4 rounded-xl bg-gray-900 text-white p-5 shadow-xl">
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-lg font-semibold">
Android Auto DEBUG
</div>
<div class="text-sm text-gray-400">
Simulazione MediaSession
</div>
</div>
<span
class="material-symbols text-2xl cursor-pointer"
@click="closeAndroidAutoDebug"
>
close
</span>
</div>
<div v-if="currentChapter">
<div class="text-xs text-gray-500 mt-2 space-y-1">
<div>Tempo assoluto: {{ currentTime.toFixed(2) }} s</div>
<div>Inizio capitolo: {{ Number(currentChapter.start).toFixed(2) }} s</div>
<div>Posizione capitolo: {{ Math.max(0, currentTime - currentChapter.start).toFixed(2) }} s</div>
<div>Durata capitolo: {{ Number(currentChapterDuration).toFixed(2) }} s</div>
</div>
<div class="text-base font-semibold">
{{ currentChapterTitle || 'Senza titolo' }}
</div>
<div class="text-sm text-gray-400 mt-1">
Capitolo {{ chapters.findIndex(c => c.id === currentChapter.id) + 1 }}
/ {{ chapters.length }}
</div>
<div class="mt-5 h-3 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-blue-500"
:style="{
width: `${Math.min(
100,
Math.max(
0,
((currentTime - currentChapter.start) / currentChapterDuration) * 100
)
)}%`
}"
/>
</div>
<div class="flex justify-between text-sm text-gray-400 mt-2">
<span>
{{ $secondsToTimestamp(Math.max(0, currentTime - currentChapter.start)) }}
</span>
<span>
{{ $secondsToTimestamp(currentChapterDuration) }}
</span>
</div>
<div class="flex items-center justify-center gap-8 mt-6">
<span
class="material-symbols text-4xl cursor-pointer"
@click="jumpChapterStart"
>
skip_previous
</span>
<span
class="material-symbols text-5xl cursor-pointer"
@click="playPauseClick"
>
{{ isPlaying ? 'pause_circle' : 'play_circle' }}
</span>
<span
class="material-symbols text-4xl cursor-pointer"
:class="{ 'text-opacity-30': !nextChapter }"
@click="jumpNextChapter"
>
skip_next
</span>
</div>
<div class="flex justify-center gap-4 mt-5">
<button
class="px-4 py-2 rounded bg-gray-700"
@click="jumpBackwards"
>
−{{ jumpBackwardsLabel }}
</button>
<button
class="px-4 py-2 rounded bg-gray-700"
@click="jumpForward"
>
+{{ jumpForwardLabel }}
</button>
</div>
<details class="w-full mt-5 rounded bg-gray-800">
<summary
class="w-full py-3 px-4 cursor-pointer select-none font-semibold"
>
Elenco capitoli
</summary>
<div
v-if="chapters.length"
class="max-h-64 overflow-y-auto border-t border-gray-700"
>
<button
v-for="(chapter, index) in chapters"
:key="chapter.id"
type="button"
class="w-full px-4 py-3 text-left border-b border-gray-700 last:border-b-0"
:class="{
'bg-blue-700':
currentChapter && chapter.id === currentChapter.id,
'bg-gray-800':
!currentChapter || chapter.id !== currentChapter.id
}"
@click.stop="selectChapter(chapter)"
>
<div class="font-semibold">
Capitolo {{ index + 1 }}/{{ chapters.length }}
</div>
<div class="text-sm text-gray-300 truncate">
{{ chapter.title || 'Senza titolo' }}
</div>
<div class="text-xs text-gray-500 mt-1">
{{ $secondsToTimestamp(Number(chapter.start)) }}
-
{{ $secondsToTimestamp(Number(chapter.end)) }}
</div>
</button>
</div>
<div
v-else
class="px-4 py-4 text-sm text-gray-400 border-t border-gray-700"
>
Nessun capitolo disponibile
</div>
</details>
</div>
<div v-else class="text-center text-gray-400 py-10">
Nessun capitolo corrente
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -155,6 +521,7 @@ export default {
playbackSession: null, playbackSession: null,
showChapterModal: false, showChapterModal: false,
showFullscreen: false, showFullscreen: false,
showAndroidAutoDebug: false,
totalDuration: 0, totalDuration: 0,
currentPlaybackRate: 1, currentPlaybackRate: 1,
currentTime: 0, currentTime: 0,
@ -448,6 +815,104 @@ export default {
this.showFullscreen = false 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) { async selectChapter(chapter) {
await this.$hapticsImpact() await this.$hapticsImpact()
this.seek(chapter.start) this.seek(chapter.start)
@ -580,20 +1045,26 @@ export default {
ts.innerText = this.$secondsToTimestamp(currentTime) ts.innerText = this.$secondsToTimestamp(currentTime)
}, },
timeupdate() { timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
this.$emit('updateTime', this.currentTime) 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) { if (this.seekLoading) {
this.seekLoading = false this.seekLoading = false
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
this.$refs.playedTrack.classList.remove('bg-yellow-300') this.$refs.playedTrack.classList.remove('bg-yellow-300')
this.$refs.playedTrack.classList.add('bg-gray-200') 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.updateTimestamp()
this.updateTrack() this.updateTrack()
}, },
@ -921,6 +1392,14 @@ export default {
AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
AbsAudioPlayer.addListener(
'onBookmarkRequested',
this.onBookmarkRequested
)
AbsAudioPlayer.addListener(
'onBookmarkDeleteRequested',
this.onBookmarkDeleteRequested
)
}, },
async screenOrientationChange() { async screenOrientationChange() {
if (this.isRefreshingUI) return if (this.isRefreshingUI) return

View file

@ -41,6 +41,15 @@ export default {
} }
}, },
mixins: [CellularPermissionHelpers], mixins: [CellularPermissionHelpers],
watch: {
bookmarks: {
deep: true,
immediate: true,
handler() {
this.syncAndroidAutoBookmarks()
}
}
},
computed: { computed: {
bookmarks() { bookmarks() {
if (!this.serverLibraryItemId) return [] if (!this.serverLibraryItemId) return []
@ -54,6 +63,32 @@ export default {
} }
}, },
methods: { 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() { showBookmarks() {
this.showBookmarksModal = true this.showBookmarksModal = true
}, },