merge upstream

This commit is contained in:
David Leimroth 2022-02-28 18:18:25 +01:00
commit 61bf30e08a
89 changed files with 2963 additions and 1805 deletions

View file

@ -1,13 +1,6 @@
<template>
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<!-- <div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> -->
<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">
<span class="material-icons text-3xl">format_list_bulleted</span>
@ -19,7 +12,7 @@
</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="updateVolume" />
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
</div>
<div class="flex pb-4 md:pb-2">
@ -28,13 +21,13 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="backward10">
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
@ -82,20 +75,15 @@
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
<script>
import Hls from 'hls.js'
export default {
props: {
streamId: String,
audiobookId: String,
loading: Boolean,
paused: Boolean,
chapters: {
type: Array,
default: () => []
@ -107,57 +95,41 @@ export default {
},
data() {
return {
hlsInstance: null,
staleHlsInstance: null,
usingNativeAudioPlayer: false,
playOnLoad: false,
startTime: 0,
volume: 1,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
url: null,
src: null,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge
listenTimeInterval: null,
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0
duration: 0
}
},
computed: {
token() {
return this.$store.getters['user/getToken']
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() {
if (!this.audioEl) return 0
return (this.totalDuration - this.currentTime) / this.playbackRate
return (this.duration - this.currentTime) / this.playbackRate
},
timeRemainingPretty() {
if (this.timeRemaining < 0) {
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining)
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
return this.$secondsToTimestamp(this.timeRemaining * -1)
}
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.totalDuration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration)
if (!this.duration) return 0
return Math.round((100 * this.currentTime) / this.duration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
@ -175,188 +147,77 @@ export default {
}
},
methods: {
audioPlayed() {
if (!this.$refs.audio) return
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
this.startListenTimeInterval()
this.isPaused = this.$refs.audio.paused
setDuration(duration) {
this.duration = duration
},
audioPaused() {
if (!this.$refs.audio) return
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
this.isPaused = this.$refs.audio.paused
this.cancelListenTimeInterval()
setCurrentTime(time) {
this.currentTime = time
this.updateTimestamp()
this.updatePlayedTrack()
},
audioError(err) {
if (!this.$refs.audio) return
console.error('Audio Error', this.$refs.audio.paused, this.$refs.audio.currentTime, err)
playPause() {
this.$emit('playPause')
},
audioEnded() {
if (!this.$refs.audio) return
console.log('Audio Ended', this.$refs.audio.paused, this.$refs.audio.currentTime)
jumpBackward() {
this.$emit('jumpBackward')
},
audioStalled() {
if (!this.$refs.audio) return
console.warn('Audio Stalled', this.$refs.audio.paused, this.$refs.audio.currentTime)
jumpForward() {
this.$emit('jumpForward')
},
audioSuspended() {
if (!this.$refs.audio) return
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
increaseVolume() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.setVolume(this.volume)
},
sendStreamSync(timeListened = 0) {
// If currentTime is null then currentTime wont be updated
var currentTime = null
if (this.$refs.audio) {
currentTime = this.$refs.audio.currentTime
} else if (!timeListened) {
console.warn('Not sending stream sync, no data to sync')
return
decreaseVolume() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.setVolume(this.volume)
},
setVolume(volume) {
this.$emit('setVolume', volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
var syncData = {
timeListened,
currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId
}
this.$emit('sync', syncData)
},
sendAddListeningTime() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
this.sendStreamSync(listeningTimeToAdd)
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
cancelListenTimeInterval() {
this.sendAddListeningTime()
clearInterval(this.listenTimeInterval)
this.listenTimeInterval = null
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
startListenTimeInterval() {
if (!this.$refs.audio) return
clearInterval(this.listenTimeInterval)
var lastTime = this.$refs.audio.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
if (!this.$refs.audio) {
console.error('Canceling audio played interval no audio player')
this.cancelListenTimeInterval()
return
}
if (this.$refs.audio.paused) {
console.warn('Canceling audio played interval audio player paused')
this.cancelListenTimeInterval()
return
}
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.$refs.audio.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.cancelListenTimeInterval()
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) {
if (this.loading) {
return
}
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
if (!this.audioEl) {
console.error('No Audio el for seek', time)
return
}
if (!this.audioEl.paused) {
this.cancelListenTimeInterval()
}
this.seekedTime = time
this.seekLoading = true
this.audioEl.currentTime = time
this.sendStreamSync()
this.$nextTick(() => {
if (this.audioEl && !this.audioEl.paused) {
this.startListenTimeInterval()
}
})
if (this.$refs.playedTrack) {
var perc = time / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
updateVolume(volume) {
if (this.audioEl) {
this.audioEl.volume = volume
}
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
try {
this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else {
console.error('No Audio El updatePlaybackRate')
}
this.$emit('seek', time)
},
playbackRateUpdated(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.setPlaybackRate(playbackRate)
},
playbackRateChanged(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration
var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
@ -402,17 +263,6 @@ export default {
},
restart() {
this.seek(0)
this.$nextTick(this.sendStreamSync)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
newTime = Math.max(0, newTime)
this.seek(newTime)
},
forward10() {
var newTime = this.audioEl.currentTime + 10
newTime = Math.min(this.audioEl.duration, newTime)
this.seek(newTime)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
@ -442,114 +292,11 @@ export default {
console.error('No timestamp el')
return
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
clickTrack(e) {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.audioEl.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
this.play()
} else {
this.pause()
}
},
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
},
getBufferedRanges() {
if (!this.audioEl) return []
const ranges = []
const seekable = this.audioEl.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.audioEl.currentTime && buff.end > this.audioEl.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
},
progress() {
if (!this.audioEl) {
return
}
var lastbuff = this.getLastBufferedTime()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
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')
}
}
this.updateTimestamp()
// Send update to server when currentTime > 0
// this prevents errors when seeking to position not yet transcoded
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
// if (this.audioEl.currentTime) {
// this.sendStreamUpdate()
// }
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
updatePlayedTrack() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
@ -557,83 +304,27 @@ export default {
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
audioLoadedMetadata() {
this.totalDuration = this.audioEl.duration
this.$emit('loaded', this.totalDuration)
if (this.usingNativeAudioPlayer) {
this.audioEl.currentTime = this.startTime
this.play()
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
set(url, currentTime, playOnLoad = false) {
if (this.hlsInstance) {
this.terminateStream()
}
if (!this.$refs.audio) {
console.error('No audio widget')
setBufferTime(bufferTime) {
if (!this.audioEl) {
return
}
this.listeningTimeSinceLastUpdate = 0
this.playOnLoad = playOnLoad
this.startTime = currentTime
this.url = url
if (process.env.NODE_ENV === 'development') {
url = `${process.env.serverUrl}${url}`
}
this.src = url
console.log('[AudioPlayer-Set] Set url', url)
var audio = this.$refs.audio
audio.volume = this.volume
audio.defaultPlaybackRate = this.playbackRate
// 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.usingNativeAudioPlayer = true
audio.src = this.src + '?token=' + this.token
audio.currentTime = currentTime
return
}
var hlsOptions = {
startPosition: currentTime || -1,
xhrSetup: (xhr) => {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('Starting HLS audio stream at time', currentTime)
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
this.hlsInstance.loadSource(url)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
if (playOnLoad) {
this.play()
}
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (this.$refs.audio) {
console.log('Hls error check audio', this.$refs.audio.paused, this.$refs.audio.currentTime, this.$refs.audio.readyState)
}
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
showChapters() {
if (!this.chapters.length) return
@ -642,39 +333,9 @@ export default {
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
return
}
this.$refs.audio.play()
},
pause() {
if (!this.$refs.audio) return
this.$refs.audio.pause()
},
terminateStream() {
if (this.hlsInstance) {
if (!this.hlsInstance.destroy) {
console.error('HLS Instance has no destroy property', this.hlsInstance)
return
}
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
}
},
async resetStream(startTime) {
if (this.$refs.audio) this.$refs.audio.pause()
this.terminateStream()
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Waited 1 second after terminating stream to start again')
this.set(this.url, startTime, true)
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
this.$emit('setPlaybackRate', this.playbackRate)
this.setTrackWidth()
},
setTrackWidth() {
@ -686,48 +347,19 @@ export default {
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
this.setPlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()

View file

@ -15,6 +15,13 @@
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
</ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">equalizer</span>
</nuxt-link>
@ -39,10 +46,6 @@
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<!-- <ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ entitiesLoaded }})</span></ui-btn
> -->
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
@ -124,6 +127,15 @@ export default {
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
isChromecastInitialized() {
return this.$store.state.globals.isChromecastInitialized
},
isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
}
},
methods: {

View file

@ -168,8 +168,7 @@ export default {
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
this.$eventBus.$emit('play-audiobook', this.book.id)
},
editClick() {
this.$emit('edit', this.book)

View file

@ -2,9 +2,6 @@
<div id="bookshelf" class="w-full overflow-y-auto">
<template v-for="shelf in totalShelves">
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
<!-- <div class="absolute top-0 left-0 bottom-0 p-4 z-10">
<p class="text-white text-2xl">{{ shelf }}</p>
</div> -->
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
@ -26,7 +23,9 @@
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
</div>
</template>
@ -114,6 +113,9 @@ export default {
bookshelfView() {
return this.$store.getters['settings/getServerSetting']('bookshelfView')
},
sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
},
@ -245,7 +247,7 @@ export default {
console.error('failed to fetch books', error)
return null
})
console.log('payload', payload)
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false

View file

@ -6,7 +6,7 @@
<div class="flex items-start pl-24 mb-6 md:mb-0">
<div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
{{ title }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
@ -22,26 +22,29 @@
</div>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
</div>
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" />
<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" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
</div>
</template>
<script>
import PlayerHandler from '@/players/PlayerHandler'
export default {
data() {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null,
playerHandler: new PlayerHandler(this),
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null
bookmarkAudiobookId: null,
playerLoading: false,
isPlaying: false,
currentTime: 0
}
},
computed: {
@ -69,18 +72,13 @@ export default {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
},
bookmarks() {
if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
},
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
// IF Stream exists, set loading if stream is diff from next stream
return this.stream.audiobook.id !== this.streamAudiobook.id
}
return true
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
@ -105,12 +103,6 @@ export default {
authorsList() {
return this.authorFL ? this.authorFL.split(', ') : []
},
streamId() {
return this.stream ? this.stream.id : null
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
},
@ -119,8 +111,40 @@ export default {
}
},
methods: {
addListeningTime(time) {
console.log('Send listening time to server', time)
playPause() {
this.playerHandler.playPause()
},
jumpForward() {
this.playerHandler.jumpForward()
},
jumpBackward() {
this.playerHandler.jumpBackward()
},
setVolume(volume) {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
this.playerHandler.seek(time)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setCurrentTime(time)
}
},
setDuration(duration) {
this.totalDuration = duration
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setDuration(duration)
}
},
setBufferTime(buffertime) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setBufferTime(buffertime)
}
},
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
@ -128,47 +152,12 @@ export default {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.seek(bookmark.time)
this.showBookmarksModal = false
},
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayer Mounted w/ Stream', this.stream)
this.openStream()
}
},
cancelStream() {
this.$root.socket.emit('close_stream')
},
terminateStream() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
},
openStream() {
var playOnLoad = this.$store.state.playOnLoad
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
if (!this.$refs.audioPlayer) {
console.error('NO Audio Player')
return
}
var currentTime = this.stream.clientCurrentTime || 0
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
if (this.stream.isTranscodeComplete) {
this.$refs.audioPlayer.setStreamReady()
}
closePlayer() {
this.playerHandler.closePlayer()
this.$store.commit('setStreamAudiobook', null)
},
streamProgress(data) {
if (!data.numSegments) return
@ -181,21 +170,14 @@ export default {
}
},
streamOpen(stream) {
this.stream = stream
this.$store.commit('updateStreamAudiobook', stream.audiobook)
if (this.$refs.audioPlayer) {
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
}
this.$store.commit('setStreamAudiobook', stream.audiobook)
this.playerHandler.prepareStream(stream)
},
streamClosed(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
// Stream was closed from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to request from server')
this.playerHandler.closePlayer()
}
},
streamReady() {
@ -207,41 +189,42 @@ export default {
}
},
streamError(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
// Stream had critical error from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to stream error from server')
this.playerHandler.closePlayer()
}
},
sendStreamSync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
this.$root.socket.emit('stream_sync', syncData)
},
// updateTime(currentTime) {
// var diff = currentTime - this.lastServerUpdateSentSeconds
// if (diff > 4 || diff < 0) {
// this.lastServerUpdateSentSeconds = currentTime
// var updatePayload = {
// currentTime,
// streamId: this.streamId
// }
// this.$root.socket.emit('stream_update', updatePayload)
// }
// },
streamReset({ startTime, streamId }) {
if (streamId !== this.streamId) {
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
return
}
if (this.$refs.audioPlayer) {
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
this.$refs.audioPlayer.resetStream(startTime)
this.playerHandler.resetStream(startTime, streamId)
},
castSessionActive(isActive) {
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
// Cast session started switch to cast player
this.playerHandler.switchPlayer()
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
// Cast session ended switch to local player
this.playerHandler.switchPlayer()
}
},
async playAudiobook(audiobookId) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed to fetch full audiobook', error)
return null
})
if (!audiobook) return
this.$store.commit('setStreamAudiobook', audiobook)
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
}
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('play-audiobook', this.playAudiobook)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('play-audiobook', this.playAudiobook)
}
}
</script>

View file

@ -0,0 +1,120 @@
<template>
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
</div>
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
</div>
<template v-if="!uploadSuccess && !uploadFailed">
<widgets-alert v-if="error" type="error">
<p class="text-base">{{ error }}</p>
</widgets-alert>
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
</div>
</div>
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
</div>
</div>
</div>
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
</template>
<widgets-alert v-if="uploadSuccess" type="success">
<p class="text-base">Successfully Uploaded!</p>
</widgets-alert>
<widgets-alert v-if="uploadFailed" type="error">
<p class="text-base">Failed to upload</p>
</widgets-alert>
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
book: {
type: Object,
default: () => {}
},
processing: Boolean
},
data() {
return {
bookData: {
title: '',
author: '',
series: ''
},
error: '',
isUploading: false,
uploadFailed: false,
uploadSuccess: false
}
},
computed: {
directory() {
if (!this.bookData.title) return ''
if (this.bookData.series && this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
} else if (this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.title)
} else {
return this.bookData.title
}
}
},
methods: {
setUploadStatus(status) {
this.isUploading = status === 'uploading'
this.uploadFailed = status === 'failed'
this.uploadSuccess = status === 'success'
},
titleUpdated() {
this.error = ''
},
getData() {
if (!this.bookData.title) {
this.error = 'Must have a title'
return null
}
this.error = ''
var files = this.book.bookFiles.concat(this.book.otherFiles)
return {
index: this.book.index,
...this.bookData,
files
}
}
},
mounted() {
if (this.book) {
this.bookData.title = this.book.title
this.bookData.author = this.book.author
this.bookData.series = this.book.series
}
}
}
</script>

View file

@ -5,11 +5,13 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${sizeMultiplier * 3}rem` }">
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ title }}
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ authorFL }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
@ -38,13 +40,13 @@
<!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
@ -61,16 +63,20 @@
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
<!-- Series name overlay -->
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
</div>
<!-- Error widget -->
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
<!-- Volume number -->
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
@ -99,7 +105,10 @@ export default {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
}
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
@ -114,6 +123,15 @@ export default {
showCoverBg: false
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.audiobook = newVal
}
}
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
@ -180,6 +198,24 @@ export default {
volumeNumber() {
return this.book.volumeNumber || null
},
displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF
return this.authorFL
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
return null
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
},
@ -322,6 +358,11 @@ export default {
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.TITLES
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
}
},
methods: {
@ -461,8 +502,8 @@ export default {
this.$emit('select', this.audiobook)
},
play() {
this.store.commit('setStreamAudiobook', this.audiobook)
this._socket.emit('open_stream', this.audiobookId)
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-audiobook', this.audiobookId)
},
mouseover() {
this.isHovering = true

View file

@ -59,6 +59,14 @@ export default {
{
text: 'Size',
value: 'size'
},
{
text: 'File Birthtime',
value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
}
]
}

View file

@ -89,6 +89,8 @@ export default {
this.showMenu = val
}
},
mounted() {}
mounted() {
this.currentPlaybackRate = this.playbackRate
}
}
</script>

View file

@ -51,11 +51,25 @@ export default {
}
},
methods: {
scroll(e) {
if (!e || !e.wheelDeltaY) return
if (e.wheelDeltaY > 0) {
this.volume = Math.min(1, this.volume + 0.1)
} else {
this.volume = Math.max(0, this.volume - 0.1)
}
},
mouseover() {
if (!this.isHovering) {
window.addEventListener('mousewheel', this.scroll)
}
this.isHovering = true
this.setOpen()
},
mouseleave() {
if (this.isHovering) {
window.removeEventListener('mousewheel', this.scroll)
}
this.isHovering = false
},
setOpen() {
@ -127,6 +141,11 @@ export default {
if (this.value === 0) {
this.isMute = true
}
},
beforeDestroy() {
window.removeEventListener('mousewheel', this.scroll)
document.body.removeEventListener('mousemove', this.mousemove)
document.body.removeEventListener('mouseup', this.mouseup)
}
}
</script>

View file

@ -4,9 +4,6 @@
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<!-- <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div> -->
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">

View file

@ -39,20 +39,6 @@
</template>
</div>
</div>
<!-- <form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
</div>
<div class="w-24 px-1">
<ui-btn type="submit" class="mt-5 w-full" :padding-x="0">Search</ui-btn>
</div>
</div>
</form> -->
</div>
</div>
<form @submit.prevent="submitSearchForm">

View file

@ -72,6 +72,10 @@
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
@ -113,7 +117,8 @@ export default {
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
rescanning: false
rescanning: false,
quickMatching: false
}
},
watch: {
@ -163,12 +168,41 @@ export default {
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: this.details.title,
author: this.details.author !== this.book.author ? this.details.author : null
}
this.$axios
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Audiobook details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {

View file

@ -1,12 +1,19 @@
<template>
<div class="w-full h-full px-4 py-2 mb-12">
<div class="flex items-center py-1 mb-2">
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<div class="w-full h-full px-4 py-2 mb-4">
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<ui-text-input-with-label v-model="name" label="Library Name" />
<div class="flex flex-wrap -mx-1">
<div class="w-full md:w-2/3 px-1">
<ui-text-input-with-label v-model="name" label="Library Name" />
</div>
<div class="w-full md:w-1/3 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
</div>
</div>
<div class="w-full py-4">
<p class="px-1 text-sm font-semibold">Folders</p>
@ -26,7 +33,17 @@
</div>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
<div v-if="!showDirectoryPicker">
<div class="flex items-center pt-2">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-lg">Disable folder watcher for library</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div>
</div>
</template>
@ -42,8 +59,10 @@ export default {
data() {
return {
name: '',
provider: '',
folders: [],
showDirectoryPicker: false
showDirectoryPicker: false,
disableWatcher: false
}
},
computed: {
@ -61,7 +80,13 @@ 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
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher
},
providers() {
return this.$store.state.scanners.providers
},
globalWatcherDisabled() {
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
}
},
methods: {
@ -75,7 +100,9 @@ export default {
},
init() {
this.name = this.library ? this.library.name : ''
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.showDirectoryPicker = false
},
selectFolder(fullPath) {
@ -100,7 +127,9 @@ export default {
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
provider: this.provider,
folders: this.folders,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)
@ -132,7 +161,9 @@ export default {
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
provider: this.provider,
folders: this.folders,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)

View file

@ -9,8 +9,11 @@
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
@ -59,6 +62,18 @@ export default {
}
},
methods: {
matchAll() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchbooks`)
.then(() => {
console.log('Starting scan for matches')
})
.catch((error) => {
console.error('Failed', error)
var errorMsg = err.response ? err.response.data : ''
this.$toast.error(errorMsg || 'Match all failed')
})
},
editClick() {
this.$emit('edit', this.library)
},

View file

@ -16,6 +16,8 @@
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
</div>
</template>

View file

@ -0,0 +1,60 @@
<template>
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">{{ title }}</p>
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="flex-grow" />
<div class="cursor-pointer h-9 w-9 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expand ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="expand">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Type</th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
{{ file.name }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
<td class="font-book">
{{ file.filetype }}
</td>
</tr>
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
title: String,
files: {
type: Array,
default: () => []
}
},
data() {
return {
expand: false
}
},
computed: {},
methods: {
clickBar() {
this.expand = !this.expand
}
},
mounted() {}
}
</script>

View file

@ -133,8 +133,7 @@ export default {
this.isHovering = false
},
playClick() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
this.$eventBus.$emit('play-audiobook', this.book.id)
},
clickEdit() {
this.$emit('edit', this.book)

View file

@ -1,7 +1,7 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
</span>

View file

@ -1,9 +1,9 @@
<template>
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>

View file

@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>

View file

@ -80,7 +80,7 @@ input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
color: #bbb;
background-color: #444;
}
</style>

View file

@ -3,7 +3,7 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div>
</template>
@ -32,7 +32,13 @@ export default {
}
}
},
methods: {},
methods: {
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,33 @@
<template>
<div class="w-full bg-opacity-5 border border-opacity-60 rounded-lg flex items-center relative py-4 pl-16" :class="wrapperClass">
<div class="absolute top-0 left-4 h-full flex items-center">
<span class="material-icons-outlined text-2xl">{{ icon }}</span>
</div>
<slot />
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: 'error'
}
},
data() {
return {}
},
computed: {
icon() {
if (this.type === 'error' || this.type === 'warning') return 'report'
return 'info'
},
wrapperClass() {
return `bg-${this.type} border-${this.type} text-${this.type}`
}
},
methods: {},
mounted() {}
}
</script>