diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index 1a2b1d30..da951c26 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -528,7 +528,11 @@ export default { if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack() }) - this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime) + // Resolve per-book playback rate for the new item, falling back to current rate + const mediaProgress = this.$store.getters['user/getUserMediaProgress'](libraryItemId, episodeId) + const playbackRate = mediaProgress?.playbackRate || this.currentPlaybackRate + + this.playerHandler.load(libraryItem, episodeId, true, playbackRate, payload.startTime) }, pauseItem() { this.playerHandler.pause() diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index f929943c..d003b57a 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -110,6 +110,12 @@ export default { useChapterTrack() { if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) this.updateTimestamp() + }, + '$store.state.streamLibraryItem'() { + this.initPlaybackRate() + }, + '$store.state.streamEpisodeId'() { + this.initPlaybackRate() } }, computed: { @@ -224,18 +230,27 @@ export default { increasePlaybackRate() { if (this.playbackRate >= 10) return this.playbackRate = Number((this.playbackRate + this.playbackRateIncrementDecrement || 0.1).toFixed(2)) - this.setPlaybackRate(this.playbackRate) + this.playbackRateChanged(this.playbackRate) }, decreasePlaybackRate() { if (this.playbackRate <= 0.5) return this.playbackRate = Number((this.playbackRate - this.playbackRateIncrementDecrement || 0.1).toFixed(2)) - this.setPlaybackRate(this.playbackRate) + this.playbackRateChanged(this.playbackRate) }, playbackRateChanged(playbackRate) { this.setPlaybackRate(playbackRate) this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { console.error('Failed to update settings', err) }) + + // Save per-book playback rate to mediaProgress + const libraryItemId = this.$store.state.streamLibraryItem?.id + if (!libraryItemId) return + const episodeId = this.$store.state.streamEpisodeId + const progressPath = episodeId ? `${libraryItemId}/${episodeId}` : libraryItemId + this.$axios.$patch(`/api/me/progress/${progressPath}`, { playbackRate }).catch((err) => { + console.error('Failed to save playback rate to progress', err) + }) }, setPlaybackRate(playbackRate) { this.$emit('setPlaybackRate', playbackRate) @@ -321,15 +336,27 @@ export default { showPlayerSettings() { this.showPlayerSettingsModal = !this.showPlayerSettingsModal }, + initPlaybackRate() { + const libraryItemId = this.$store.state.streamLibraryItem?.id + const episodeId = this.$store.state.streamEpisodeId + const mediaProgress = this.$store.getters['user/getUserMediaProgress'](libraryItemId, episodeId) + this.playbackRate = mediaProgress?.playbackRate || this.$store.getters['user/getUserSetting']('playbackRate') || 1 + this.setPlaybackRate(this.playbackRate) + }, init() { - this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 + this.initPlaybackRate() if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) - this.setPlaybackRate(this.playbackRate) }, settingsUpdated(settings) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { - this.setPlaybackRate(settings.playbackRate) + // Don't let global setting override a per-book rate + const libraryItemId = this.$store.state.streamLibraryItem?.id + const episodeId = this.$store.state.streamEpisodeId + const mediaProgress = this.$store.getters['user/getUserMediaProgress'](libraryItemId, episodeId) + if (!mediaProgress?.playbackRate) { + this.setPlaybackRate(settings.playbackRate) + } } }, closePlayer() { diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 9c0269a9..3b8f9bad 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -169,6 +169,7 @@ class MediaProgress extends Model { hideFromContinueListening: !!this.hideFromContinueListening, ebookLocation: this.ebookLocation, ebookProgress: this.ebookProgress, + playbackRate: this.extraData?.playbackRate || null, lastUpdate: this.updatedAt.valueOf(), startedAt: this.createdAt.valueOf(), finishedAt: this.finishedAt?.valueOf() || null @@ -209,6 +210,11 @@ class MediaProgress extends Model { this.changed('extraData', true) } + if (progressPayload.playbackRate !== undefined) { + this.extraData.playbackRate = progressPayload.playbackRate + this.changed('extraData', true) + } + this.set(progressPayload) // Reset hideFromContinueListening if the progress has changed diff --git a/test/server/models/MediaProgress.test.js b/test/server/models/MediaProgress.test.js new file mode 100644 index 00000000..38bc25dd --- /dev/null +++ b/test/server/models/MediaProgress.test.js @@ -0,0 +1,102 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const { Sequelize } = require('sequelize') +const Database = require('../../../server/Database') +const Logger = require('../../../server/Logger') + +describe('MediaProgress', () => { + let 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() + + const user = await Database.userModel.create({ username: 'testuser', type: 'root' }) + const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) + const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id }) + + mediaProgress = await Database.mediaProgressModel.create({ + mediaItemId: book.id, + mediaItemType: 'book', + userId: user.id, + duration: 36000, + currentTime: 1234.5, + extraData: { libraryItemId: libraryItem.id, progress: 0.034 } + }) + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + describe('getOldMediaProgress', () => { + it('includes playbackRate from extraData when set', () => { + mediaProgress.extraData = { ...mediaProgress.extraData, playbackRate: 1.5 } + const result = mediaProgress.getOldMediaProgress() + expect(result.playbackRate).to.equal(1.5) + }) + + it('returns null when playbackRate not in extraData', () => { + const result = mediaProgress.getOldMediaProgress() + expect(result.playbackRate).to.be.null + }) + + it('returns null when extraData is null', () => { + mediaProgress.extraData = null + const result = mediaProgress.getOldMediaProgress() + expect(result.playbackRate).to.be.null + }) + + it('preserves existing extraData fields', () => { + mediaProgress.extraData = { libraryItemId: 'li_test', progress: 0.5, playbackRate: 2.0 } + const result = mediaProgress.getOldMediaProgress() + expect(result.playbackRate).to.equal(2.0) + expect(result.progress).to.equal(0.5) + expect(result.libraryItemId).to.equal('li_test') + }) + }) + + describe('applyProgressUpdate', () => { + it('stores playbackRate in extraData', async () => { + await mediaProgress.applyProgressUpdate({ playbackRate: 1.5 }) + expect(mediaProgress.extraData.playbackRate).to.equal(1.5) + }) + + it('updates existing playbackRate', async () => { + mediaProgress.extraData = { ...mediaProgress.extraData, playbackRate: 1.5 } + await mediaProgress.applyProgressUpdate({ playbackRate: 2.0 }) + expect(mediaProgress.extraData.playbackRate).to.equal(2.0) + }) + + it('does not touch playbackRate when not in payload', async () => { + mediaProgress.extraData = { ...mediaProgress.extraData, playbackRate: 1.5 } + await mediaProgress.applyProgressUpdate({ currentTime: 5000 }) + expect(mediaProgress.extraData.playbackRate).to.equal(1.5) + }) + + it('preserves other extraData fields when setting playbackRate', async () => { + const originalLibraryItemId = mediaProgress.extraData.libraryItemId + const originalProgress = mediaProgress.extraData.progress + await mediaProgress.applyProgressUpdate({ playbackRate: 1.75 }) + expect(mediaProgress.extraData.libraryItemId).to.equal(originalLibraryItemId) + expect(mediaProgress.extraData.progress).to.equal(originalProgress) + expect(mediaProgress.extraData.playbackRate).to.equal(1.75) + }) + + it('initializes extraData if null', async () => { + mediaProgress.extraData = null + await mediaProgress.applyProgressUpdate({ playbackRate: 1.25 }) + expect(mediaProgress.extraData).to.be.an('object') + expect(mediaProgress.extraData.playbackRate).to.equal(1.25) + }) + }) +})