Merge branch 'advplyr:master' into feat/all-stats-page

This commit is contained in:
Vito0912 2024-12-29 20:28:49 +01:00 committed by GitHub
commit df15c22c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
336 changed files with 21709 additions and 13151 deletions

View file

@ -32,9 +32,48 @@
</form>
</div>
<div v-if="showEreaderTable">
<div class="w-full h-px bg-white/10 my-4" />
<app-settings-content :header-text="$strings.HeaderEreaderDevices">
<template #header-items>
<div class="flex-grow" />
<ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
</template>
<table v-if="ereaderDevices.length" class="tracksTable mt-4">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelEmail }}</th>
<th class="w-40"></th>
</tr>
<tr v-for="device in ereaderDevices" :key="device.name">
<td>
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
</td>
<td class="text-left">
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
</td>
<td class="w-40">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name || device.users?.length !== 1" class="mx-1" @click="editDeviceClick(device)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name || device.users?.length !== 1" @click="deleteDeviceClick(device)" />
</div>
</td>
</tr>
</table>
<div v-else-if="!loading" class="text-center py-4">
<p class="text-lg text-gray-100">{{ $strings.MessageNoDevices }}</p>
</div>
</app-settings-content>
</div>
<div class="py-4 mt-8 flex">
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
</div>
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
</div>
</div>
</template>
@ -43,11 +82,20 @@
export default {
data() {
return {
loading: false,
password: null,
newPassword: null,
confirmPassword: null,
changingPassword: false,
selectedLanguage: ''
selectedLanguage: '',
newEReaderDevice: {
name: '',
email: ''
},
ereaderDevices: [],
deletingDeviceName: null,
selectedEReaderDevice: null,
showEReaderDeviceModal: false
}
},
computed: {
@ -75,6 +123,12 @@ export default {
},
showChangePasswordForm() {
return !this.isGuest && this.isPasswordAuthEnabled
},
showEreaderTable() {
return this.usertype !== 'root' && this.usertype !== 'admin' && this.user.permissions?.createEreader
},
revisedEreaderDevices() {
return this.ereaderDevices.filter((device) => device.users?.length === 1)
}
},
methods: {
@ -117,10 +171,10 @@ export default {
},
submitChangePassword() {
if (this.newPassword !== this.confirmPassword) {
return this.$toast.error('New password and confirm password do not match')
return this.$toast.error(this.$strings.ToastUserPasswordMismatch)
}
if (this.password === this.newPassword) {
return this.$toast.error('Password and New Password cannot be the same')
return this.$toast.error(this.$strings.ToastUserPasswordMustChange)
}
this.changingPassword = true
this.$axios
@ -130,22 +184,64 @@ export default {
})
.then((res) => {
if (res.success) {
this.$toast.success('Password Changed Successfully')
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
this.resetForm()
} else {
this.$toast.error(res.error || 'Unknown Error')
this.$toast.error(res.error || this.$strings.ToastUnknownError)
}
this.changingPassword = false
})
.catch((error) => {
console.error(error)
this.$toast.error('Api call failed')
this.$toast.error(this.$strings.ToastUnknownError)
this.changingPassword = false
})
},
addNewDeviceClick() {
this.selectedEReaderDevice = null
this.showEReaderDeviceModal = true
},
editDeviceClick(device) {
this.selectedEReaderDevice = device
this.showEReaderDeviceModal = true
},
deleteDeviceClick(device) {
const payload = {
message: this.$getString('MessageConfirmDeleteDevice', [device.name]),
callback: (confirmed) => {
if (confirmed) {
this.deleteDevice(device)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteDevice(device) {
const payload = {
ereaderDevices: this.revisedEreaderDevices.filter((d) => d.name !== device.name)
}
this.deletingDeviceName = device.name
this.$axios
.$post(`/api/me/ereader-devices`, payload)
.then((data) => {
this.ereaderDevicesUpdated(data.ereaderDevices)
})
.catch((error) => {
console.error('Failed to delete device', error)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.deletingDeviceName = null
})
},
ereaderDevicesUpdated(ereaderDevices) {
this.ereaderDevices = ereaderDevices
}
},
mounted() {
this.selectedLanguage = this.$languageCodes.current
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
}
}
</script>
</script>

View file

@ -415,7 +415,7 @@ export default {
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}`
src = `${process.env.serverUrl}${src}`
}
audioEl.src = src
@ -486,7 +486,7 @@ export default {
.then((data) => {
this.saving = false
if (data.updated) {
this.$toast.success('Chapters updated')
this.$toast.success(this.$strings.ToastChaptersUpdated)
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
@ -499,7 +499,7 @@ export default {
.catch((error) => {
this.saving = false
console.error('Failed to update chapters', error)
this.$toast.error('Failed to update chapters')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
},
applyChapterNamesOnly() {
@ -533,7 +533,7 @@ export default {
},
findChapters() {
if (!this.asinInput) {
this.$toast.error('Must input an ASIN')
this.$toast.error(this.$strings.ToastAsinRequired)
return
}
@ -560,7 +560,7 @@ export default {
.catch((error) => {
this.findingChapters = false
console.error('Failed to get chapter data', error)
this.$toast.error('Failed to find chapters')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.showFindChaptersModal = false
})
},
@ -611,7 +611,7 @@ export default {
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
if (data.updated) {
this.$toast.success('Chapters removed')
this.$toast.success(this.$strings.ToastChaptersRemoved)
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
@ -623,20 +623,32 @@ export default {
})
.catch((error) => {
console.error('Failed to remove chapters', error)
this.$toast.error('Failed to remove chapters')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.saving = false
})
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItem.id) {
if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) {
this.asinInput = libraryItem.media.metadata.asin
}
this.libraryItem = libraryItem
}
}
},
mounted() {
this.regionInput = localStorage.getItem('audibleRegion') || 'US'
this.asinInput = this.mediaMetadata.asin || null
this.initChapters()
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
},
beforeDestroy() {
this.destroyAudioEl()
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
}
</script>

View file

@ -63,11 +63,11 @@
<div class="w-full max-w-4xl mx-auto">
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
<p class="text-lg">{{ $getString('MessageEmbedQueue', [queuedEmbedLIds.length]) }}</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" :label="$strings.LabelBackupAudioFiles" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
@ -78,7 +78,7 @@
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
</button>
<div class="flex-grow" />
@ -94,11 +94,11 @@
<transition name="slide">
<div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" @input="codecChanged" />
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
</div>
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
</div>
</transition>
</div>
@ -106,36 +106,36 @@
<div class="mb-4">
<div v-if="isEmbedTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingInfoEmbedded }}</p>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
{{ $strings.LabelEncodingFinishedM4B }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
</p>
</div>
<div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
{{ $strings.LabelEncodingBackupLocation }} <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. {{ $strings.LabelEncodingClearItemCache }}
</p>
</div>
<div v-if="isEmbedTool && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingChaptersNotEmbedded }}</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingTimeWarning }}</p>
</div>
<div v-if="isM4BTool" class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingWatcherDisabled }}</p>
</div>
<div class="flex items-start mb-2">
<span class="material-symbols text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
<p class="text-gray-200 ml-2">{{ $strings.LabelEncodingStartedNavigation }}</p>
</div>
</div>
</div>
@ -269,11 +269,11 @@ export default {
},
availableTools() {
if (this.isSingleM4b) {
return [{ value: 'embed', text: 'Embed Metadata' }]
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
} else {
return [
{ value: 'embed', text: 'Embed Metadata' },
{ value: 'm4b', text: 'M4B Encoder' }
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
]
}
},
@ -331,11 +331,11 @@ export default {
this.$axios
.$delete(`/api/tools/item/${this.libraryItemId}/encode-m4b`)
.then(() => {
this.$toast.success('Encode canceled')
this.$toast.success(this.$strings.ToastEncodeCancelSucces)
})
.catch((error) => {
console.error('Failed to cancel encode', error)
this.$toast.error('Failed to cancel encode')
this.$toast.error(this.$strings.ToastEncodeCancelFailed)
})
.finally(() => {
this.isCancelingEncode = false
@ -370,7 +370,7 @@ export default {
},
embedClick() {
const payload = {
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
message: this.$getString('MessageConfirmEmbedMetadataInAudioFiles', [this.audioFiles.length]),
callback: (confirmed) => {
if (confirmed) {
this.updateAudioFileMetadata()

View file

@ -53,7 +53,7 @@ export default {
})
if (!author) {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
return redirect(`/library/${store.state.libraries.currentLibraryId}/bookshelf/authors`)
}
if (store.state.libraries.currentLibraryId !== author.libraryId || !store.state.libraries.filterData) {
@ -109,7 +109,7 @@ export default {
authorRemoved(author) {
if (author.id === this.author.id) {
console.warn('Author was removed')
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/authors`)
}
}
},

View file

@ -97,8 +97,8 @@
<div class="flex justify-center flex-wrap">
<template v-for="libraryItem in libraryItemCopies">
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
</div>
</template>
</div>
@ -108,7 +108,7 @@
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
<div class="flex-grow" />
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" :disabled="!hasChanges" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</template>
@ -170,7 +170,8 @@ export default {
abridged: false
},
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false
openMapOptions: false,
itemsWithChanges: []
}
},
computed: {
@ -221,9 +222,19 @@ export default {
},
hasSelectedBatchUsage() {
return Object.values(this.selectedBatchUsage).some((b) => !!b)
},
hasChanges() {
return this.itemsWithChanges.length > 0
}
},
methods: {
handleItemChange(itemChange) {
if (!itemChange.hasChanges) {
this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)
} else if (!this.itemsWithChanges.includes(itemChange.libraryItemId)) {
this.itemsWithChanges.push(itemChange.libraryItemId)
}
},
blurBatchForm() {
if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {
this.$refs.seriesSelect.forceBlur()
@ -283,38 +294,10 @@ export default {
removedSeriesItem(item) {},
newNarratorItem(item) {},
removedNarratorItem(item) {},
newTagItem(item) {
// if (item && !this.newTagItems.includes(item)) {
// this.newTagItems.push(item)
// }
},
removedTagItem(item) {
// If newly added, remove if not used on any other items
// if (item && this.newTagItems.includes(item)) {
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
// return ab.tags && ab.tags.includes(item)
// })
// if (!usedByOtherAb) {
// this.newTagItems = this.newTagItems.filter((t) => t !== item)
// }
// }
},
newGenreItem(item) {
// if (item && !this.newGenreItems.includes(item)) {
// this.newGenreItems.push(item)
// }
},
removedGenreItem(item) {
// If newly added, remove if not used on any other items
// if (item && this.newGenreItems.includes(item)) {
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
// return ab.book.genres && ab.book.genres.includes(item)
// })
// if (!usedByOtherAb) {
// this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
// }
// }
},
newTagItem(item) {},
removedTagItem(item) {},
newGenreItem(item) {},
removedGenreItem(item) {},
init() {
// TODO: Better deep cloning of library items
this.libraryItemCopies = this.libraryItems.map((li) => {
@ -366,7 +349,7 @@ export default {
}
}
if (!updates.length) {
return this.$toast.warning('No updates were made')
return this.$toast.warning(this.$strings.ToastNoUpdatesNecessary)
}
console.log('Pushing updates', updates)
@ -376,6 +359,7 @@ export default {
.then((data) => {
this.isProcessing = false
if (data.updates) {
this.itemsWithChanges = []
this.$toast.success(`Successfully updated ${data.updates} items`)
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
} else {
@ -387,10 +371,28 @@ export default {
this.$toast.error('Failed to batch update')
this.isProcessing = false
})
},
beforeUnload(e) {
if (!e || !this.hasChanges) return
e.preventDefault()
e.returnValue = ''
}
},
beforeRouteLeave(to, from, next) {
if (this.hasChanges) {
next(false)
window.location = to.path
} else {
next()
}
},
mounted() {
this.init()
window.addEventListener('beforeunload', this.beforeUnload)
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.beforeUnload)
}
}
</script>
@ -406,4 +408,4 @@ export default {
transform: translateY(-100%);
transition: all 150ms ease-in 0s;
}
</style>
</style>

View file

@ -16,7 +16,7 @@
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}
</ui-btn>
<!-- RSS feed -->

View file

@ -64,6 +64,20 @@
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
@ -164,6 +178,27 @@ export default {
value: 'username'
}
]
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
}
},
methods: {
@ -317,7 +352,7 @@ export default {
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.$toast.error(this.$strings.ToastServerSettingsUpdateFailed)
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.savingSettings = false
@ -325,7 +360,8 @@ export default {
},
init() {
this.newAuthSettings = {
...this.authSettings
...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')

View file

@ -162,7 +162,7 @@ export default {
})
.catch((error) => {
console.error('Failed to save backup path', error)
const errorMsg = error.response?.data || 'Failed to save backup path'
const errorMsg = error.response?.data || this.$strings.ToastFailedToUpdate
this.$toast.error(errorMsg)
})
.finally(() => {
@ -171,11 +171,11 @@ export default {
},
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
this.$toast.error('Invalid maximum backup size')
this.$toast.error(this.$strings.ToastBackupInvalidMaxSize)
return
}
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
this.$toast.error(this.$strings.ToastBackupInvalidMaxKeep)
return
}
const updatePayload = {

View file

@ -109,7 +109,7 @@
</tr>
</table>
<div v-else-if="!loading" class="text-center py-4">
<p class="text-lg text-gray-100">No Devices</p>
<p class="text-lg text-gray-100">{{ $strings.MessageNoDevices }}</p>
</div>
</app-settings-content>
@ -199,7 +199,7 @@ export default {
},
deleteDeviceClick(device) {
const payload = {
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
message: this.$getString('MessageConfirmDeleteDevice', [device.name]),
callback: (confirmed) => {
if (confirmed) {
this.deleteDevice(device)
@ -218,11 +218,10 @@ export default {
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.ereaderDevicesUpdated(data.ereaderDevices)
this.$toast.success('Device deleted')
})
.catch((error) => {
console.error('Failed to delete device', error)
this.$toast.error('Failed to delete device')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.deletingDeviceName = null
@ -246,11 +245,11 @@ export default {
this.$axios
.$post('/api/emails/test')
.then(() => {
this.$toast.success('Test Email Sent')
this.$toast.success(this.$strings.ToastDeviceTestEmailSuccess)
})
.catch((error) => {
console.error('Failed to send test email', error)
const errorMsg = error.response.data || 'Failed to send test email'
const errorMsg = error.response.data || this.$strings.ToastDeviceTestEmailFailed
this.$toast.error(errorMsg)
})
.finally(() => {
@ -289,11 +288,11 @@ export default {
this.newSettings = {
...data.settings
}
this.$toast.success('Email settings updated')
this.$toast.success(this.$strings.ToastEmailSettingsUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update email settings', error)
this.$toast.error('Failed to update email settings')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.savingSettings = false

View file

@ -6,9 +6,9 @@
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div>
<div class="flex items-end py-2">
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
<ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4">
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-symbols icon-text">info</span>
@ -16,9 +16,9 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4">
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-symbols icon-text">info</span>
@ -26,9 +26,9 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4">
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-symbols icon-text">info</span>
@ -42,18 +42,13 @@
</div>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4">
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-symbols icon-text">info</span>
@ -61,9 +56,9 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4">
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-symbols icon-text">info</span>
@ -75,9 +70,9 @@
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4">
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-symbols icon-text">info</span>
@ -85,15 +80,29 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
<div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
<p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :label="$strings.LabelSettingsChromecastSupport" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
</div>
<div class="flex-1">
@ -290,7 +299,7 @@ export default {
})
.catch((error) => {
console.error('Failed to update prefixes', error)
this.$toast.error(this.$strings.ToastSortingPrefixesUpdateFailed)
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.savingPrefixes = false
@ -324,22 +333,21 @@ export default {
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then(() => {
this.updatingServerSettings = false
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
this.$store.dispatch('updateServerSettings', payload).then((response) => {
this.updatingServerSettings = false
if (payload.language) {
// Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language)
}
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
this.$toast.error(this.$strings.ToastServerSettingsUpdateFailed)
})
if (response.error) {
console.error('Failed to update server settins', response.error)
this.$toast.error(response.error)
this.initServerSettings()
return
}
if (payload.language) {
// Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language)
}
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}

View file

@ -130,7 +130,7 @@ export default {
})
.catch((error) => {
console.error('Failed to rename genre', error)
this.$toast.error('Failed to rename genre')
this.$toast.error(this.$strings.ToastRenameFailed)
})
.finally(() => {
this.loading = false
@ -147,7 +147,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove genre', error)
this.$toast.error('Failed to remove genre')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.loading = false

View file

@ -126,7 +126,7 @@ export default {
})
.catch((error) => {
console.error('Failed to rename tag', error)
this.$toast.error('Failed to rename tag')
this.$toast.error(this.$strings.ToastRenameFailed)
})
.finally(() => {
this.loading = false
@ -143,7 +143,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove tag', error)
this.$toast.error('Failed to remove tag')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.loading = false

View file

@ -10,9 +10,9 @@
</template>
<div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
<ui-dropdown v-model="newServerSettings.logLevel" :label="$strings.LabelServerLogLevel" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
</div>
<div class="relative">

View file

@ -105,12 +105,12 @@ export default {
}
if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) {
this.$toast.error('Max notification queue must be >= 0')
this.$toast.error(this.$strings.ToastNotificationQueueMaximum)
return false
}
if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) {
this.$toast.error('Max failed attempts must be >= 0')
this.$toast.error(this.$strings.ToastNotificationFailedMaximum)
return false
}
@ -128,11 +128,11 @@ export default {
this.$axios
.$patch('/api/notifications', updatePayload)
.then(() => {
this.$toast.success('Notification settings updated')
this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update notification settings', error)
this.$toast.error('Failed to update notification settings')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.savingSettings = false

View file

@ -25,7 +25,7 @@
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
<img :src="coverUrl(feed)" class="h-auto w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
@ -126,7 +126,7 @@ export default {
},
coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover`
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
},
async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {

View file

@ -88,7 +88,7 @@
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
</div>
<div class="inline-flex items-center">
<p class="text-sm mx-2">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<p class="text-sm mx-2">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>
<ui-icon-btn icon="arrow_back_ios_new" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<ui-icon-btn icon="arrow_forward_ios" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
@ -103,7 +103,7 @@
<div v-if="openListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
<!-- open listening sessions table -->
<p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p>
<p v-if="openListeningSessions.length" class="text-lg my-4">{{ $strings.HeaderOpenListeningSessions }}</p>
<div v-if="openListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
@ -290,7 +290,6 @@ export default {
this.$axios
.$post(`/api/sessions/batch/delete`, payload)
.then(() => {
this.$toast.success('Sessions removed')
if (isAllSessions) {
// If all sessions were removed from the current page then go to the previous page
if (this.currentPage > 0) {
@ -303,7 +302,7 @@ export default {
}
})
.catch((error) => {
const errorMsg = error.response?.data || 'Failed to remove sessions'
const errorMsg = error.response?.data || this.$strings.ToastRemoveFailed
this.$toast.error(errorMsg)
})
.finally(() => {
@ -358,12 +357,13 @@ export default {
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
@ -377,7 +377,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}

View file

@ -14,7 +14,7 @@
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div v-if="userToken" class="flex text-xs mt-4">
<ui-text-input-with-label label="API Token" :value="userToken" readonly />
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly />
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
<span class="material-symbols pl-2 text-base">content_copy</span>

View file

@ -54,7 +54,7 @@
</table>
<div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
@ -127,12 +127,13 @@ export default {
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
@ -146,7 +147,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}

View file

@ -2,6 +2,10 @@
<div>
<app-settings-content :header-text="$strings.HeaderUsers">
<template #header-items>
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
<span>{{ numUsers }}</span>
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
@ -13,7 +17,7 @@
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
</template>
<tables-users-table class="pt-2" @edit="setShowUserModal" />
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
</app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div>
@ -29,7 +33,8 @@ export default {
data() {
return {
selectedAccount: null,
showAccountModal: false
showAccountModal: false,
numUsers: 0
}
},
computed: {},

View file

@ -12,12 +12,12 @@
<!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
<button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
<span class="material-symbols fill text-4xl">play_arrow</span>
</div>
</button>
</div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div>
</div>
</div>
@ -39,16 +39,11 @@
><span :key="index" v-if="index < seriesList.length - 1">, </span>
</template>
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="musicArtists.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<content-library-item-details :library-item="libraryItem" />
</div>
@ -92,7 +87,7 @@
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn>
@ -101,26 +96,26 @@
</ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span>
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }}
</ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" />
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && !isMusic" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">&#xe5d3;</span>
</button>
</template>
@ -220,12 +215,6 @@ export default {
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMusic() {
return this.libraryItem.mediaType === 'music'
},
isMissing() {
return this.libraryItem.isMissing
},
@ -240,8 +229,6 @@ export default {
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isMusic) return !!this.audioFile
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
},
@ -292,9 +279,6 @@ export default {
authors() {
return this.mediaMetadata.authors || []
},
musicArtists() {
return this.mediaMetadata.artists || []
},
series() {
return this.mediaMetadata.series || []
},
@ -309,7 +293,7 @@ export default {
})
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
if (!this.tracks.length) return 0
return this.media.duration
},
libraryFiles() {
@ -321,18 +305,10 @@ export default {
ebookFile() {
return this.media.ebookFile
},
videoFile() {
return this.media.videoFile
},
audioFile() {
// Music track
return this.media.audioFile
},
description() {
return this.mediaMetadata.description || ''
},
userMediaProgress() {
if (this.isMusic) return null
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userIsFinished() {
@ -484,23 +460,23 @@ export default {
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => {
this.$toast.success('Episode download queue cleared')
this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess)
this.episodeDownloadQueued = []
})
.catch((error) => {
console.error('Failed to clear queue', error)
this.$toast.error('Failed to clear queue')
this.$toast.error(this.$strings.ToastEpisodeDownloadQueueClearFailed)
})
}
},
async findEpisodesClick() {
if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed')
return this.$toast.error(this.$strings.ToastNoRSSFeed)
}
this.fetchingRSSFeed = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.fetchingRSSFeed = false
@ -509,7 +485,7 @@ export default {
console.log('Podcast feed', payload)
const podcastfeed = payload.podcast
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info('No episodes found in RSS feed')
this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed)
return
}
@ -578,7 +554,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.libraryItem.media.coverPath || null
})
@ -622,13 +598,12 @@ export default {
},
clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(`Are you sure you want to reset your progress?`)) {
if (confirm(this.$strings.MessageConfirmResetProgress)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
@ -663,6 +638,11 @@ export default {
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
},
episodeDownloadQueueCleared(libraryItemId) {
if (libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = []
}
},
rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
@ -722,12 +702,12 @@ export default {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
this.$router.replace(`/library/${this.libraryId}`)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
this.$toast.error(this.$strings.ToastItemDeleteFailed)
})
}
},
@ -801,6 +781,7 @@ export default {
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
@ -812,6 +793,7 @@ export default {
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
}
}
</script>

View file

@ -1,113 +0,0 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
<div id="bookshelf" class="w-full h-full p-8e overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
<div class="flex flex-wrap justify-center">
<template v-for="author in authorsSorted">
<cards-author-card :key="author.id" :author="author" class="p-3e" @edit="editAuthor" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
return redirect('/oops?message=Library not found')
}
const library = libraryData.library
if (library.mediaType === 'podcast') {
return redirect(`/library/${libraryId}`)
}
return {
libraryId
}
},
data() {
return {
loading: true,
authors: []
}
},
computed: {
sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier']
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedAuthor() {
return this.$store.state.globals.selectedAuthor
},
authorSortBy() {
return this.$store.getters['user/getUserSetting']('authorSortBy') || 'name'
},
authorSortDesc() {
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
},
authorsSorted() {
const sortProp = this.authorSortBy
const bDesc = this.authorSortDesc ? -1 : 1
return this.authors.sort((a, b) => {
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
}
return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
})
}
},
methods: {
async init() {
this.authors = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
.then((response) => response.authors)
.catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false
},
authorAdded(author) {
if (!this.authors.some((au) => au.id === author.id)) {
this.authors.push(author)
}
},
authorUpdated(author) {
this.authors = this.authors.map((au) => {
if (au.id === author.id) {
return author
}
return au
})
},
authorRemoved(author) {
this.authors = this.authors.filter((au) => au.id !== author.id)
},
editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author)
}
},
mounted() {
this.init()
this.$root.socket.on('author_added', this.authorAdded)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
},
beforeDestroy() {
this.$root.socket.off('author_added', this.authorAdded)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
}
}
</script>

View file

@ -27,7 +27,7 @@ export default {
// Redirect podcast libraries
const library = libraryData.library
if (library.mediaType === 'podcast' && (params.id === 'collections' || params.id === 'series')) {
if (library.mediaType === 'podcast' && (params.id === 'collections' || params.id === 'series' || params.id === 'authors')) {
return redirect(`/library/${libraryId}`)
}

View file

@ -120,7 +120,7 @@ export default {
})
.catch((error) => {
console.error('Failed to updated narrator', error)
this.$toast.error('Failed to update narrator')
this.$toast.error(this.$strings.ToastFailedToUpdate)
this.loading = false
})
},
@ -138,7 +138,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove narrator', error)
this.$toast.error('Failed to remove narrator')
this.$toast.error(this.$strings.ToastRemoveFailed)
this.loading = false
})
},
@ -158,4 +158,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>

View file

@ -104,14 +104,11 @@ export default {
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
},
episodeDownloadQueueUpdated(downloadQueueDetails) {
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
},
async loadInitialDownloadQueue() {
this.processing = true
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
console.error('Failed to get download queue', error)
this.$toast.error('Failed to get download queue')
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null
})
this.processing = false
@ -128,7 +125,6 @@ export default {
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
},
mounted() {
@ -138,7 +134,6 @@ export default {
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
}
}
</script>

View file

@ -48,7 +48,7 @@
<p dir="auto" class="text-sm text-gray-200 mb-4 line-clamp-4" v-html="episode.subtitle || episode.description" />
<div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress?.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-symbols text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-symbols fill text-2xl text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
@ -56,9 +56,10 @@
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" />
<!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-symbols text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button> -->
</ui-tooltip>
<ui-tooltip :text="!!episode.progress?.isFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="episodesProcessingMap[episode.id]" :is-read="!!episode.progress?.isFinished" borderless class="mx-1 mt-0.5" @click="toggleEpisodeFinished(episode)" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
@ -98,6 +99,7 @@ export default {
data() {
return {
recentEpisodes: [],
episodesProcessingMap: {},
totalEpisodes: 0,
currentPage: 0,
processing: false,
@ -143,6 +145,44 @@ export default {
}
},
methods: {
async toggleEpisodeFinished(episode, confirmed = false) {
if (this.episodesProcessingMap[episode.id]) {
console.warn('Episode is already processing')
return
}
const isFinished = !!episode.progress?.isFinished
const itemProgressPercent = episode.progress?.progress || 0
if (!isFinished && itemProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${episode.title}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleEpisodeFinished(episode, true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
return
}
const updatePayload = {
isFinished: !isFinished
}
this.$set(this.episodesProcessingMap, episode.id, true)
this.$axios
.$patch(`/api/me/progress/${episode.libraryItemId}/${episode.id}`, updatePayload)
.catch((error) => {
console.error('Failed to update progress', error)
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
.finally(() => {
this.$set(this.episodesProcessingMap, episode.id, false)
})
},
clickAddToPlaylist(episode) {
// Makeshift libraryItem
const libraryItem = {
@ -194,7 +234,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
})
@ -211,11 +251,10 @@ export default {
this.processing = true
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
console.error('Failed to get recent episodes', error)
this.$toast.error('Failed to get recent episodes')
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null
})
this.processing = false
console.log('Episodes', episodePayload)
this.recentEpisodes = episodePayload.episodes || []
this.totalEpisodes = episodePayload.total
this.currentPage = page
@ -232,7 +271,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
}

View file

@ -5,7 +5,7 @@
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-text-input v-model="searchInput" type="search" :disabled="processing" :placeholder="$strings.MessagePodcastSearchField" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
</form>
@ -22,7 +22,7 @@
<div class="flex items-center">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<widgets-explicit-indicator v-if="podcast.explicit" />
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary" />
<widgets-already-in-library-indicator v-if="podcast.alreadyInLibrary" />
</div>
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [podcast.artistName]) }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
@ -108,7 +108,7 @@ export default {
if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {
// Quick lazy check for valid OPML
this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found')
this.$toast.error(this.$strings.MessageTaskOpmlParseFastFail)
this.processing = false
return
}
@ -117,7 +117,7 @@ export default {
.$post(`/api/podcasts/opml/parse`, { opmlText: txt })
.then((data) => {
if (!data.feeds?.length) {
this.$toast.error('No feeds found in OPML file')
this.$toast.error(this.$strings.MessageTaskOpmlParseNoneFound)
} else {
this.opmlFeeds = data.feeds || []
this.showOPMLFeedsModal = true
@ -125,7 +125,7 @@ export default {
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to parse OPML file')
this.$toast.error(this.$strings.MessageTaskOpmlParseFailed)
})
.finally(() => {
this.processing = false
@ -146,7 +146,7 @@ export default {
this.processing = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.processing = false
@ -191,13 +191,13 @@ export default {
return
}
if (!podcast.feedUrl) {
this.$toast.error('Invalid podcast - no feed')
this.$toast.error(this.$strings.MessageNoPodcastFeed)
return
}
this.processing = true
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.processing = false
@ -211,15 +211,15 @@ export default {
async fetchExistentPodcastsInYourLibrary() {
this.processing = true
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
const podcastsResponse = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/podcast-titles`).catch((error) => {
console.error('Failed to fetch podcasts', error)
return []
})
this.existentPodcasts = podcasts.results.map((p) => {
this.existentPodcasts = podcastsResponse.podcasts.map((p) => {
return {
title: p.media.metadata.title.toLowerCase(),
itunesId: p.media.metadata.itunesId,
id: p.id
title: p.title.toLowerCase(),
itunesId: p.itunesId,
id: p.libraryItemId
}
})
this.processing = false

View file

@ -31,7 +31,7 @@
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />

View file

@ -132,11 +132,11 @@ export default {
methods: {
async submitServerSetup() {
if (!this.newRoot.username || !this.newRoot.username.trim()) {
this.$toast.error('Must enter a root username')
this.$toast.error(this.$strings.ToastUserRootRequireName)
return
}
if (this.newRoot.password !== this.confirmPassword) {
this.$toast.error('Password mismatch')
this.$toast.error(this.$strings.ToastUserPasswordMismatch)
return
}
if (!this.newRoot.password) {

View file

@ -16,7 +16,7 @@
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}
</ui-btn>
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />

View file

@ -10,7 +10,7 @@
<p v-if="mediaItemShare.playbackSession.displayAuthor" class="text-lg lg:text-xl text-slate-400 font-semibold text-center mb-1 truncate">{{ mediaItemShare.playbackSession.displayAuthor }}</p>
<div class="w-full pt-16">
<player-ui ref="audioPlayer" :chapters="chapters" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
<player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
</div>
</div>
</div>
@ -51,7 +51,8 @@ export default {
windowHeight: 0,
listeningTimeSinceSync: 0,
coverRgb: null,
coverBgIsLight: false
coverBgIsLight: false,
currentTime: 0
}
},
computed: {
@ -60,16 +61,10 @@ export default {
},
coverUrl() {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
if (process.env.NODE_ENV === 'development') {
return `http://localhost:3333/public/share/${this.mediaItemShare.slug}/cover`
}
return `/public/share/${this.mediaItemShare.slug}/cover`
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
},
audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => {
if (process.env.NODE_ENV === 'development') {
track.contentUrl = `${process.env.serverUrl}${track.contentUrl}`
}
track.relativeContentUrl = track.contentUrl
return track
})
@ -83,6 +78,9 @@ export default {
chapters() {
return this.playbackSession.chapters || []
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
coverAspectRatio() {
const coverAspectRatio = this.playbackSession.coverAspectRatio
return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
@ -128,12 +126,14 @@ export default {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
const duration = this.localAudioPlayer.getDuration()
this.seek(Math.min(currentTime + 10, duration))
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
},
jumpBackward() {
if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime()
this.seek(Math.max(currentTime - 10, 0))
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
},
setVolume(volume) {
if (!this.localAudioPlayer || !this.hasLoaded) return
@ -154,6 +154,7 @@ export default {
// Update UI
this.$refs.audioPlayer.setCurrentTime(time)
this.currentTime = time
},
setDuration() {
if (!this.localAudioPlayer) return
@ -249,6 +250,8 @@ export default {
}
},
mounted() {
this.$store.dispatch('user/loadUserSettings')
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)

View file

@ -1,20 +1,20 @@
<template>
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div id="page-wrapper" class="page p-1 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-6xl mx-auto">
<!-- Library & folder picker -->
<div class="flex my-6 -mx-2">
<div class="w-1/5 px-2">
<div class="flex flex-wrap my-6 md:-mx-2">
<div class="w-full md:w-1/5 px-2">
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
</div>
<div class="w-3/5 px-2">
<div class="w-full md:w-3/5 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
</div>
<div class="w-1/5 px-2">
<div class="w-full md:w-1/5 px-2">
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
</div>
</div>
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6 px-2 md:px-0">
<label class="flex cursor-pointer pt-4">
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
@ -33,13 +33,13 @@
</widgets-alert>
<!-- Picker display -->
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
<div class="w-full max-w-xl mx-auto">
<div class="flex">
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn>
<ui-btn v-if="!isIOS" class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }} </ui-btn>
</div>
</div>
<div class="pt-8 text-center">
@ -48,7 +48,7 @@
</p>
<p class="text-sm text-white text-opacity-70">
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
<span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
</p>
</div>
</div>
@ -84,8 +84,8 @@
</div>
</div>
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileInput" type="file" multiple :accept="isIOS ? '' : inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" v-if="!isIOS" />
</div>
</template>
@ -127,6 +127,10 @@ export default {
})
return extensions
},
isIOS() {
const ua = window.navigator.userAgent
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
@ -384,12 +388,6 @@ export default {
else itemsFailed++
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
}
if (itemsUploaded) {
this.$toast.success(`Successfully uploaded ${itemsUploaded} item${itemsUploaded > 1 ? 's' : ''}`)
}
if (itemsFailed) {
this.$toast.success(`Failed to upload ${itemsFailed} item${itemsFailed > 1 ? 's' : ''}`)
}
this.processing = false
this.uploadFinished = true
}