mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-12 06:21:30 +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
|
||||
},
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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() {
|
||||
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
|
||||
|
|
|
|||
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