mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-23 20:01:35 +00:00
Fix Smart Speed playback time contract
This commit is contained in:
parent
5c747a7f8f
commit
97c5d6341e
9 changed files with 205 additions and 17 deletions
|
|
@ -159,8 +159,7 @@ export default {
|
||||||
return this.streamLibraryItem?.libraryId || null
|
return this.streamLibraryItem?.libraryId || null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
// Adjusted by playback rate
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
|
||||||
},
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
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="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">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
{{ this.$secondsToTimestamp(currentTime) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow px-2">
|
<div class="grow px-2">
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<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">
|
<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">
|
<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">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</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="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 v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,15 +43,11 @@ export default {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_playbackRate() {
|
|
||||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
|
||||||
return this.playbackRate
|
|
||||||
},
|
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter?.id || null
|
return this.currentChapter?.id || null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
return this.currentChapter?.start || 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ export default {
|
||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
}
|
}
|
||||||
if (this.$refs.hoverTimestampText) {
|
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)
|
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||||
if (chapter && chapter.title) {
|
if (chapter && chapter.title) {
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,9 @@ export default {
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
var currChapTime = this.currentTime - this.currentChapter.start
|
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() {
|
timeRemainingPretty() {
|
||||||
if (this.timeRemaining < 0) {
|
if (this.timeRemaining < 0) {
|
||||||
|
|
@ -309,7 +309,7 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
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) {
|
setBufferTime(bufferTime) {
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
|
|
|
||||||
110
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
110
client/cypress/tests/players/LocalAudioPlayer.cy.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import LocalAudioPlayer from '../../../players/LocalAudioPlayer'
|
||||||
|
|
||||||
|
describe('LocalAudioPlayer', () => {
|
||||||
|
let mockPort;
|
||||||
|
let mockAudioContext;
|
||||||
|
let mockAudioWorkletNode;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock for AudioWorkletNode message port
|
||||||
|
mockPort = {
|
||||||
|
onmessage: null,
|
||||||
|
postMessage: cy.stub()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock AudioWorkletNode
|
||||||
|
mockAudioWorkletNode = {
|
||||||
|
port: mockPort,
|
||||||
|
connect: cy.stub(),
|
||||||
|
disconnect: cy.stub()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock AudioContext
|
||||||
|
mockAudioContext = {
|
||||||
|
audioWorklet: {
|
||||||
|
addModule: cy.stub().resolves()
|
||||||
|
},
|
||||||
|
createMediaElementSource: cy.stub().returns({
|
||||||
|
connect: cy.stub(),
|
||||||
|
disconnect: cy.stub()
|
||||||
|
}),
|
||||||
|
destination: {},
|
||||||
|
state: 'running',
|
||||||
|
currentTime: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make AudioWorkletNode available globally so `new AudioWorkletNode` works
|
||||||
|
if (!window.AudioWorkletNode) {
|
||||||
|
window.AudioWorkletNode = function() { return mockAudioWorkletNode; };
|
||||||
|
} else {
|
||||||
|
cy.stub(window, 'AudioWorkletNode').returns(mockAudioWorkletNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.AudioContext) {
|
||||||
|
window.AudioContext = function() { return mockAudioContext; };
|
||||||
|
} else {
|
||||||
|
cy.stub(window, 'AudioContext').returns(mockAudioContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.webkitAudioContext) {
|
||||||
|
cy.stub(window, 'webkitAudioContext').returns(mockAudioContext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases playbackRate during silence', () => {
|
||||||
|
const localPlayer = new LocalAudioPlayer({});
|
||||||
|
|
||||||
|
// Default playback rate should be 1
|
||||||
|
expect(localPlayer.player.playbackRate).to.equal(1);
|
||||||
|
|
||||||
|
cy.wrap(localPlayer).should('have.property', 'usingWebAudio', true).then(() => {
|
||||||
|
// Enable smart speed (this should trigger initSilenceDetector)
|
||||||
|
return localPlayer.setSmartSpeed(true);
|
||||||
|
}).then(() => {
|
||||||
|
expect(localPlayer.enableSmartSpeed).to.be.true;
|
||||||
|
expect(mockAudioContext.audioWorklet.addModule).to.have.been.calledWith('/client/players/smart-speed/SilenceDetectorProcessor.js');
|
||||||
|
expect(localPlayer.silenceDetectorNode).to.equal(mockAudioWorkletNode);
|
||||||
|
|
||||||
|
// Simulate silence start
|
||||||
|
mockPort.onmessage({
|
||||||
|
data: {
|
||||||
|
type: 'silence-start',
|
||||||
|
time: 5000 // 5 seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The smartSpeedRatio is 2.0 by default, so playbackRate should be 2.0
|
||||||
|
expect(localPlayer.player.playbackRate).to.equal(2.0);
|
||||||
|
|
||||||
|
// Simulate silence end
|
||||||
|
mockPort.onmessage({
|
||||||
|
data: {
|
||||||
|
type: 'silence-end',
|
||||||
|
time: 8000 // 8 seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should return to default 1.0
|
||||||
|
expect(localPlayer.player.playbackRate).to.equal(1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -399,14 +399,21 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||||
getCurrentTime() {
|
getCurrentTime() {
|
||||||
var currentTrackOffset = this.currentTrack.startOffset || 0
|
var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||||
if (!this.player) return 0
|
if (!this.player) return 0
|
||||||
|
|
||||||
|
if (this.enableSmartSpeed) {
|
||||||
|
return this.timeMapper.audioToWallClock((currentTrackOffset + this.player.currentTime) * 1000) / 1000
|
||||||
|
}
|
||||||
return currentTrackOffset + this.player.currentTime
|
return currentTrackOffset + this.player.currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
getDuration() {
|
getDuration() {
|
||||||
if (!this.audioTracks.length) return 0
|
if (!this.audioTracks.length) return 0
|
||||||
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
|
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) {
|
setPlaybackRate(playbackRate) {
|
||||||
|
|
@ -435,6 +442,10 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||||
|
|
||||||
var mappedTime = time
|
var mappedTime = time
|
||||||
|
|
||||||
|
if (this.enableSmartSpeed) {
|
||||||
|
mappedTime = this.timeMapper.wallClockToAudio(time * 1000) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
if (this.silenceDetectorNode) {
|
if (this.silenceDetectorNode) {
|
||||||
this.silenceDetectorNode.port.postMessage({ type: 'reset' })
|
this.silenceDetectorNode.port.postMessage({ type: 'reset' })
|
||||||
this._silenceStartTime = null
|
this._silenceStartTime = null
|
||||||
|
|
|
||||||
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