mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f29b245d7 | |||
| ecf62c5443 | |||
| eb82d9c300 | |||
| 45582343b8 | |||
| 6a4d3a55b1 | |||
| 95bacce5e5 | |||
| 408775a25a | |||
| d3a8ecc8d1 |
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div v-if="chapters.length" class="hidden md:flex absolute right-20 top-0 bottom-0 h-full items-end">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-icons text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
|
||||
</div>
|
||||
|
||||
<div class="flex pb-4 md:pb-2">
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
@@ -91,7 +97,9 @@ export default {
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
sleepTimerSet: Boolean,
|
||||
sleepTimerRemaining: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -106,10 +114,23 @@ export default {
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
duration: 0
|
||||
duration: 0,
|
||||
chapterTicks: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
if (rounded < 90) {
|
||||
return `${rounded}s`
|
||||
}
|
||||
var minutesRounded = Math.round(rounded / 60)
|
||||
if (minutesRounded < 90) {
|
||||
return `${minutesRounded}m`
|
||||
}
|
||||
var hoursRounded = Math.round(minutesRounded / 60)
|
||||
return `${hoursRounded}h`
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
@@ -118,7 +139,6 @@ export default {
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
@@ -127,15 +147,6 @@ export default {
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
@@ -149,6 +160,14 @@ export default {
|
||||
methods: {
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
@@ -330,9 +349,6 @@ export default {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showBookmarks() {
|
||||
this.$emit('showBookmarks', this.currentTime)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-4 pt-2">
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
@@ -22,12 +22,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
|
||||
<audio-player
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:paused="!isPlaying"
|
||||
:loading="playerLoading"
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-set="sleepTimerSet"
|
||||
:sleep-timer-remaining="sleepTimerRemaining"
|
||||
@playPause="playPause"
|
||||
@jumpForward="jumpForward"
|
||||
@jumpBackward="jumpBackward"
|
||||
@setVolume="setVolume"
|
||||
@setPlaybackRate="setPlaybackRate"
|
||||
@seek="seek"
|
||||
@close="closePlayer"
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,10 +63,18 @@ export default {
|
||||
bookmarkAudiobookId: null,
|
||||
playerLoading: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
@@ -111,6 +138,49 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setSleepTimer(seconds) {
|
||||
this.sleepTimerSet = true
|
||||
this.sleepTimerTime = seconds
|
||||
this.sleepTimerRemaining = seconds
|
||||
this.runSleepTimer()
|
||||
this.showSleepTimerModal = false
|
||||
},
|
||||
runSleepTimer() {
|
||||
var lastTick = Date.now()
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimer = setInterval(() => {
|
||||
var elapsed = Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
this.sleepTimerRemaining -= elapsed / 1000
|
||||
|
||||
if (this.sleepTimerRemaining <= 0) {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
this.showSleepTimerModal = false
|
||||
this.clearSleepTimer()
|
||||
},
|
||||
clearSleepTimer() {
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimerRemaining = 0
|
||||
this.sleepTimer = null
|
||||
this.sleepTimerSet = false
|
||||
},
|
||||
incrementSleepTimer(amount) {
|
||||
if (!this.sleepTimerSet) return
|
||||
this.sleepTimerRemaining += amount
|
||||
},
|
||||
decrementSleepTimer(amount) {
|
||||
if (this.sleepTimerRemaining < amount) {
|
||||
this.sleepTimerRemaining = 3
|
||||
return
|
||||
}
|
||||
this.sleepTimerRemaining = Math.max(0, this.sleepTimerRemaining - amount)
|
||||
},
|
||||
playPause() {
|
||||
this.playerHandler.playPause()
|
||||
},
|
||||
@@ -146,9 +216,9 @@ export default {
|
||||
this.$refs.audioPlayer.setBufferTime(buffertime)
|
||||
}
|
||||
},
|
||||
showBookmarks(currentTime) {
|
||||
showBookmarks() {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = currentTime
|
||||
this.bookmarkCurrentTime = this.currentTime
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
|
||||
<path
|
||||
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
|
||||
transform="translate(-557.82 -281.22)"
|
||||
/>
|
||||
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
|
||||
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg class="p-px" viewBox="0 0 122.877 120.596">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">Sleep Timer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
||||
<span class="material-icons text-lg">remove</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
||||
|
||||
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
|
||||
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
||||
|
||||
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn class="w-full" @click="$emit('cancel')">Cancel</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
timerSet: Boolean,
|
||||
timerTime: Number,
|
||||
remaining: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 10,
|
||||
text: '10 seconds'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: '90 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 180,
|
||||
text: '3 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setTime(time) {
|
||||
this.$emit('set', time.seconds)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
},
|
||||
decrement(amount) {
|
||||
if (amount > this.remaining) {
|
||||
if (this.remaining > 60) amount = 60
|
||||
else amount = 5
|
||||
}
|
||||
this.$emit('decrement', amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,11 +6,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<div class="flex flex-wrap -mx-1">
|
||||
<div class="w-full md:w-2/3 px-1">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/3 px-1">
|
||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-media-type-picker v-model="mediaType" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +21,6 @@
|
||||
<div class="w-full py-4">
|
||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
||||
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||
@@ -60,6 +62,7 @@ export default {
|
||||
return {
|
||||
name: '',
|
||||
provider: '',
|
||||
mediaType: '',
|
||||
folders: [],
|
||||
showDirectoryPicker: false,
|
||||
disableWatcher: false
|
||||
@@ -80,7 +83,7 @@ export default {
|
||||
var newfolderpaths = this.folderPaths.join(',')
|
||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
||||
|
||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher
|
||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher && this.mediaType === this.library.mediaType
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
@@ -103,6 +106,7 @@ export default {
|
||||
this.provider = this.library ? this.library.provider : ''
|
||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
||||
this.mediaType = this.library ? this.library.mediaType : 'default'
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
selectFolder(fullPath) {
|
||||
@@ -129,6 +133,8 @@ export default {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
mediaType: this.mediaType,
|
||||
icon: this.mediaType,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
@@ -163,6 +169,8 @@ export default {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
mediaType: this.mediaType,
|
||||
icon: this.mediaType,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
|
||||
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
||||
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<widgets-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
|
||||
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
|
||||
@@ -9,21 +9,21 @@
|
||||
<table id="backups">
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th class="hidden sm:block w-32 md:w-56">Datetime</th>
|
||||
<th class="hidden sm:block w-20 md:w-28">Size</th>
|
||||
<th class="hidden sm:table-cell w-32 md:w-56">Datetime</th>
|
||||
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
||||
<th class="w-36"></th>
|
||||
</tr>
|
||||
<tr v-for="backup in backups" :key="backup.id">
|
||||
<td>
|
||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:block font-sans text-base">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:block font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td class="hidden sm:table-cell font-sans text-base">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||
|
||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-0.5 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
|
||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||
</div>
|
||||
|
||||
@@ -87,9 +87,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getLastRead(audiobooks) {
|
||||
var abs = Object.values(audiobooks)
|
||||
var abs = Object.values(audiobooks).filter((ab) => {
|
||||
return ab.progress > 0
|
||||
})
|
||||
if (abs.length) {
|
||||
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
||||
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
// Book object is attached on request
|
||||
if (abs[0].book) return abs[0].book.title
|
||||
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||
<widgets-library-icon :icon="library.icon" class="mr-2" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="relative w-full h-9" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold">{{ label }}</p>
|
||||
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<widgets-library-icon :icon="selected" class="mr-2" />
|
||||
<span class="block truncate text-sm">{{ selectedName }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">expand_more</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="type in types">
|
||||
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="select(type)">
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="type.id" class="mr-2" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ type.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
disabled: Boolean,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Media Type'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false,
|
||||
types: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default'
|
||||
},
|
||||
{
|
||||
id: 'audiobooks',
|
||||
name: 'Audiobooks'
|
||||
},
|
||||
{
|
||||
id: 'books',
|
||||
name: 'Books'
|
||||
},
|
||||
{
|
||||
id: 'podcasts',
|
||||
name: 'Podcasts'
|
||||
},
|
||||
{
|
||||
id: 'comics',
|
||||
name: 'Comics'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value || 'default'
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedItem() {
|
||||
return this.types.find((t) => t.id === this.selected)
|
||||
},
|
||||
selectedName() {
|
||||
return this.selectedItem ? this.selectedItem.name : 'Default'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
select(type) {
|
||||
if (this.disabled) return
|
||||
this.selected = type.id
|
||||
this.showMenu = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,20 +1,27 @@
|
||||
<template>
|
||||
<div class="h-4 w-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<div :class="`h-${size} w-${size}`">
|
||||
<component :is="iconComponentName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: String
|
||||
icon: String,
|
||||
size: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
iconComponentName() {
|
||||
if (this.icon === 'default') return `icons-database-svg`
|
||||
return `icons-${this.icon}-svg`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ module.exports = {
|
||||
dev: process.env.NODE_ENV !== 'production',
|
||||
env: {
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||
chromecastReceiver: 'FD1F76C5',
|
||||
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
|
||||
chromecastReceiver: 'FD1F76C5'
|
||||
},
|
||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||
telemetry: false,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
<!-- <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishYear" class="flex py-0.5">
|
||||
@@ -95,9 +94,13 @@
|
||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<!-- Progress -->
|
||||
<div v-if="progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
|
||||
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
@@ -372,7 +375,13 @@ export default {
|
||||
return this.duration - this.userCurrentTime
|
||||
},
|
||||
progressPercent() {
|
||||
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||
return this.userAudiobook ? Math.max(Math.min(1, this.userAudiobook.progress), 0) : 0
|
||||
},
|
||||
userProgressStartedAt() {
|
||||
return this.userAudiobook ? this.userAudiobook.startedAt : 0
|
||||
},
|
||||
userProgressFinishedAt() {
|
||||
return this.userAudiobook ? this.userAudiobook.finishedAt : 0
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
</div>
|
||||
<div v-if="showExperimentalFeatures" class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<div v-if="showExperimentalFeatures" class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats <span class="pl-2 text-xs text-error">(web app only)</span></h1>
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats <span class="pl-2 text-xs text-error">(experimental)</span></h1>
|
||||
<p class="text-sm text-gray-300">
|
||||
Total Time Listened:
|
||||
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
<p v-if="timeListenedToday" class="text-sm text-gray-300">
|
||||
Time Listened Today:
|
||||
<span class="font-mono text-base">{{ $elapsedPrettyExtended(timeListenedToday) }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="latestSession" class="mt-4">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session <span class="pl-2 text-xs text-error">(web app only)</span></h1>
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
|
||||
<p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,4 +16,14 @@ export default class AudioTrack {
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}`
|
||||
}
|
||||
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}`
|
||||
}
|
||||
|
||||
return this.contentUrl
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export default class CastPlayer extends EventEmitter {
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.playableMimetypes = {}
|
||||
|
||||
this.coverUrl = ''
|
||||
this.castPlayerState = 'IDLE'
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ export default class LocalPlayer extends EventEmitter {
|
||||
this.hlsStreamId = null
|
||||
this.hlsInstance = null
|
||||
this.usingNativeplayer = false
|
||||
this.currentTime = 0
|
||||
this.startTime = 0
|
||||
this.trackStartTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.playableMimetypes = {}
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
@@ -38,9 +41,16 @@ export default class LocalPlayer extends EventEmitter {
|
||||
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac']
|
||||
mimeTypes.forEach((mt) => {
|
||||
this.playableMimetypes[mt] = this.player.canPlayType(mt)
|
||||
})
|
||||
console.log(`[LocalPlayer] Supported mime types`, this.playableMimetypes)
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
@@ -53,11 +63,27 @@ export default class LocalPlayer extends EventEmitter {
|
||||
var lastBufferTime = this.getLastBufferedTime()
|
||||
this.emit('buffertimeUpdate', lastBufferTime)
|
||||
}
|
||||
evtEnded() {
|
||||
if (this.currentTrackIndex < this.audioTracks.length - 1) {
|
||||
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
||||
// Has next track
|
||||
this.currentTrackIndex++
|
||||
this.playWhenReady = true
|
||||
this.startTime = this.currentTrack.startOffset
|
||||
this.loadCurrentTrack()
|
||||
} else {
|
||||
console.log(`[LocalPlayer] Ended`)
|
||||
}
|
||||
}
|
||||
evtError(error) {
|
||||
console.error('Player error', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
evtLoadedMetadata(data) {
|
||||
console.log('Audio Loaded Metadata', data)
|
||||
if (!this.hlsStreamId) {
|
||||
this.player.currentTime = this.trackStartTime
|
||||
}
|
||||
|
||||
this.emit('stateChange', 'LOADED')
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
@@ -89,23 +115,33 @@ export default class LocalPlayer extends EventEmitter {
|
||||
this.audioTracks = tracks
|
||||
this.hlsStreamId = hlsStreamId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.startTime = startTime
|
||||
|
||||
if (this.hlsInstance) {
|
||||
this.destroyHlsInstance()
|
||||
}
|
||||
|
||||
this.currentTime = startTime
|
||||
if (this.hlsStreamId) {
|
||||
this.setHlsStream()
|
||||
} else {
|
||||
this.setDirectPlay()
|
||||
}
|
||||
}
|
||||
|
||||
setHlsStream() {
|
||||
this.trackStartTime = 0
|
||||
|
||||
// iOS does not support Media Elements but allows for HLS in the native audio player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using audio element')
|
||||
this.usingNativeplayer = true
|
||||
this.player.src = this.currentTrack.contentUrl
|
||||
this.player.currentTime = this.currentTime
|
||||
this.player.src = this.currentTrack.relativeContentUrl
|
||||
this.player.currentTime = this.startTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.currentTime || -1
|
||||
startPosition: this.startTime || -1
|
||||
// No longer needed because token is put in a query string
|
||||
// xhrSetup: (xhr) => {
|
||||
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
@@ -115,7 +151,7 @@ export default class LocalPlayer extends EventEmitter {
|
||||
|
||||
this.hlsInstance.attachMedia(this.player)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
this.hlsInstance.loadSource(this.currentTrack.contentUrl)
|
||||
this.hlsInstance.loadSource(this.currentTrack.relativeContentUrl)
|
||||
|
||||
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest Parsed')
|
||||
@@ -133,6 +169,23 @@ export default class LocalPlayer extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
setDirectPlay() {
|
||||
// Set initial track and track time offset
|
||||
var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration))
|
||||
this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0
|
||||
|
||||
this.loadCurrentTrack()
|
||||
}
|
||||
|
||||
loadCurrentTrack() {
|
||||
if (!this.currentTrack) return
|
||||
// When direct play track is loaded current time needs to be set
|
||||
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
||||
this.player.src = this.currentTrack.relativeContentUrl
|
||||
console.log(`[LocalPlayer] Loading track src ${this.currentTrack.relativeContentUrl}`)
|
||||
this.player.load()
|
||||
}
|
||||
|
||||
destroyHlsInstance() {
|
||||
if (!this.hlsInstance) return
|
||||
if (this.hlsInstance.destroy) {
|
||||
@@ -181,8 +234,31 @@ export default class LocalPlayer extends EventEmitter {
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
if (this.hlsStreamId) {
|
||||
// Seeking HLS stream
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
} else {
|
||||
// Seeking Direct play
|
||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
// Change Track
|
||||
var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration))
|
||||
if (trackIndex >= 0) {
|
||||
this.startTime = time
|
||||
this.currentTrackIndex = trackIndex
|
||||
|
||||
if (!this.player.paused) {
|
||||
// audio player playing so play when track loads
|
||||
this.playWhenReady = true
|
||||
}
|
||||
this.loadCurrentTrack()
|
||||
}
|
||||
} else {
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
|
||||
@@ -38,7 +38,6 @@ export default class PlayerHandler {
|
||||
load(audiobook, playWhenReady, startTime = 0) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
console.log('Load audiobook', audiobook)
|
||||
this.audiobook = audiobook
|
||||
this.startTime = startTime
|
||||
this.playWhenReady = playWhenReady
|
||||
@@ -88,6 +87,15 @@ export default class PlayerHandler {
|
||||
this.player.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
||||
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
||||
this.player.on('error', this.playerError.bind(this))
|
||||
}
|
||||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
}
|
||||
|
||||
playerStateChange(state) {
|
||||
@@ -117,8 +125,36 @@ export default class PlayerHandler {
|
||||
this.ctx.setBufferTime(buffertime)
|
||||
}
|
||||
|
||||
async prepare() {
|
||||
var useHls = !this.isCasting
|
||||
async prepare(forceHls = false) {
|
||||
var useHls = false
|
||||
|
||||
var runningTotal = 0
|
||||
var audioTracks = (this.audiobook.tracks || []).map((track) => {
|
||||
var audioTrack = new AudioTrack(track)
|
||||
audioTrack.startOffset = runningTotal
|
||||
audioTrack.contentUrl = `/lib/${this.audiobook.libraryId}/${this.audiobook.folderId}/${track.path}?token=${this.userToken}`
|
||||
audioTrack.mimeType = this.getMimeTypeForTrack(track)
|
||||
audioTrack.canDirectPlay = !!this.player.playableMimetypes[audioTrack.mimeType]
|
||||
|
||||
runningTotal += audioTrack.duration
|
||||
return audioTrack
|
||||
})
|
||||
|
||||
// All html5 audio player plays use HLS unless experimental features is on
|
||||
if (!this.isCasting) {
|
||||
if (forceHls || !this.ctx.showExperimentalFeatures) {
|
||||
useHls = true
|
||||
} else {
|
||||
// Use HLS if any audio track cannot be direct played
|
||||
useHls = !!audioTracks.find(at => !at.canDirectPlay)
|
||||
|
||||
if (useHls) {
|
||||
console.warn(`[PlayerHandler] An audio track cannot be direct played`, audioTracks.find(at => !at.canDirectPlay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (useHls) {
|
||||
var stream = await this.ctx.$axios.$get(`/api/books/${this.audiobook.id}/stream`).catch((error) => {
|
||||
console.error('Failed to start stream', error)
|
||||
@@ -126,23 +162,30 @@ export default class PlayerHandler {
|
||||
if (stream) {
|
||||
console.log(`[PlayerHandler] prepare hls stream`, stream)
|
||||
this.setHlsStream(stream)
|
||||
} else {
|
||||
console.error(`[PlayerHandler] Failed to start HLS stream`)
|
||||
}
|
||||
} else {
|
||||
// Setup tracks
|
||||
var runningTotal = 0
|
||||
var audioTracks = (this.audiobook.tracks || []).map((track) => {
|
||||
var audioTrack = new AudioTrack(track)
|
||||
audioTrack.startOffset = runningTotal
|
||||
audioTrack.contentUrl = `/lib/${this.audiobook.libraryId}/${this.audiobook.folderId}/${track.path}?token=${this.userToken}`
|
||||
audioTrack.mimeType = (track.codec === 'm4b' || track.codec === 'm4a') ? 'audio/mp4' : `audio/${track.codec}`
|
||||
|
||||
runningTotal += audioTrack.duration
|
||||
return audioTrack
|
||||
})
|
||||
this.setDirectPlay(audioTracks)
|
||||
}
|
||||
}
|
||||
|
||||
getMimeTypeForTrack(track) {
|
||||
var ext = track.ext
|
||||
if (ext === '.mp3' || ext === '.m4b' || ext === '.m4a') {
|
||||
return 'audio/mpeg'
|
||||
} else if (ext === '.mp4') {
|
||||
return 'audio/mp4'
|
||||
} else if (ext === '.ogg') {
|
||||
return 'audio/ogg'
|
||||
} else if (ext === '.aac' || ext === '.m4p') {
|
||||
return 'audio/aac'
|
||||
} else if (ext === '.flac') {
|
||||
return 'audio/flac'
|
||||
}
|
||||
return 'audio/mpeg'
|
||||
}
|
||||
|
||||
closePlayer() {
|
||||
console.log('[PlayerHandler] CLose Player')
|
||||
if (this.player) {
|
||||
@@ -255,8 +298,7 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
play() {
|
||||
if (!this.player) return
|
||||
this.player.play()
|
||||
if (this.player) this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -55,4 +55,4 @@
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class Library {
|
||||
this.folders = []
|
||||
this.displayOrder = 1
|
||||
this.icon = 'database'
|
||||
this.mediaType = 'default'
|
||||
this.provider = 'google'
|
||||
this.disableWatcher = false
|
||||
|
||||
@@ -31,6 +32,7 @@ class Library {
|
||||
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||
this.displayOrder = library.displayOrder || 1
|
||||
this.icon = library.icon || 'database'
|
||||
this.mediaType = library.mediaType || 'default'
|
||||
this.provider = library.provider || 'google'
|
||||
this.disableWatcher = !!library.disableWatcher
|
||||
|
||||
@@ -45,6 +47,7 @@ class Library {
|
||||
folders: (this.folders || []).map(f => f.toJSON()),
|
||||
displayOrder: this.displayOrder,
|
||||
icon: this.icon,
|
||||
mediaType: this.mediaType,
|
||||
provider: this.provider,
|
||||
disableWatcher: this.disableWatcher,
|
||||
createdAt: this.createdAt,
|
||||
@@ -71,6 +74,7 @@ class Library {
|
||||
}
|
||||
this.displayOrder = data.displayOrder || 1
|
||||
this.icon = data.icon || 'database'
|
||||
this.mediaType = data.mediaType || 'default'
|
||||
this.disableWatcher = !!data.disableWatcher
|
||||
this.createdAt = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
@@ -86,6 +90,15 @@ class Library {
|
||||
this.provider = payload.provider
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.mediaType && payload.mediaType !== this.mediaType) {
|
||||
this.mediaType = payload.mediaType
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.icon && payload.icon !== this.icon) {
|
||||
this.icon = payload.icon
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.disableWatcher !== this.disableWatcher) {
|
||||
this.disableWatcher = !!payload.disableWatcher
|
||||
hasUpdates = true
|
||||
|
||||
Reference in New Issue
Block a user