Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container

This commit is contained in:
Mark Cooper 2021-09-21 16:42:01 -05:00
parent baccbaf82a
commit f4d6e65380
17 changed files with 109 additions and 33 deletions

View File

@ -12,9 +12,6 @@
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span> <span class="material-icons text-3xl">format_list_bulleted</span>
</div> </div>
<div v-else class="flex items-center justify-center text-gray-500">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div> </div>
<div class="absolute right-32 top-0 bottom-0"> <div class="absolute right-32 top-0 bottom-0">
@ -56,6 +53,11 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" /> <div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" /> <div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div> </div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp --> <!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none"> <div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
@ -68,7 +70,7 @@
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" /> <audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
@ -100,7 +102,8 @@ export default {
totalDuration: 0, totalDuration: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false, seekLoading: false,
showChaptersModal: false showChaptersModal: false,
currentTime: 0
} }
}, },
computed: { computed: {
@ -109,6 +112,18 @@ export default {
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
} }
}, },
methods: { methods: {
@ -175,7 +190,13 @@ export default {
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px' this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
} }
if (this.$refs.hoverTimestampText) { if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time) var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
} }
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1 this.$refs.trackCursor.style.opacity = 1
@ -289,7 +310,6 @@ export default {
end: end + offset end: end + offset
}) })
} }
return ranges return ranges
}, },
getLastBufferedTime() { getLastBufferedTime() {
@ -334,6 +354,8 @@ export default {
this.updateTimestamp() this.updateTimestamp()
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth) var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {

View File

@ -10,8 +10,11 @@
</div> </div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn> <div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div> </div>
<div v-else class="w-full flex flex-col items-center"> <div v-else class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks"> <template v-for="(shelf, index) in groupedBooks">
@ -43,7 +46,8 @@ export default {
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3, selectedSizeIndex: 3,
rowPaddingX: 40, rowPaddingX: 40,
keywordFilterTimeout: null keywordFilterTimeout: null,
scannerParseSubtitle: false
} }
}, },
watch: { watch: {

View File

@ -42,7 +42,6 @@ export default {
this.saveSettings() this.saveSettings()
}, },
saveSettings() { saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings) this.$store.dispatch('user/updateUserSettings', this.settings)
}, },
init() { init() {

View File

@ -1,14 +1,14 @@
<template> <template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4"> <div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div class="absolute -top-16 left-4"> <nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" /> <cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div> </nuxt-link>
<div class="flex items-center pl-24"> <div class="flex items-center pl-24">
<div> <div>
<h1> <nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span> {{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1> </nuxt-link>
<p class="text-gray-400 text-sm">by {{ author }}</p> <p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span> <span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
@ -66,6 +66,15 @@ export default {
} }
}, },
methods: { methods: {
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push('/')
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() { audioPlayerMounted() {
this.audioPlayerReady = true this.audioPlayerReady = true
if (this.stream) { if (this.stream) {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative"> <div class="relative">
<!-- New Book Flag --> <!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl"> <div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center"> <div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p> <p class="text-center text-sm">New</p>
</div> </div>
@ -65,7 +65,7 @@ export default {
}, },
computed: { computed: {
isNew() { isNew() {
return this.tags.includes('new') return this.tags.includes('New')
}, },
tags() { tags() {
return this.audiobook.tags || [] return this.audiobook.tags || []

View File

@ -86,6 +86,11 @@ export default {
text: 'Authors', text: 'Authors',
value: 'authors', value: 'authors',
sublist: true sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
} }
] ]
} }
@ -132,6 +137,9 @@ export default {
authors() { authors() {
return this.$store.getters['audiobooks/getUniqueAuthors'] return this.$store.getters['audiobooks/getUniqueAuthors']
}, },
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
return { return {

View File

@ -74,10 +74,10 @@ export default {
this.showMenu = false this.showMenu = false
}, },
leftArrowClick() { leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4) this.rateIndex = Math.max(0, this.rateIndex - 1)
}, },
rightArrowClick() { rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4) this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
} }
}, },
mounted() {} mounted() {}

View File

@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="500" :height="'unset'"> <modals-modal v-model="show" :width="500" :height="'unset'">
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px"> <div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)"> <div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }} {{ chap.title }}
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
@ -19,6 +19,10 @@ export default {
chapters: { chapters: {
type: Array, type: Array,
default: () => [] default: () => []
},
currentChapter: {
type: Object,
default: () => null
} }
}, },
data() { data() {
@ -32,6 +36,9 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
} }
}, },
methods: { methods: {

View File

@ -1,5 +1,14 @@
<template> <template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click"> <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> --> <!-- <span class="material-icons animate-spin">refresh</span> -->
@ -13,6 +22,7 @@
<script> <script>
export default { export default {
props: { props: {
to: String,
color: { color: {
type: String, type: String,
default: 'primary' default: 'primary'

View File

@ -113,7 +113,6 @@ export default {
this.currentSearch = null this.currentSearch = null
}, },
clickedOption(e, item) { clickedOption(e, item) {
var newValue = this.input === item ? null : item
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.input = this.textInput ? this.textInput.trim() : null this.input = this.textInput ? this.textInput.trim() : null

View File

@ -180,7 +180,7 @@ export default {
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim() var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => { var matchesItem = this.items.find((i) => {
return i === cleaned return i === cleaned
}) })

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave"> <div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot /> <slot />
</div> </div>
</template> </template>
@ -51,8 +51,9 @@ export default {
createTooltip() { createTooltip() {
if (!this.$refs.box) return if (!this.$refs.box) return
var tooltip = document.createElement('div') var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg' tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.75)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip) this.setTooltipPosition(tooltip)

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.1.13", "version": "1.1.14",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -49,7 +49,12 @@
<div class="flex-grow" /> <div class="flex-grow" />
<div class="w-40 flex flex-col"> <div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn> <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
<div class="w-full">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,12 +16,12 @@ export const getters = {
getAudiobook: (state) => id => { getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id) return state.audiobooks.find(ab => ab.id === id)
}, },
getFiltered: (state, getters, rootState) => () => { getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors'] var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = decode(filterBy.replace(`${group}.`, '')) var filter = decode(filterBy.replace(`${group}.`, ''))
@ -29,6 +29,16 @@ export const getters = {
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter) else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
}
} }
if (state.keywordFilter) { if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator'] const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']

View File

@ -44,6 +44,8 @@ export const actions = {
var updatePayload = { var updatePayload = {
...payload ...payload
} }
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => { return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) { if (result.success) {
commit('setSettings', result.settings) commit('setSettings', result.settings)

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.13", "version": "1.1.14",
"description": "Self-hosted audiobook server for managing and playing audiobooks.", "description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {