mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-09 10:51:37 +00:00
Merge branch 'master' of github.com:advplyr/audiobookshelf
# Conflicts: # client/components/app/BookShelfCategorized.vue # client/components/cards/LazySeriesCard.vue
This commit is contained in:
commit
2896e32f4e
147 changed files with 8627 additions and 5171 deletions
|
|
@ -11,10 +11,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
|
||||
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl"></div>
|
||||
</div>
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in toneObject">
|
||||
<template v-for="(value, key, index) in metadataObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
|
|
@ -208,7 +207,7 @@ export default {
|
|||
processing: false,
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
toneObject: null,
|
||||
metadataObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
showEncodeOptions: false,
|
||||
|
|
@ -387,7 +386,7 @@ export default {
|
|||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
},
|
||||
init() {
|
||||
this.fetchToneObject()
|
||||
this.fetchMetadataEmbedObject()
|
||||
if (this.$route.query.tool === 'm4b') {
|
||||
if (this.availableTools.some((t) => t.value === 'm4b')) {
|
||||
this.selectedTool = 'm4b'
|
||||
|
|
@ -401,15 +400,14 @@ export default {
|
|||
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
||||
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
|
||||
},
|
||||
fetchToneObject() {
|
||||
fetchMetadataEmbedObject() {
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/tone-object`)
|
||||
.then((toneObject) => {
|
||||
delete toneObject.CoverFile
|
||||
this.toneObject = toneObject
|
||||
.$get(`/api/items/${this.libraryItemId}/metadata-object`)
|
||||
.then((metadataObject) => {
|
||||
this.metadataObject = metadataObject
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch tone object', error)
|
||||
console.error('Failed to fetch metadata object', error)
|
||||
})
|
||||
},
|
||||
taskUpdated(task) {
|
||||
|
|
@ -426,4 +424,4 @@ export default {
|
|||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
|
||||
<div v-if="backupLocation" class="flex items-center mb-4">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelBackupLocation }}:</span>
|
||||
<div class="text-gray-100 pl-4">{{ backupLocation }}</div>
|
||||
<div v-if="backupLocation" class="mb-4 max-w-full overflow-hidden">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
|
||||
<span class="text-white text-opacity-60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span>
|
||||
</div>
|
||||
<div v-if="!showEditBackupPath" class="inline-flex items-center w-full overflow-hidden">
|
||||
<p class="text-gray-100 max-w-[calc(100%-40px)] text-sm sm:text-base break-words">{{ backupLocation }}</p>
|
||||
<div class="w-10 min-w-10 flex items-center justify-center">
|
||||
<button class="text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showEditBackupPath = !showEditBackupPath">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath">
|
||||
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath || !canEditBackup" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
|
||||
<ui-btn v-if="canEditBackup" small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</form>
|
||||
<p class="text-sm text-warning/80 pt-1">{{ canEditBackup ? $strings.MessageBackupsLocationEditNote : $strings.MessageBackupsLocationNoEditNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
|
|
@ -15,21 +32,23 @@
|
|||
</div>
|
||||
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-40">
|
||||
<div class="flex items-center pl-0 sm:pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
<div class="text-gray-100 text-sm sm:text-base">{{ scheduleDescription }}</div>
|
||||
<button class="ml-2 text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showCronBuilder = !showCronBuilder">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-40">
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-0 sm:pl-6 py-0.5">
|
||||
<span class="material-icons-outlined text-xl sm:text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
<div class="text-gray-100 text-sm sm:text-base">{{ nextBackupDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -49,7 +68,7 @@
|
|||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<tables-backups-table @loaded="backupsLoaded" />
|
||||
<tables-backups-table ref="backupsTable" @loaded="backupsLoaded" />
|
||||
|
||||
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
|
||||
</app-settings-content>
|
||||
|
|
@ -72,7 +91,11 @@ export default {
|
|||
cronExpression: '',
|
||||
newServerSettings: {},
|
||||
showCronBuilder: false,
|
||||
backupLocation: ''
|
||||
showEditBackupPath: false,
|
||||
backupPathEnvSet: false,
|
||||
backupLocation: '',
|
||||
newBackupLocation: '',
|
||||
savingBackupPath: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -93,6 +116,10 @@ export default {
|
|||
timeFormat() {
|
||||
return this.serverSettings.timeFormat
|
||||
},
|
||||
canEditBackup() {
|
||||
// Prevent editing of backup path if an environment variable is set
|
||||
return !this.backupPathEnvSet
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
|
|
@ -105,8 +132,42 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
backupsLoaded(backupLocation) {
|
||||
this.backupLocation = backupLocation
|
||||
backupsLoaded(data) {
|
||||
this.backupLocation = data.backupLocation
|
||||
this.newBackupLocation = data.backupLocation
|
||||
this.backupPathEnvSet = data.backupPathEnvSet
|
||||
},
|
||||
cancelEditBackupPath() {
|
||||
this.newBackupLocation = this.backupLocation
|
||||
this.showEditBackupPath = false
|
||||
},
|
||||
saveBackupPath() {
|
||||
if (!this.newBackupLocation?.trim()) {
|
||||
this.$toast.error(this.$strings.MessageBackupsLocationPathEmpty)
|
||||
return
|
||||
}
|
||||
this.newBackupLocation = this.newBackupLocation.trim()
|
||||
if (this.newBackupLocation === this.backupLocation) {
|
||||
this.showEditBackupPath = false
|
||||
return
|
||||
}
|
||||
|
||||
this.savingBackupPath = true
|
||||
this.$axios
|
||||
.patch('/api/backups/path', { path: this.newBackupLocation })
|
||||
.then(() => {
|
||||
this.backupLocation = this.newBackupLocation
|
||||
this.showEditBackupPath = false
|
||||
this.$refs.backupsTable.loadBackups()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to save backup path', error)
|
||||
const errorMsg = error.response?.data || 'Failed to save backup path'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingBackupPath = false
|
||||
})
|
||||
},
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@
|
|||
</div>
|
||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
|
||||
<div class="w-full my-8 h-px bg-white/10" />
|
||||
<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>
|
||||
|
|
@ -144,6 +144,45 @@
|
|||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="openShareListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
|
||||
|
||||
<!-- open share listening sessions table -->
|
||||
<p v-if="openShareListeningSessions.length" class="text-lg my-4">Open Share Listening Sessions</p>
|
||||
<div v-if="openShareListeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in openShareListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell"></td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
||||
|
|
@ -180,6 +219,7 @@ export default {
|
|||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
openListeningSessions: [],
|
||||
openShareListeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
|
|
@ -455,6 +495,7 @@ export default {
|
|||
s.open = true
|
||||
return s
|
||||
})
|
||||
this.openShareListeningSessions = data.shareSessions || []
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export default {
|
|||
}
|
||||
|
||||
// Include episode downloads for podcasts
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
|
@ -170,7 +170,8 @@ export default {
|
|||
}
|
||||
return {
|
||||
libraryItem: item,
|
||||
rssFeed: item.rssFeed || null
|
||||
rssFeed: item.rssFeed || null,
|
||||
mediaItemShare: item.mediaItemShare || null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
|
@ -437,6 +438,13 @@ export default {
|
|||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShare,
|
||||
action: 'share'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
|
|
@ -666,6 +674,16 @@ export default {
|
|||
this.rssFeed = null
|
||||
}
|
||||
},
|
||||
shareOpen(mediaItemShare) {
|
||||
if (mediaItemShare.mediaItemId === this.media.id) {
|
||||
this.mediaItemShare = mediaItemShare
|
||||
}
|
||||
},
|
||||
shareClosed(mediaItemShare) {
|
||||
if (mediaItemShare.mediaItemId === this.media.id) {
|
||||
this.mediaItemShare = null
|
||||
}
|
||||
},
|
||||
queueBtnClick() {
|
||||
if (this.isQueued) {
|
||||
// Remove from queue
|
||||
|
|
@ -761,6 +779,9 @@ export default {
|
|||
this.deleteLibraryItem()
|
||||
} else if (action === 'sendToDevice') {
|
||||
this.sendToDevice(data)
|
||||
} else if (action === 'share') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -778,6 +799,8 @@ export default {
|
|||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
this.$root.socket.on('share_open', this.shareOpen)
|
||||
this.$root.socket.on('share_closed', this.shareClosed)
|
||||
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)
|
||||
|
|
@ -787,6 +810,8 @@ export default {
|
|||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||
this.$root.socket.off('share_open', this.shareOpen)
|
||||
this.$root.socket.off('share_closed', this.shareClosed)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<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-8 overflow-y-auto">
|
||||
<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" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
<cards-author-card :key="author.id" :author="author" class="p-3e" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,6 +38,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
|
|
|||
278
client/pages/share/_slug.vue
Normal file
278
client/pages/share/_slug.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<template>
|
||||
<div class="w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
|
||||
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
|
||||
<div class="w-full p-2 sm:p-4 md:p-8">
|
||||
<div v-if="!isMobileLandscape" :style="{ width: coverWidth + 'px', height: coverHeight + 'px' }" class="mx-auto overflow-hidden rounded-xl my-2">
|
||||
<img ref="coverImg" :src="coverUrl" class="object-contain w-full h-full" @load="coverImageLoaded" />
|
||||
</div>
|
||||
<p class="text-2xl lg:text-3xl font-semibold text-center mb-1 line-clamp-2">{{ mediaItemShare.playbackSession.displayTitle || 'No title' }}</p>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LocalAudioPlayer from '../../players/LocalAudioPlayer'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
export default {
|
||||
layout: 'blank',
|
||||
async asyncData({ params, error, app, query }) {
|
||||
let endpoint = `/public/share/${params.slug}`
|
||||
if (query.t && !isNaN(query.t)) {
|
||||
endpoint += `?t=${query.t}`
|
||||
}
|
||||
const mediaItemShare = await app.$axios.$get(endpoint, { timeout: 10000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!mediaItemShare) {
|
||||
return error({ statusCode: 404, message: 'Media item not found or expired' })
|
||||
}
|
||||
|
||||
return {
|
||||
mediaItemShare: mediaItemShare
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localAudioPlayer: new LocalAudioPlayer(),
|
||||
playerState: null,
|
||||
playInterval: null,
|
||||
hasLoaded: false,
|
||||
totalDuration: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0,
|
||||
listeningTimeSinceSync: 0,
|
||||
coverRgb: null,
|
||||
coverBgIsLight: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playbackSession() {
|
||||
return this.mediaItemShare.playbackSession
|
||||
},
|
||||
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`
|
||||
},
|
||||
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
|
||||
})
|
||||
},
|
||||
isPlaying() {
|
||||
return this.playerState === 'PLAYING'
|
||||
},
|
||||
isPaused() {
|
||||
return !this.isPlaying
|
||||
},
|
||||
chapters() {
|
||||
return this.playbackSession.chapters || []
|
||||
},
|
||||
coverAspectRatio() {
|
||||
const coverAspectRatio = this.playbackSession.coverAspectRatio
|
||||
return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
|
||||
},
|
||||
isMobileLandscape() {
|
||||
return this.windowWidth > this.windowHeight && this.windowHeight < 450
|
||||
},
|
||||
coverWidth() {
|
||||
const availableCoverWidth = Math.min(450, this.windowWidth - 32)
|
||||
const availableCoverHeight = Math.min(450, this.windowHeight - 250)
|
||||
|
||||
const mostCoverHeight = availableCoverWidth * this.coverAspectRatio
|
||||
if (mostCoverHeight > availableCoverHeight) {
|
||||
return availableCoverHeight / this.coverAspectRatio
|
||||
}
|
||||
return availableCoverWidth
|
||||
},
|
||||
coverHeight() {
|
||||
return this.coverWidth * this.coverAspectRatio
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async coverImageLoaded(e) {
|
||||
if (!this.playbackSession.coverPath) return
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(e.target)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
|
||||
document.body.style.backgroundColor = color.hex
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
playPause() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.playPause()
|
||||
},
|
||||
jumpForward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
const duration = this.localAudioPlayer.getDuration()
|
||||
this.seek(Math.min(currentTime + 10, duration))
|
||||
},
|
||||
jumpBackward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
this.seek(Math.max(currentTime - 10, 0))
|
||||
},
|
||||
setVolume(volume) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
|
||||
this.localAudioPlayer.seek(time, this.isPlaying)
|
||||
this.setCurrentTime(time)
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
if (!this.$refs.audioPlayer) return
|
||||
|
||||
// Update UI
|
||||
this.$refs.audioPlayer.setCurrentTime(time)
|
||||
},
|
||||
setDuration() {
|
||||
if (!this.localAudioPlayer) return
|
||||
this.totalDuration = this.localAudioPlayer.getDuration()
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setDuration(this.totalDuration)
|
||||
}
|
||||
},
|
||||
sendProgressSync(currentTime) {
|
||||
console.log('Sending progress sync for time', currentTime)
|
||||
const progress = {
|
||||
currentTime
|
||||
}
|
||||
this.$axios.$patch(`/public/share/${this.mediaItemShare.slug}/progress`, progress, { progress: false }).catch((error) => {
|
||||
console.error('Failed to send progress sync', error)
|
||||
})
|
||||
},
|
||||
startPlayInterval() {
|
||||
let lastTick = Date.now()
|
||||
clearInterval(this.playInterval)
|
||||
this.playInterval = setInterval(() => {
|
||||
if (!this.localAudioPlayer) return
|
||||
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
this.setCurrentTime(currentTime)
|
||||
const exactTimeElapsed = (Date.now() - lastTick) / 1000
|
||||
lastTick = Date.now()
|
||||
this.listeningTimeSinceSync += exactTimeElapsed
|
||||
if (this.listeningTimeSinceSync >= 30) {
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.sendProgressSync(currentTime)
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
stopPlayInterval() {
|
||||
clearInterval(this.playInterval)
|
||||
this.playInterval = null
|
||||
},
|
||||
playerStateChange(state) {
|
||||
this.playerState = state
|
||||
if (state === 'LOADED' || state === 'PLAYING') {
|
||||
this.setDuration()
|
||||
}
|
||||
if (state === 'LOADED') {
|
||||
this.hasLoaded = true
|
||||
}
|
||||
if (state === 'PLAYING') {
|
||||
this.startPlayInterval()
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
},
|
||||
playerTimeUpdate(time) {
|
||||
this.setCurrentTime(time)
|
||||
},
|
||||
getHotkeyName(e) {
|
||||
var keyCode = e.keyCode || e.which
|
||||
if (!this.$keynames[keyCode]) {
|
||||
// Unused hotkey
|
||||
return null
|
||||
}
|
||||
|
||||
var keyName = this.$keynames[keyCode]
|
||||
var name = keyName
|
||||
if (e.shiftKey) name = 'Shift-' + keyName
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('Hotkey command', name)
|
||||
}
|
||||
return name
|
||||
},
|
||||
keyDown(e) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
|
||||
var name = this.getHotkeyName(e)
|
||||
if (!name) return
|
||||
|
||||
// Playing audiobook
|
||||
if (Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
|
||||
this.$eventBus.$emit('player-hotkey', name)
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
},
|
||||
playerError(error) {
|
||||
console.error('Player error', error)
|
||||
this.$toast.error('Failed to play audio on device')
|
||||
},
|
||||
playerFinished() {
|
||||
console.log('Player finished')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Loaded media item share', this.mediaItemShare)
|
||||
}
|
||||
|
||||
const startTime = this.playbackSession.currentTime || 0
|
||||
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
|
||||
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
this.localAudioPlayer.on('error', this.playerError.bind(this))
|
||||
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
|
||||
this.localAudioPlayer.off('stateChange', this.playerStateChange.bind(this))
|
||||
this.localAudioPlayer.off('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
this.localAudioPlayer.off('error', this.playerError.bind(this))
|
||||
this.localAudioPlayer.off('finished', this.playerFinished.bind(this))
|
||||
this.localAudioPlayer.destroy()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue