Fix Smart Speed playback time contract

This commit is contained in:
Jonathan Baldie 2026-05-02 00:25:37 +01:00
parent 5c747a7f8f
commit 97c5d6341e
9 changed files with 205 additions and 17 deletions

View file

@ -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

View file

@ -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">

View file

@ -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: {

View file

@ -189,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) {

View file

@ -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)

View 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);
});
});

View file

@ -399,14 +399,21 @@ export default class LocalAudioPlayer extends EventEmitter {
getCurrentTime() {
var currentTrackOffset = this.currentTrack.startOffset || 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) {
@ -435,6 +442,10 @@ export default class LocalAudioPlayer extends EventEmitter {
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

View 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)
})
})

View 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
})
})
})