Merge pull request #1861 from lkiesow/landscape-player

Player view in landscape mode
This commit is contained in:
advplyr
2026-07-31 18:06:31 -04:00
committed by GitHub
+110 -30
View File
@@ -1,20 +1,31 @@
<template> <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 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' }">
<!-- 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" 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"> <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 -->
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer"> <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> <span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
</div> </div>
<!-- Player options menu button -->
<div class="top-6 right-4 absolute cursor-pointer"> <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> <span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
</div> </div>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :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 (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>
</div> </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" 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>
@@ -22,7 +33,7 @@
<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"> <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="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="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="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
@@ -30,16 +41,19 @@
</div> </div>
</div> </div>
<!-- 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 -->
<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 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> <span class="material-symbols text-error text-3xl">error</span>
</div> </div>
</div> </div>
<!-- Title and author text -->
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor"> <div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<div ref="titlewrapper" class="overflow-hidden relative"> <div ref="titlewrapper" class="overflow-hidden relative">
<p class="title-text whitespace-nowrap"></p> <p class="title-text whitespace-nowrap"></p>
@@ -48,6 +62,7 @@
</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"> <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 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"> <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> <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>
@@ -67,6 +82,7 @@
</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 -->
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px"> <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'"> <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> <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>
@@ -88,13 +104,14 @@
</div> </div>
</div> </div>
<!-- Chapter progress bar -->
<div id="playerTrack" class="absolute left-0 w-full px-6"> <div id="playerTrack" class="absolute left-0 w-full px-6">
<div class="flex pointer-events-none"> <div class="flex pointer-events-none">
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p> <p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p> <p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div> </div>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop> <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="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="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="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
@@ -105,7 +122,9 @@
</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" /> <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" /> <modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
</div> </div>
</template> </template>
@@ -449,9 +468,8 @@ export default {
this.showFullscreen = true this.showFullscreen = true
if (this.titleMarquee) this.titleMarquee.reset() if (this.titleMarquee) this.titleMarquee.reset()
// Update track for total time bar if useChapterTrack is set
this.$nextTick(() => { this.$nextTick(() => {
this.updateTrack() this.measureAndUpdateTrackWidth()
}) })
}, },
collapseFullscreen() { collapseFullscreen() {
@@ -531,13 +549,17 @@ export default {
this.updateReadyTrack() this.updateReadyTrack()
}, },
updateReadyTrack() { updateReadyTrack() {
if (!this.$refs.readyTrack) return
if (this.playerSettings.useChapterTrack) { if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalReadyTrack) { if (this.$refs.totalReadyTrack && this.trackWidth) {
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px' const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
} }
this.$refs.readyTrack.style.width = this.trackWidth + 'px' this.$refs.readyTrack.style.width = '100%'
} else { } else if (this.trackWidth) {
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px' const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.readyTrack.style.width = readyPercent + '%'
} }
}, },
updateTimestamp() { updateTimestamp() {
@@ -589,21 +611,23 @@ export default {
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration)) bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
} }
const ptWidth = Math.round(percentDone * this.trackWidth) const playedPercent = percentDone * 100
const bufferedPercentWidth = bufferedPercent * 100
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = playedPercent + '%'
} }
if (this.$refs.bufferedTrack) { if (this.$refs.bufferedTrack) {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
} }
if (this.$refs.trackCursor) { if (this.$refs.trackCursor && this.$refs.track) {
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px' const trackWidth = this.$refs.track.clientWidth
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
} }
if (this.playerSettings.useChapterTrack) { if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px' if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
} }
}, },
seek(time) { seek(time) {
@@ -621,8 +645,7 @@ export default {
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
const perc = time / this.totalDuration const perc = time / this.totalDuration
const ptWidth = Math.round(perc * this.trackWidth) this.$refs.playedTrack.style.width = perc * 100 + '%'
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.classList.remove('bg-gray-200') this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300') this.$refs.playedTrack.classList.add('bg-yellow-300')
@@ -731,7 +754,8 @@ export default {
maxTime = minTime + duration maxTime = minTime + duration
} }
const timePerPixel = duration / this.trackWidth const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
const timePerPixel = duration / trackWidth
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime)) this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
@@ -869,11 +893,7 @@ export default {
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper) this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
this.titleMarquee.init(this.title) this.titleMarquee.init(this.title)
if (this.$refs.track) { this.measureAndUpdateTrackWidth()
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
}) })
}, },
onPlaybackClosed() { onPlaybackClosed() {
@@ -919,15 +939,26 @@ export default {
} }
} }
// playerContainer width transition is 150ms, remeasure after layout settles
await new Promise((resolve) => setTimeout(resolve, 200))
this.refreshUI()
this.isRefreshingUI = false 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() { refreshUI() {
this.updateScreenSize() this.updateScreenSize()
if (this.$refs.track) { this.$nextTick(() => {
this.trackWidth = this.$refs.track.clientWidth this.measureAndUpdateTrackWidth()
this.updateTrack() })
this.updateReadyTrack()
}
}, },
updateScreenSize() { updateScreenSize() {
setTimeout(() => { setTimeout(() => {
@@ -1018,6 +1049,12 @@ export default {
.fullscreen .playerContainer { .fullscreen .playerContainer {
height: 200px; height: 200px;
} }
@media (orientation: landscape) {
.fullscreen .playerContainer {
width: 50%;
left: 50%;
}
}
#playerContent { #playerContent {
box-shadow: 0px -8px 8px #11111155; box-shadow: 0px -8px 8px #11111155;
} }
@@ -1029,6 +1066,7 @@ export default {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin; transition-property: margin;
bottom: 35px; bottom: 35px;
max-width: 100%;
} }
.fullscreen #playerTrack { .fullscreen #playerTrack {
bottom: unset; bottom: unset;
@@ -1045,13 +1083,45 @@ export default {
border-radius: 3px; border-radius: 3px;
overflow: hidden; 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 { .total-track {
bottom: 215px; bottom: 215px;
left: 0; left: 0;
right: 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 { .title-author-texts {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height; transition-property: left, bottom, width, height;
@@ -1068,12 +1138,22 @@ export default {
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.5; line-height: 1.5;
} }
@media (orientation: landscape) {
.fullscreen .title-author-texts .title-text {
font-size: larger !important;
}
}
.title-author-texts .author-text { .title-author-texts .author-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size; transition-property: font-size;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.2; line-height: 1.2;
} }
@media (orientation: landscape) {
.fullscreen .title-author-texts .author-text {
font-size: inherit !important;
}
}
.fullscreen .title-author-texts { .fullscreen .title-author-texts {
bottom: calc(50% - var(--cover-image-height) / 2 + 50px); bottom: calc(50% - var(--cover-image-height) / 2 + 50px);