mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-19 11:21:27 +02:00
Add: User listening sessions and user listening stats #167
This commit is contained in:
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
|
||||
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @loadeddata="audioLoadedData" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
@@ -85,6 +85,8 @@ import Hls from 'hls.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
streamId: String,
|
||||
audiobookId: String,
|
||||
loading: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
@@ -115,7 +117,9 @@ export default {
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playStartTime: 0
|
||||
listenTimeInterval: null,
|
||||
listeningTimeSinceLastUpdate: 0,
|
||||
totalListeningTimeInSession: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -130,6 +134,10 @@ export default {
|
||||
return this.totalDuration - this.currentTime
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining)
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
@@ -155,14 +163,19 @@ export default {
|
||||
methods: {
|
||||
audioPlayed() {
|
||||
if (!this.$refs.audio) return
|
||||
// console.log('Audio Played', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
this.playStartTime = Date.now()
|
||||
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
||||
// setTimeout(() => {
|
||||
// console.log('Audio Played FOLLOW UP', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
||||
// this.startListenTimeInterval()
|
||||
// }, 500)
|
||||
this.startListenTimeInterval()
|
||||
this.isPaused = this.$refs.audio.paused
|
||||
},
|
||||
audioPaused() {
|
||||
if (!this.$refs.audio) return
|
||||
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
this.isPaused = this.$refs.audio.paused
|
||||
this.cancelListenTimeInterval()
|
||||
},
|
||||
audioError(err) {
|
||||
if (!this.$refs.audio) return
|
||||
@@ -180,6 +193,77 @@ export default {
|
||||
if (!this.$refs.audio) return
|
||||
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
},
|
||||
sendStreamSync(timeListened = 0) {
|
||||
// If currentTime is null then currentTime wont be updated
|
||||
var currentTime = null
|
||||
if (this.$refs.audio) {
|
||||
currentTime = this.$refs.audio.currentTime
|
||||
} else if (!timeListened) {
|
||||
console.warn('Not sending stream sync, no data to sync')
|
||||
return
|
||||
}
|
||||
var syncData = {
|
||||
timeListened,
|
||||
currentTime,
|
||||
streamId: this.streamId,
|
||||
audiobookId: this.audiobookId
|
||||
}
|
||||
this.$emit('sync', syncData)
|
||||
},
|
||||
sendAddListeningTime() {
|
||||
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
|
||||
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
|
||||
this.sendStreamSync(listeningTimeToAdd)
|
||||
},
|
||||
cancelListenTimeInterval() {
|
||||
this.sendAddListeningTime()
|
||||
clearInterval(this.listenTimeInterval)
|
||||
this.listenTimeInterval = null
|
||||
},
|
||||
startListenTimeInterval() {
|
||||
if (!this.$refs.audio) return
|
||||
|
||||
clearInterval(this.listenTimeInterval)
|
||||
var lastTime = this.$refs.audio.currentTime
|
||||
var lastTick = Date.now()
|
||||
this.listenTimeInterval = setInterval(() => {
|
||||
if (!this.$refs.audio) {
|
||||
console.error('Canceling audio played interval no audio player')
|
||||
this.cancelListenTimeInterval()
|
||||
return
|
||||
}
|
||||
if (this.$refs.audio.paused) {
|
||||
console.warn('Canceling audio played interval audio player paused')
|
||||
this.cancelListenTimeInterval()
|
||||
return
|
||||
}
|
||||
|
||||
var timeSinceLastTick = Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
|
||||
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
|
||||
var currentTime = this.$refs.audio.currentTime
|
||||
var differenceFromExpected = expectedAudioTime - currentTime
|
||||
if (currentTime === lastTime) {
|
||||
console.error('Audio current time has not increased - cancel interval and pause player')
|
||||
this.cancelListenTimeInterval()
|
||||
this.pause()
|
||||
} else if (Math.abs(differenceFromExpected) > 0.1) {
|
||||
console.warn('Invalid time between interval - resync last', differenceFromExpected)
|
||||
lastTime = currentTime
|
||||
} else {
|
||||
var exactPlayTimeDifference = currentTime - lastTime
|
||||
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
|
||||
lastTime = currentTime
|
||||
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
|
||||
this.totalListeningTimeInSession += exactPlayTimeDifference
|
||||
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
|
||||
if (this.listeningTimeSinceLastUpdate > 5) {
|
||||
this.sendAddListeningTime()
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
@@ -201,11 +285,23 @@ export default {
|
||||
console.error('No Audio el for seek', time)
|
||||
return
|
||||
}
|
||||
if (!this.audioEl.paused) {
|
||||
this.cancelListenTimeInterval()
|
||||
}
|
||||
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
|
||||
this.audioEl.currentTime = time
|
||||
|
||||
this.sendStreamSync()
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.audioEl && !this.audioEl.paused) {
|
||||
this.startListenTimeInterval()
|
||||
}
|
||||
})
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
var perc = time / this.audioEl.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
@@ -287,7 +383,7 @@ export default {
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
this.$nextTick(this.sendStreamUpdate)
|
||||
this.$nextTick(this.sendStreamSync)
|
||||
},
|
||||
backward10() {
|
||||
var newTime = this.audioEl.currentTime - 10
|
||||
@@ -299,10 +395,6 @@ export default {
|
||||
newTime = Math.min(this.audioEl.duration, newTime)
|
||||
this.seek(newTime)
|
||||
},
|
||||
sendStreamUpdate() {
|
||||
if (!this.audioEl) return
|
||||
this.$emit('updateTime', this.audioEl.currentTime)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
@@ -432,9 +524,9 @@ export default {
|
||||
// Send update to server when currentTime > 0
|
||||
// this prevents errors when seeking to position not yet transcoded
|
||||
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
||||
if (this.audioEl.currentTime) {
|
||||
this.sendStreamUpdate()
|
||||
}
|
||||
// if (this.audioEl.currentTime) {
|
||||
// this.sendStreamUpdate()
|
||||
// }
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
@@ -446,10 +538,12 @@ export default {
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
audioLoadedData() {
|
||||
audioLoadedMetadata() {
|
||||
console.log('Audio METADATA Loaded, total duration', this.audioEl.duration)
|
||||
this.totalDuration = this.audioEl.duration
|
||||
this.$emit('loaded', this.totalDuration)
|
||||
},
|
||||
audioLoadedData() {},
|
||||
set(url, currentTime, playOnLoad = false) {
|
||||
if (this.hlsInstance) {
|
||||
this.terminateStream()
|
||||
@@ -458,6 +552,8 @@ export default {
|
||||
console.error('No audio widget')
|
||||
return
|
||||
}
|
||||
this.listeningTimeSinceLastUpdate = 0
|
||||
|
||||
this.url = url
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
url = `${process.env.serverUrl}${url}`
|
||||
@@ -471,6 +567,7 @@ export default {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
}
|
||||
}
|
||||
console.log('Starting HLS audio stream at time', currentTime)
|
||||
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
var audio = this.$refs.audio
|
||||
|
@@ -25,7 +25,7 @@
|
||||
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
|
||||
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" />
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||
</div>
|
||||
@@ -109,6 +109,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addListeningTime(time) {
|
||||
console.log('Send listening time to server', time)
|
||||
},
|
||||
showBookmarks(currentTime) {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = currentTime
|
||||
@@ -191,17 +194,25 @@ export default {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
updateTime(currentTime) {
|
||||
var diff = currentTime - this.lastServerUpdateSentSeconds
|
||||
if (diff > 4 || diff < 0) {
|
||||
this.lastServerUpdateSentSeconds = currentTime
|
||||
var updatePayload = {
|
||||
currentTime,
|
||||
streamId: this.streamId
|
||||
}
|
||||
this.$root.socket.emit('stream_update', updatePayload)
|
||||
sendStreamSync(syncData) {
|
||||
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
|
||||
if (Math.abs(diff) < 1 && !syncData.timeListened) {
|
||||
// No need to sync
|
||||
return
|
||||
}
|
||||
this.$root.socket.emit('stream_sync', syncData)
|
||||
},
|
||||
// updateTime(currentTime) {
|
||||
// var diff = currentTime - this.lastServerUpdateSentSeconds
|
||||
// if (diff > 4 || diff < 0) {
|
||||
// this.lastServerUpdateSentSeconds = currentTime
|
||||
// var updatePayload = {
|
||||
// currentTime,
|
||||
// streamId: this.streamId
|
||||
// }
|
||||
// this.$root.socket.emit('stream_update', updatePayload)
|
||||
// }
|
||||
// },
|
||||
streamReset({ startTime, streamId }) {
|
||||
if (streamId !== this.streamId) {
|
||||
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
||||
|
@@ -6,26 +6,42 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * 1.6" />
|
||||
<!-- <ui-btn type="button" @click="showUploadImageModal = true">Upload</ui-btn> -->
|
||||
</div>
|
||||
<div class="flex-grow px-4">
|
||||
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
|
||||
<template v-if="!showImageUploader">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * 1.6" />
|
||||
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||
</div>
|
||||
<div class="flex-grow px-4">
|
||||
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
|
||||
|
||||
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
|
||||
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="hover:bg-white hover:bg-opacity-10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full" @click="showImageUploader = false">
|
||||
<span class="material-icons text-4xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="ml-2 text-xl mb-1">Collection Cover Image</p>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||
<div class="flex mb-4">
|
||||
<ui-btn small class="mr-2">Upload</ui-btn>
|
||||
<ui-text-input v-model="newCoverImage" class="flex-grow" placeholder="Collection Cover Image" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" />
|
||||
<div class="flex justify-end">
|
||||
<ui-btn color="success">Upload</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -37,7 +53,7 @@ export default {
|
||||
processing: false,
|
||||
newCollectionName: null,
|
||||
newCollectionDescription: null,
|
||||
showUploadImageModal: false
|
||||
showImageUploader: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 items-center justify-center z-40 opacity-0 hidden">
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 items-center justify-center opacity-0 hidden" :class="`z-${zIndex}`">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
@@ -36,6 +36,10 @@ export default {
|
||||
contentMarginTop: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 40
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
Reference in New Issue
Block a user