Add: New audio player w/ fullscreen and condensed views

This commit is contained in:
advplyr 2021-11-01 21:06:51 -05:00
parent 082c303caf
commit 08195af0dd
9 changed files with 1248 additions and 7 deletions

View file

@ -0,0 +1,600 @@
<template>
<div class="fixed top-0 bottom-0 left-0 right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div>
<div class="top-2 right-4 absolute cursor-pointer">
<span class="material-icons text-3xl" @click="$emit('close')">close</span>
</div>
</div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<cards-player-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="showFullscreen ? 200 : 60" />
</div>
</div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
<p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorFL }}</p>
</div>
<div id="streamContainer" class="w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
<div class="flex items-center justify-between pointer-events-auto">
<span class="material-icons text-3xl text-white text-opacity-10 cursor-pointer">bookmark_border</span>
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ playbackRate }}x</span>
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
<p v-if="sleepTimerEndOfChapterTime" class="text-xl font-mono text-warning">-{{ $secondsToTimestamp(Math.floor(sleepTimerEndOfChapterTime / 1000)) }}</p>
<p v-else class="text-xl font-mono text-success">{{ Math.ceil(sleepTimeoutCurrentTime / 1000 / 60) }}m</p>
</div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="$emit('selectChapter')">format_list_bulleted</span>
</div>
</div>
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white text-opacity-75 cursor-pointer" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
</div>
<span class="material-icons jump-icon text-white text-opacity-75 cursor-pointer" @click.stop="forward10">forward_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
</div>
<div class="flex pt-0.5">
<p class="font-mono text-sm" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
<div class="flex-grow" />
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
download: {
type: Object,
default: () => {}
},
loading: Boolean,
sleepTimerRunning: Boolean,
sleepTimeoutCurrentTime: Number,
sleepTimerEndOfChapterTime: Number
},
data() {
return {
showFullscreen: false,
totalDuration: 0,
currentPlaybackRate: 1,
currentTime: 0,
isResetting: false,
initObject: null,
stateName: 'idle',
playInterval: null,
trackWidth: 0,
isPaused: true,
src: null,
volume: 0.5,
readyTrackWidth: 0,
bufferTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlayingUpdateListener: null,
onMetadataListener: null,
noSyncUpdateTime: false,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0
}
},
computed: {
book() {
return this.audiobook.book || {}
},
title() {
return this.book.title
},
authorFL() {
return this.book.authorFL
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => ch.start <= this.currentTime && ch.end > this.currentTime)
},
currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : ''
},
downloadedCover() {
return this.download ? this.download.cover : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
playbackRate() {
return this.$store.getters['user/getUserSetting']('playbackRate')
},
nextChapter() {
if (!this.chapters.length) return
return this.chapters.find((c) => c.start >= this.currentTime)
}
},
methods: {
clickContainer() {
this.showFullscreen = true
},
collapseFullscreen() {
this.showFullscreen = false
},
jumpNextChapter() {
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
jumpChapterStart() {
if (!this.currentChapter) {
return this.restart()
}
this.seek(this.currentChapter.start)
},
showSleepTimerModal() {
this.$emit('showSleepTimer')
},
updatePlaybackRate() {
this.currentPlaybackRate = this.playbackRate
MyNativeAudio.setPlaybackSpeed({ speed: this.playbackRate })
},
restart() {
this.seek(0)
},
backward10() {
MyNativeAudio.seekBackward({ amount: '10000' })
},
forward10() {
MyNativeAudio.seekForward({ amount: '10000' })
},
sendStreamUpdate() {
this.$emit('updateTime', this.currentTime)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i]
if (typeof chunk === 'string') {
var chunkRange = chunk.split('-').map((c) => Number(c))
if (chunkRange.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) {
largestSeg = chunk
}
}
var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) {
return
}
this.readyTrackWidth = widthReady
this.$refs.readyTrack.style.width = widthReady + 'px'
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (this.seekLoading) {
this.seekLoading = false
if (this.$refs.playedTrack) {
this.$refs.playedTrack.classList.remove('bg-yellow-300')
this.$refs.playedTrack.classList.add('bg-gray-200')
}
}
this.updateTimestamp()
if (this.noSyncUpdateTime) this.noSyncUpdateTime = false
else this.sendStreamUpdate()
var perc = this.currentTime / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
seek(time) {
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
this.seekedTime = time
this.seekLoading = true
MyNativeAudio.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
if (this.$refs.playedTrack) {
var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
updateVolume(volume) {},
clickTrack(e) {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.totalDuration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
console.log('playPause PLAY')
this.play()
} else {
console.log('playPause PAUSE')
this.pause()
}
},
calcSeekBackTime(lastUpdate) {
var time = Date.now() - lastUpdate
var seekback = 0
if (time < 3000) seekback = 0
else if (time < 60000) seekback = time / 6
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback
},
async set(audiobookStreamData, stream, fromAppDestroy) {
this.isResetting = false
this.initObject = { ...audiobookStreamData }
var init = true
if (!!stream) {
//console.log(JSON.stringify(stream))
var data = await MyNativeAudio.getStreamSyncData()
console.log('getStreamSyncData', JSON.stringify(data))
console.log('lastUpdate', stream.lastUpdate || 0)
//Same audiobook
if (data.id == stream.id && (data.isPlaying || data.lastPauseTime >= (stream.lastUpdate || 0))) {
console.log('Same audiobook')
this.isPaused = !data.isPlaying
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.timeupdate()
if (data.isPlaying) {
console.log('playing - continue')
if (fromAppDestroy) this.startPlayInterval()
} else console.log('paused and newer')
if (!fromAppDestroy) return
init = false
this.initObject.startTime = String(Math.floor(this.currentTime * 1000))
}
//new audiobook stream or sync from other client
else if (stream.clientCurrentTime > 0) {
console.log('new audiobook stream or sync from other client')
if (!!stream.lastUpdate) {
var backTime = this.calcSeekBackTime(stream.lastUpdate)
var currentTime = Math.floor(stream.clientCurrentTime * 1000)
if (backTime >= currentTime) backTime = currentTime - 500
console.log('SeekBackTime', backTime)
this.initObject.startTime = String(Math.floor(currentTime - backTime))
}
}
}
this.currentPlaybackRate = this.initObject.playbackSpeed
if (init)
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
setFromObj() {
if (!this.initObject) {
console.error('Cannot set from obj')
return
}
this.isResetting = false
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
play() {
MyNativeAudio.playPlayer()
this.startPlayInterval()
},
pause() {
MyNativeAudio.pausePlayer()
this.stopPlayInterval()
},
startPlayInterval() {
clearInterval(this.playInterval)
this.playInterval = setInterval(async () => {
var data = await MyNativeAudio.getCurrentTime()
this.currentTime = Number((data.value / 1000).toFixed(2))
this.timeupdate()
}, 1000)
},
stopPlayInterval() {
clearInterval(this.playInterval)
},
resetStream(startTime) {
var _time = String(Math.floor(startTime * 1000))
if (!this.initObject) {
console.error('Terminate stream when no init object is set...')
return
}
this.isResetting = true
this.initObject.currentTime = _time
this.terminateStream()
},
terminateStream() {
MyNativeAudio.terminateStream()
},
onPlayingUpdate(data) {
this.isPaused = !data.value
if (!this.isPaused) {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
},
onMetadata(data) {
console.log('Native Audio On Metadata', JSON.stringify(data))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.stateName = data.stateName
if (this.stateName === 'ended' && this.isResetting) {
this.setFromObj()
}
if (this.stateName === 'ready_no_sync' || this.stateName === 'buffering_no_sync') this.noSyncUpdateTime = true
this.timeupdate()
},
init() {
this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
handleGesture() {
var touchDistance = this.touchEndY - this.touchStartY
if (touchDistance > 100) {
console.log('Collapsing')
this.collapseFullscreen()
} else {
console.log('Not collapsing touch distance =', touchDistance)
}
},
touchstart(e) {
if (!this.showFullscreen || !e.changedTouches) return
this.touchStartY = e.changedTouches[0].screenY
if (this.touchStartY > window.innerHeight / 3) {
// console.log('touch too low')
return
}
this.touchStartTime = Date.now()
},
touchend(e) {
if (!this.showFullscreen || !e.changedTouches) return
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
}
},
mounted() {
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
this.$nextTick(this.init)
},
beforeDestroy() {
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
clearInterval(this.playInterval)
}
}
</script>
<style>
.bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066;
}
#streamContainer {
box-shadow: 0px -8px 8px #11111155;
height: 100px;
}
.fullscreen #streamContainer {
height: 200px;
}
#playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin;
margin-bottom: 4px;
}
.fullscreen #playerTrack {
margin-bottom: 18px;
}
.cover-wrapper {
bottom: 44px;
left: 12px;
height: 96px;
width: 60px;
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
}
.title-author-texts {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
width: 40%;
bottom: 50px;
left: 80px;
text-align: left;
}
.title-author-texts .title-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 0.85rem;
line-height: 1.5;
}
.title-author-texts .author-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 0.75rem;
line-height: 1.2;
}
.fullscreen .title-author-texts {
bottom: 36%;
width: 80%;
left: 10%;
text-align: center;
}
.fullscreen .title-author-texts .title-text {
font-size: 1.2rem;
}
.fullscreen .title-author-texts .author-text {
font-size: 1rem;
}
#playerControls {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: width, bottom;
height: 48px;
width: 140px;
padding-left: 12px;
padding-right: 12px;
bottom: 45px;
}
#playerControls .jump-icon {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
margin: 0px 0px;
font-size: 1.6rem;
}
#playerControls .play-btn {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: padding, margin;
margin: 0px 14px;
padding: 8px;
}
#playerControls .play-btn .material-icons {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 1.5rem;
}
.fullscreen .cover-wrapper {
bottom: 46%;
left: calc(50% - 100px);
margin: 0 auto;
height: 320px;
width: 200px;
}
.fullscreen #playerControls {
width: 100%;
bottom: 100px;
}
.fullscreen #playerControls .jump-icon {
margin: 0px 18px;
font-size: 2.4rem;
}
.fullscreen #playerControls .next-icon {
margin: 0px 20px;
font-size: 2rem;
}
.fullscreen #playerControls .play-btn {
padding: 16px;
margin: 0px 26px;
}
.fullscreen #playerControls .play-btn .material-icons {
font-size: 2.1rem;
}
</style>

View file

@ -0,0 +1,464 @@
<template>
<div>
<div v-if="audiobook" id="streamContainer">
<app-audio-player
ref="audioPlayer"
:audiobook="audiobook"
:download="download"
:loading="isLoading"
:sleep-timer-running="isSleepTimerRunning"
:sleep-timer-end-of-chapter-time="sleepTimerEndOfChapterTime"
:sleep-timeout-current-time="sleepTimeoutCurrentTime"
@close="cancelStream"
@updateTime="updateTime"
@selectPlaybackSpeed="showPlaybackSpeedModal = true"
@selectChapter="clickChapterBtn"
@showSleepTimer="showSleepTimer"
@hook:mounted="audioPlayerMounted"
/>
</div>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeoutCurrentTime" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :end-of-chapter-time-set="sleepTimerEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
data() {
return {
audioPlayerReady: false,
stream: null,
download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false,
showSleepTimerModal: false,
playbackSpeed: 1,
showChapterModal: false,
currentTime: 0,
sleepTimeoutCurrentTime: 0,
isSleepTimerRunning: false,
sleepTimerEndOfChapterTime: 0,
onSleepTimerEndedListener: null,
sleepInterval: null,
currentEndOfChapterTime: 0
}
},
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => ch.start <= this.currentTime && ch.end > this.currentTime)
},
socketConnected() {
return this.$store.state.socketConnected
},
isLoading() {
if (this.playingDownload) return false
if (!this.streamAudiobook) return false
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
},
author() {
return this.book ? this.book.author : ''
},
cover() {
return this.book ? this.book.cover : ''
},
downloadedCover() {
return this.download ? this.download.cover : null
},
series() {
return this.book ? this.book.series : ''
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
},
seriesTxt() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.audiobook ? this.audiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
return `${this.$store.state.serverUrl}/Logo.png`
}
if (this.cover.startsWith('http')) return this.cover
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
return coverSrc
}
},
methods: {
onSleepTimerEnded({ value: currentPosition }) {
this.isSleepTimerRunning = false
if (this.sleepInterval) clearInterval(this.sleepInterval)
if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
var currentTime = Math.floor(currentPosition / 1000)
this.updateTime(currentTime)
}
},
showSleepTimer() {
console.log('show sleep timer')
if (this.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.currentChapter.end)
} else {
this.currentEndOfChapterTime = 0
}
this.showSleepTimerModal = true
},
async getSleepTimerTime() {
var res = await MyNativeAudio.getSleepTimerTime()
if (res && res.value) {
var time = Number(res.value)
return time - Date.now()
}
return 0
},
async selectSleepTimeout({ time, isChapterTime }) {
console.log('Setting sleep timer', time, isChapterTime)
var res = await MyNativeAudio.setSleepTimer({ time: String(time), isChapterTime })
if (!res.success) {
return this.$toast.error('Sleep timer did not set, invalid time')
}
if (isChapterTime) {
this.sleepTimerEndOfChapterTime = time
this.isSleepTimerRunning = true
} else {
this.sleepTimerEndOfChapterTime = 0
this.setSleepTimeoutTimer(time)
}
},
async cancelSleepTimer() {
console.log('Canceling sleep timer')
await MyNativeAudio.cancelSleepTimer()
this.isSleepTimerRunning = false
this.sleepTimerEndOfChapterTime = 0
if (this.sleepInterval) clearInterval(this.sleepInterval)
},
async syncSleepTimer() {
var time = await this.getSleepTimerTime()
this.setSleepTimeoutTimer(time)
},
setSleepTimeoutTimer(startTime) {
if (this.sleepInterval) clearInterval(this.sleepInterval)
this.sleepTimeoutCurrentTime = startTime
this.isSleepTimerRunning = true
var elapsed = 0
this.sleepInterval = setInterval(() => {
this.sleepTimeoutCurrentTime = Math.max(0, this.sleepTimeoutCurrentTime - 1000)
if (this.sleepTimeoutCurrentTime <= 0) {
clearInterval(this.sleepInterval)
return
}
// Sync with the actual time from android Timer
elapsed++
if (elapsed > 5) {
clearInterval(this.sleepInterval)
this.syncSleepTimer()
}
}, 1000)
},
clickChapterBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
selectChapter(chapter) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(chapter.start)
}
this.showChapterModal = false
},
async cancelStream() {
this.currentTime = 0
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
this.$server.stream = null
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
}
}
},
updateTime(currentTime) {
this.currentTime = currentTime
var diff = currentTime - this.lastProgressTimeUpdate
if (diff > 4 || diff < 0) {
this.lastProgressTimeUpdate = currentTime
if (this.stream) {
var updatePayload = {
currentTime,
streamId: this.stream.id
}
this.$server.socket.emit('stream_update', updatePayload)
} else if (this.download) {
var progressUpdate = {
audiobookId: this.download.id,
currentTime: currentTime,
totalDuration: this.download.audiobook.duration,
progress: Number((currentTime / this.download.audiobook.duration).toFixed(3)),
lastUpdate: Date.now(),
isRead: false
}
if (this.$server.connected) {
this.$server.socket.emit('progress_update', progressUpdate)
}
this.$localStore.updateUserAudiobookProgress(progressUpdate).then(() => {
console.log('Updated user audiobook progress', currentTime)
})
}
}
},
closeStream() {},
streamClosed(audiobookId) {
console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
}
},
streamReady() {
console.log('[StreamContainer] Stream Ready')
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
}
},
streamReset({ streamId, startTime }) {
if (this.$refs.audioPlayer) {
if (this.stream && this.stream.id === streamId) {
this.$refs.audioPlayer.resetStream(startTime)
}
}
},
async getDownloadStartTime() {
var userAudiobook = await this.$localStore.getMostRecentUserAudiobook(this.audiobookId)
if (!userAudiobook) {
console.log('[StreamContainer] getDownloadStartTime no user audiobook record found')
return 0
}
return userAudiobook.currentTime
},
async playDownload() {
if (this.stream) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.stream = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Playing local', this.playingDownload)
if (!this.$refs.audioPlayer) {
console.error('No Audio Player Mini')
return
}
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var currentTime = await this.getDownloadStartTime()
if (isNaN(currentTime) || currentTime === null) currentTime = 0
this.currentTime = currentTime
// Update local current time
this.$localStore.setCurrent({
audiobookId: this.download.id,
lastUpdate: Date.now()
})
var audiobookStreamData = {
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.download.coverUrl || null,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
token: this.userToken,
contentUrl: this.playingDownload.contentUrl,
isLocal: true
}
this.$refs.audioPlayer.set(audiobookStreamData, null, false)
},
streamOpen(stream) {
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayer) {
console.error('No Audio Player Mini')
return
}
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
this.currentTime = currentTime
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var audiobookStreamData = {
id: stream.id,
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.coverForNative,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
playlistUrl: this.$server.url + playlistUrl,
token: this.$store.getters['user/getToken']
}
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
this.stream = stream
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
changePlaybackSpeed(speed) {
this.$store.dispatch('user/updateUserSettings', { playbackRate: speed })
},
settingsUpdated(settings) {
if (this.$refs.audioPlayer && this.$refs.audioPlayer.currentPlaybackRate !== settings.playbackRate) {
this.playbackSpeed = settings.playbackRate
this.$refs.audioPlayer.updatePlaybackRate()
}
},
streamUpdated(type, data) {
if (type === 'download') {
if (data) {
this.download = { ...data }
if (this.audioPlayerReady) {
this.playDownload()
}
} else if (this.download) {
this.cancelStream()
}
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
}
},
mounted() {
this.onSleepTimerEndedListener = MyNativeAudio.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
this.setListeners()
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
},
beforeDestroy() {
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.$server.socket) {
this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_progress', this.streamProgress)
this.$server.socket.off('stream_ready', this.streamReady)
this.$server.socket.off('stream_reset', this.streamReset)
}
this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$store.commit('removeStreamListener')
}
}
</script>

View file

@ -0,0 +1,167 @@
<template>
<div class="relative rounded-sm overflow-hidden w-full h-full">
<div class="w-full h-full relative">
<div class="bg-primary absolute top-0 left-0 w-full h-full">
<!-- Blurred background for covers that dont fill -->
<div v-if="showCoverBg" class="w-full h-full z-0" ref="coverBg" />
<!-- Image Loading indicator -->
<div v-if="!isImageLoaded" class="w-full h-full flex items-center justify-center text-white">
<svg class="animate-spin w-12 h-12" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
downloadCover: String,
authorOverride: String,
width: {
type: Number,
default: 120
}
},
data() {
return {
imageFailed: false,
showCoverBg: false,
isImageLoaded: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
book() {
return this.audiobook.book || {}
},
title() {
return this.book.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
serverUrl() {
return this.$store.state.serverUrl
},
networkConnected() {
return this.$store.state.networkConnected
},
fullCoverUrl() {
if (this.downloadCover) return this.downloadCover
else if (!this.networkConnected) return this.placeholderUrl
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
// if (this.cover.startsWith('http')) return this.cover
// var _clean = this.cover.replace(/\\/g, '/')
// if (_clean.startsWith('/local')) {
// var _cover = process.env.NODE_ENV !== 'production' && process.env.PROD !== '1' ? _clean.replace('/local', '') : _clean
// return `${this.$store.state.serverUrl}${_cover}?token=${this.userToken}&ts=${Date.now()}`
// } else if (_clean.startsWith('/metadata')) {
// return `${this.$store.state.serverUrl}${_clean}?token=${this.userToken}&ts=${Date.now()}`
// }
// return _clean
},
cover() {
return this.book.cover || this.placeholderUrl
},
hasCover() {
if (!this.networkConnected && !this.downloadCover) return false
return !!this.book.cover
},
sizeMultiplier() {
return this.width / 120
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
this.$refs.coverBg.style.backgroundSize = 'cover'
this.$refs.coverBg.style.backgroundPosition = 'center'
this.$refs.coverBg.style.opacity = 0.25
this.$refs.coverBg.style.filter = 'blur(1px)'
}
},
hideCoverBg() {},
imageLoaded() {
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
this.isImageLoaded = true
},
imageError(err) {
this.imageFailed = true
console.error('ImgError', err, `SET IMAGE FAILED ${this.imageFailed}`)
}
}
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
<div class="absolute top-0 left-0 w-full h-36 bg-gradient-to-b from-black to-transparent opacity-70 pointer-events-none" />
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">

View file

@ -38,7 +38,7 @@ export default {
value: Boolean,
currentTime: Number,
sleepTimerRunning: Boolean,
currentEndOfChapterTime: Boolean,
currentEndOfChapterTime: Number,
endOfChapterTimeSet: Number
},
data() {