mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
feat: add cursor-aware endless scroll
This commit is contained in:
parent
be98c79007
commit
706f219d17
2 changed files with 177 additions and 4 deletions
|
|
@ -50,6 +50,10 @@ export default {
|
||||||
return {
|
return {
|
||||||
routeFullPath: null,
|
routeFullPath: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
paginationMode: 'offset',
|
||||||
|
nextCursor: null,
|
||||||
|
isCountDeferred: false,
|
||||||
|
pageCursors: {},
|
||||||
bookshelfHeight: 0,
|
bookshelfHeight: 0,
|
||||||
bookshelfWidth: 0,
|
bookshelfWidth: 0,
|
||||||
shelvesPerPage: 0,
|
shelvesPerPage: 0,
|
||||||
|
|
@ -327,6 +331,36 @@ export default {
|
||||||
this.lastItemIndexSelected = -1
|
this.lastItemIndexSelected = -1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
resetPaginationState() {
|
||||||
|
this.paginationMode = 'offset'
|
||||||
|
this.nextCursor = null
|
||||||
|
this.isCountDeferred = false
|
||||||
|
this.pageCursors = { 0: null }
|
||||||
|
},
|
||||||
|
getFetchQueryString(page = 0) {
|
||||||
|
const cursor = this.paginationMode === 'keyset' && page > 0 ? this.pageCursors[page] : null
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (this.currentSFQueryString) {
|
||||||
|
const currentSearchParams = new URLSearchParams(this.currentSFQueryString)
|
||||||
|
for (const [key, value] of currentSearchParams.entries()) {
|
||||||
|
searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParams.set('limit', this.booksPerFetch)
|
||||||
|
searchParams.set('pageMode', 'endless')
|
||||||
|
searchParams.set('minified', 1)
|
||||||
|
searchParams.set('include', 'rssfeed,numEpisodesIncomplete,share')
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
searchParams.set('cursor', cursor)
|
||||||
|
} else {
|
||||||
|
searchParams.set('page', page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `?${searchParams.toString()}`
|
||||||
|
},
|
||||||
async fetchEntites(page = 0) {
|
async fetchEntites(page = 0) {
|
||||||
const startIndex = page * this.booksPerFetch
|
const startIndex = page * this.booksPerFetch
|
||||||
|
|
||||||
|
|
@ -334,11 +368,11 @@ export default {
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
|
this.resetPaginationState()
|
||||||
}
|
}
|
||||||
|
|
||||||
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const fullQueryString = this.getFetchQueryString(page)
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`
|
|
||||||
|
|
||||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch items', error)
|
console.error('failed to fetch items', error)
|
||||||
|
|
@ -352,6 +386,18 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
this.paginationMode = payload.paginationMode || this.paginationMode
|
||||||
|
this.nextCursor = payload.nextCursor || null
|
||||||
|
this.isCountDeferred = !!payload.isCountDeferred
|
||||||
|
|
||||||
|
if (this.paginationMode === 'keyset') {
|
||||||
|
if (this.nextCursor) {
|
||||||
|
this.pageCursors[page + 1] = this.nextCursor
|
||||||
|
} else {
|
||||||
|
delete this.pageCursors[page + 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.totalEntities = payload.total
|
this.totalEntities = payload.total
|
||||||
|
|
@ -371,7 +417,17 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loadPage(page) {
|
loadPage(page) {
|
||||||
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
|
if (this.pagesLoaded[page]) return this.pagesLoaded[page]
|
||||||
|
|
||||||
|
if (this.paginationMode === 'keyset' && page > 0) {
|
||||||
|
this.pagesLoaded[page] = this.loadPage(page - 1).then(() => {
|
||||||
|
if (!this.pageCursors[page]) return Promise.resolve()
|
||||||
|
return this.fetchEntites(page)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.pagesLoaded[page] = this.fetchEntites(page)
|
||||||
|
}
|
||||||
|
|
||||||
return this.pagesLoaded[page]
|
return this.pagesLoaded[page]
|
||||||
},
|
},
|
||||||
showHideBookPlaceholder(index, show) {
|
showHideBookPlaceholder(index, show) {
|
||||||
|
|
@ -413,7 +469,15 @@ export default {
|
||||||
clearTimeout(this.postScrollTimeout)
|
clearTimeout(this.postScrollTimeout)
|
||||||
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
|
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
|
||||||
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
|
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
|
||||||
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
|
|
||||||
|
// Mount whatever is cached immediately — cached cards show at once,
|
||||||
|
// uncached slots render as skeleton until data arrives
|
||||||
|
this.mountEntities(firstEntityIndex, lastEntityIndex)
|
||||||
|
|
||||||
|
// Then fetch missing pages and remount to fill in any skeleton placeholders
|
||||||
|
const visiblePageLoads = this.paginationMode === 'keyset' ? this.loadPage(firstPage).then(() => this.loadPage(lastPage)) : Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
|
||||||
|
|
||||||
|
visiblePageLoads
|
||||||
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
|
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
|
||||||
.catch((error) => console.error('Failed to load page', error))
|
.catch((error) => console.error('Failed to load page', error))
|
||||||
|
|
||||||
|
|
@ -426,6 +490,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
this.pagesLoaded = {}
|
this.pagesLoaded = {}
|
||||||
|
this.resetPaginationState()
|
||||||
this.entities = []
|
this.entities = []
|
||||||
this.totalShelves = 0
|
this.totalShelves = 0
|
||||||
this.totalEntities = 0
|
this.totalEntities = 0
|
||||||
|
|
|
||||||
108
client/cypress/tests/components/app/LazyBookshelf.cy.js
Normal file
108
client/cypress/tests/components/app/LazyBookshelf.cy.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import LazyBookshelf from '@/components/app/LazyBookshelf.vue'
|
||||||
|
|
||||||
|
describe('LazyBookshelf', () => {
|
||||||
|
it('uses the server cursor for keyset follow-up chunks', () => {
|
||||||
|
const requestUrls = []
|
||||||
|
const getStub = cy.stub().callsFake((url) => {
|
||||||
|
requestUrls.push(url)
|
||||||
|
|
||||||
|
if (requestUrls.length === 1) {
|
||||||
|
return Promise.resolve({
|
||||||
|
results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
|
||||||
|
total: 4,
|
||||||
|
nextCursor: 'cursor-1',
|
||||||
|
paginationMode: 'keyset',
|
||||||
|
isCountDeferred: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
results: [{ id: 'item-2', mediaType: 'book', media: { metadata: { title: 'Beta' } } }],
|
||||||
|
total: 4,
|
||||||
|
nextCursor: 'cursor-2',
|
||||||
|
paginationMode: 'keyset',
|
||||||
|
isCountDeferred: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
getters: {
|
||||||
|
'user/getIsAdminOrUp': true,
|
||||||
|
'libraries/getCurrentLibraryMediaType': 'book',
|
||||||
|
'libraries/getCurrentLibraryName': 'Library',
|
||||||
|
'user/getUserSetting': (key) => {
|
||||||
|
const settings = {
|
||||||
|
orderBy: 'media.metadata.title',
|
||||||
|
orderDesc: false,
|
||||||
|
filterBy: 'all',
|
||||||
|
collapseSeries: false,
|
||||||
|
collapseBookSeries: false,
|
||||||
|
seriesSortBy: 'name',
|
||||||
|
seriesSortDesc: false,
|
||||||
|
seriesFilterBy: 'all',
|
||||||
|
authorSortBy: 'name',
|
||||||
|
authorSortDesc: false
|
||||||
|
}
|
||||||
|
return settings[key]
|
||||||
|
},
|
||||||
|
'libraries/getBookCoverAspectRatio': 1.6,
|
||||||
|
'getServerSetting': () => false,
|
||||||
|
getBookshelfView: 'grid',
|
||||||
|
'user/getSizeMultiplier': 1,
|
||||||
|
'tasks/getRunningLibraryScanTask': () => null
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
libraries: {
|
||||||
|
currentLibraryId: 'lib-1'
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
selectedMediaItems: []
|
||||||
|
},
|
||||||
|
streamLibraryItem: null,
|
||||||
|
lastBookshelfScrollData: {}
|
||||||
|
},
|
||||||
|
dispatch: cy.stub().resolves(),
|
||||||
|
commit: cy.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.mount(LazyBookshelf, {
|
||||||
|
mocks: {
|
||||||
|
$axios: { $get: getStub },
|
||||||
|
$store: store,
|
||||||
|
$eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} },
|
||||||
|
$root: { socket: { on: () => {}, off: () => {} } },
|
||||||
|
$route: { query: {} },
|
||||||
|
$router: { push: cy.stub() },
|
||||||
|
$toast: { error: cy.stub() },
|
||||||
|
$getString: (key) => key,
|
||||||
|
$encode: encodeURIComponent,
|
||||||
|
$decode: decodeURIComponent
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async setCardSize() {
|
||||||
|
this.cardWidth = 120
|
||||||
|
this.cardHeight = 180
|
||||||
|
this.coverHeight = 160
|
||||||
|
},
|
||||||
|
mountEntityCard() {},
|
||||||
|
initListeners() {}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
booksPerFetch: 1,
|
||||||
|
entitiesPerShelf: 1,
|
||||||
|
shelvesPerPage: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).then(async ({ wrapper }) => {
|
||||||
|
await wrapper.vm.fetchEntites(0)
|
||||||
|
await wrapper.vm.fetchEntites(1)
|
||||||
|
|
||||||
|
expect(requestUrls).to.have.length(2)
|
||||||
|
expect(requestUrls[0]).to.include('page=0')
|
||||||
|
expect(requestUrls[0]).to.not.include('cursor=')
|
||||||
|
expect(requestUrls[1]).to.include('cursor=cursor-1')
|
||||||
|
expect(requestUrls[1]).to.not.include('page=1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue