Update Android Auto chapters and bookmarks
This commit is contained in:
parent
fc946702c1
commit
1cccb8efe9
8 changed files with 1926 additions and 72 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<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
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
|
|
|
|||
|
|
@ -1,85 +1,280 @@
|
|||
<template>
|
||||
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
|
||||
<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' }">
|
||||
<div class="audio-player-root">
|
||||
<!-- 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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<!-- Cast button - Chromecast toggle -->
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||
<!-- Top right controls -->
|
||||
<div class="top-6 right-4 absolute flex items-center gap-5 z-50">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Player options menu button -->
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
|
||||
<!-- Playback method indicator (Direct/Local/Transcode) -->
|
||||
<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>
|
||||
<!-- Playback method indicator -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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" />
|
||||
<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 class="w-full">
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden">
|
||||
<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
|
||||
class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Book cover wrapper -->
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<!-- Book cover wrapper -->
|
||||
<div
|
||||
class="cover-wrapper absolute z-30 pointer-events-auto"
|
||||
@click="clickContainer"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Sync failed indicator - shows error icon if progress sync failed -->
|
||||
<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">
|
||||
<span class="material-symbols text-error text-3xl">error</span>
|
||||
<!-- 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"
|
||||
>
|
||||
<span class="material-symbols text-error text-3xl">
|
||||
error
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and author text -->
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<div
|
||||
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>
|
||||
</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 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">
|
||||
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters -->
|
||||
<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">
|
||||
<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>
|
||||
<!-- Main player content -->
|
||||
<div
|
||||
v-if="!showAndroidAutoDebug"
|
||||
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"
|
||||
>
|
||||
|
||||
<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" />
|
||||
<!-- Top controls bar - fullscreen only -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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 -->
|
||||
|
|
@ -126,6 +321,177 @@
|
|||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<!-- More menu dialog - player settings and options -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue