audiobookshelf-app/components/app/AudioPlayer.vue
FabioMich66 867b4467bc
Some checks failed
Build APK / main (push) Has been cancelled
Build iOS / Build iOS simulator app (push) Has been cancelled
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled
Fix Android Auto chapter and bookmark synchronization
2026-10-20 15:26:22 +02:00

1751 lines
57 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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 && !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)"
/>
<!-- 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>
</div>
<!-- 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>
<!-- 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 &&
!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>
<div class="flex-grow" />
<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>
</div>
</div>
<!-- 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"
/>
</div>
<!-- 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"
>
<p class="title-text whitespace-nowrap"></p>
</div>
<p class="author-text text-fg text-opacity-75 truncate">
{{ authorName }}
</p>
</div>
<!-- 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"
>
<!-- 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>
<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 -->
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span class="material-symbols text-3xl leading-none">replay</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
</div>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<span class="material-symbols text-3xl leading-none">forward_media</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
</div>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<!-- Chapter progress bar -->
<div id="playerTrack" class="absolute left-0 w-full px-6">
<div class="flex pointer-events-none">
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full overflow-hidden" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
</div>
</div>
</div>
</div>
<!-- Chapters modal - lists chapters for navigation -->
<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>
<script>
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { Dialog } from '@capacitor/dialog'
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import WrappingMarquee from '@/assets/WrappingMarquee.js'
import jumpLabelMixin from '@/mixins/jumpLabel'
export default {
props: {
bookmarks: {
type: Array,
default: () => []
},
sleepTimerRunning: Boolean,
sleepTimeRemaining: Number,
serverLibraryItemId: String
},
mixins: [jumpLabelMixin],
data() {
return {
windowHeight: 0,
windowWidth: 0,
playbackSession: null,
showChapterModal: false,
showFullscreen: false,
showAndroidAutoDebug: false,
totalDuration: 0,
currentPlaybackRate: 1,
currentTime: 0,
bufferedTime: 0,
playInterval: null,
trackWidth: 0,
isPlaying: false,
isEnded: false,
volume: 0.5,
readyTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
touchStartY: 0,
touchStartTime: 0,
playerSettings: {
useChapterTrack: false,
useTotalTrack: true,
scaleElapsedTimeBySpeed: true,
lockUi: false
},
isLoading: false,
isCheckingServerProgress: false,
isDraggingCursor: false,
draggingTouchStartX: 0,
draggingTouchStartTime: 0,
draggingCurrentTime: 0,
syncStatus: 0,
showMoreMenuDialog: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false,
titleMarquee: null,
isRefreshingUI: false
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
},
bookCoverAspectRatio() {
this.updateScreenSize()
},
title(val) {
if (this.titleMarquee) this.titleMarquee.init(val)
}
},
computed: {
theme() {
return document.documentElement.dataset.theme || 'dark'
},
menuItems() {
const items = []
// TODO: Implement on iOS
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
items.push({
text: this.$strings.ButtonHistory,
value: 'history',
icon: 'history'
})
}
items.push(
...[
{
text: this.$strings.LabelTotalTrack,
value: 'total_track',
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.$strings.LabelChapterTrack,
value: 'chapter_track',
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.$strings.LabelScaleElapsedTimeBySpeed,
value: 'scale_elapsed_time',
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
value: 'lock',
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
},
{
text: this.$strings.LabelClosePlayer,
value: 'close',
icon: 'close'
}
]
)
return items
},
jumpForwardLabel() {
return this.getJumpLabel(this.jumpForwardTime)
},
jumpBackwardsLabel() {
return this.getJumpLabel(this.jumpBackwardsTime)
},
jumpForwardTime() {
return this.$store.getters['getJumpForwardTime']
},
jumpBackwardsTime() {
return this.$store.getters['getJumpBackwardsTime']
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 46 / this.bookCoverAspectRatio
},
fullscreenBookCoverWidth() {
if (this.windowWidth < this.windowHeight) {
// Portrait
let sideSpace = 20
if (this.bookCoverAspectRatio === 1.6) sideSpace += (this.windowWidth - sideSpace) * 0.375
const availableHeight = this.windowHeight - 400
let width = this.windowWidth - sideSpace
const totalHeight = width * this.bookCoverAspectRatio
if (totalHeight > availableHeight) {
width = availableHeight / this.bookCoverAspectRatio
}
return width
} else {
// Landscape
const heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) {
return 260 * heightScale
}
return 190 * heightScale
}
},
showLoadingState() {
return this.isLoading || this.isCheckingServerProgress
},
showCastBtn() {
return this.$store.state.isCastAvailable
},
isCasting() {
return this.mediaPlayer === 'cast-player'
},
mediaPlayer() {
return this.playbackSession?.mediaPlayer || null
},
mediaType() {
return this.playbackSession?.mediaType || null
},
isPodcast() {
return this.mediaType === 'podcast'
},
mediaMetadata() {
return this.playbackSession?.mediaMetadata || null
},
libraryItem() {
return this.playbackSession?.libraryItem || null
},
localLibraryItem() {
return this.playbackSession?.localLibraryItem || null
},
localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem?.coverContentUrl || null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null
},
playMethod() {
return this.playbackSession?.playMethod || 0
},
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
},
isDirectPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
},
title() {
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
if (this.currentChapterTitle) {
if (this.showFullscreen) return this.currentChapterTitle
return `${mediaItemTitle} | ${this.currentChapterTitle}`
}
return mediaItemTitle
},
authorName() {
if (this.playbackSession) return this.playbackSession.displayAuthor
return this.mediaMetadata?.authorName || 'Author'
},
chapters() {
return this.playbackSession?.chapters || []
},
currentChapter() {
if (!this.chapters.length) return null
return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime)
},
nextChapter() {
if (!this.chapters.length) return
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
},
currentChapterTitle() {
return this.currentChapter?.title || ''
},
currentChapterDuration() {
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
currentTimePretty() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.playerSettings.scaleElapsedTimeBySpeed) {
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
}
return this.$secondsToTimestamp(currentTimeToUse)
},
timeRemaining() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.playerSettings.useChapterTrack && this.currentChapter) {
var currChapTime = currentTimeToUse - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
}
return this.totalTimeRemaining
},
totalTimeRemaining() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
return (this.totalDuration - currentTimeToUse) / this.currentPlaybackRate
},
totalTimeRemainingPretty() {
if (this.totalTimeRemaining < 0) {
return this.$secondsToTimestamp(this.totalTimeRemaining * -1)
}
return '-' + this.$secondsToTimestamp(this.totalTimeRemaining)
},
timeRemainingPretty() {
if (this.timeRemaining < 0) {
return this.$secondsToTimestamp(this.timeRemaining * -1)
}
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
sleepTimeRemainingPretty() {
if (!this.sleepTimeRemaining) return '0s'
var secondsRemaining = Math.round(this.sleepTimeRemaining)
if (secondsRemaining > 91) {
return Math.ceil(secondsRemaining / 60) + 'm'
} else {
return secondsRemaining + 's'
}
},
socketConnected() {
return this.$store.state.socketConnected
},
mediaId() {
if (this.isPodcast || !this.playbackSession) return null
if (this.playbackSession.libraryItemId) {
return this.playbackSession.episodeId ? `${this.playbackSession.libraryItemId}-${this.playbackSession.episodeId}` : this.playbackSession.libraryItemId
}
const localLibraryItem = this.playbackSession.localLibraryItem
if (!localLibraryItem) return null
return this.playbackSession.localEpisodeId ? `${localLibraryItem.id}-${this.playbackSession.localEpisodeId}` : localLibraryItem.id
}
},
methods: {
showSyncsFailedDialog() {
Dialog.alert({
title: this.$strings.HeaderProgressSyncFailed,
message: this.$strings.MessageProgressSyncFailed,
cancelText: this.$strings.ButtonOk
})
},
clickChaptersBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
if (!avg) {
this.coverRgb = 'rgb(55, 56, 56)'
this.coverBgIsLight = false
} else {
this.coverRgb = avg.rgba
this.coverBgIsLight = avg.isLight
}
},
clickTitleAndAuthor() {
if (!this.showFullscreen) return
const llid = this.serverLibraryItemId || this.libraryItem?.id || this.localLibraryItem?.id
if (llid) {
this.$router.push(`/item/${llid}`)
this.showFullscreen = false
}
},
toggleAndroidAutoDebug() {
this.showAndroidAutoDebug = true
this.showFullscreen = true
},
closeAndroidAutoDebug() {
this.showAndroidAutoDebug = false
},
async onBookmarkDeleteRequested(payload) {
console.log(
'[AA BOOKMARK DELETE REQUEST]',
JSON.stringify({
payload,
currentServerLibraryItemId: this.serverLibraryItemId
})
)
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}`
)
console.log(
'[AA BOOKMARK DELETE API OK]',
JSON.stringify({
libraryItemId,
time,
bookmarksBefore:
this.$store.state.user?.user?.bookmarks || []
})
)
this.$store.commit(
'user/deleteBookmark',
{
libraryItemId,
time
}
)
console.log(
'[AA BOOKMARK DELETE STORE OK]',
JSON.stringify({
libraryItemId,
time,
bookmarksAfter:
this.$store.state.user?.user?.bookmarks || []
})
)
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.$store.commit('user/updateBookmark', {
...bookmark,
libraryItemId
})
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)
this.showChapterModal = false
},
async castClick() {
await this.$hapticsImpact()
if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item')
return
}
AbsAudioPlayer.requestSession()
},
clickContainer() {
this.expandToFullscreen()
},
expandToFullscreen() {
this.showFullscreen = true
if (this.titleMarquee) this.titleMarquee.reset()
this.$nextTick(() => {
this.measureAndUpdateTrackWidth()
})
},
collapseFullscreen() {
this.showFullscreen = false
if (this.titleMarquee) this.titleMarquee.reset()
this.forceCloseDropdownMenu()
},
async jumpNextChapter() {
await this.$hapticsImpact()
if (this.showLoadingState) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
async jumpChapterStart() {
await this.$hapticsImpact()
if (this.showLoadingState) return
if (!this.currentChapter) {
return this.restart()
}
// If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 4) {
const currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
if (currChapterIndex > 0) {
const prevChapter = this.chapters[currChapterIndex - 1]
this.seek(prevChapter.start)
}
} else {
this.seek(this.currentChapter.start)
}
},
showSleepTimerModal() {
this.$emit('showSleepTimer')
},
async setPlaybackSpeed(speed) {
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed
this.updateTimestamp()
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
},
restart() {
this.seek(0)
},
async jumpBackwards() {
await this.$hapticsImpact()
if (this.showLoadingState) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
async jumpForward() {
await this.$hapticsImpact()
if (this.showLoadingState) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.updateReadyTrack()
},
setChunksReady(chunks, numSegments) {
let largestSeg = 0
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
if (typeof chunk === 'string') {
const chunkRange = chunk.split('-').map((c) => Number(c))
if (chunkRange.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) {
largestSeg = chunk
}
}
const percentageReady = largestSeg / numSegments
const widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) {
return
}
this.readyTrackWidth = widthReady
this.updateReadyTrack()
},
updateReadyTrack() {
if (!this.$refs.readyTrack) return
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalReadyTrack && this.trackWidth) {
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
}
this.$refs.readyTrack.style.width = '100%'
} else if (this.trackWidth) {
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.readyTrack.style.width = readyPercent + '%'
}
},
updateTimestamp() {
const ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.playerSettings.useChapterTrack && this.currentChapter) {
currentTime = Math.max(0, currentTime - this.currentChapter.start)
}
if (this.playerSettings.scaleElapsedTimeBySpeed) {
currentTime = currentTime / this.currentPlaybackRate
}
ts.innerText = this.$secondsToTimestamp(currentTime)
},
timeupdate() {
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()
},
updateTrack() {
// Update progress track UI
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
let percentDone = currentTimeToUse / this.totalDuration
const totalPercentDone = percentDone
let bufferedPercent = this.bufferedTime / this.totalDuration
const totalBufferedPercent = bufferedPercent
if (this.playerSettings.useChapterTrack && this.currentChapter) {
const currChapTime = currentTimeToUse - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
}
const playedPercent = percentDone * 100
const bufferedPercentWidth = bufferedPercent * 100
if (this.$refs.playedTrack) {
this.$refs.playedTrack.style.width = playedPercent + '%'
}
if (this.$refs.bufferedTrack) {
this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
}
if (this.$refs.trackCursor && this.$refs.track) {
const trackWidth = this.$refs.track.clientWidth
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
}
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
}
},
seek(time) {
if (this.showLoadingState) return
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
this.seekedTime = time
this.seekLoading = true
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
AbsAudioPlayer.seek({ value: time })
if (this.$refs.playedTrack) {
const perc = time / this.totalDuration
this.$refs.playedTrack.style.width = perc * 100 + '%'
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
async touchstartCursor(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
await this.$hapticsImpact()
this.isDraggingCursor = true
this.draggingTouchStartX = e.touches[0].pageX
this.draggingTouchStartTime = this.currentTime
this.draggingCurrentTime = this.currentTime
this.updateTrack()
},
async playPauseClick() {
await this.$hapticsImpact()
if (this.showLoadingState) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
this.isEnded = false
},
setIsCheckingServerProgress(value) {
this.isCheckingServerProgress = !!value
},
play() {
AbsAudioPlayer.playPlayer()
this.startPlayInterval()
this.isPlaying = true
},
pause() {
AbsAudioPlayer.pausePlayer()
this.stopPlayInterval()
this.isPlaying = false
},
startPlayInterval() {
clearInterval(this.playInterval)
this.playInterval = setInterval(async () => {
var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number(data.value.toFixed(2))
this.bufferedTime = Number(data.bufferedTime.toFixed(2))
this.timeupdate()
}, 1000)
},
stopPlayInterval() {
clearInterval(this.playInterval)
},
resetStream(startTime) {
this.closePlayback()
},
touchstart(e) {
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
const touchPosY = e.changedTouches[0].pageY
// when minimized only listen to touchstart on the player
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
// for ios
if (!this.showFullscreen && e.pageX < 20) {
e.preventDefault()
e.stopImmediatePropagation()
}
this.touchStartY = touchPosY
this.touchStartTime = Date.now()
},
touchend(e) {
if (!e.changedTouches) return
const touchDuration = Date.now() - this.touchStartTime
const touchEndY = e.changedTouches[0].pageY
const touchDistanceY = touchEndY - this.touchStartY
// reset touch start data
this.touchStartTime = 0
this.touchStartY = 0
if (this.isDraggingCursor) {
if (this.draggingCurrentTime !== this.currentTime) {
this.seek(this.draggingCurrentTime)
}
this.isDraggingCursor = false
} else {
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
if (this.showFullscreen) {
// Touch start higher than touchend
if (touchDistanceY > 100) {
this.collapseFullscreen()
}
} else if (touchDistanceY < -100) {
this.expandToFullscreen()
}
}
},
touchmove(e) {
if (!this.isDraggingCursor || !e.touches) return
const distanceMoved = e.touches[0].pageX - this.draggingTouchStartX
let duration = this.totalDuration
let minTime = 0
let maxTime = duration
if (this.playerSettings.useChapterTrack && this.currentChapter) {
duration = this.currentChapterDuration
minTime = this.currentChapter.start
maxTime = minTime + duration
}
const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
const timePerPixel = duration / trackWidth
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
this.updateTimestamp()
this.updateTrack()
},
async clickMenuAction(action) {
await this.$hapticsImpact()
this.showMoreMenuDialog = false
this.$nextTick(() => {
if (action === 'history') {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
this.showFullscreen = false
} else if (action === 'scale_elapsed_time') {
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
this.updateTimestamp()
this.savePlayerSettings()
} else if (action === 'lock') {
this.playerSettings.lockUi = !this.playerSettings.lockUi
this.savePlayerSettings()
} else if (action === 'chapter_track') {
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.updateUseChapterTrack()
this.savePlayerSettings()
} else if (action === 'total_track') {
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.updateUseChapterTrack()
this.savePlayerSettings()
} else if (action === 'close') {
this.closePlayback()
}
})
},
updateUseChapterTrack() {
// Chapter track in NowPlaying only supported on iOS for now
if (this.$platform === 'ios') {
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
}
},
forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
this.$refs.dropdownMenu.closeMenu()
}
},
closePlayback() {
this.endPlayback()
AbsAudioPlayer.closePlayback()
},
endPlayback() {
this.$store.commit('setPlaybackSession', null)
this.showFullscreen = false
this.isEnded = false
this.isLoading = false
this.playbackSession = null
},
async loadPlayerSettings() {
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
if (!savedPlayerSettings) {
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
// Check if this old key was set and if so migrate them over to 'playerSettings'
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
if (chapterTrackPref) {
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
this.playerSettings.useTotalTrack = totalTrackPref === '1'
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
this.playerSettings.lockUi = playerLockPref === '1'
}
this.savePlayerSettings()
} else {
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
}
},
savePlayerSettings() {
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
},
//
// Listeners from audio AbsAudioPlayer
//
onPlayingUpdate(data) {
console.log('onPlayingUpdate', JSON.stringify(data))
this.isPlaying = !!data.value
this.$store.commit('setPlayerPlaying', this.isPlaying)
if (this.isPlaying) {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
},
onMetadata(data) {
console.log('onMetadata', JSON.stringify(data))
this.totalDuration = Number(data.duration.toFixed(2))
this.currentTime = Number(data.currentTime.toFixed(2))
// Done loading
if (data.playerState !== 'BUFFERING' && data.playerState !== 'IDLE') {
this.isLoading = false
}
if (data.playerState === 'ENDED') {
console.log('[AudioPlayer] Playback ended')
}
this.isEnded = data.playerState === 'ENDED'
console.log('received metadata update', data)
this.timeupdate()
},
// When a playback session is started the native android/ios will send the session
onPlaybackSession(playbackSession) {
console.log('onPlaybackSession received', JSON.stringify(playbackSession))
this.playbackSession = playbackSession
this.isEnded = false
this.isLoading = true
this.syncStatus = 0
this.$store.commit('setPlaybackSession', this.playbackSession)
// Set track width
this.$nextTick(() => {
if (this.titleMarquee) this.titleMarquee.reset()
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
this.titleMarquee.init(this.title)
this.measureAndUpdateTrackWidth()
})
},
onPlaybackClosed() {
this.endPlayback()
},
onPlaybackFailed(data) {
console.log('Received onPlaybackFailed evt')
var errorMessage = data.value || 'Unknown Error'
this.$toast.error(`Playback Failed: ${errorMessage}`)
this.endPlayback()
},
onPlaybackSpeedChanged(data) {
if (!data.value || isNaN(data.value)) return
this.currentPlaybackRate = Number(data.value)
this.updateTimestamp()
},
async init() {
await this.loadPlayerSettings()
AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
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
this.isRefreshingUI = true
const windowWidth = window.innerWidth
this.refreshUI()
// Window width does not always change right away. Wait up to 250ms for a change.
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
// but landscape to portrait was immediate
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
if (window.innerWidth !== windowWidth) {
this.refreshUI()
break
}
}
// playerContainer width transition is 150ms, remeasure after layout settles
await new Promise((resolve) => setTimeout(resolve, 200))
this.refreshUI()
this.isRefreshingUI = false
},
measureAndUpdateTrackWidth() {
if (!this.$refs.track) {
console.error('Track not loaded', this.$refs)
return
}
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
},
refreshUI() {
this.updateScreenSize()
this.$nextTick(() => {
this.measureAndUpdateTrackWidth()
})
},
updateScreenSize() {
setTimeout(() => {
if (this.titleMarquee) this.titleMarquee.init(this.title)
}, 500)
this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
},
minimizePlayerEvt() {
this.collapseFullscreen()
},
showProgressSyncIsFailing() {
this.syncStatus = this.$constants.SyncStatus.FAILED
},
showProgressSyncSuccess() {
this.syncStatus = this.$constants.SyncStatus.SUCCESS
}
},
mounted() {
this.updateScreenSize()
if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
}
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init)
},
beforeDestroy() {
if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
}
window.removeEventListener('resize', this.screenOrientationChange)
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu()
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (AbsAudioPlayer.removeAllListeners) {
AbsAudioPlayer.removeAllListeners()
}
clearInterval(this.playInterval)
}
}
</script>
<style>
:root {
--cover-image-width: 0px;
--cover-image-height: 0px;
--cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px;
--title-author-left-offset-collapsed: 80px;
--title-author-width-collapsed: 40%;
}
.playerContainer {
height: 120px;
}
.fullscreen .playerContainer {
height: 200px;
}
@media (orientation: landscape) {
.fullscreen .playerContainer {
width: 50%;
left: 50%;
}
}
#playerContent {
box-shadow: 0px -8px 8px #11111155;
}
.fullscreen #playerContent {
box-shadow: none;
}
#playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin;
bottom: 35px;
max-width: 100%;
}
.fullscreen #playerTrack {
bottom: unset;
}
.cover-wrapper {
bottom: 68px;
left: 24px;
height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed);
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
border-radius: 3px;
overflow: hidden;
}
@media (orientation: landscape) {
.fullscreen .cover-wrapper {
left: 25px !important;
top: 50px;
width: calc(50% - 25px) !important;
height: calc(100% - 85px) !important;
}
.fullscreen .cover-wrapper > div > div {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
.fullscreen .cover-wrapper > div > div > div {
background-color: transparent;
}
.fullscreen .cover-wrapper img {
object-fit: contain;
}
}
.total-track {
bottom: 215px;
left: 0;
right: 0;
}
@media (orientation: landscape) {
.fullscreen .total-track {
left: 50%;
width: 50%;
}
}
@media (orientation: landscape) {
.fullscreen .title-author-texts {
left: 50% !important;
width: 50% !important;
}
}
.title-author-texts {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
width: var(--title-author-width-collapsed);
bottom: 76px;
left: var(--title-author-left-offset-collapsed);
text-align: left;
}
.title-author-texts .title-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 0.85rem;
line-height: 1.5;
}
@media (orientation: landscape) {
.fullscreen .title-author-texts .title-text {
font-size: larger !important;
}
}
.title-author-texts .author-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 0.75rem;
line-height: 1.2;
}
@media (orientation: landscape) {
.fullscreen .title-author-texts .author-text {
font-size: inherit !important;
}
}
.fullscreen .title-author-texts {
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
width: 80%;
left: 10%;
text-align: center;
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
pointer-events: auto;
}
.fullscreen .title-author-texts .title-text {
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.3rem);
}
.fullscreen .title-author-texts .author-text {
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
}
#playerControls {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: width, bottom;
width: 128px;
padding-right: 24px;
bottom: 70px;
}
#playerControls .jump-icon {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
margin: 0px 0px;
font-size: 1.6rem;
}
#playerControls .jump-label {
margin-top: 2px;
}
#playerControls .play-btn {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: padding, margin, height, width, min-width, min-height;
height: 40px;
width: 40px;
min-width: 40px;
min-height: 40px;
margin: 0px 7px;
}
#playerControls .play-btn .material-symbols {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 1.5rem;
}
.fullscreen .cover-wrapper {
margin: 0 auto;
height: var(--cover-image-height);
width: var(--cover-image-width);
left: calc(50% - (calc(var(--cover-image-width)) / 2));
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
border-radius: 16px;
overflow: hidden;
}
.fullscreen #playerControls {
width: 100%;
padding-left: 24px;
padding-right: 24px;
bottom: 78px;
left: 0;
}
.fullscreen #playerControls .jump-icon {
font-size: 2.4rem;
}
.fullscreen #playerControls .next-icon {
font-size: 2rem;
}
.fullscreen #playerControls .play-btn {
height: 65px;
width: 65px;
min-width: 65px;
min-height: 65px;
}
.fullscreen #playerControls .play-btn .material-symbols {
font-size: 2.1rem;
}
</style>