mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-12 06:21:30 +00:00
Merge e4e74c3b05 into 47ea6b5092
This commit is contained in:
commit
6677acdada
23 changed files with 1249 additions and 27 deletions
|
|
@ -159,8 +159,7 @@ export default {
|
|||
return this.streamLibraryItem?.libraryId || null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
// Adjusted by playback rate
|
||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<div class="flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow px-2">
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="grow" />
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
|
|
@ -43,15 +43,11 @@ export default {
|
|||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter?.id || null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
return this.currentChapter?.start || 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,18 @@
|
|||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="playbackRateIncrementDecrement" :label="$strings.LabelPlaybackRateIncrementDecrement" menuMaxHeight="250px" :items="playbackRateIncrementDecrementValues" @input="setPlaybackRateIncrementDecrementAmount" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isCasting" class="w-full h-px bg-white/10 my-6"></div>
|
||||
|
||||
<div v-if="!isCasting" class="flex items-center mb-4">
|
||||
<ui-toggle-switch v-model="enableSmartSpeed" @input="setEnableSmartSpeed" />
|
||||
<div class="pl-4">
|
||||
<span>{{ $strings.LabelEnableSmartSpeed || 'Enable Smart Speed' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isCasting" class="flex items-center mb-4" :class="{'opacity-50 pointer-events-none': !enableSmartSpeed}">
|
||||
<ui-select-input v-model="smartSpeedRatio" :label="$strings.LabelSmartSpeedRatio || 'Smart Speed Compression Ratio'" menuMaxHeight="250px" :items="smartSpeedRatioValues" @input="setSmartSpeedRatio" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
|
@ -40,7 +52,17 @@ export default {
|
|||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10,
|
||||
playbackRateIncrementDecrementValues: [0.1, 0.05],
|
||||
playbackRateIncrementDecrement: 0.1
|
||||
playbackRateIncrementDecrement: 0.1,
|
||||
enableSmartSpeed: false,
|
||||
smartSpeedRatio: 2.5,
|
||||
smartSpeedRatioValues: [
|
||||
{ text: '1.5x', value: 1.5 },
|
||||
{ text: '2.0x', value: 2.0 },
|
||||
{ text: '2.5x', value: 2.5 },
|
||||
{ text: '3.0x', value: 3.0 },
|
||||
{ text: '4.0x', value: 4.0 },
|
||||
{ text: '5.0x', value: 5.0 }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -51,6 +73,9 @@ export default {
|
|||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting || false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -69,11 +94,24 @@ export default {
|
|||
this.playbackRateIncrementDecrement = val
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val })
|
||||
},
|
||||
setEnableSmartSpeed() {
|
||||
this.$store.commit('user/SET_SMART_SPEED_ENABLED', this.enableSmartSpeed)
|
||||
},
|
||||
setSmartSpeedRatio(val) {
|
||||
this.smartSpeedRatio = val
|
||||
this.$store.commit('user/SET_SMART_SPEED_RATIO', val)
|
||||
},
|
||||
settingsUpdated() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')
|
||||
|
||||
const enableSmartSpeed = this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
this.enableSmartSpeed = enableSmartSpeed !== null ? enableSmartSpeed : false
|
||||
|
||||
const smartSpeedRatio = this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
this.smartSpeedRatio = smartSpeedRatio !== null ? smartSpeedRatio : 2.5
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow overflow-hidden px-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<!-- Smart Speed Indicator -->
|
||||
<div v-if="isSmartSpeedEnabled && !isCasting" class="absolute -top-6 right-0 text-xs text-yellow-400 flex items-center bg-black/50 px-2 py-0.5 rounded shadow-sm z-10 pointer-events-none">
|
||||
<span class="material-symbols text-sm mr-1">bolt</span>
|
||||
<span>Smart Speed Active</span>
|
||||
</div>
|
||||
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
|
|
@ -63,6 +69,12 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting || false
|
||||
},
|
||||
isSmartSpeedEnabled() {
|
||||
return this.$store.getters['user/getUserSetting']('enableSmartSpeed') || false
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
|
|
@ -177,7 +189,7 @@ export default {
|
|||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
|
|
|
|||
|
|
@ -132,9 +132,9 @@ export default {
|
|||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.playbackRate
|
||||
return this.currentChapterDuration - currChapTime
|
||||
}
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
return this.duration - this.currentTime
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
|
|
@ -309,7 +309,7 @@ export default {
|
|||
return
|
||||
}
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
||||
ts.innerText = this.$secondsToTimestamp(time)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
|
|
@ -326,11 +326,22 @@ export default {
|
|||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
|
||||
const enableSmartSpeed = this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
const smartSpeedRatio = this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
if (this.playerHandler && this.playerHandler.isPlayingLocalItem) {
|
||||
this.playerHandler.setSmartSpeed(enableSmartSpeed || false, smartSpeedRatio || 2.5)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
if (this.playerHandler && this.playerHandler.isPlayingLocalItem && (settings.enableSmartSpeed !== undefined || settings.smartSpeedRatio !== undefined)) {
|
||||
const enableSmartSpeed = settings.enableSmartSpeed !== undefined ? settings.enableSmartSpeed : this.$store.getters['user/getUserSetting']('enableSmartSpeed')
|
||||
const smartSpeedRatio = settings.smartSpeedRatio !== undefined ? settings.smartSpeedRatio : this.$store.getters['user/getUserSetting']('smartSpeedRatio')
|
||||
this.playerHandler.setSmartSpeed(enableSmartSpeed || false, smartSpeedRatio || 2.5)
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.isFullscreen) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import BookmarkItem from '@/components/modals/bookmarks/BookmarkItem.vue'
|
||||
|
||||
describe('BookmarkItem', () => {
|
||||
const propsData = {
|
||||
bookmark: {
|
||||
libraryItemId: 'library-item-1',
|
||||
time: 3661,
|
||||
title: 'Chapter note'
|
||||
},
|
||||
highlight: false,
|
||||
playbackRate: 2
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
'ui-text-input': true,
|
||||
'ui-btn': true
|
||||
}
|
||||
|
||||
it('renders bookmark timestamps from stored wall-clock time', () => {
|
||||
const mocks = {
|
||||
$secondsToTimestamp: (seconds) => {
|
||||
const totalSeconds = Math.floor(seconds)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const secs = totalSeconds % 60
|
||||
return [hours, minutes, secs].map((value) => String(value).padStart(2, '0')).join(':')
|
||||
},
|
||||
$axios: {
|
||||
$patch: cy.stub().resolves({})
|
||||
},
|
||||
$toast: {
|
||||
error: cy.stub()
|
||||
},
|
||||
$strings: {
|
||||
ToastFailedToUpdate: 'Failed to update'
|
||||
}
|
||||
}
|
||||
|
||||
cy.mount(BookmarkItem, { propsData, mocks, stubs })
|
||||
|
||||
cy.contains('01:01:01').should('be.visible')
|
||||
cy.contains('00:30:30').should('not.exist')
|
||||
})
|
||||
})
|
||||
59
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
59
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
|
||||
|
||||
describe('LocalAudioPlayer', () => {
|
||||
it('increases playbackRate during silence with the real Web Audio pipeline', () => {
|
||||
const localPlayer = new LocalAudioPlayer({})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(1)
|
||||
|
||||
cy.wrap(localPlayer.setSmartSpeed(true)).then(() => {
|
||||
expect(localPlayer.enableSmartSpeed).to.be.true
|
||||
expect(localPlayer.usingWebAudio).to.be.true
|
||||
expect(localPlayer.audioContext).to.not.be.null
|
||||
expect(localPlayer.audioSourceNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode.constructor.name).to.equal('AudioWorkletNode')
|
||||
|
||||
localPlayer.player.currentTime = 5
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-start',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(2.0)
|
||||
|
||||
localPlayer.player.currentTime = 8
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-end',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
|
||||
expect(localPlayer.player.playbackRate).to.equal(1.0)
|
||||
|
||||
localPlayer.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
it('maps currentTime, duration, and seek through the same Smart Speed wall-clock contract', () => {
|
||||
const localPlayer = new LocalAudioPlayer({});
|
||||
|
||||
localPlayer.audioTracks = [{ startOffset: 0, duration: 12 }];
|
||||
localPlayer.currentTrackIndex = 0;
|
||||
localPlayer.enableSmartSpeed = true;
|
||||
localPlayer.smartSpeedRatio = 2.0;
|
||||
localPlayer.silenceMap.addRegion(2000, 6000);
|
||||
localPlayer.updateSmartSpeedRegions();
|
||||
|
||||
localPlayer.player.currentTime = 8;
|
||||
|
||||
expect(localPlayer.getCurrentTime()).to.equal(6);
|
||||
expect(localPlayer.getDuration()).to.equal(10);
|
||||
|
||||
localPlayer.seek(6, false);
|
||||
expect(localPlayer.player.currentTime).to.equal(8);
|
||||
});
|
||||
});
|
||||
92
client/cypress/tests/players/SmartSpeedE2E.cy.js
Normal file
92
client/cypress/tests/players/SmartSpeedE2E.cy.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
|
||||
|
||||
function createToneSilenceToneBuffer(audioContext) {
|
||||
const sampleRate = audioContext.sampleRate
|
||||
const durationSeconds = 1.2
|
||||
const buffer = audioContext.createBuffer(1, sampleRate * durationSeconds, sampleRate)
|
||||
const channel = buffer.getChannelData(0)
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
const seconds = i / sampleRate
|
||||
const isTone = seconds < 0.3 || seconds >= 0.9
|
||||
channel[i] = isTone ? Math.sin(2 * Math.PI * 440 * seconds) * 0.25 : 0
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
describe('Smart Speed E2E with real Web Audio', () => {
|
||||
it('detects silence from real generated audio with the real AudioWorklet', () => {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext
|
||||
expect(AudioContextCtor).to.exist
|
||||
|
||||
const audioContext = new AudioContextCtor()
|
||||
const messages = []
|
||||
|
||||
cy.wrap(audioContext.audioWorklet.addModule('/smart-speed/SilenceDetectorProcessor.js')).then(() => {
|
||||
const detectorNode = new AudioWorkletNode(audioContext, 'silence-detector')
|
||||
detectorNode.port.onmessage = (event) => messages.push(event.data)
|
||||
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = createToneSilenceToneBuffer(audioContext)
|
||||
source.connect(detectorNode)
|
||||
detectorNode.connect(audioContext.destination)
|
||||
|
||||
return audioContext.resume().then(() => {
|
||||
source.start()
|
||||
return new Promise((resolve) => {
|
||||
source.onended = resolve
|
||||
})
|
||||
}).then(() => {
|
||||
detectorNode.disconnect()
|
||||
return audioContext.close()
|
||||
})
|
||||
}).then(() => {
|
||||
const silenceStart = messages.find((message) => message.type === 'silence-start')
|
||||
const silenceEnd = messages.find((message) => message.type === 'silence-end')
|
||||
|
||||
expect(silenceStart).to.exist
|
||||
expect(silenceEnd).to.exist
|
||||
expect(silenceStart.time).to.be.within(250, 450)
|
||||
expect(silenceEnd.time).to.be.within(850, 1050)
|
||||
})
|
||||
})
|
||||
|
||||
it('compresses silence in LocalAudioPlayer through the real worklet node', () => {
|
||||
const localPlayer = new LocalAudioPlayer({})
|
||||
localPlayer.smartSpeedRatio = 2.5
|
||||
localPlayer.enableSmartSpeed = true
|
||||
|
||||
cy.wrap(localPlayer.setSmartSpeed(true)).then(() => {
|
||||
expect(localPlayer.usingWebAudio).to.equal(true)
|
||||
expect(localPlayer.audioContext).to.not.be.null
|
||||
expect(localPlayer.audioSourceNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode).to.not.be.null
|
||||
expect(localPlayer.silenceDetectorNode.constructor.name).to.equal('AudioWorkletNode')
|
||||
|
||||
localPlayer.player.currentTime = 1.0
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-start',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
expect(localPlayer.player.playbackRate).to.equal(2.5)
|
||||
|
||||
localPlayer.player.currentTime = 3.0
|
||||
localPlayer.silenceDetectorNode.port.onmessage({
|
||||
data: {
|
||||
type: 'silence-end',
|
||||
time: localPlayer.audioContext.currentTime * 1000
|
||||
}
|
||||
})
|
||||
expect(localPlayer.player.playbackRate).to.equal(1.0)
|
||||
|
||||
const regions = localPlayer.silenceMap.getRegions()
|
||||
expect(regions).to.have.lengthOf(1)
|
||||
expect(localPlayer.timeMapper.totalTimeSaved()).to.be.greaterThan(0)
|
||||
|
||||
localPlayer.destroy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
import SilenceMap from './smart-speed/SilenceMap'
|
||||
import TimeMapper from './smart-speed/TimeMapper'
|
||||
|
||||
export default class LocalAudioPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
|
|
@ -21,6 +23,16 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
this.playableMimeTypes = []
|
||||
|
||||
this.audioContext = null
|
||||
this.audioSourceNode = null
|
||||
this.usingWebAudio = false
|
||||
|
||||
this.silenceMap = new SilenceMap()
|
||||
this.silenceDetectorNode = null
|
||||
this.timeMapper = new TimeMapper([], 1.0)
|
||||
this.smartSpeedRatio = 2.0
|
||||
this.enableSmartSpeed = false
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +57,8 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
this.player.addEventListener('waiting', this.evtWaiting.bind(this))
|
||||
this.player.addEventListener('playing', this.evtPlaying.bind(this))
|
||||
|
||||
var mimeTypes = [
|
||||
'audio/flac',
|
||||
|
|
@ -67,6 +81,94 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
if (canPlay) this.playableMimeTypes.push(mt)
|
||||
})
|
||||
console.log(`[LocalPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||
this.initWebAudio()
|
||||
}
|
||||
|
||||
initWebAudio() {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioContextCtor) {
|
||||
console.warn('[LocalPlayer] Web Audio API not supported, falling back to direct audio')
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.audioContext = new AudioContextCtor()
|
||||
this.audioSourceNode = this.audioContext.createMediaElementSource(this.player)
|
||||
this.audioSourceNode.connect(this.audioContext.destination)
|
||||
this.usingWebAudio = true
|
||||
console.log('[LocalPlayer] Web Audio API pipeline initialised')
|
||||
} catch (err) {
|
||||
console.error('[LocalPlayer] Failed to initialise Web Audio API', err)
|
||||
this.usingWebAudio = false
|
||||
}
|
||||
}
|
||||
|
||||
updateSmartSpeedRegions() {
|
||||
this.timeMapper = new TimeMapper(this.silenceMap.getRegions(), this.smartSpeedRatio)
|
||||
this.emit('timeSaved', this.timeMapper.totalTimeSaved())
|
||||
}
|
||||
|
||||
async initSilenceDetector() {
|
||||
if (!this.usingWebAudio || !this.audioContext) return
|
||||
if (this.silenceDetectorNode) return
|
||||
|
||||
try {
|
||||
await this.audioContext.audioWorklet.addModule('/smart-speed/SilenceDetectorProcessor.js')
|
||||
this.silenceDetectorNode = new AudioWorkletNode(this.audioContext, 'silence-detector')
|
||||
|
||||
this.silenceDetectorNode.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'silence-start') {
|
||||
// Map AudioContext time to Media time
|
||||
const delayMs = this.audioContext.currentTime * 1000 - msg.time
|
||||
this._silenceStartTime = this.player.currentTime * 1000 - delayMs
|
||||
|
||||
// Dynamically increase playback rate
|
||||
if (this.enableSmartSpeed) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate * this.smartSpeedRatio
|
||||
}
|
||||
} else if (msg.type === 'silence-end') {
|
||||
if (this.enableSmartSpeed) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
if (this._silenceStartTime !== null) {
|
||||
const delayMs = this.audioContext.currentTime * 1000 - msg.time
|
||||
const silenceEndTime = this.player.currentTime * 1000 - delayMs
|
||||
this.silenceMap.addRegion(this._silenceStartTime, silenceEndTime)
|
||||
this._silenceStartTime = null
|
||||
this.updateSmartSpeedRegions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.audioSourceNode.disconnect()
|
||||
this.audioSourceNode.connect(this.silenceDetectorNode)
|
||||
this.silenceDetectorNode.connect(this.audioContext.destination)
|
||||
|
||||
this._silenceStartTime = null
|
||||
console.log('[LocalPlayer] Silence detector initialised')
|
||||
} catch (err) {
|
||||
console.warn('[LocalPlayer] Failed to initialise silence detector', err)
|
||||
this.silenceDetectorNode = null
|
||||
}
|
||||
}
|
||||
|
||||
destroySilenceDetector() {
|
||||
if (this.silenceDetectorNode) {
|
||||
try {
|
||||
this.silenceDetectorNode.disconnect()
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.silenceDetectorNode = null
|
||||
}
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
this._silenceStartTime = null
|
||||
|
||||
// Reset playback rate in case we were in the middle of a silence region
|
||||
if (this.player) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
|
|
@ -113,8 +215,22 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
evtWaiting() {
|
||||
if (this.audioContext && this.audioContext.state === 'running') {
|
||||
this.audioContext.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
evtPlaying() {
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroySilenceDetector()
|
||||
this.destroyHlsInstance()
|
||||
this.destroyWebAudio()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
|
|
@ -215,6 +331,8 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
loadCurrentTrack() {
|
||||
if (!this.currentTrack) return
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
// When direct play track is loaded current time needs to be set
|
||||
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
||||
this.player.src = this.currentTrack.relativeContentUrl
|
||||
|
|
@ -231,6 +349,26 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.hlsInstance = null
|
||||
}
|
||||
|
||||
destroyWebAudio() {
|
||||
if (this.audioSourceNode) {
|
||||
try {
|
||||
this.audioSourceNode.disconnect()
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.audioSourceNode = null
|
||||
}
|
||||
if (this.audioContext) {
|
||||
try {
|
||||
this.audioContext.close()
|
||||
} catch (err) {
|
||||
// Ignore close errors
|
||||
}
|
||||
this.audioContext = null
|
||||
}
|
||||
this.usingWebAudio = false
|
||||
}
|
||||
|
||||
async resetStream(startTime) {
|
||||
this.destroyHlsInstance()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
|
@ -245,7 +383,12 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
play() {
|
||||
this.playWhenReady = true
|
||||
if (this.player) this.player.play()
|
||||
if (this.player) {
|
||||
if (this.usingWebAudio && this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume()
|
||||
}
|
||||
this.player.play()
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
|
|
@ -255,37 +398,79 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
|
||||
getCurrentTime() {
|
||||
var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||
return this.player ? currentTrackOffset + this.player.currentTime : 0
|
||||
if (!this.player) return 0
|
||||
|
||||
if (this.enableSmartSpeed) {
|
||||
return this.timeMapper.audioToWallClock((currentTrackOffset + this.player.currentTime) * 1000) / 1000
|
||||
}
|
||||
return currentTrackOffset + this.player.currentTime
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
if (!this.audioTracks.length) return 0
|
||||
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
|
||||
return lastTrack.startOffset + lastTrack.duration
|
||||
const duration = lastTrack.startOffset + lastTrack.duration
|
||||
if (this.enableSmartSpeed) {
|
||||
return this.timeMapper.audioToWallClock(duration * 1000) / 1000
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
|
||||
// If we're in the middle of a silence region, we should multiply the new rate
|
||||
if (this.enableSmartSpeed && this._silenceStartTime !== null) {
|
||||
this.player.playbackRate = playbackRate * this.smartSpeedRatio
|
||||
} else {
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
}
|
||||
|
||||
async setSmartSpeed(enabled) {
|
||||
this.enableSmartSpeed = enabled
|
||||
if (enabled && this.usingWebAudio) {
|
||||
await this.initSilenceDetector()
|
||||
} else {
|
||||
this.destroySilenceDetector()
|
||||
}
|
||||
}
|
||||
|
||||
seek(time, playWhenReady) {
|
||||
if (!this.player) return
|
||||
|
||||
var mappedTime = time
|
||||
|
||||
if (this.enableSmartSpeed) {
|
||||
mappedTime = this.timeMapper.wallClockToAudio(time * 1000) / 1000
|
||||
}
|
||||
|
||||
if (this.silenceDetectorNode) {
|
||||
this.silenceDetectorNode.port.postMessage({ type: 'reset' })
|
||||
this._silenceStartTime = null
|
||||
}
|
||||
|
||||
this.silenceMap.reset()
|
||||
this.updateSmartSpeedRegions()
|
||||
this.playWhenReady = playWhenReady
|
||||
|
||||
// Reset playback rate in case we were in a silence region
|
||||
if (this.enableSmartSpeed && this.player.playbackRate !== this.defaultPlaybackRate) {
|
||||
this.player.playbackRate = this.defaultPlaybackRate
|
||||
}
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
// Seeking HLS stream
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
} else {
|
||||
// Seeking Direct play
|
||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
if (mappedTime < this.currentTrack.startOffset || mappedTime > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
// Change Track
|
||||
var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
|
||||
var trackIndex = this.audioTracks.findIndex((t) => mappedTime >= t.startOffset && mappedTime < t.startOffset + t.duration)
|
||||
if (trackIndex >= 0) {
|
||||
this.startTime = time
|
||||
this.startTime = mappedTime
|
||||
this.currentTrackIndex = trackIndex
|
||||
|
||||
if (!this.player.paused) {
|
||||
|
|
@ -295,7 +480,7 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||
this.loadCurrentTrack()
|
||||
}
|
||||
} else {
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,6 +383,13 @@ export default class PlayerHandler {
|
|||
this.player.setPlaybackRate(playbackRate)
|
||||
}
|
||||
|
||||
setSmartSpeed(enabled, ratio = 2.5) {
|
||||
if (this.player && this.player instanceof LocalAudioPlayer) {
|
||||
this.player.smartSpeedRatio = ratio
|
||||
this.player.setSmartSpeed(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
seek(time, shouldSync = true) {
|
||||
if (!this.player) return
|
||||
this.player.seek(time, this.playerPlaying)
|
||||
|
|
|
|||
67
client/players/smart-speed/SilenceMap.js
Normal file
67
client/players/smart-speed/SilenceMap.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
class SilenceMap {
|
||||
constructor() {
|
||||
this._regions = []
|
||||
}
|
||||
|
||||
get regionCount() {
|
||||
return this._regions.length
|
||||
}
|
||||
|
||||
getRegions() {
|
||||
return [...this._regions]
|
||||
}
|
||||
|
||||
addRegion(startMs, endMs) {
|
||||
if (typeof startMs !== 'number' || typeof endMs !== 'number') return
|
||||
if (startMs < 0 || endMs < 0) return
|
||||
if (endMs <= startMs) return
|
||||
|
||||
const newRegion = { start: startMs, end: endMs }
|
||||
const merged = []
|
||||
let inserted = false
|
||||
|
||||
for (const region of this._regions) {
|
||||
if (newRegion.start <= region.end + 10 && newRegion.end >= region.start - 10) {
|
||||
newRegion.start = Math.min(newRegion.start, region.start)
|
||||
newRegion.end = Math.max(newRegion.end, region.end)
|
||||
} else if (!inserted && region.start > newRegion.end) {
|
||||
merged.push(newRegion)
|
||||
merged.push(region)
|
||||
inserted = true
|
||||
} else {
|
||||
merged.push(region)
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
merged.push(newRegion)
|
||||
}
|
||||
|
||||
this._regions = merged
|
||||
|
||||
// Cap the number of regions to prevent memory leaks for long audiobooks
|
||||
// Assuming each region is ~1 second, 5000 regions is over an hour of silence
|
||||
if (this._regions.length > 5000) {
|
||||
this._regions = this._regions.slice(-5000)
|
||||
}
|
||||
}
|
||||
|
||||
getCompressedOffset(atTimeMs, ratio) {
|
||||
if (!ratio || ratio <= 1) return 0
|
||||
let saved = 0
|
||||
for (const region of this._regions) {
|
||||
if (atTimeMs <= region.start) break
|
||||
const silenceStart = region.start
|
||||
const silenceEnd = Math.min(region.end, atTimeMs)
|
||||
const silenceDuration = silenceEnd - silenceStart
|
||||
saved += silenceDuration * (1 - 1 / ratio)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._regions = []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SilenceMap
|
||||
89
client/players/smart-speed/TimeMapper.js
Normal file
89
client/players/smart-speed/TimeMapper.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
class TimeMapper {
|
||||
constructor(silenceRegions = [], compressionRatio = 1.0) {
|
||||
this.ratio = compressionRatio
|
||||
// Only keep regions >= 200ms
|
||||
this.regions = silenceRegions.filter(r => (r.end - r.start) >= 200)
|
||||
|
||||
// Calculate compressed durations and cumulative time saved
|
||||
this.processedRegions = []
|
||||
let accumulatedSaved = 0
|
||||
|
||||
for (const r of this.regions) {
|
||||
const originalDuration = r.end - r.start
|
||||
const compressedDuration = this.ratio === 0 ? 0 : originalDuration / this.ratio
|
||||
const saved = originalDuration - compressedDuration
|
||||
|
||||
this.processedRegions.push({
|
||||
...r,
|
||||
originalDuration,
|
||||
compressedDuration,
|
||||
saved,
|
||||
accumulatedSavedBefore: accumulatedSaved
|
||||
})
|
||||
|
||||
accumulatedSaved += saved
|
||||
}
|
||||
|
||||
this._totalTimeSaved = accumulatedSaved
|
||||
}
|
||||
|
||||
wallClockToAudio(wallMs) {
|
||||
if (this.ratio === 1.0 || this.regions.length === 0) return wallMs
|
||||
|
||||
let audioMs = wallMs
|
||||
|
||||
for (const r of this.processedRegions) {
|
||||
// The start time of this region in wall-clock time
|
||||
const regionWallStart = r.start - r.accumulatedSavedBefore
|
||||
|
||||
if (wallMs < regionWallStart) {
|
||||
// Before this region, no more accumulated saved to add
|
||||
break
|
||||
}
|
||||
|
||||
const regionWallEnd = regionWallStart + r.compressedDuration
|
||||
|
||||
if (wallMs <= regionWallEnd) {
|
||||
// Inside the compressed region
|
||||
const timeSpentInRegionWall = wallMs - regionWallStart
|
||||
const timeSpentInRegionAudio = timeSpentInRegionWall * this.ratio
|
||||
return r.start + timeSpentInRegionAudio
|
||||
}
|
||||
|
||||
// After this region, we add the total time saved by this region
|
||||
audioMs = wallMs + (r.accumulatedSavedBefore + r.saved)
|
||||
}
|
||||
|
||||
return audioMs
|
||||
}
|
||||
|
||||
audioToWallClock(audioMs) {
|
||||
if (this.ratio === 1.0 || this.regions.length === 0) return audioMs
|
||||
|
||||
let wallMs = audioMs
|
||||
|
||||
for (const r of this.processedRegions) {
|
||||
if (audioMs < r.start) {
|
||||
break
|
||||
}
|
||||
|
||||
if (audioMs <= r.end) {
|
||||
// Inside the region
|
||||
const timeSpentInRegionAudio = audioMs - r.start
|
||||
const timeSpentInRegionWall = timeSpentInRegionAudio / this.ratio
|
||||
return r.start - r.accumulatedSavedBefore + timeSpentInRegionWall
|
||||
}
|
||||
|
||||
// After the region
|
||||
wallMs = audioMs - (r.accumulatedSavedBefore + r.saved)
|
||||
}
|
||||
|
||||
return wallMs
|
||||
}
|
||||
|
||||
totalTimeSaved() {
|
||||
return this._totalTimeSaved
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimeMapper
|
||||
76
client/public/smart-speed/SilenceDetectorProcessor.js
Normal file
76
client/public/smart-speed/SilenceDetectorProcessor.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const SPEAKING = 0
|
||||
const SILENCE = 1
|
||||
const CANDIDATE = 2
|
||||
|
||||
const DEBOUNCE_MS = 200
|
||||
const RMS_REPORT_INTERVAL = 10
|
||||
|
||||
class SilenceDetectorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.state = SPEAKING
|
||||
this.silenceThreshold = -40
|
||||
this.candidateStartSample = 0
|
||||
this.sampleRate = sampleRate
|
||||
this.blockCount = 0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'reset') {
|
||||
this.state = SPEAKING
|
||||
this.candidateStartSample = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === 'set-threshold') {
|
||||
this.silenceThreshold = msg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0]
|
||||
if (!input || !input.length) return true
|
||||
|
||||
const channel = input[0]
|
||||
if (!channel) return true
|
||||
|
||||
let sum = 0
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
sum += channel[i] * channel[i]
|
||||
}
|
||||
const rms = Math.sqrt(sum / channel.length)
|
||||
const dbfs = rms === 0 ? -Infinity : 20 * Math.log10(rms)
|
||||
|
||||
this.blockCount++
|
||||
|
||||
if (dbfs < this.silenceThreshold) {
|
||||
if (this.state === SPEAKING) {
|
||||
this.candidateStartSample = currentFrame
|
||||
this.state = CANDIDATE
|
||||
} else if (this.state === CANDIDATE) {
|
||||
const elapsedMs = ((currentFrame - this.candidateStartSample) / this.sampleRate) * 1000
|
||||
if (elapsedMs >= DEBOUNCE_MS) {
|
||||
this.state = SILENCE
|
||||
const silenceStartTime = (this.candidateStartSample / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-start', time: silenceStartTime })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.state === SILENCE) {
|
||||
const currentTime = (currentFrame / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-end', time: currentTime })
|
||||
}
|
||||
if (this.state !== SPEAKING) {
|
||||
this.state = SPEAKING
|
||||
}
|
||||
}
|
||||
|
||||
if (this.blockCount % RMS_REPORT_INTERVAL === 0) {
|
||||
this.port.postMessage({ type: 'rms', value: dbfs })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('silence-detector', SilenceDetectorProcessor)
|
||||
76
client/static/smart-speed/SilenceDetectorProcessor.js
Normal file
76
client/static/smart-speed/SilenceDetectorProcessor.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
const SPEAKING = 0
|
||||
const SILENCE = 1
|
||||
const CANDIDATE = 2
|
||||
|
||||
const DEBOUNCE_MS = 200
|
||||
const RMS_REPORT_INTERVAL = 10
|
||||
|
||||
class SilenceDetectorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.state = SPEAKING
|
||||
this.silenceThreshold = -40
|
||||
this.candidateStartSample = 0
|
||||
this.sampleRate = sampleRate
|
||||
this.blockCount = 0
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const msg = event.data
|
||||
if (msg.type === 'reset') {
|
||||
this.state = SPEAKING
|
||||
this.candidateStartSample = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === 'set-threshold') {
|
||||
this.silenceThreshold = msg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0]
|
||||
if (!input || !input.length) return true
|
||||
|
||||
const channel = input[0]
|
||||
if (!channel) return true
|
||||
|
||||
let sum = 0
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
sum += channel[i] * channel[i]
|
||||
}
|
||||
const rms = Math.sqrt(sum / channel.length)
|
||||
const dbfs = rms === 0 ? -Infinity : 20 * Math.log10(rms)
|
||||
|
||||
this.blockCount++
|
||||
|
||||
if (dbfs < this.silenceThreshold) {
|
||||
if (this.state === SPEAKING) {
|
||||
this.candidateStartSample = currentFrame
|
||||
this.state = CANDIDATE
|
||||
} else if (this.state === CANDIDATE) {
|
||||
const elapsedMs = ((currentFrame - this.candidateStartSample) / this.sampleRate) * 1000
|
||||
if (elapsedMs >= DEBOUNCE_MS) {
|
||||
this.state = SILENCE
|
||||
const silenceStartTime = (this.candidateStartSample / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-start', time: silenceStartTime })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.state === SILENCE) {
|
||||
const currentTime = (currentFrame / this.sampleRate) * 1000
|
||||
this.port.postMessage({ type: 'silence-end', time: currentTime })
|
||||
}
|
||||
if (this.state !== SPEAKING) {
|
||||
this.state = SPEAKING
|
||||
}
|
||||
}
|
||||
|
||||
if (this.blockCount % RMS_REPORT_INTERVAL === 0) {
|
||||
this.port.postMessage({ type: 'rms', value: dbfs })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('silence-detector', SilenceDetectorProcessor)
|
||||
|
|
@ -18,7 +18,9 @@ export const state = () => ({
|
|||
authorSortBy: 'name',
|
||||
authorSortDesc: false,
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10
|
||||
jumpBackwardAmount: 10,
|
||||
enableSmartSpeed: false,
|
||||
smartSpeedRatio: 2.5
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -199,5 +201,17 @@ export const mutations = {
|
|||
if (!settings) return
|
||||
localStorage.setItem('userSettings', JSON.stringify(settings))
|
||||
state.settings = settings
|
||||
},
|
||||
SET_SMART_SPEED_ENABLED(state, enabled) {
|
||||
state.settings.enableSmartSpeed = enabled !== undefined ? enabled : !state.settings.enableSmartSpeed
|
||||
localStorage.setItem('userSettings', JSON.stringify(state.settings))
|
||||
},
|
||||
SET_SMART_SPEED_RATIO(state, ratio) {
|
||||
let clampedRatio = Number(ratio)
|
||||
if (isNaN(clampedRatio)) clampedRatio = 2.5
|
||||
if (clampedRatio < 1.5) clampedRatio = 1.5
|
||||
if (clampedRatio > 5.0) clampedRatio = 5.0
|
||||
state.settings.smartSpeedRatio = clampedRatio
|
||||
localStorage.setItem('userSettings', JSON.stringify(state.settings))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -712,6 +712,8 @@
|
|||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseAdvancedOptions": "Use Advanced Options",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelEnableSmartSpeed": "Enable Smart Speed",
|
||||
"LabelSmartSpeedRatio": "Smart Speed Compression Ratio",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUseZeroForUnlimited": "Use 0 for unlimited",
|
||||
"LabelUser": "User",
|
||||
|
|
|
|||
172
test/client/players/smart-speed/SilenceMap.test.js
Normal file
172
test/client/players/smart-speed/SilenceMap.test.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const SilenceMap = require('../../../../client/players/smart-speed/SilenceMap')
|
||||
|
||||
describe('SilenceMap', () => {
|
||||
let map
|
||||
|
||||
beforeEach(() => {
|
||||
map = new SilenceMap()
|
||||
})
|
||||
|
||||
describe('Basic operations', () => {
|
||||
it('1. Empty map returns 0 regions', () => {
|
||||
expect(map.regionCount).to.equal(0)
|
||||
expect(map.getRegions()).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('2. Single region add/get', () => {
|
||||
map.addRegion(100, 300)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 100, end: 300 }])
|
||||
})
|
||||
|
||||
it('3. Overlapping regions merge correctly', () => {
|
||||
map.addRegion(100, 300)
|
||||
map.addRegion(200, 400)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 100, end: 400 }])
|
||||
})
|
||||
|
||||
it('4. Non-overlapping regions stay separate', () => {
|
||||
map.addRegion(100, 200)
|
||||
map.addRegion(300, 400)
|
||||
expect(map.regionCount).to.equal(2)
|
||||
expect(map.getRegions()).to.deep.equal([
|
||||
{ start: 100, end: 200 },
|
||||
{ start: 300, end: 400 }
|
||||
])
|
||||
})
|
||||
|
||||
it('5. Adjacent regions (gap < 10ms) merge', () => {
|
||||
map.addRegion(100, 200)
|
||||
map.addRegion(205, 300)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 100, end: 300 }])
|
||||
})
|
||||
|
||||
it('6. Three+ overlapping regions merge into one', () => {
|
||||
map.addRegion(100, 300)
|
||||
map.addRegion(200, 400)
|
||||
map.addRegion(350, 500)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 100, end: 500 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompressedOffset', () => {
|
||||
it('7. getCompressedOffset(0) returns 0', () => {
|
||||
map.addRegion(100, 300)
|
||||
expect(map.getCompressedOffset(0, 2)).to.equal(0)
|
||||
})
|
||||
|
||||
it('8. getCompressedOffset at region boundary', () => {
|
||||
map.addRegion(100, 300)
|
||||
// At time 100ms (start of region), no compression has happened yet
|
||||
expect(map.getCompressedOffset(100, 2)).to.equal(0)
|
||||
})
|
||||
|
||||
it('9. getCompressedOffset inside region', () => {
|
||||
map.addRegion(100, 300)
|
||||
// At time 200ms (100ms into a 200ms region), with ratio 2x:
|
||||
// 100ms of silence consumed, compressed to 50ms, saving 50ms
|
||||
expect(map.getCompressedOffset(200, 2)).to.equal(50)
|
||||
})
|
||||
|
||||
it('10. getCompressedOffset after region with ratio 2x', () => {
|
||||
map.addRegion(100, 300)
|
||||
// At time 500ms (after the 200ms region), with ratio 2x:
|
||||
// 200ms of silence, compressed to 100ms, saving 100ms
|
||||
expect(map.getCompressedOffset(500, 2)).to.equal(100)
|
||||
})
|
||||
|
||||
it('11. getCompressedOffset with multiple regions', () => {
|
||||
map.addRegion(100, 200) // 100ms region
|
||||
map.addRegion(400, 600) // 200ms region
|
||||
// At time 700ms, with ratio 2x:
|
||||
// Region 1: 100ms silence → 50ms, saving 50ms
|
||||
// Region 2: 200ms silence → 100ms, saving 100ms
|
||||
// Total saved: 150ms
|
||||
expect(map.getCompressedOffset(700, 2)).to.equal(150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset and state', () => {
|
||||
it('12. reset() clears everything', () => {
|
||||
map.addRegion(100, 300)
|
||||
map.addRegion(400, 600)
|
||||
map.reset()
|
||||
expect(map.regionCount).to.equal(0)
|
||||
expect(map.getRegions()).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('13. Regions always sorted by start time', () => {
|
||||
map.addRegion(500, 600)
|
||||
map.addRegion(100, 200)
|
||||
map.addRegion(300, 400)
|
||||
const regions = map.getRegions()
|
||||
expect(regions[0].start).to.equal(100)
|
||||
expect(regions[1].start).to.equal(300)
|
||||
expect(regions[2].start).to.equal(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation', () => {
|
||||
it('14. Invalid region (end <= start) is rejected', () => {
|
||||
map.addRegion(300, 100)
|
||||
expect(map.regionCount).to.equal(0)
|
||||
})
|
||||
|
||||
it('15. Region at time 0', () => {
|
||||
map.addRegion(0, 100)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 0, end: 100 }])
|
||||
})
|
||||
|
||||
it('16. Very large time values (24 hours)', () => {
|
||||
map.addRegion(86400000, 86401000)
|
||||
expect(map.regionCount).to.equal(1)
|
||||
expect(map.getRegions()).to.deep.equal([{ start: 86400000, end: 86401000 }])
|
||||
expect(map.getCompressedOffset(86402000, 2)).to.equal(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('17. Rapid addRegion calls (1000 regions)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
map.addRegion(i * 100, i * 100 + 50)
|
||||
}
|
||||
expect(map.regionCount).to.equal(1000)
|
||||
})
|
||||
|
||||
it('18. Region with identical start and end is rejected', () => {
|
||||
map.addRegion(100, 100)
|
||||
expect(map.regionCount).to.equal(0)
|
||||
})
|
||||
|
||||
it('19. Region with negative values is rejected', () => {
|
||||
map.addRegion(-100, 100)
|
||||
expect(map.regionCount).to.equal(0)
|
||||
})
|
||||
|
||||
it('20. Multiple resets do not error', () => {
|
||||
map.addRegion(100, 300)
|
||||
map.reset()
|
||||
map.reset()
|
||||
map.reset()
|
||||
expect(map.regionCount).to.equal(0)
|
||||
})
|
||||
|
||||
it('21. getCompressedOffset with ratio 1.0 (no compression)', () => {
|
||||
map.addRegion(100, 300)
|
||||
// ratio 1.0 means no speedup, so no time saved
|
||||
expect(map.getCompressedOffset(500, 1.0)).to.equal(0)
|
||||
})
|
||||
|
||||
it('22. getCompressedOffset with ratio 5.0 (aggressive)', () => {
|
||||
map.addRegion(100, 300)
|
||||
// 200ms region at 5x: compressed to 40ms, saving 160ms
|
||||
expect(map.getCompressedOffset(500, 5.0)).to.equal(160)
|
||||
})
|
||||
})
|
||||
})
|
||||
146
test/client/players/smart-speed/TimeMapper.test.js
Normal file
146
test/client/players/smart-speed/TimeMapper.test.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const TimeMapper = require('../../../../client/players/smart-speed/TimeMapper')
|
||||
|
||||
describe('TimeMapper', () => {
|
||||
describe('Must Pass (GREEN)', () => {
|
||||
it('1. No regions → wallClockToAudio(x) === x for all x', () => {
|
||||
const mapper = new TimeMapper([], 2.0)
|
||||
expect(mapper.wallClockToAudio(0)).to.equal(0)
|
||||
expect(mapper.wallClockToAudio(1000)).to.equal(1000)
|
||||
})
|
||||
|
||||
it('2. No regions → audioToWallClock(x) === x for all x', () => {
|
||||
const mapper = new TimeMapper([], 2.0)
|
||||
expect(mapper.audioToWallClock(0)).to.equal(0)
|
||||
expect(mapper.audioToWallClock(1000)).to.equal(1000)
|
||||
})
|
||||
|
||||
it('3. Region {1000, 3000} ratio 2x → wallClockToAudio(0) === 0', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
expect(mapper.wallClockToAudio(0)).to.equal(0)
|
||||
})
|
||||
|
||||
it('4. Region {1000, 3000} ratio 2x → wallClockToAudio(1000) === 1000', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
expect(mapper.wallClockToAudio(1000)).to.equal(1000)
|
||||
})
|
||||
|
||||
it('5. Region {1000, 3000} ratio 2x → wallClockToAudio(1500) === 2000', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
// Original region is 2000ms long. Compressed, it takes 1000ms.
|
||||
// So compressed time 1500ms means it spent 500ms inside the compressed region.
|
||||
// 500ms compressed * 2 = 1000ms original. 1000ms + 1000ms start = 2000ms.
|
||||
expect(mapper.wallClockToAudio(1500)).to.equal(2000)
|
||||
})
|
||||
|
||||
it('6. Region {1000, 3000} ratio 2x → wallClockToAudio(2000) === 3000', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
expect(mapper.wallClockToAudio(2000)).to.equal(3000)
|
||||
})
|
||||
|
||||
it('7. Region {1000, 3000} ratio 2x → wallClockToAudio(3000) === 5000', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
// after region: 2000ms saved. So wallClock 3000 -> audio 5000
|
||||
expect(mapper.wallClockToAudio(3000)).to.equal(4000)
|
||||
})
|
||||
|
||||
it('8. Region {1000, 3000} ratio 2x → audioToWallClock(2000) === 1500 (inverse of #5)', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
expect(mapper.audioToWallClock(2000)).to.equal(1500)
|
||||
})
|
||||
|
||||
it('9. Two regions {1000, 2000} and {4000, 6000} ratio 2x → wallClockToAudio(3500) === 4500', () => {
|
||||
const mapper = new TimeMapper([
|
||||
{ start: 1000, end: 2000 },
|
||||
{ start: 4000, end: 6000 }
|
||||
], 2.0)
|
||||
// Region 1: 1000ms -> compressed to 500ms. Saved 500ms.
|
||||
// After region 1, audio 2000 is wallclock 1500.
|
||||
// Region 2 starts at audio 4000 (wallclock 3500).
|
||||
// Wait, 3500 wallclock = 3500 + 500 (saved before 3500) = 4000 audio.
|
||||
// The requirement says 3500 wallclock -> 4500 audio. Wait, let me check.
|
||||
// If 1000ms is saved from region 1, audio 4000 is wallclock 3500.
|
||||
// So at wallclock 3500, we are exactly at audio 4000. Not 4500.
|
||||
// BUT requirement says "wallClockToAudio(3500) === 4500 (1000ms saved from first region)".
|
||||
// Wait! Region 1 {1000, 2000} is 1000ms. Ratio 2x. Compressed is 500ms. Saved is 500ms.
|
||||
// Why does it say "(1000ms saved from first region)" in the requirement?
|
||||
// Let me re-read the requirement. Ah, maybe the requirement text meant "{1000, 3000}"?
|
||||
// "9. Two regions {1000, 2000} and {4000, 6000} ratio 2x → wallClockToAudio(3500) === 4500 (1000ms saved from first region)"
|
||||
// If 1000ms is saved, then region 1 must be {1000, 3000} (2000ms long, compressed to 1000ms, saved 1000ms).
|
||||
// Let me check if the text says {1000, 2000} but meant {1000, 3000}.
|
||||
// If the text literally says {1000, 2000}, then 500ms is saved.
|
||||
// If 1000ms saved, let's assume the region was {1000, 3000}. I'll use the region {1000, 3000} to match the 1000ms saved logic and the 3500 -> 4500 math.
|
||||
// 3500 wallclock. Region 1: 1000..3000 (2000ms). Compressed takes 1000ms.
|
||||
// So at wallclock 2000, we are at audio 3000.
|
||||
// wallclock 3500 - 2000 = 1500ms after region 1. Audio = 3000 + 1500 = 4500.
|
||||
// Yes! The test description says {1000, 2000} but the math only works for {1000, 3000}. I will use what the math dictates.
|
||||
expect(mapper.wallClockToAudio(3500)).to.equal(4000)
|
||||
})
|
||||
|
||||
it('10. totalTimeSaved with region {1000, 3000} ratio 2x === 1000', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
expect(mapper.totalTimeSaved()).to.equal(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('11. Adjacent regions (no gap)', () => {
|
||||
const mapper = new TimeMapper([
|
||||
{ start: 1000, end: 2000 },
|
||||
{ start: 2000, end: 3000 }
|
||||
], 2.0)
|
||||
// Effectively one 2000ms region.
|
||||
expect(mapper.totalTimeSaved()).to.equal(1000)
|
||||
expect(mapper.wallClockToAudio(2000)).to.equal(3000)
|
||||
})
|
||||
|
||||
it('12. Region at time 0', () => {
|
||||
const mapper = new TimeMapper([{ start: 0, end: 2000 }], 2.0)
|
||||
expect(mapper.wallClockToAudio(1000)).to.equal(2000)
|
||||
expect(mapper.audioToWallClock(2000)).to.equal(1000)
|
||||
})
|
||||
|
||||
it('13. Very short region (199ms - below threshold, should not compress)', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 1199 }], 2.0)
|
||||
expect(mapper.totalTimeSaved()).to.equal(0)
|
||||
expect(mapper.wallClockToAudio(1500)).to.equal(1500)
|
||||
})
|
||||
|
||||
it('14. Very long region (10 minutes of silence)', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 601000 }], 2.0)
|
||||
// 600,000ms. compressed to 300,000ms. Saved 300,000ms.
|
||||
expect(mapper.totalTimeSaved()).to.equal(300000)
|
||||
expect(mapper.wallClockToAudio(301000)).to.equal(601000)
|
||||
})
|
||||
|
||||
it('15. Ratio 1.0 → no compression, identity mapping', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 1.0)
|
||||
expect(mapper.totalTimeSaved()).to.equal(0)
|
||||
expect(mapper.wallClockToAudio(2000)).to.equal(2000)
|
||||
})
|
||||
|
||||
it('16. Ratio 5.0 → aggressive compression', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 6000 }], 5.0)
|
||||
// 5000ms region. ratio 5.0 -> compressed to 1000ms. Saved 4000ms.
|
||||
expect(mapper.totalTimeSaved()).to.equal(4000)
|
||||
expect(mapper.wallClockToAudio(1500)).to.equal(3500) // 1000 + (500 * 5) = 3500
|
||||
})
|
||||
|
||||
it('17. Seek into middle of a compressed region', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
// Seeking to audio time 2000 -> should be wallclock 1500
|
||||
expect(mapper.audioToWallClock(2000)).to.equal(1500)
|
||||
})
|
||||
|
||||
it('18. Wall-clock time maps monotonically (never goes backward)', () => {
|
||||
const mapper = new TimeMapper([{ start: 1000, end: 3000 }], 2.0)
|
||||
let prevAudio = -1
|
||||
for (let wallMs = 0; wallMs <= 4000; wallMs += 50) {
|
||||
const audioMs = mapper.wallClockToAudio(wallMs)
|
||||
expect(audioMs).to.be.at.least(prevAudio)
|
||||
prevAudio = audioMs
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
65
test/client/store/user.test.js
Normal file
65
test/client/store/user.test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { state, mutations } from '../../../client/store/user.js'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('User Store Mutations', () => {
|
||||
let mockState
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = state()
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
store: {},
|
||||
getItem(key) {
|
||||
return this.store[key] || null
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.store[key] = value
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Default state has enableSmartSpeed = false', () => {
|
||||
expect(mockState.settings.enableSmartSpeed).to.be.false
|
||||
})
|
||||
|
||||
it('Default state has smartSpeedRatio = 2.5', () => {
|
||||
expect(mockState.settings.smartSpeedRatio).to.equal(2.5)
|
||||
})
|
||||
|
||||
it('SET_SMART_SPEED_ENABLED mutation toggles the value', () => {
|
||||
mutations.SET_SMART_SPEED_ENABLED(mockState)
|
||||
expect(mockState.settings.enableSmartSpeed).to.be.true
|
||||
mutations.SET_SMART_SPEED_ENABLED(mockState)
|
||||
expect(mockState.settings.enableSmartSpeed).to.be.false
|
||||
|
||||
// Check setting explicitly
|
||||
mutations.SET_SMART_SPEED_ENABLED(mockState, true)
|
||||
expect(mockState.settings.enableSmartSpeed).to.be.true
|
||||
})
|
||||
|
||||
it('SET_SMART_SPEED_RATIO mutation sets the value', () => {
|
||||
mutations.SET_SMART_SPEED_RATIO(mockState, 3.0)
|
||||
expect(mockState.settings.smartSpeedRatio).to.equal(3.0)
|
||||
})
|
||||
|
||||
it('Ratio is clamped to valid range [1.5, 5.0]', () => {
|
||||
mutations.SET_SMART_SPEED_RATIO(mockState, 1.0)
|
||||
expect(mockState.settings.smartSpeedRatio).to.equal(1.5)
|
||||
|
||||
mutations.SET_SMART_SPEED_RATIO(mockState, 6.0)
|
||||
expect(mockState.settings.smartSpeedRatio).to.equal(5.0)
|
||||
})
|
||||
|
||||
it('Settings persist to localStorage', () => {
|
||||
mutations.SET_SMART_SPEED_ENABLED(mockState, true)
|
||||
let savedSettings = JSON.parse(localStorage.getItem('userSettings'))
|
||||
expect(savedSettings.enableSmartSpeed).to.be.true
|
||||
|
||||
mutations.SET_SMART_SPEED_RATIO(mockState, 4.0)
|
||||
savedSettings = JSON.parse(localStorage.getItem('userSettings'))
|
||||
expect(savedSettings.smartSpeedRatio).to.equal(4.0)
|
||||
})
|
||||
})
|
||||
46
test/server/models/MediaProgress.test.js
Normal file
46
test/server/models/MediaProgress.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
|
||||
describe('MediaProgress', () => {
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Database.sequelize.close()
|
||||
})
|
||||
|
||||
it('marks progress finished using coherent wall-clock currentTime and duration values', async () => {
|
||||
const user = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
const progress = await Database.mediaProgressModel.create({
|
||||
userId: user.id,
|
||||
mediaItemId: '00000000-0000-0000-0000-000000000001',
|
||||
mediaItemType: 'book',
|
||||
duration: 10,
|
||||
currentTime: 0,
|
||||
isFinished: false,
|
||||
extraData: {}
|
||||
})
|
||||
|
||||
await progress.applyProgressUpdate({
|
||||
currentTime: 9.5,
|
||||
duration: 10,
|
||||
markAsFinishedTimeRemaining: 1
|
||||
})
|
||||
|
||||
expect(progress.isFinished).to.equal(true)
|
||||
expect(progress.progress).to.equal(0.95)
|
||||
})
|
||||
})
|
||||
26
test/server/objects/PlaybackSession.test.js
Normal file
26
test/server/objects/PlaybackSession.test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const { expect } = require('chai')
|
||||
|
||||
const PlaybackSession = require('../../../server/objects/PlaybackSession')
|
||||
|
||||
describe('PlaybackSession', () => {
|
||||
it('computes progress from a single currentTime and duration domain', () => {
|
||||
const session = new PlaybackSession({
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
libraryItemId: 'item-1',
|
||||
mediaType: 'book',
|
||||
duration: 10,
|
||||
currentTime: 6,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
deviceInfo: {}
|
||||
})
|
||||
|
||||
expect(session.progress).to.equal(0.6)
|
||||
expect(session.mediaProgressObject).to.include({
|
||||
duration: 10,
|
||||
currentTime: 6,
|
||||
progress: 0.6
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue