diff --git a/contrib/navidrome b/contrib/navidrome
index bb6908797..8ab5afe2e 100644
--- a/contrib/navidrome
+++ b/contrib/navidrome
@@ -2,7 +2,7 @@
name=$RC_SVCNAME
command="/opt/navidrome/${RC_SVCNAME}"
-command_args="-datafolder /opt/navidrome"
+command_args="--datafolder /opt/navidrome"
command_user="${RC_SVCNAME}"
pidfile="/var/run/${RC_SVCNAME}.pid"
output_log="/opt/navidrome/${RC_SVCNAME}.log"
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index 5c9ec898d..a9a634c01 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -71,6 +71,11 @@ var fieldMap = map[string]FieldInfo{
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
+ "albumdateadded": {},
+ "albumdatemodified": {},
+ "albumduration": {},
+ "albumsongcount": {},
+ "albumsize": {},
"artistrating": {},
"artistloved": {Boolean: true},
"artistplaycount": {},
diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go
index 43cab4fdd..70f9a1fb5 100644
--- a/persistence/criteria_sql.go
+++ b/persistence/criteria_sql.go
@@ -21,6 +21,7 @@ const (
smartPlaylistJoinNone smartPlaylistJoinType = 0
smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
smartPlaylistJoinArtistAnnotation
+ smartPlaylistJoinAlbum
)
func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
@@ -103,6 +104,11 @@ var smartPlaylistFields = map[string]smartPlaylistField{
"albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
+ "albumdateadded": {expr: "album.created_at", joinType: smartPlaylistJoinAlbum},
+ "albumdatemodified": {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum},
+ "albumduration": {expr: "album.duration", joinType: smartPlaylistJoinAlbum},
+ "albumsongcount": {expr: "album.song_count", joinType: smartPlaylistJoinAlbum},
+ "albumsize": {expr: "album.size", joinType: smartPlaylistJoinAlbum},
"artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation},
"artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation},
"artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation},
diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go
index 59bdc4452..0cd7187c5 100644
--- a/persistence/criteria_sql_test.go
+++ b/persistence/criteria_sql_test.go
@@ -51,6 +51,11 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3),
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
+ Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5),
+ Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600),
+ Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000),
+ Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
+ Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
// Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks
// with no annotation row behave as that default. To keep the annotation index usable, the
// COALESCE is dropped when the compared value cannot match the default (the missing-row
@@ -298,6 +303,11 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
})
+ It("sorts by album columns bare, with no COALESCE default", func() {
+ Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()).
+ To(Equal("album.created_at desc, media_file.track_number asc"))
+ })
+
It("sorts by multiple fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
})
@@ -323,6 +333,28 @@ var _ = Describe("Smart playlist criteria SQL", func() {
}
})
+ It("declares a joinType matching the table each field selects from", func() {
+ // Omitting the joinType still compiles, so without this the field would only fail at
+ // refresh time with "no such column".
+ joinByTable := map[string]smartPlaylistJoinType{
+ "media_file": smartPlaylistJoinNone,
+ "annotation": smartPlaylistJoinNone,
+ "album": smartPlaylistJoinAlbum,
+ "album_annotation": smartPlaylistJoinAlbumAnnotation,
+ "artist_annotation": smartPlaylistJoinArtistAnnotation,
+ }
+ for name, field := range smartPlaylistFields {
+ if field.expr == "" {
+ continue
+ }
+ table, _, ok := strings.Cut(field.expr, ".")
+ Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr)
+ want, known := joinByTable[table]
+ Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table)
+ Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table)
+ }
+ })
+
Describe("JSON condition merging", func() {
It("merges multiple role conditions in an OR group into a single EXISTS", func() {
expr := criteria.Any{
@@ -585,5 +617,21 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
+
+ It("keeps a sort-only album join out of the expression joins", func() {
+ c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"}
+ cSQL := newSmartPlaylistCriteria(c)
+
+ Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
+ Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue())
+ })
+
+ It("distinguishes the album join from the album annotation join", func() {
+ c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
+ joins := newSmartPlaylistCriteria(c).RequiredJoins()
+
+ Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
+ Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse())
+ })
})
})
diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go
index 4966d3b8a..b96605d09 100644
--- a/persistence/e2e/smartplaylist_test.go
+++ b/persistence/e2e/smartplaylist_test.go
@@ -204,6 +204,43 @@ var _ = Describe("Smart Playlists", func() {
})
})
+ Describe("Album aggregate fields", func() {
+ // Abbey Road and IV have two tracks each; the other four albums have one.
+ It("matches albums with more than one track", func() {
+ results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog"))
+ })
+
+ It("matches single-track albums", func() {
+ results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`)
+ Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower",
+ "We Are the Champions"))
+ })
+
+ It("matches albumDateAdded inTheLast 1 day", func() {
+ results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":1}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
+ "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("matches albumDateModified before a far-future date", func() {
+ results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`)
+ Expect(results).To(HaveLen(8))
+ })
+
+ // Fixture durations are randomized, so only the aggregate being populated can be asserted.
+ It("resolves albumDuration and albumSize to non-zero aggregates", func() {
+ Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8))
+ Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8))
+ })
+
+ It("groups by album date and orders within the album (issue #5347)", func() {
+ results := evaluateRuleOrdered(
+ `{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`)
+ Expect(results).To(Equal([]string{"Come Together", "Something"}))
+ })
+ })
+
Describe("Logic operators", func() {
It("matches with ALL (AND)", func() {
results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`)
diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go
index f7c41597d..5f84981b3 100644
--- a/persistence/smart_playlist_repository.go
+++ b/persistence/smart_playlist_repository.go
@@ -122,7 +122,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file")
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
- countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
+ countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
@@ -144,7 +144,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ
}
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
-// including necessary joins for annotations and library filtering.
+// including the joins its fields require and library filtering.
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
@@ -152,7 +152,7 @@ func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesS
sq = r.addMediaFileAnnotationJoin(sq, userID)
requiredJoins := rulesSQL.RequiredJoins()
- sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
+ sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID)
sq = r.applyLibraryFilter(sq, "media_file")
return sq
}
@@ -166,9 +166,8 @@ func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID
" AND annotation.user_id = ?)", userID)
}
-// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on
-// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation.
-func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
+// addSmartPlaylistJoins adds the left joins required by the criteria's fields.
+func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
@@ -181,6 +180,9 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
+ if joins.has(smartPlaylistJoinAlbum) {
+ sq = sq.LeftJoin("album ON album.id = media_file.album_id")
+ }
return sq
}
diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go
index 8e3ae488f..e62436890 100644
--- a/persistence/smart_playlist_repository_test.go
+++ b/persistence/smart_playlist_repository_test.go
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@@ -392,6 +393,40 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
})
})
+ Describe("Smart Playlists with Album Aggregate Criteria", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+ })
+
+ trackIDsOf := func(rules *criteria.Criteria) []string {
+ newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(newPls.ID) })
+
+ pls, err := repo.GetWithTracks(newPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+ return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })
+ }
+
+ It("filters on albumSongCount", func() {
+ // albumMultiDisc (ID "104") is the only fixture album with SongCount > 3
+ rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}}
+
+ Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004"))
+ })
+
+ It("sorts by an album field not referenced in the expression (issue #5347)", func() {
+ // All four tracks share album 104, so the album date ties and disc/track number decide.
+ rules := &criteria.Criteria{
+ Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}},
+ Sort: "-albumDateAdded,discNumber,trackNumber",
+ }
+
+ Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001"))
+ })
+ })
+
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
diff --git a/scheduler/log_adapter.go b/scheduler/log_adapter.go
index 057818785..f7089f97c 100644
--- a/scheduler/log_adapter.go
+++ b/scheduler/log_adapter.go
@@ -11,7 +11,7 @@ func (l *logger) Info(msg string, keysAndValues ...any) {
"Scheduler: " + msg,
}
args = append(args, keysAndValues...)
- log.Debug(args...)
+ log.Trace(args...)
}
func (l *logger) Error(err error, msg string, keysAndValues ...any) {
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index 5108bfaa1..cd73fa0c3 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -1,3 +1,4 @@
+import { cloneElement } from 'react'
import { useSelector } from 'react-redux'
import { Redirect, useLocation } from 'react-router-dom'
import {
@@ -23,6 +24,7 @@ import {
Title,
useAlbumsPerPage,
useResourceRefresh,
+ useScrollRestoration,
useSetToggleableFields,
} from '../common'
import AlbumListActions from './AlbumListActions'
@@ -39,6 +41,13 @@ import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import { humanize } from 'inflection'
import { makeStyles } from '@material-ui/core/styles'
+// Waits for rows: restoring into an unrendered list leaves the page too short to hold the offset.
+const ScrollRestorer = ({ children, ...rest }) => {
+ const { loaded, total } = useListContext()
+ useScrollRestoration(loaded && total > 0)
+ return cloneElement(children, rest)
+}
+
const useStyles = makeStyles({
chip: {
margin: 0,
@@ -255,11 +264,13 @@ const AlbumList = (props) => {
}
title={}
>
- {albumView.grid ? (
-
- ) : (
-
- )}
+
+ {albumView.grid ? (
+
+ ) : (
+
+ )}
+
} />
>
diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx
index c9e944999..86b5ab1bc 100644
--- a/ui/src/album/AlbumShow.jsx
+++ b/ui/src/album/AlbumShow.jsx
@@ -10,7 +10,7 @@ import { makeStyles } from '@material-ui/core/styles'
import AlbumSongs from './AlbumSongs'
import AlbumDetails from './AlbumDetails'
import AlbumActions from './AlbumActions'
-import { useResourceRefresh, Title } from '../common'
+import { useResourceRefresh, useScrollRestoration, Title } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -28,6 +28,7 @@ const AlbumShowLayout = (props) => {
const { record } = context
const classes = useStyles()
useResourceRefresh('album', 'song')
+ useScrollRestoration(!!record?.id)
return (
<>
diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx
index 955a565d6..eef2989d5 100644
--- a/ui/src/artist/ArtistShow.jsx
+++ b/ui/src/artist/ArtistShow.jsx
@@ -13,7 +13,12 @@ import subsonic from '../subsonic'
import AlbumGridView from '../album/AlbumGridView'
import MobileArtistDetails from './MobileArtistDetails'
import DesktopArtistDetails from './DesktopArtistDetails'
-import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
+import {
+ useAlbumsPerPage,
+ useResourceRefresh,
+ useScrollRestoration,
+ Title,
+} from '../common/index.js'
import ArtistActions from './ArtistActions'
import { makeStyles } from '@material-ui/core'
@@ -85,6 +90,7 @@ const ArtistShowLayout = (props) => {
const [, perPageOptions] = useAlbumsPerPage(width)
const classes = useStyles()
useResourceRefresh('artist', 'album')
+ useScrollRestoration(!!record?.id)
const maxPerPage = 90
let perPage = 0
diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx
index d41b47b06..2a70ded7c 100644
--- a/ui/src/common/ArtistLinkField.jsx
+++ b/ui/src/common/ArtistLinkField.jsx
@@ -7,7 +7,8 @@ import { intersperse } from '../utils/index.js'
import { useDispatch } from 'react-redux'
import { closeExtendedInfoDialog } from '../actions/dialogs.js'
-const ALink = withWidth()((props) => {
+// noSSR: withWidth otherwise renders null until mounted, so the artist line pops in and grows the row.
+const ALink = withWidth({ noSSR: true })((props) => {
const { artist, width, ...rest } = props
const artistLink = useGetHandleArtistClick(width)
const dispatch = useDispatch()
diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx
index d2c98bbe7..4a813f84d 100644
--- a/ui/src/common/SongDatagrid.jsx
+++ b/ui/src/common/SongDatagrid.jsx
@@ -78,7 +78,7 @@ const useStyles = makeStyles({
},
})
-const DiscSubtitleRow = forwardRef(
+export const DiscSubtitleRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const translate = useTranslate()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
@@ -153,13 +153,17 @@ const DiscSubtitleRow = forwardRef(
{subtitle}
{isLightboxOpen && !imageError && (
-
+ // Lightbox portals out of the row, but React still bubbles its
+ // events up this tree, where the row's onClick would play the disc.
+ e.stopPropagation()}>
+
+
)}
diff --git a/ui/src/common/SongDatagrid.test.jsx b/ui/src/common/SongDatagrid.test.jsx
new file mode 100644
index 000000000..f8459d8eb
--- /dev/null
+++ b/ui/src/common/SongDatagrid.test.jsx
@@ -0,0 +1,74 @@
+import React from 'react'
+import { render, fireEvent, screen } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createTheme, ThemeProvider } from '@material-ui/core/styles'
+import { DiscSubtitleRow } from './SongDatagrid'
+
+vi.mock('../subsonic', () => ({
+ default: { getDiscCoverArtUrl: () => 'http://localhost/cover.jpg' },
+}))
+
+vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
+
+vi.mock('../common', () => ({ AlbumContextMenu: () => null }))
+
+vi.mock('react-dnd', () => ({ useDrag: () => [{}, vi.fn()] }))
+
+const record = {
+ id: 'song-1',
+ albumId: 'album-1',
+ album: 'The Album',
+ discNumber: 2,
+ discSubtitle: 'Bonus Disc',
+ updatedAt: '2024-01-01',
+}
+
+const renderRow = (onClick) =>
+ render(
+
+
+ ,
+ )
+
+const openLightbox = () => {
+ fireEvent.click(document.querySelector('img'))
+ expect(document.querySelector('.ril__closeButton')).toBeTruthy()
+}
+
+describe('DiscSubtitleRow', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('plays the disc when the row is clicked', () => {
+ const onClick = vi.fn()
+ renderRow(onClick)
+ fireEvent.click(screen.getByText('Bonus Disc'))
+ expect(onClick).toHaveBeenCalledWith(2)
+ })
+
+ it('does not play the disc when opening the lightbox', () => {
+ const onClick = vi.fn()
+ renderRow(onClick)
+ openLightbox()
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('does not play the disc when closing the lightbox', () => {
+ const onClick = vi.fn()
+ renderRow(onClick)
+ openLightbox()
+ fireEvent.click(document.querySelector('.ril__closeButton'))
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('does not play the disc when clicking the lightbox backdrop', () => {
+ const onClick = vi.fn()
+ renderRow(onClick)
+ openLightbox()
+ fireEvent.click(document.querySelector('.ril__inner'))
+ expect(onClick).not.toHaveBeenCalled()
+ })
+})
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index ac8d7f62c..c88ea3e9c 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -28,6 +28,7 @@ export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick'
export * from './useInterval'
export * from './useResourceRefresh'
+export * from './useScrollRestoration'
export * from './useRefreshOnEvents'
export * from './useToggleLove'
export * from './useTraceUpdate'
diff --git a/ui/src/common/useScrollRestoration.jsx b/ui/src/common/useScrollRestoration.jsx
new file mode 100644
index 000000000..94e5cb2fd
--- /dev/null
+++ b/ui/src/common/useScrollRestoration.jsx
@@ -0,0 +1,64 @@
+import { useEffect, useLayoutEffect, useRef } from 'react'
+import { useHistory, useLocation } from 'react-router-dom'
+
+// Keyed on the route because hash history assigns no location.key, so every page would
+// otherwise share one slot and overwrite the offset we came back for.
+const positions = new Map()
+const MAX_ENTRIES = 50
+const MAX_FRAMES = 60
+
+export const useScrollRestoration = (ready = true) => {
+ const { pathname, search } = useLocation()
+ const history = useHistory()
+ const key = pathname + search
+ const handled = useRef(null)
+ const latest = useRef(0)
+
+ useEffect(() => {
+ latest.current = window.scrollY
+ const track = () => {
+ latest.current = window.scrollY
+ }
+ window.addEventListener('scroll', track, { passive: true })
+ return () => window.removeEventListener('scroll', track)
+ }, [key])
+
+ // Layout cleanup, so the offset is banked while this page is torn down. A passive one runs
+ // after the incoming page tops itself, and this page's listener records that 0 first.
+ useLayoutEffect(
+ () => () => {
+ positions.delete(key)
+ positions.set(key, latest.current)
+ if (positions.size > MAX_ENTRIES) {
+ positions.delete(positions.keys().next().value)
+ }
+ },
+ [key],
+ )
+
+ // Layout effect: a passive one runs after paint, so the list would be painted at the old
+ // offset first and visibly jump.
+ useLayoutEffect(() => {
+ if (!ready || handled.current === key) return
+ handled.current = key
+ const saved = positions.get(key)
+ const top = history.action === 'POP' && saved !== undefined ? saved : 0
+ window.scrollTo({ top })
+ if (!top || Math.round(window.scrollY) === top) return
+
+ // The page can still be filling in, and until it is tall enough the browser clamps us to
+ // the top. Keep asking until the offset sticks, yielding if anything else moves us.
+ let frame
+ let frames = 0
+ let landed = Math.round(window.scrollY)
+ const retry = () => {
+ const y = Math.round(window.scrollY)
+ if (y === top || y !== landed || ++frames > MAX_FRAMES) return
+ window.scrollTo({ top })
+ landed = Math.round(window.scrollY)
+ frame = requestAnimationFrame(retry)
+ }
+ frame = requestAnimationFrame(retry)
+ return () => cancelAnimationFrame(frame)
+ }, [ready, key, history.action])
+}
diff --git a/ui/src/common/useScrollRestoration.test.jsx b/ui/src/common/useScrollRestoration.test.jsx
new file mode 100644
index 000000000..7ca67a724
--- /dev/null
+++ b/ui/src/common/useScrollRestoration.test.jsx
@@ -0,0 +1,222 @@
+import { renderHook, act } from '@testing-library/react-hooks'
+import { render } from '@testing-library/react'
+import { useLayoutEffect } from 'react'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+const mockLocation = { pathname: '/album', search: '' }
+const mockHistory = { action: 'PUSH' }
+vi.mock('react-router-dom', () => ({
+ useLocation: () => mockLocation,
+ useHistory: () => mockHistory,
+}))
+
+import { useScrollRestoration } from './useScrollRestoration'
+
+describe('useScrollRestoration', () => {
+ beforeEach(() => {
+ window.scrollTo = vi.fn()
+ window.scrollY = 0
+ mockLocation.pathname = '/album'
+ mockLocation.search = ''
+ mockHistory.action = 'PUSH'
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ const scrollTo = (y) => {
+ window.scrollY = y
+ act(() => {
+ window.dispatchEvent(new Event('scroll'))
+ })
+ }
+
+ // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter
+ // grows the page to the height it reaches once they do.
+ const returnToUnfilledPage = (saved) => {
+ vi.useFakeTimers()
+ let maxScroll = saved
+ window.scrollTo = vi.fn(({ top }) => {
+ window.scrollY = Math.min(top, maxScroll)
+ })
+
+ const first = renderHook(() => useScrollRestoration())
+ scrollTo(saved)
+ first.unmount()
+
+ maxScroll = 0
+ window.scrollY = 0
+ mockHistory.action = 'POP'
+ renderHook(() => useScrollRestoration())
+ return (height) => (maxScroll = height)
+ }
+
+ const advanceFrames = () => act(() => vi.advanceTimersByTime(100))
+
+ it('starts a pushed route at the top', () => {
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 })
+ })
+
+ it('restores the saved offset when returning to an entry', () => {
+ const first = renderHook(() => useScrollRestoration())
+ scrollTo(640)
+ first.unmount()
+
+ mockLocation.pathname = '/album/al-1/show'
+ const second = renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 })
+ second.unmount()
+
+ mockLocation.pathname = '/album'
+ mockHistory.action = 'POP'
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 })
+ })
+
+ it('tops a pushed route even if that key was seen before', () => {
+ const first = renderHook(() => useScrollRestoration())
+ scrollTo(500)
+ first.unmount()
+
+ mockHistory.action = 'PUSH'
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 })
+ })
+
+ // Restoring before the rows exist leaves the document too short: the browser clamps to the
+ // top and the offset is lost with no error.
+ it('waits for readiness before touching the scroll position', () => {
+ const { rerender } = renderHook(
+ ({ ready }) => useScrollRestoration(ready),
+ {
+ initialProps: { ready: false },
+ },
+ )
+ expect(window.scrollTo).not.toHaveBeenCalled()
+
+ rerender({ ready: true })
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 })
+ })
+
+ // Hash history assigns no location.key, so an implementation keyed on it would collapse every
+ // route into one slot and the detail page would erase the list's offset before we returned.
+ it('keeps a separate offset per route', () => {
+ const list = renderHook(() => useScrollRestoration())
+ scrollTo(900)
+ list.unmount()
+
+ mockLocation.pathname = '/album/al-1/show'
+ const detail = renderHook(() => useScrollRestoration())
+ scrollTo(0)
+ detail.unmount()
+
+ mockLocation.pathname = '/album'
+ mockHistory.action = 'POP'
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 })
+ })
+
+ // Without the per-instance guard this fires again when readiness flickers, fighting a user who
+ // has already started scrolling. Nothing else in the suite pins it.
+ it('does not scroll again when readiness flickers on the same route', () => {
+ const { rerender } = renderHook(
+ ({ ready }) => useScrollRestoration(ready),
+ {
+ initialProps: { ready: true },
+ },
+ )
+ expect(window.scrollTo).toHaveBeenCalledTimes(1)
+
+ rerender({ ready: false })
+ rerender({ ready: true })
+ expect(window.scrollTo).toHaveBeenCalledTimes(1)
+ })
+
+ it('scrolls once per entry, not on every re-render', () => {
+ const { rerender } = renderHook(() => useScrollRestoration())
+ rerender()
+ rerender()
+ expect(window.scrollTo).toHaveBeenCalledTimes(1)
+ })
+
+ // The artist page reports ready as soon as its header record is cached, while the albums below
+ // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0.
+ it('keeps trying until the page is tall enough to hold the offset', () => {
+ const fillPageTo = returnToUnfilledPage(800)
+ expect(window.scrollY).toBe(0)
+
+ fillPageTo(1200)
+ advanceFrames()
+ expect(window.scrollY).toBe(800)
+ })
+
+ it('stops retrying once the user scrolls somewhere else', () => {
+ const fillPageTo = returnToUnfilledPage(800)
+
+ fillPageTo(1200)
+ window.scrollY = 300
+ advanceFrames()
+ expect(window.scrollY).toBe(300)
+ })
+
+ // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as
+ // the yield signal cancels the gesture's own restore.
+ it('keeps restoring while wheel momentum fires, as a back-swipe does', () => {
+ const fillPageTo = returnToUnfilledPage(800)
+
+ act(() => {
+ for (let i = 0; i < 10; i++) {
+ window.dispatchEvent(
+ new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }),
+ )
+ }
+ })
+ fillPageTo(1200)
+ advanceFrames()
+ expect(window.scrollY).toBe(800)
+ })
+
+ // The incoming page tops itself in its own layout effect, while this page's scroll listener is
+ // still attached. Banking the offset any later than the teardown records that 0 instead.
+ it('records the offset before the next page scrolls to the top', () => {
+ const Outgoing = () => {
+ useScrollRestoration()
+ return null
+ }
+ const Incoming = () => {
+ useLayoutEffect(() => {
+ window.scrollY = 0
+ window.dispatchEvent(new Event('scroll'))
+ }, [])
+ return null
+ }
+
+ const { rerender } = render()
+ scrollTo(1400)
+
+ mockLocation.pathname = '/album/al-1/show'
+ rerender()
+
+ mockLocation.pathname = '/album'
+ mockHistory.action = 'POP'
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 })
+ })
+
+ it('keeps saving after the restore, so leaving again remembers the new offset', () => {
+ const first = renderHook(() => useScrollRestoration())
+ scrollTo(200)
+ first.unmount()
+
+ mockHistory.action = 'POP'
+ const second = renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 })
+ scrollTo(900)
+ second.unmount()
+
+ renderHook(() => useScrollRestoration())
+ expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 })
+ })
+})
diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js
index 0d986033d..2b5d13d13 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -3,10 +3,14 @@ import useMediaQuery from '@material-ui/core/useMediaQuery'
import themes from './index'
import { AUTO_THEME_ID } from '../consts'
import config from '../config'
-import { useEffect } from 'react'
+import { useEffect, useMemo } from 'react'
const useCurrentTheme = () => {
- const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)')
+ // Runs above the ThemeProvider carrying the prop below, so it needs its own noSsr or the
+ // auto theme renders dark first and flips.
+ const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', {
+ noSsr: true,
+ })
const theme = useSelector((state) => {
if (state.theme === AUTO_THEME_ID) {
return prefersLightMode ? themes.LightTheme : themes.DarkTheme
@@ -50,7 +54,15 @@ const useCurrentTheme = () => {
document.body.style.backgroundColor = bgColor
}, [theme])
- return theme
+ // We never server-render, so let media queries resolve on the first render: the default
+ // defers them to an effect, which makes every mount paint the wrong breakpoint and reflow.
+ return useMemo(
+ () => ({
+ ...theme,
+ props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
+ }),
+ [theme],
+ )
}
export default useCurrentTheme