+
{{ chap.title }}
-
{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}
+
{{ $elapsedPrettyExtended(chap.end - chap.start) }}
-
{{ $secondsToTimestamp(chap.start / _playbackRate) }}
+
{{ $secondsToTimestamp(chap.start) }}
@@ -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: {
diff --git a/client/components/player/PlayerTrackBar.vue b/client/components/player/PlayerTrackBar.vue
index 2d09ea53..fda007d5 100644
--- a/client/components/player/PlayerTrackBar.vue
+++ b/client/components/player/PlayerTrackBar.vue
@@ -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) {
diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue
index 64fee1ad..3a66d7dc 100644
--- a/client/components/player/PlayerUi.vue
+++ b/client/components/player/PlayerUi.vue
@@ -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)
diff --git a/client/cypress/tests/players/LocalAudioPlayer.cy.js b/client/cypress/tests/players/LocalAudioPlayer.cy.js
new file mode 100644
index 00000000..03765479
--- /dev/null
+++ b/client/cypress/tests/players/LocalAudioPlayer.cy.js
@@ -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);
+ });
+});
diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js
index ef566e2c..fe6ec9be 100644
--- a/client/players/LocalAudioPlayer.js
+++ b/client/players/LocalAudioPlayer.js
@@ -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
diff --git a/test/server/models/MediaProgress.test.js b/test/server/models/MediaProgress.test.js
new file mode 100644
index 00000000..cf48a8d9
--- /dev/null
+++ b/test/server/models/MediaProgress.test.js
@@ -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)
+ })
+})
diff --git a/test/server/objects/PlaybackSession.test.js b/test/server/objects/PlaybackSession.test.js
new file mode 100644
index 00000000..19a54289
--- /dev/null
+++ b/test/server/objects/PlaybackSession.test.js
@@ -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
+ })
+ })
+})