mirror of
https://github.com/sudoxnym/audiobookshelf-atv.git
synced 2026-07-13 15:39:08 +00:00
Update:Add epub ereader settings and table of contents
This commit is contained in:
parent
481d0d0285
commit
953bde5a42
6 changed files with 357 additions and 16 deletions
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen h-screen fixed top-0 left-0 flex items-center justify-center z-50">
|
||||
<div ref="content" class="relative text-white bg-bg h-full w-full">
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-white h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
processing: Boolean
|
||||
processing: Boolean,
|
||||
halfScreen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ progress }}%</p>
|
||||
</div>
|
||||
|
|
@ -28,7 +28,12 @@ export default {
|
|||
book: null,
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null,
|
||||
progress: 0
|
||||
progress: 0,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -89,9 +94,46 @@ export default {
|
|||
// Validate ebookLocation is an epubcfi
|
||||
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
|
||||
return this.userItemProgress.ebookLocation
|
||||
},
|
||||
isLightTheme() {
|
||||
return this.ereaderSettings.theme === 'light'
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
|
||||
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
||||
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': lineSpacing * fontScale + 'rem!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateSettings(settings) {
|
||||
this.ereaderSettings = settings
|
||||
|
||||
if (!this.rendition) return
|
||||
|
||||
this.applyTheme()
|
||||
|
||||
const fontScale = settings.fontScale || 100
|
||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||
this.rendition.spread(settings.spread || 'auto')
|
||||
},
|
||||
goToChapter(href) {
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
updateHeight() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
|
|
@ -262,9 +304,6 @@ export default {
|
|||
flow: 'paginated'
|
||||
})
|
||||
|
||||
// load style
|
||||
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' }, a: { color: '#fff!important' } })
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// load saved progress
|
||||
// when not checking spine first uncaught exception is thrown
|
||||
|
|
@ -274,6 +313,10 @@ export default {
|
|||
reader.rendition.display(reader.book.locations.start)
|
||||
}
|
||||
|
||||
reader.rendition.on('rendered', () => {
|
||||
this.applyTheme()
|
||||
})
|
||||
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
|
||||
|
|
@ -298,6 +341,12 @@ export default {
|
|||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
applyTheme() {
|
||||
if (!this.rendition) return
|
||||
this.rendition.getContents().forEach((c) => {
|
||||
c.addStylesheetRules(this.themeRules)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,81 @@
|
|||
<template>
|
||||
<div v-if="show" class="fixed top-0 left-0 right-0 layout-wrapper w-full bg-primary z-40 pt-8" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<div class="h-28 pt-8 w-full bg-bg px-2 fixed top-0 left-0 z-30 transition-transform" :class="showingToolbar ? 'translate-y-0' : '-translate-y-28'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<!-- toolbar -->
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="flex items-center mb-2">
|
||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false"><span class="material-icons-outlined text-3xl text-white">chevron_left</span></button>
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isComic" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn"><span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span></button>
|
||||
<!-- <button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn"><span class="material-icons text-2xl text-white">settings</span></button> -->
|
||||
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn"><span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span></button>
|
||||
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn"><span class="material-icons text-2xl text-white">settings</span></button>
|
||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn"><span class="material-icons text-2xl text-white">more</span></button>
|
||||
</div>
|
||||
|
||||
<p class="text-center truncate">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" />
|
||||
<!-- ereader -->
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||
|
||||
<!-- table of contents modal -->
|
||||
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2">
|
||||
<h1 class="text-lg">Table of Contents</h1>
|
||||
<button class="flex" @click.stop="showTOCModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- chapters list -->
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<ul>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
<ul v-if="chapter.subitems.length">
|
||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
|
||||
<p class="text-xl">{{ 'No Chapters' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-fullscreen-modal>
|
||||
|
||||
<!-- ereader settings modal -->
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
|
||||
<h1 class="text-lg">Ereader Settings</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Theme:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Font scale:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Line spacing:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-fullscreen-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -32,7 +95,12 @@ export default {
|
|||
showTOCModal: false,
|
||||
showSettingsModal: false,
|
||||
comicHasMetadata: false,
|
||||
chapters: []
|
||||
chapters: [],
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -70,6 +138,22 @@ export default {
|
|||
mediaMetadata() {
|
||||
return this.media?.metadata || {}
|
||||
},
|
||||
ereaderTheme() {
|
||||
if (this.isEpub) return this.ereaderSettings.theme
|
||||
return 'dark'
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
text: 'Dark',
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: 'Light',
|
||||
value: 'light'
|
||||
}
|
||||
]
|
||||
},
|
||||
readerComponentName() {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
|
|
@ -140,6 +224,19 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
settingsUpdated() {
|
||||
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
||||
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
||||
},
|
||||
goToChapter(href) {
|
||||
this.showTOCModal = false
|
||||
this.$refs.readerComponent?.goToChapter(href)
|
||||
},
|
||||
readerMounted() {
|
||||
if (this.isEpub) {
|
||||
this.loadEreaderSettings()
|
||||
}
|
||||
},
|
||||
readerLoaded(data) {
|
||||
if (this.isComic) {
|
||||
this.comicHasMetadata = data.hasMetadata
|
||||
|
|
@ -149,6 +246,7 @@ export default {
|
|||
this.$refs.readerComponent?.clickShowInfoMenu()
|
||||
},
|
||||
clickTOCBtn() {
|
||||
this.hideToolbar()
|
||||
if (this.isComic) {
|
||||
this.$refs.readerComponent?.clickShowPageMenu?.()
|
||||
} else {
|
||||
|
|
@ -157,6 +255,7 @@ export default {
|
|||
}
|
||||
},
|
||||
clickSettingsBtn() {
|
||||
this.hideToolbar()
|
||||
this.showSettingsModal = true
|
||||
},
|
||||
next() {
|
||||
|
|
@ -181,7 +280,11 @@ export default {
|
|||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
|
||||
if (touchDistance < 60) {
|
||||
this.toggleToolbar()
|
||||
if (this.showSettingsModal) {
|
||||
this.showSettingsModal = false
|
||||
} else {
|
||||
this.toggleToolbar()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +316,7 @@ export default {
|
|||
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('touchstart', e)
|
||||
this.touchstartX = e.touches[0].screenX
|
||||
this.touchstartY = e.touches[0].screenY
|
||||
this.touchstartTime = Date.now()
|
||||
|
|
@ -223,6 +326,7 @@ export default {
|
|||
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
|
||||
return
|
||||
}
|
||||
console.log('touchend', e)
|
||||
this.touchendX = e.changedTouches[0].screenX
|
||||
this.touchendY = e.changedTouches[0].screenY
|
||||
this.handleGesture()
|
||||
|
|
@ -230,6 +334,17 @@ export default {
|
|||
closeEvt() {
|
||||
this.show = false
|
||||
},
|
||||
loadEreaderSettings() {
|
||||
try {
|
||||
const settings = localStorage.getItem('ereaderSettings')
|
||||
if (settings) {
|
||||
this.ereaderSettings = JSON.parse(settings)
|
||||
this.settingsUpdated()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load ereader settings', error)
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
|
|
|
|||
90
components/ui/RangeInput.vue
Normal file
90
components/ui/RangeInput.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div class="inline-flex">
|
||||
<input v-model="input" type="range" :min="min" :max="max" :step="step" :style="{ width: inputWidth }" />
|
||||
|
||||
<p class="text-xs ml-2">{{ input }}%</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: Number,
|
||||
inputWidth: {
|
||||
type: String,
|
||||
default: 'unset'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* chromium */
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
background-color: rgb(0 0 0 / 0.25);
|
||||
border-radius: 9999px;
|
||||
height: 0.75rem;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -0.25rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
input[type='range']:focus::-webkit-slider-thumb {
|
||||
border: 1px solid #6b6b6b;
|
||||
outline: 3px solid #6b6b6b;
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
|
||||
/* firefox */
|
||||
input[type='range']::-moz-range-track {
|
||||
background-color: rgb(0 0 0 / 0.25);
|
||||
border-radius: 9999px;
|
||||
height: 0.75rem;
|
||||
}
|
||||
input[type='range']::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
margin-top: -0.25rem;
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
input[type='range']:focus::-moz-range-thumb {
|
||||
border: 1px solid #6b6b6b;
|
||||
outline: 3px solid #6b6b6b;
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
85
components/ui/ToggleBtns.vue
Normal file
85
components/ui/ToggleBtns.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
{{ item.text }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
/**
|
||||
* [{ "text", "", "value": "" }]
|
||||
*/
|
||||
items: {
|
||||
type: Array,
|
||||
default: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBtn(value) {
|
||||
this.$emit('input', value)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toggle-btn-wrapper .toggle-btn:first-child {
|
||||
border-top-left-radius: 0.375rem /* 6px */;
|
||||
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:last-child {
|
||||
border-top-right-radius: 0.375rem /* 6px */;
|
||||
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:first-child::before {
|
||||
border-top-left-radius: 0.375rem /* 6px */;
|
||||
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:last-child::before {
|
||||
border-top-right-radius: 0.375rem /* 6px */;
|
||||
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
|
||||
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.toggle-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
.toggle-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.toggle-btn:hover:not(:disabled) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.toggle-btn.selected {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.toggle-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -17,6 +17,7 @@ if (Capacitor.getPlatform() != 'web') {
|
|||
}
|
||||
|
||||
Vue.prototype.$showHideStatusBar = async (show) => {
|
||||
if (Capacitor.getPlatform() === 'web') return
|
||||
if (show) {
|
||||
StatusBar.show()
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue