<template> <div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div class="flex items-center py-4 max-w-7xl mx-auto"> <nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline"> <h1 class="text-xl">{{ title }}</h1> </nuxt-link> <button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem"> <span class="material-icons text-base">edit</span> </button> <div class="flex-grow" /> <p class="text-base">{{ $strings.LabelDuration }}:</p> <p class="text-base font-mono ml-8">{{ $secondsToTimestamp(mediaDurationRounded) }}</p> </div> <div class="flex flex-wrap-reverse justify-center py-4"> <div class="w-full max-w-3xl py-4"> <div class="flex items-center"> <p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p> <div class="flex-grow" /> <ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" /> <div class="w-40" /> </div> <div class="flex items-center mb-3 py-1"> <div class="flex-grow" /> <ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn> <ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn> <ui-btn color="success" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn> <div class="w-40" /> </div> <div class="overflow-hidden"> <transition name="slide"> <div v-if="showShiftTimes" class="flex mb-4"> <div class="w-12"></div> <div class="flex-grow"> <div class="flex items-center"> <p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p> <ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" /> <ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn> <div class="flex-grow" /> <span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span> </div> <p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p> </div> <div class="w-40"></div> </div> </transition> </div> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="w-12"></div> <div class="w-32 px-2">{{ $strings.LabelStart }}</div> <div class="flex-grow px-2">{{ $strings.LabelTitle }}</div> <div class="w-40"></div> </div> <template v-for="chapter in newChapters"> <div :key="chapter.id" class="flex py-1"> <div class="w-12">#{{ chapter.id + 1 }}</div> <div class="w-32 px-1"> <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> </div> <div class="flex-grow px-1"> <ui-text-input v-model="chapter.title" class="text-xs" /> </div> <div class="w-40 px-2 py-1"> <div class="flex items-center"> <ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom"> <button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)"> <span class="material-icons-outlined text-base">remove</span> </button> </ui-tooltip> <ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom"> <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)"> <span class="material-icons text-lg">add</span> </button> </ui-tooltip> <ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom"> <button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)"> <widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" /> <span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span> <span v-else class="material-icons-outlined text-base">play_arrow</span> </button> </ui-tooltip> <ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left"> <button class="w-7 h-7 rounded-full flex items-center justify-center text-error"> <span class="material-icons-outlined text-lg">error_outline</span> </button> </ui-tooltip> </div> </div> </div> </template> </div> <div class="w-full max-w-xl py-4"> <p class="text-lg mb-4 font-semibold py-1">{{ $strings.HeaderAudioTracks }}</p> <div class="flex text-xs uppercase text-gray-300 font-semibold mb-2"> <div class="flex-grow">{{ $strings.LabelFilename }}</div> <div class="w-20">{{ $strings.LabelDuration }}</div> <div class="w-20 text-center">{{ $strings.HeaderChapters }}</div> </div> <template v-for="track in audioTracks"> <div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''"> <div class="flex-grow"> <p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p> </div> <div class="w-20" style="min-width: 80px"> <p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p> </div> <div class="w-20 flex justify-center" style="min-width: 80px"> <span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span> </div> </div> </template> </div> </div> <div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center"> <ui-loading-indicator /> </div> <modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters"> <template #outer> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> <p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderFindChapters }}</p> </div> </template> <div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative"> <div v-if="!chapterData" class="flex p-20"> <ui-text-input-with-label v-model="asinInput" label="ASIN" /> <ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" /> <ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn> </div> <div v-else class="w-full p-4"> <div class="flex justify-between mb-4"> <p> {{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span ><br /> <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }} </p> <p> {{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span ><br /> Your audiobook has <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapters.length }}</span> chapters </p> </div> <widgets-alert v-if="chapterData.runtimeLengthSec > mediaDurationRounded" type="warning" class="mb-2"> {{ $strings.MessageYourAudiobookDurationIsShorter }} </widgets-alert> <widgets-alert v-else-if="chapterData.runtimeLengthSec < mediaDurationRounded" type="warning" class="mb-2"> {{ $strings.MessageYourAudiobookDurationIsLonger }} </widgets-alert> <div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1"> <div class="w-24 px-2">{{ $strings.LabelStart }}</div> <div class="flex-grow px-2">{{ $strings.LabelTitle }}</div> </div> <div class="w-full max-h-80 overflow-y-auto my-2"> <div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''"> <div class="w-24 min-w-24 px-2"> <p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p> </div> <div class="flex-grow px-2"> <p class="truncate max-w-sm">{{ chapter.title }}</p> </div> </div> </div> <div v-if="chapterData.runtimeLengthSec > mediaDurationRounded" class="w-full pt-2"> <div class="flex items-center"> <div class="w-2 h-2 bg-warning bg-opacity-50" /> <p class="pl-2">{{ $strings.MessageChapterEndIsAfter }}</p> </div> <div class="flex items-center"> <div class="w-2 h-2 bg-error bg-opacity-50" /> <p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p> </div> </div> <div class="flex items-center pt-2"> <ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn> <ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top"> <span class="material-icons-outlined">info</span> </ui-tooltip> <div class="flex-grow" /> <ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn> </div> </div> </div> </modals-modal> </div> </template> <script> export default { async asyncData({ store, params, app, redirect, from }) { if (!store.getters['user/getUserCanUpdate']) { return redirect('/?error=unauthorized') } var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { console.error('Failed', error) return false }) if (!libraryItem) { console.error('Not found...', params.id) return redirect('/') } if (libraryItem.mediaType != 'book') { console.error('Invalid media type') return redirect('/') } var previousRoute = from ? from.fullPath : null if (from && from.path === '/login') previousRoute = null return { libraryItem, previousRoute } }, data() { return { newChapters: [], selectedChapter: null, showShiftTimes: false, shiftAmount: 0, audioEl: null, isPlayingChapter: false, isLoadingChapter: false, currentTrackIndex: 0, saving: false, asinInput: null, regionInput: 'US', findingChapters: false, showFindChaptersModal: false, chapterData: null, showSecondInputs: false, audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'] } }, computed: { streamLibraryItem() { return this.$store.state.streamLibraryItem }, userToken() { return this.$store.getters['user/getToken'] }, media() { return this.libraryItem.media || {} }, mediaMetadata() { return this.media.metadata || {} }, title() { return this.mediaMetadata.title }, mediaDuration() { return this.media.duration }, mediaDurationRounded() { return Math.round(this.mediaDuration) }, chapters() { return this.media.chapters || [] }, tracks() { return this.media.tracks || [] }, audioFiles() { return this.media.audioFiles || [] }, audioTracks() { return this.audioFiles.filter((af) => !af.exclude && !af.invalid) }, selectedChapterId() { return this.selectedChapter ? this.selectedChapter.id : null } }, methods: { shiftChapterTimes() { if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) { return } const amount = Number(this.shiftAmount) const lastChapter = this.newChapters[this.newChapters.length - 1] if (lastChapter.start + amount > this.mediaDurationRounded) { this.$toast.error('Invalid shift amount. Last chapter start time would extend beyond the duration of this audiobook.') return } if (this.newChapters[0].end + amount <= 0) { this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.') return } for (let i = 0; i < this.newChapters.length; i++) { const chap = this.newChapters[i] chap.end = Math.min(chap.end + amount, this.mediaDuration) if (i > 0) { chap.start = Math.max(0, chap.start + amount) } } }, editItem() { this.$store.commit('showEditModal', this.libraryItem) }, addChapter(chapter) { console.log('Add chapter', chapter) const newChapter = { id: chapter.id + 1, start: chapter.start, end: chapter.end, title: '' } this.newChapters.splice(chapter.id + 1, 0, newChapter) this.checkChapters() }, removeChapter(chapter) { this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id) this.checkChapters() }, checkChapters() { var previousStart = 0 for (let i = 0; i < this.newChapters.length; i++) { this.newChapters[i].id = i this.newChapters[i].start = Number(this.newChapters[i].start) if (i === 0 && this.newChapters[i].start !== 0) { this.newChapters[i].error = 'First chapter must start at 0' } else if (this.newChapters[i].start <= previousStart && i > 0) { this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time' } else if (this.newChapters[i].start >= this.mediaDuration) { this.newChapters[i].error = 'Invalid start time must be < duration' } else { this.newChapters[i].error = null } previousStart = this.newChapters[i].start } }, playChapter(chapter) { console.log('Play Chapter', chapter.id) if (this.selectedChapterId === chapter.id) { console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter) if (this.isLoadingChapter) return if (this.isPlayingChapter) { this.destroyAudioEl() return } } if (this.selectedChapterId) { this.destroyAudioEl() } const audioTrack = this.tracks.find((at) => { return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration }) console.log('audio track', audioTrack) this.selectedChapter = chapter this.isLoadingChapter = true const trackOffset = chapter.start - audioTrack.startOffset this.playTrackAtTime(audioTrack, trackOffset) }, playTrackAtTime(audioTrack, trackOffset) { this.currentTrackIndex = audioTrack.index const audioEl = this.audioEl || document.createElement('audio') var src = audioTrack.contentUrl + `?token=${this.userToken}` if (this.$isDev) { src = `http://localhost:3333${this.$config.routerBasePath}${src}` } console.log('src', src) audioEl.src = src audioEl.id = 'chapter-audio' document.body.appendChild(audioEl) audioEl.addEventListener('loadeddata', () => { console.log('Audio loaded data', audioEl.duration) audioEl.currentTime = trackOffset audioEl.play() console.log('Playing audio at current time', trackOffset) }) audioEl.addEventListener('play', () => { console.log('Audio playing') this.isLoadingChapter = false this.isPlayingChapter = true }) audioEl.addEventListener('ended', () => { console.log('Audio ended') const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1) if (nextTrack) { console.log('Playing next track', nextTrack.index) this.currentTrackIndex = nextTrack.index this.playTrackAtTime(nextTrack, 0) } else { console.log('No next track') this.destroyAudioEl() } }) this.audioEl = audioEl }, destroyAudioEl() { if (!this.audioEl) return this.audioEl.remove() this.audioEl = null this.selectedChapter = null this.isPlayingChapter = false this.isLoadingChapter = false }, saveChapters() { this.checkChapters() for (let i = 0; i < this.newChapters.length; i++) { if (this.newChapters[i].error) { this.$toast.error('Chapters have errors') return } if (!this.newChapters[i].title) { this.$toast.error('Chapters must have titles') return } const nextChapter = this.newChapters[i + 1] if (nextChapter) { this.newChapters[i].end = nextChapter.start } else { this.newChapters[i].end = this.mediaDuration } } this.saving = true const payload = { chapters: this.newChapters } this.$axios .$post(`/api/items/${this.libraryItem.id}/chapters`, payload) .then((data) => { this.saving = false if (data.updated) { this.$toast.success('Chapters updated') if (this.previousRoute) { this.$router.push(this.previousRoute) } else { this.$router.push(`/item/${this.libraryItem.id}`) } } else { this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) } }) .catch((error) => { this.saving = false console.error('Failed to update chapters', error) this.$toast.error('Failed to update chapters') }) }, applyChapterNamesOnly() { this.newChapters.forEach((chapter, index) => { if (this.chapterData.chapters[index]) { chapter.title = this.chapterData.chapters[index].title } }) this.showFindChaptersModal = false this.chapterData = null }, applyChapterData() { var index = 0 this.newChapters = this.chapterData.chapters .filter((chap) => chap.startOffsetSec < this.mediaDuration) .map((chap) => { var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000) return { id: index++, start: chap.startOffsetMs / 1000, end: chapEnd, title: chap.title } }) this.showFindChaptersModal = false this.chapterData = null }, findChapters() { if (!this.asinInput) { this.$toast.error('Must input an ASIN') return } // Update local storage region if (this.regionInput !== localStorage.getItem('audibleRegion')) { localStorage.setItem('audibleRegion', this.regionInput) } this.findingChapters = true this.chapterData = null this.$axios .$get(`/api/search/chapters?asin=${this.asinInput}®ion=${this.regionInput}`) .then((data) => { this.findingChapters = false if (data.error) { this.$toast.error(data.error) this.showFindChaptersModal = false } else { console.log('Chapter data', data) this.chapterData = data } }) .catch((error) => { this.findingChapters = false console.error('Failed to get chapter data', error) this.$toast.error('Failed to find chapters') this.showFindChaptersModal = false }) } }, mounted() { this.regionInput = localStorage.getItem('audibleRegion') || 'US' this.asinInput = this.mediaMetadata.asin || null this.newChapters = this.chapters.map((c) => ({ ...c })) if (!this.newChapters.length) { this.newChapters = [ { id: 0, start: 0, end: this.mediaDuration, title: '' } ] } }, beforeDestroy() { this.destroyAudioEl() } } </script>