New data model play media entity, PlaybackSessionManager

This commit is contained in:
advplyr 2022-03-17 19:10:47 -05:00
parent 1cf9e85272
commit 099ae7c776
54 changed files with 841 additions and 902 deletions

View file

@ -175,8 +175,8 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)

View file

@ -300,11 +300,11 @@ export default {
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}

View file

@ -95,16 +95,17 @@ export default {
user() {
return this.$store.state.user.user
},
userAudiobook() {
userLibraryItemProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserLibraryItemProgress'](this.libraryItemId)
},
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
userItemCurrentTime() {
return this.userLibraryItemProgress ? this.userLibraryItemProgress.currentTime || 0 : 0
},
bookmarks() {
if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
return []
// if (!this.userAudiobook) return []
// return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
@ -236,9 +237,9 @@ export default {
console.error('No Audio Ref')
}
},
streamOpen(stream) {
this.$store.commit('setLibraryItemStream', stream.libraryItem)
this.playerHandler.prepareStream(stream)
sessionOpen(session) {
this.$store.commit('setLibraryItemStream', session.libraryItem)
this.playerHandler.prepareOpenSession(session)
},
streamClosed(streamId) {
// Stream was closed from the server
@ -282,7 +283,7 @@ export default {
if (!libraryItem) return
this.$store.commit('setLibraryItemStream', libraryItem)
this.playerHandler.load(libraryItem, true, this.userAudiobookCurrentTime)
this.playerHandler.load(libraryItem, true)
}
},
mounted() {

View file

@ -161,10 +161,12 @@ export default {
return this._libraryItem.libraryId
},
hasEbook() {
return this.media.numEbooks
if (!this.media.ebooks) return 0
return this.media.ebooks.length
},
hasTracks() {
return this.media.numTracks
hasAudiobook() {
if (!this.media.audiobooks) return 0
return this.media.audiobooks.length
},
processingBatch() {
return this.store.state.processingBatch
@ -244,7 +246,7 @@ export default {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasTracks && !this.isStreaming
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasAudiobook && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
@ -310,7 +312,7 @@ export default {
}
]
if (this.userCanUpdate) {
if (this.hasTracks) {
if (this.hasAudiobook) {
items.push({
func: 'showEditModalTracks',
text: 'Tracks'

View file

@ -175,7 +175,7 @@ export default {
return this.filterData.languages || []
},
progress() {
return ['Read', 'Unread', 'In Progress']
return ['Finished', 'In Progress', 'Not Started']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {

View file

@ -168,7 +168,6 @@ export default {
},
async updateDetails(updatedDetails) {
this.isProcessing = true
console.log('Sending update', updatedDetails.updatePayload)
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
console.error('Failed to update', error)
return false

View file

@ -64,7 +64,6 @@ export default {
computed: {
_directories() {
return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath
var classes = []

View file

@ -1,105 +0,0 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer">
<p class="pr-4">All Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
</div>
<div class="w-full">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="file in allFiles">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
otherFiles() {
return this.audiobook.otherFiles || []
},
audioFiles() {
return this.audiobook.audioFiles || []
},
audioFilesCleaned() {
return this.audioFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: 'audio'
}
})
},
otherFilesCleaned() {
return this.otherFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: af.filetype
}
})
},
allFiles() {
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
}
},
methods: {
getRelativePath(path) {
var relativePath = path.replace(/\\/g, '/').replace(this.audiobookPath.replace(/\\/g, '/') + '/', '')
return this.$encodeUriPath(relativePath)
}
},
mounted() {}
}
</script>

View file

@ -26,11 +26,11 @@
</td>
<td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.libraryItem && usersOnline[user.id].stream.libraryItem.media">
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.libraryItem.media.metadata.title || '' }}</p>
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
</div>
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
<div v-else-if="user.mostRecent">
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
</div>
</td>
<td class="text-xs font-mono hidden sm:table-cell">
@ -78,23 +78,11 @@ export default {
},
usersOnline() {
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
return usermap
}
},
methods: {
getLastRead(audiobooks) {
var abs = Object.values(audiobooks).filter((ab) => {
return ab.progress > 0
})
if (abs.length) {
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {

View file

@ -33,7 +33,6 @@ export default {
var _files = Array.from(e.target.files)
if (_files && _files.length) {
var file = _files[0]
console.log('File', file)
this.$emit('change', file)
}
}

View file

@ -78,7 +78,7 @@ export default {
console.error('Failed to get search results', error)
return []
})
console.log('Search results', results)
// console.log('Search results', results)
this.items = results || []
this.searching = false
},

View file

@ -276,7 +276,7 @@ export default {
if (!matchingItem) return false
for (var key in item) {
if (item[key] !== matchingItem[key]) {
console.log('Object array item keys changed', key, item[key], matchingItem[key])
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
return false
}
}

View file

@ -97,9 +97,9 @@ export default {
return
}
console.log('Init Payload', payload)
if (payload.stream) {
if (payload.session) {
if (this.$refs.streamContainer) {
this.$refs.streamContainer.streamOpen(payload.stream)
this.$refs.streamContainer.sessionOpen(payload.session)
} else {
console.warn('Stream Container not mounted')
}

View file

@ -74,7 +74,6 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }

View file

@ -95,7 +95,7 @@ export default {
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var payload = await app.$axios.$get(`/api/audiobooks/${params.id}/item?expanded=1`).catch((error) => {
var payload = await app.$axios.$get(`/api/entities/${params.id}/item?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
@ -103,7 +103,7 @@ export default {
console.error('Not found...', params.id)
return redirect('/')
}
const audiobook = payload.audiobook
const audiobook = payload.mediaEntity
return {
audiobook,
libraryItem: payload.libraryItem,
@ -218,7 +218,7 @@ export default {
this.saving = true
this.$axios
.$patch(`/api/audiobooks/${this.audiobook.id}/tracks`, { orderedFileData })
.$patch(`/api/entities/${this.audiobook.id}/tracks`, { orderedFileData })
.then((data) => {
console.log('Finished patching files', data)
this.saving = false

View file

@ -9,8 +9,8 @@
/>
</svg>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ userAudiobooksRead.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books Read</p>
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items Finished</p>
</div>
</div>
@ -35,17 +35,17 @@
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
<template v-for="(book, index) in mostRecentListeningSessions">
<div :key="book.id" class="w-full py-0.5">
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ book.audiobookTitle }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(book.lastUpdate) }}</p>
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata.title }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
</div>
<div class="flex-grow" />
<div class="w-18 text-right">
<p class="text-sm font-bold">{{ $elapsedPretty(book.timeListening) }}</p>
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
</div>
</div>
</div>
@ -76,16 +76,11 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userAudiobooks() {
return Object.values(this.user.audiobooks || {})
userItemProgress() {
return this.user.libraryItemProgress || []
},
userAudiobooksRead() {
return this.userAudiobooks.filter((ab) => !!ab.isRead)
},
mostRecentBooksListened() {
if (!this.listeningStats) return []
var sorted = Object.values(this.listeningStats.books || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
return sorted.slice(0, 10)
userItemsFinished() {
return this.userItemProgress.filter((lip) => !!lip.isFinished)
},
mostRecentListeningSessions() {
if (!this.listeningStats) return []

View file

@ -14,7 +14,10 @@
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
<p class="py-2 text-xs"><strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span><span class="material-icons pl-2 text-base">content_copy</span></p>
<p class="py-2 text-xs">
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span>
</p>
</div>
<div v-if="showExperimentalFeatures" class="w-full h-px bg-white bg-opacity-10 my-2" />
<div v-if="showExperimentalFeatures" class="py-2">
@ -35,32 +38,32 @@
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Reading Progress</h1>
<table v-if="userAudiobooks.length" class="userAudiobooksTable">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Item Progress</h1>
<table v-if="libraryItemProgress.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Book</th>
<th class="w-16 text-left">Item</th>
<th class="text-left"></th>
<th class="w-32">Progress</th>
<th class="w-40 hidden sm:table-cell">Started At</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="ab in userAudiobooks" :key="ab.audiobookId" :class="!ab.isRead ? '' : 'isRead'">
<tr v-for="item in libraryItemProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
<td>
<covers-book-cover :width="50" :library-item="ab" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</td>
<td class="font-book">
<p>{{ ab.media && ab.media.metadata ? ab.media.metadata.title : ab.audiobookTitle || 'Unknown' }}</p>
<p v-if="ab.media && ab.media.metadata && ab.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ ab.media.metadata.authorName }}</p>
<p>{{ item.media && item.media.metadata ? item.media.metadata.title : 'Unknown' }}</p>
<p v-if="item.media && item.media.metadata && item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
</td>
<td class="text-center">{{ Math.floor(ab.progress * 100) }}%</td>
<td class="text-center">{{ Math.floor(item.progress * 100) }}%</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="ab.startedAt" direction="top" :text="$formatDate(ab.startedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.startedAt) }}</p>
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDate(item.startedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p>
</ui-tooltip>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="ab.lastUpdate" direction="top" :text="$formatDate(ab.lastUpdate, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.lastUpdate) }}</p>
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDate(item.lastUpdate, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
</ui-tooltip>
</td>
</tr>
@ -108,15 +111,8 @@ export default {
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userAudiobooks() {
return Object.values(this.user.audiobooks || {})
.map((uab) => {
return {
id: uab.audiobookId,
...uab
}
})
.sort((a, b) => b.lastUpdate - a.lastUpdate)
libraryItemProgress() {
return this.user.libraryItemProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
},
totalListeningTime() {
return this.listeningStats.totalTime || 0
@ -169,7 +165,7 @@ export default {
.userAudiobooksTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userAudiobooksTable tr.isRead {
.userAudiobooksTable tr.isFinished {
background-color: rgba(76, 175, 80, 0.1);
}
.userAudiobooksTable td {

View file

@ -1,29 +1,31 @@
export default class AudioTrack {
constructor(track) {
constructor(track, userToken) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
this.title = track.metadata ? track.metadata.filename || '' : ''
this.title = track.title || ''
this.contentUrl = track.contentUrl || null
this.mimeType = track.mimeType
this.userToken = userToken
}
get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}`
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return `${window.location.origin}${this.contentUrl}`
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
}
get relativeContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}`
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return this.contentUrl
return this.contentUrl + '?token=${this.userToken}'
}
}

View file

@ -12,17 +12,19 @@ export default class CastPlayer extends EventEmitter {
this.audiobook = null
this.audioTracks = []
this.currentTrackIndex = 0
this.hlsStreamId = null
this.isHlsTranscode = null
this.currentTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.playableMimetypes = {}
// TODO: Use canDisplayType on receiver to check mime types
this.playableMimeTypes = {}
this.coverUrl = ''
this.castPlayerState = 'IDLE'
// Supported audio codecs for chromecast
this.supportedAudioCodecs = ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']
this.initialize()
@ -68,10 +70,10 @@ export default class CastPlayer extends EventEmitter {
}
}
async set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) {
async set(audiobook, tracks, isHlsTranscode, startTime, playWhenReady = false) {
this.audiobook = audiobook
this.audioTracks = tracks
this.hlsStreamId = hlsStreamId
this.isHlsTranscode = isHlsTranscode
this.playWhenReady = playWhenReady
this.currentTime = startTime

View file

@ -11,7 +11,7 @@ export default class LocalPlayer extends EventEmitter {
this.libraryItem = null
this.audioTracks = []
this.currentTrackIndex = 0
this.hlsStreamId = null
this.isHlsTranscode = null
this.hlsInstance = null
this.usingNativeplayer = false
this.startTime = 0
@ -19,7 +19,7 @@ export default class LocalPlayer extends EventEmitter {
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.playableMimetypes = {}
this.playableMimeTypes = {}
this.initialize()
}
@ -48,9 +48,9 @@ export default class LocalPlayer extends EventEmitter {
var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac']
mimeTypes.forEach((mt) => {
this.playableMimetypes[mt] = this.player.canPlayType(mt)
this.playableMimeTypes[mt] = this.player.canPlayType(mt)
})
console.log(`[LocalPlayer] Supported mime types`, this.playableMimetypes)
console.log(`[LocalPlayer] Supported mime types`, this.playableMimeTypes)
}
evtPlay() {
@ -80,7 +80,7 @@ export default class LocalPlayer extends EventEmitter {
this.emit('error', error)
}
evtLoadedMetadata(data) {
if (!this.hlsStreamId) {
if (!this.isHlsTranscode) {
this.player.currentTime = this.trackStartTime
}
@ -97,23 +97,16 @@ export default class LocalPlayer extends EventEmitter {
}
destroy() {
if (this.hlsStreamId) {
// Close HLS Stream
console.log('Closing HLS Streams', this.hlsStreamId)
this.ctx.$axios.$post(`/api/streams/${this.hlsStreamId}/close`).catch((error) => {
console.error('Failed to request close hls stream', this.hlsStreamId, error)
})
}
this.destroyHlsInstance()
if (this.player) {
this.player.remove()
}
}
set(libraryItem, tracks, hlsStreamId, startTime, playWhenReady = false) {
set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) {
this.libraryItem = libraryItem
this.audioTracks = tracks
this.hlsStreamId = hlsStreamId
this.isHlsTranscode = isHlsTranscode
this.playWhenReady = playWhenReady
this.startTime = startTime
@ -121,7 +114,7 @@ export default class LocalPlayer extends EventEmitter {
this.destroyHlsInstance()
}
if (this.hlsStreamId) {
if (this.isHlsTranscode) {
this.setHlsStream()
} else {
this.setDirectPlay()
@ -198,7 +191,7 @@ export default class LocalPlayer extends EventEmitter {
async resetStream(startTime) {
this.destroyHlsInstance()
await new Promise((resolve) => setTimeout(resolve, 1000))
this.set(this.libraryItem, this.audioTracks, this.hlsStreamId, startTime, true)
this.set(this.libraryItem, this.audioTracks, this.isHlsTranscode, startTime, true)
}
playPause() {
@ -234,7 +227,7 @@ export default class LocalPlayer extends EventEmitter {
seek(time) {
if (!this.player) return
if (this.hlsStreamId) {
if (this.isHlsTranscode) {
// Seeking HLS stream
var offsetTime = time - (this.currentTrack.startOffset || 0)
this.player.currentTime = Math.max(0, offsetTime)

View file

@ -9,7 +9,8 @@ export default class PlayerHandler {
this.playWhenReady = false
this.player = null
this.playerState = 'IDLE'
this.currentStreamId = null
this.isHlsTranscode = false
this.currentSessionId = null
this.startTime = 0
this.lastSyncTime = 0
@ -35,11 +36,10 @@ export default class PlayerHandler {
return this.playerState === 'PLAYING'
}
load(libraryItem, playWhenReady, startTime = 0) {
load(libraryItem, playWhenReady) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem
this.startTime = startTime
this.playWhenReady = playWhenReady
this.prepare()
}
@ -125,118 +125,61 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime)
}
async prepare(forceHls = false) {
var useHls = false
var runningTotal = 0
var audioTracks = (this.libraryItem.media.tracks || []).map((track) => {
if (!track.metadata) {
console.error('INVALID TRACK', track)
return null
}
var audioTrack = new AudioTrack(track)
audioTrack.startOffset = runningTotal
audioTrack.contentUrl = `/s/item/${this.libraryItem.id}/${this.ctx.$encodeUriPath(track.metadata.relPath.replace(/^\//, ''))}?token=${this.userToken}`
audioTrack.mimeType = this.getMimeTypeForTrack(track)
audioTrack.canDirectPlay = !!this.player.playableMimetypes[audioTrack.mimeType]
runningTotal += audioTrack.duration
return audioTrack
async prepare(forceTranscode = false) {
var payload = {
supportedMimeTypes: Object.keys(this.player.playableMimeTypes),
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
}
var session = await this.ctx.$axios.$post(`/api/items/${this.libraryItem.id}/play`, payload).catch((error) => {
console.error('Failed to start stream', error)
})
// All html5 audio player plays use HLS unless experimental features is on
if (!this.isCasting) {
if (forceHls || !this.ctx.showExperimentalFeatures) {
useHls = true
} else {
// Use HLS if any audio track cannot be direct played
useHls = !!audioTracks.find(at => !at.canDirectPlay)
if (useHls) {
console.warn(`[PlayerHandler] An audio track cannot be direct played`, audioTracks.find(at => !at.canDirectPlay))
}
}
}
if (useHls) {
var stream = await this.ctx.$axios.$get(`/api/items/${this.libraryItem.id}/stream`).catch((error) => {
console.error('Failed to start stream', error)
})
if (stream) {
console.log(`[PlayerHandler] prepare hls stream`, stream)
this.setHlsStream(stream)
} else {
console.error(`[PlayerHandler] Failed to start HLS stream`)
}
} else {
this.setDirectPlay(audioTracks)
}
this.prepareSession(session)
}
getMimeTypeForTrack(track) {
var ext = track.metadata.ext
if (ext === '.mp3' || ext === '.m4b' || ext === '.m4a') {
return 'audio/mpeg'
} else if (ext === '.mp4') {
return 'audio/mp4'
} else if (ext === '.ogg') {
return 'audio/ogg'
} else if (ext === '.aac' || ext === '.m4p') {
return 'audio/aac'
} else if (ext === '.flac') {
return 'audio/flac'
prepareOpenSession(session) { // Session opened on init socket
if (!this.player) this.switchPlayer()
this.libraryItem = session.libraryItem
this.playWhenReady = false
this.prepareSession(session)
}
prepareSession(session) {
this.startTime = session.currentTime
this.currentSessionId = session.id
console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
return 'audio/mpeg'
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
}
closePlayer() {
console.log('[PlayerHandler] Close Player')
this.sendCloseSession()
if (this.player) {
this.player.destroy()
}
this.player = null
this.playerState = 'IDLE'
this.libraryItem = null
this.currentStreamId = null
this.startTime = 0
this.stopPlayInterval()
}
prepareStream(stream) {
if (!this.player) this.switchPlayer()
this.libraryItem = stream.libraryItem
this.setHlsStream({
streamId: stream.id,
streamUrl: stream.clientPlaylistUri,
startTime: stream.clientCurrentTime
})
}
setHlsStream(stream) {
this.currentStreamId = stream.streamId
var audioTrack = new AudioTrack({
duration: this.libraryItem.media.duration,
contentUrl: stream.streamUrl + '?token=' + this.userToken,
mimeType: 'application/vnd.apple.mpegurl'
})
this.startTime = stream.startTime
this.ctx.playerLoading = true
this.player.set(this.libraryItem, [audioTrack], this.currentStreamId, stream.startTime, this.playWhenReady)
}
setDirectPlay(audioTracks) {
this.currentStreamId = null
this.ctx.playerLoading = true
this.player.set(this.libraryItem, audioTracks, null, this.startTime, this.playWhenReady)
}
resetStream(startTime, streamId) {
if (this.currentStreamId === streamId) {
if (this.isHlsTranscode && this.currentSessionId === streamId) {
this.player.resetStream(startTime)
} else {
console.warn('resetStream mismatch streamId', this.currentStreamId, streamId)
console.warn('resetStream mismatch streamId', this.currentSessionId, streamId)
}
}
@ -254,43 +197,39 @@ export default class PlayerHandler {
this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) {
this.sendProgressSync(currentTime)
this.listeningTimeSinceSync = 0
}
}, 1000)
}
sendCloseSession() {
var syncData = null
if (this.player) {
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
syncData = {
timeListened: listeningTimeToAdd,
currentTime: this.player.getCurrentTime()
}
}
this.listeningTimeSinceSync = 0
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to close session', error)
})
}
sendProgressSync(currentTime) {
var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
if (diffSinceLastSync < 1) return
this.lastSyncTime = currentTime
if (this.currentStreamId) { // Updating stream progress (HLS stream)
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
var syncData = {
timeListened: listeningTimeToAdd,
currentTime,
streamId: this.currentStreamId,
audiobookId: this.libraryItem.id
}
this.ctx.$axios.$post('/api/syncStream', syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to update stream progress', error)
})
} else {
// Direct play via chromecast does not yet have backend stream session model
// so the progress update for the libraryItem is updated this way (instead of through the stream)
var duration = this.getDuration()
var syncData = {
totalDuration: duration,
currentTime,
progress: duration > 0 ? currentTime / duration : 0,
isRead: false,
audiobookId: this.libraryItem.id,
lastUpdate: Date.now()
}
this.ctx.$axios.$post('/api/syncLocal', syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to update local progress', error)
})
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
var syncData = {
timeListened: listeningTimeToAdd,
currentTime
}
this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to update session progress', error)
})
}
stopPlayInterval() {

View file

@ -24,11 +24,18 @@ const BookshelfView = {
TITLES: 1
}
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
TRANSCODE: 2
}
const Constants = {
SupportedFileTypes,
DownloadStatus,
BookCoverAspectRatio,
BookshelfView
BookshelfView,
PlayMethod
}
const KeyNames = {