Adding audio playback speed control, updating volume control UI, fix stream play for small streams

This commit is contained in:
Mark Cooper 2021-08-20 18:29:10 -05:00
parent 4bcb346365
commit be7e2576f1
13 changed files with 192 additions and 211 deletions

View File

@ -28,6 +28,10 @@
background: #704922;
}
.no-scroll::-webkit-scrollbar {
display: none;
opacity: 0;
}
.tracksTable {
border-collapse: collapse;
@ -50,3 +54,11 @@
padding: 4px;
font-size: 0.75rem;
}
.arrow-down {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid white;
}

View File

@ -45,3 +45,17 @@
.menu-leave-active {
opacity: 0;
}
.menux-enter, .menux-leave-active {
transform: translateX(15px);
}
.menux-enter-active {
transition: all 0.2s;
}
.menux-leave-active {
transition: all 0.1s;
}
.menux-enter,
.menux-leave-active {
opacity: 0;
}

View File

@ -27,9 +27,7 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
<span class="material-icons text-3xl">forward_10</span>
</div>
<div class="flex items-center justify-center text-gray-300 ml-8" @mousedown.prevent @mouseup.prevent>
<span class="font-mono text-lg uppercase text-gray-500">1x</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
@ -75,6 +73,7 @@ export default {
hlsInstance: null,
staleHlsInstance: null,
volume: 0.5,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
url: null,
@ -126,7 +125,15 @@ export default {
},
updateVolume(volume) {
if (this.audioEl) {
this.audioEl.volume = 1 - volume
this.audioEl.volume = volume
}
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
console.log('UpdatePlaybackRate', playbackRate)
this.audioEl.playbackRate = playbackRate
} else {
console.error('No Audio El updatePlaybackRate')
}
},
mousemoveTrack(e) {
@ -173,7 +180,6 @@ export default {
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
console.warn('SET STREAM READY', this.readyTrackWidth)
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
@ -349,6 +355,7 @@ export default {
this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio
audio.volume = this.volume
audio.playbackRate = this.playbackRate
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
@ -367,13 +374,6 @@ export default {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.FRAG_LOADED, (e, data) => {
var frag = data.frag
// console.log('[HLS] Frag Loaded', frag.sn, this.$secondsToTimestamp(frag.start), frag)
})
this.hlsInstance.on(Hls.Events.BUFFER_APPENDED, (e, data) => {
// console.log('[HLS] BUFFER', data)
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance')
})
@ -425,14 +425,7 @@ export default {
}
</script>
<style scoped>
.arrow-down {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid white;
}
<style>
.loadingTrack {
animation-name: loadingTrack;
animation-duration: 1s;

View File

@ -92,8 +92,11 @@ export default {
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
}
},
streamOpen(stream) {
@ -101,6 +104,8 @@ export default {
if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream)
this.openStream()
} else {
console.error('No Audio Ref')
}
},
streamClosed(streamId) {
@ -111,8 +116,11 @@ export default {
}
},
streamReady() {
console.log(`[STREAM-CONTAINER] Stream Ready`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
} else {
console.error('No Audio Ref')
}
},
updateTime(currentTime) {

View File

@ -0,0 +1,62 @@
<template>
<div class="relative ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="showMenu = !showMenu">
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg"></span></span>
</div>
<div v-show="showMenu" class="absolute -top-10 left-0 z-20 h-9 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -114px">
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
<div class="w-full h-full no-scroll flex">
<template v-for="(rate, index) in rates">
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm"></span></p>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: [String, Number],
default: 1
}
},
data() {
return {
showMenu: false
}
},
computed: {
playbackRate: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
rates() {
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
}
},
methods: {
clickOutside() {
this.showMenu = false
},
set(rate) {
var newPlaybackRate = Number(rate)
var hasChanged = this.playbackRate !== newPlaybackRate
this.playbackRate = newPlaybackRate
if (hasChanged) this.$emit('change', newPlaybackRate)
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@ -1,13 +1,16 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">volume_up</span>
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
</div>
<div v-show="isOpen" class="absolute bottom-10 left-0 h-28 py-2 bg-white shadow-sm rounded-lg">
<div ref="volumeTrack" class="w-2 border-2 border-white h-full bg-gray-400 mx-4 relative cursor-pointer" @mousedown="mousedownTrack" @click="clickVolumeTrack">
<div class="w-3 h-3 bg-gray-500 shadow-sm rounded-full absolute -left-1 bottom-0 pointer-events-none" :class="isDragging ? 'transform scale-150' : ''" :style="{ top: cursorTop + 'px' }" />
<transition name="menux">
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
<div class="bg-gray-100 h-full absolute left-0 top-0 pointer-events-none rounded-full" :style="{ width: volume * trackWidth + 'px' }" />
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ left: cursorLeft + 'px', top: '-3px' }" />
</div>
</div>
</transition>
</div>
</template>
@ -20,8 +23,12 @@ export default {
return {
isOpen: false,
isDragging: false,
posY: 0,
trackHeight: 112 - 16
isHovering: false,
posX: 0,
lastValue: 0.5,
isMute: false,
trackWidth: 112 - 20,
openTimeout: null
}
},
computed: {
@ -33,22 +40,45 @@ export default {
this.$emit('input', val)
}
},
cursorTop() {
var top = this.trackHeight * this.volume
return top - 6
cursorLeft() {
var left = this.trackWidth * this.volume
return left - 3
},
volumeIcon() {
if (this.volume <= 0) return 'volume_mute'
else if (this.volume <= 0.5) return 'volume_down'
else return 'volume_up'
}
},
methods: {
mouseover() {
this.isHovering = true
this.setOpen()
},
mouseleave() {
this.isHovering = false
},
setOpen() {
this.isOpen = true
clearTimeout(this.openTimeout)
this.openTimeout = setTimeout(() => {
if (!this.isHovering && !this.isDragging) {
this.isOpen = false
} else {
this.setOpen()
}
}, 600)
},
mousemove(e) {
var diff = this.posY - e.y
this.posY = e.y
var diff = this.posX - e.x
this.posX = e.x
var volShift = 0
if (diff < 0) {
// Volume up
volShift = diff / this.trackHeight
volShift = diff / this.trackWidth
} else {
// volume down
volShift = diff / this.trackHeight
volShift = diff / this.trackWidth
}
var newVol = this.volume - volShift
newVol = Math.min(Math.max(0, newVol), 1)
@ -64,8 +94,8 @@ export default {
},
mousedownTrack(e) {
this.isDragging = true
this.posY = e.y
var vol = e.offsetY / e.target.clientHeight
this.posX = e.x
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
document.body.addEventListener('mousemove', this.mousemove)
@ -76,14 +106,24 @@ export default {
this.isOpen = false
},
clickVolumeIcon() {
this.isOpen = !this.isOpen
this.isMute = !this.isMute
if (this.isMute) {
this.lastValue = this.volume
this.volume = 0
} else {
this.volume = this.lastValue || 0.5
}
},
clickVolumeTrack(e) {
var vol = e.offsetY / e.target.clientHeight
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
}
},
mounted() {}
mounted() {
if (this.value === 0) {
this.isMute = true
}
}
}
</script>

View File

@ -85,7 +85,7 @@ export default {
console.log('Search', this.lastSearch, this.search)
this.searchResults = []
this.processing = true
this.isProcessing = true
this.lastSearch = this.search
var results = await this.$axios.$get(`/api/find/search?title=${this.search}`).catch((error) => {
console.error('Failed', error)
@ -96,7 +96,7 @@ export default {
})
console.log('Got results', results)
this.searchResults = results
this.processing = false
this.isProcessing = false
},
init() {
if (!this.audiobook.book || !this.audiobook.book.title) {

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "0.9.52",
"version": "0.9.54",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -3,18 +3,27 @@
<div class="w-full max-w-4xl mx-auto">
<h1 class="text-2xl mb-2">Config</h1>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center h-40">
<div class="p-4 text-center h-20">
<p>Nothing much here yet...</p>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<div class="flex items-center py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex-grow" />
<ui-btn color="success" @click="scan">Scan</ui-btn>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p>
<p class="font-mono">Beta v{{ $config.version }}</p>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "0.9.53",
"version": "0.9.54",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {

View File

@ -28,14 +28,11 @@ class HlsController {
async streamFileRequest(req, res) {
var streamId = req.params.stream
// Logger.info('Got hls request', streamId, req.params.file)
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
var exists = await fs.pathExists(fullFilePath)
if (!exists) {
Logger.error('File path does not exist', fullFilePath)
Logger.warn('File path does not exist', fullFilePath)
var fileExt = Path.extname(req.params.file)
if (fileExt === '.ts') {
@ -52,36 +49,16 @@ class HlsController {
} else {
var startTimeForReset = await stream.checkSegmentNumberRequest(segNum)
if (startTimeForReset) {
// HLS.js should request the file again]
// HLS.js will restart the stream at the new time
Logger.info(`[HLS-CONTROLLER] Resetting Stream - notify client @${startTimeForReset}s`)
this.emitter('stream_reset', {
startTime: startTimeForReset,
streamId: stream.id
})
return res.sendStatus(500)
// await new Promise((resolve) => {
// setTimeout(() => {
// console.log('Waited 4 seconds')
// resolve()
// }, 4000)
// })
// exists = await fs.pathExists(fullFilePath)
// if (!exists) {
// console.error('Still does not exist')
// return res.sendStatus(404)
// }
}
}
}
// await new Promise(resolve => setTimeout(resolve, 500))
// exists = await fs.pathExists(fullFilePath)
// Logger.info('Waited', exists)
// if (!exists) {
// Logger.error('still does not exist', fullFilePath)
// return res.sendStatus(404)
// }
}
// Logger.info('Sending file', fullFilePath)
res.sendFile(fullFilePath)

View File

@ -130,7 +130,6 @@ class Server {
res.sendStatus(200)
})
app.post('/stream', (req, res) => this.streamManager.openStreamRequest(req, res))
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => {
@ -165,7 +164,6 @@ class Server {
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('test', () => {
console.log('Test Request from', socket.id)
socket.emit('test_received', socket.id)
})

View File

@ -296,144 +296,12 @@ class Stream extends EventEmitter {
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[FFMPEG] Transcoding ended')
this.isTranscodeComplete = true
this.ffmpeg = null
})
this.ffmpeg.run()
// For very small fast load
if (!this.isClientInitialized) {
this.isClientInitialized = true
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.socket.emit('stream_open', this.toJSON())
}
async startConcat() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
var concatOutput = null
if (this.tracks.length > 1) {
var start = Date.now()
await new Promise(async (resolve) => {
Logger.info('Concatenating here', this.tracks.length)
this.ffmpeg = Ffmpeg()
var trackExt = this.tracks[0].ext
concatOutput = Path.join(this.streamPath, `concat${trackExt}`)
Logger.info('Concat OUTPUT', concatOutput)
var trackPaths = this.tracks.map(t => {
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(this.concatFilesPath, inputstr)
this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat')
this.ffmpeg.inputOption('-safe 0')
this.ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
this.ffmpeg.output(concatOutput)
this.ffmpeg.on('start', (command) => {
Logger.info('[CONCAT] FFMPEG transcoding started with command: ' + command)
})
this.ffmpeg.on('error', (err, stdout, stderr) => {
Logger.info('[CONCAT] ERROR', err, stderr)
})
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[CONCAT] Concat is done')
resolve()
})
this.ffmpeg.run()
})
var elapsed = ((Date.now() - start) / 1000).toFixed(1)
Logger.info(`[CONCAT] Final elapsed is ${elapsed}s`)
} else {
concatOutput = this.tracks[0].fullPath
}
this.ffmpeg = Ffmpeg()
// var currTrackEnd = 0
// var startingTrack = this.tracks.find(t => {
// currTrackEnd += t.duration
// return this.startTime < currTrackEnd
// })
// var trackStartTime = currTrackEnd - startingTrack.duration
// var currInpoint = this.startTime - trackStartTime
// var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
// var trackPaths = tracksToInclude.map(t => {
// var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
// if (t.index === startingTrack.index) {
// line += `\ninpoint ${currInpoint}`
// }
// return line
// })
// var inputstr = trackPaths.join('\n\n')
// await fs.writeFile(this.concatFilesPath, inputstr)
this.ffmpeg.addInput(concatOutput)
// this.ffmpeg.inputFormat('concat')
// this.ffmpeg.inputOption('-safe 0')
if (this.startTime > 0) {
Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(this.startTime)} and Segment #${this.segmentStartNumber}`)
this.ffmpeg.inputOption(`-ss ${this.startTime}`)
this.ffmpeg.inputOption('-noaccurate_seek')
}
this.ffmpeg.addOption([
'-loglevel warning',
'-map 0:a',
'-c:a copy'
])
this.ffmpeg.addOption([
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
"-hls_segment_type mpegts",
`-start_number ${this.segmentStartNumber}`,
"-hls_playlist_type vod",
"-hls_list_size 0",
"-hls_allow_cache 0"
])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.playlistPath)
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
if (this.isResetting) {
setTimeout(() => {
Logger.info('[STREAM] Clearing isResetting')
this.isResetting = false
}, 500)
}
this.startLoop()
})
this.ffmpeg.on('stderr', (stdErrline) => {
Logger.info(stdErrline)
})
this.ffmpeg.on('error', (err, stdout, stderr) => {
if (err.message && err.message.includes('SIGKILL')) {
// This is an intentional SIGKILL
Logger.info('[FFMPEG] Transcode Killed')
this.ffmpeg = null
} else {
Logger.error('Ffmpeg Err', err.message)
}
})
this.ffmpeg.on('end', (stdout, stderr) => {
Logger.info('[FFMPEG] Transcoding ended')
this.isTranscodeComplete = true
this.ffmpeg = null
})