From dd9d7788aa9630917b1ea3772878749dafeac2ca Mon Sep 17 00:00:00 2001 From: esson <55195336+githubesson@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:07:54 +0000 Subject: [PATCH 1/5] Mobile: one tile per album on TIDAL artist pages, switch versions on the album MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIDAL lists an album's explicit, clean and Dolby Atmos copies as separate albums, so a mobile discography showed a release two or three times. The TIDAL artist page now folds them with core's foldReleaseVersions, as the desktop page has since #77: one tile ("2026 · Single · 2 versions") that opens the stereo explicit copy. The album page finds its other versions among the releases of the artist it was opened from (passed in the route, so it's cached from the artist page), else the album's own artist once it loads. A switcher above Play names only what differs (Explicit | Clean | Dolby Atmos): segments up to three versions, a native menu past that. Picking one swaps the page in place, keeping the previous version up, dimmed, until the next one loads. Co-Authored-By: Claude Opus 5.5 --- .../(library,browse)/tidal-albums/[id].tsx | 66 ++++++- .../(library,browse)/tidal-artists/[id].tsx | 9 +- mobile/components/album-header.tsx | 21 ++- mobile/components/glass-segmented-control.tsx | 4 + .../library/release-version-picker.tsx | 129 +++++++++++++ mobile/lib/release-versions.ts | 35 ++++ mobile/tests/search-failures.test.tsx | 22 ++- mobile/tests/tidal-album-versions.test.tsx | 172 ++++++++++++++++++ 8 files changed, 441 insertions(+), 17 deletions(-) create mode 100644 mobile/components/library/release-version-picker.tsx create mode 100644 mobile/lib/release-versions.ts create mode 100644 mobile/tests/tidal-album-versions.test.tsx diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 235b92bf..9791337c 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx @@ -1,8 +1,12 @@ import { useCallback, useMemo } from "react"; -import { PixelRatio } from "react-native"; +import { PixelRatio, View } from "react-native"; import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { ALBUM_DOWNLOAD_REFRESH_MS, albumArtists, @@ -22,11 +26,13 @@ import { useBottomDockInset, useDockScrollHandler, } from "../../../../components/dock/dock-context"; +import { ReleaseVersionPicker } from "../../../../components/library/release-version-picker"; import { TrackRow } from "../../../../components/track-row"; import { usePlayerPlayback, usePlayTrack } from "../../../../context/player"; import { qk } from "../../../../lib/query-keys"; import { listPlaybackQueue, usePlayQueue } from "../../../../lib/use-play-queue"; import { useAlbumDownloadAction } from "../../../../lib/album-download"; +import { useReleaseVersions } from "../../../../lib/release-versions"; import { useTheme } from "../../../../theme/theme"; import { AlbumHeader, ALBUM_ART_SIZE } from "../../../../components/album-header"; import { EmptyState, retryAction } from "../../../../components/empty-state"; @@ -35,7 +41,10 @@ export default function TidalAlbumDetailScreen() { const theme = useTheme(); const dockInset = useBottomDockInset(); const dockScroll = useDockScrollHandler(); - const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + // `artist`: the TIDAL artist the album was opened from, whose releases list + // its versions. Without one, the album's own artist is asked once it loads. + const { id, artist } = useLocalSearchParams<{ id: string; artist?: string }>(); const { me } = useAuth(); const userId = me?.id; @@ -43,6 +52,9 @@ export default function TidalAlbumDetailScreen() { queryKey: qk.tidalAlbum(userId, id), queryFn: ({ signal }) => api.getTidalAlbum(id!, { signal }), enabled: !!userId && !!id, + // A version swaps the id in place, and the page stays up until the next + // version has loaded. + placeholderData: keepPreviousData, // While an album download runs, refetch so saved tracks show up. refetchInterval: (query) => query.state.data?.queued_count ? ALBUM_DOWNLOAD_REFRESH_MS : false, @@ -53,6 +65,25 @@ export default function TidalAlbumDetailScreen() { [albumQuery.data?.tracks], ); const onTrackPress = usePlayQueue(tracks); + const switching = albumQuery.isPlaceholderData; + const versionsArtist = artist || albumQuery.data?.artist_id || undefined; + const versions = useReleaseVersions(versionsArtist, id); + // Like a tab: picking a version replaces this page rather than pushing one. + // The artist listing the versions goes along, so the next version's page + // has them from its first frame even when this one only learned it on load. + const versionPicker = useMemo( + () => + versions && id && versionsArtist ? ( + + router.setParams({ id: version, artist: versionsArtist }) + } + /> + ) : undefined, + [versions, id, versionsArtist, router], + ); const play = usePlayTrack(); const { shuffle } = usePlayerPlayback(); const queryClient = useQueryClient(); @@ -73,11 +104,17 @@ export default function TidalAlbumDetailScreen() { onQueuedChanged, ); + // The previous version's tracks stay dimmed until the picked one loads. const renderItem = useCallback( - ({ item }: ListRenderItemInfo) => ( - - ), - [onTrackPress], + ({ item }: ListRenderItemInfo) => + switching ? ( + + + + ) : ( + + ), + [onTrackPress, switching], ); const keyExtractor = useCallback((track: TrackListItem) => track.id, []); @@ -103,9 +140,19 @@ export default function TidalAlbumDetailScreen() { : undefined } secondaryAction={downloadAction} + versions={versionPicker} + pending={switching} /> ); - }, [albumQuery.data, play, shuffle, tracks, downloadAction]); + }, [ + albumQuery.data, + play, + shuffle, + tracks, + downloadAction, + versionPicker, + switching, + ]); if (albumQuery.isLoading) return ; if (albumQuery.isError || !albumQuery.data) { @@ -133,6 +180,7 @@ export default function TidalAlbumDetailScreen() { data={tracks} renderItem={renderItem} keyExtractor={keyExtractor} + extraData={switching} ListHeaderComponent={header} contentInsetAdjustmentBehavior="automatic" style={{ backgroundColor: theme.color.bg }} diff --git a/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx index efcfd3bf..252ec5a8 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx @@ -15,6 +15,7 @@ import { } from "@music-library/core"; import { ARTIST_POPULAR_PREVIEW_COUNT, + foldReleaseVersions, tidalArtistReleases, type ArtistRelease, } from "@music-library/core/artist-releases"; @@ -52,7 +53,7 @@ export default function TidalArtistScreen() { const data = query.data; const tracks = useMemo(() => data?.tracks ?? [], [data]); const releases = useMemo( - () => (data ? tidalArtistReleases(data.albums) : []), + () => (data ? foldReleaseVersions(tidalArtistReleases(data.albums)) : []), [data], ); const shownTracks = useMemo( @@ -74,13 +75,15 @@ export default function TidalArtistScreen() { ? metaLine(tidalArtistMetaParts(releases.length, tracks.length)) : undefined; + // The album keeps the artist it was opened from, whose releases list its + // other versions. const openRelease = useCallback( (release: ArtistRelease) => router.push({ pathname: "/(tabs)/tidal-albums/[id]", - params: { id: searchEntityTarget(release).id }, + params: { id: searchEntityTarget(release).id, artist: id }, }), - [router], + [router, id], ); const renderItem = useCallback( ({ item, index }: ListRenderItemInfo) => ( diff --git a/mobile/components/album-header.tsx b/mobile/components/album-header.tsx index 513bbfc2..bb17bb41 100644 --- a/mobile/components/album-header.tsx +++ b/mobile/components/album-header.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; import { Image } from "expo-image"; import { SymbolView } from "expo-symbols"; @@ -16,6 +17,10 @@ interface Props { onPlay?: () => void; /** A second, quieter button under Play (e.g. an admin album download). */ secondaryAction?: { label: string; onPress: () => void; disabled?: boolean }; + /** Switches between the release's versions, above Play. */ + versions?: ReactNode; + /** Another version is loading: the buttons wait for it. */ + pending?: boolean; } export function AlbumHeader({ @@ -26,6 +31,8 @@ export function AlbumHeader({ metadata, onPlay, secondaryAction, + versions, + pending = false, }: Props) { const theme = useTheme(); return ( @@ -93,8 +100,14 @@ export function AlbumHeader({ + {versions ? ( + {versions} + ) : null} {!!onPlay ? ( { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); onPlay(); @@ -107,7 +120,7 @@ export function AlbumHeader({ backgroundColor: theme.color.accent, borderRadius: theme.radius.md, paddingVertical: 12, - opacity: pressed ? 0.85 : 1, + opacity: pending ? 0.5 : pressed ? 0.85 : 1, borderCurve: "continuous", })} > @@ -131,8 +144,8 @@ export function AlbumHeader({ { void Haptics.selectionAsync(); secondaryAction.onPress(); @@ -144,7 +157,7 @@ export function AlbumHeader({ backgroundColor: theme.color.bgElev1, borderRadius: theme.radius.md, paddingVertical: 12, - opacity: secondaryAction.disabled ? 0.5 : pressed ? 0.85 : 1, + opacity: pending || secondaryAction.disabled ? 0.5 : pressed ? 0.85 : 1, borderCurve: "continuous", })} > diff --git a/mobile/components/glass-segmented-control.tsx b/mobile/components/glass-segmented-control.tsx index 89659f24..3a5e48c8 100644 --- a/mobile/components/glass-segmented-control.tsx +++ b/mobile/components/glass-segmented-control.tsx @@ -92,6 +92,9 @@ export function GlassSegmentedControl({ style={styles.item} > void; +}) { + const theme = useTheme(); + const labels = releaseVersionLabels(versions); + const options = versions.map((version, i) => ({ + value: searchEntityTarget(version).id, + label: labels[i].label, + description: labels[i].description, + })); + if (options.length <= 3) { + return ( + + ); + } + + const selected = options.find((option) => option.value === current); + const accessibilityLabel = `Version: ${selected?.label ?? "None"}`; + const pick = (id: string) => { + void Haptics.selectionAsync(); + if (id !== current) onPick(id); + }; + const trigger = ( + + + {selected?.label ?? "Version"} + + + + ); + + const swiftUI = getOptionalSwiftUI(); + if (swiftUI) { + return ( + + + {trigger}} + modifiers={[ + swiftAccessibilityLabel(accessibilityLabel), + swiftButtonStyle("plain"), + swiftControlSize("regular"), + ]} + > + {options.map((option) => ( + pick(option.value)} + /> + ))} + + + + ); + } + return ( + + showActionMenu({ + title: "Version", + items: options.map((option) => ({ + label: option.description, + disabled: option.value === current, + onPress: () => pick(option.value), + })), + tintColor: theme.color.accent, + userInterfaceStyle: theme.scheme, + }) + } + style={({ pressed }) => ({ + alignSelf: "center", + opacity: pressed ? 0.7 : 1, + })} + > + {trigger} + + ); +} diff --git a/mobile/lib/release-versions.ts b/mobile/lib/release-versions.ts new file mode 100644 index 00000000..1f4d8ed4 --- /dev/null +++ b/mobile/lib/release-versions.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, useAuth } from "@music-library/core"; +import { + releaseVersionsOf, + tidalArtistReleases, + type ArtistRelease, +} from "@music-library/core/artist-releases"; +import { qk } from "./query-keys"; + +/** + * The versions of TIDAL album `id` (explicit, clean, Dolby Atmos), found + * among the releases of TIDAL artist `artistId`. Coming from the artist's + * page they're already cached, so the switcher is there on the first frame. + * Null when there's no artist to ask or the album has no other version. + */ +export function useReleaseVersions( + artistId: string | undefined, + id: string | undefined, +): ArtistRelease[] | null { + const { me } = useAuth(); + // Only the switcher depends on it, so a failure shows nothing. + const { data } = useQuery({ + queryKey: qk.tidalArtist(me?.id, artistId), + queryFn: ({ signal }) => api.getTidalArtist(artistId!, { signal }), + enabled: !!me && !!artistId, + }); + return useMemo( + () => + data && id + ? releaseVersionsOf(tidalArtistReleases(data.albums), id) + : null, + [data, id], + ); +} diff --git a/mobile/tests/search-failures.test.tsx b/mobile/tests/search-failures.test.tsx index f32c292e..1eb26c21 100644 --- a/mobile/tests/search-failures.test.tsx +++ b/mobile/tests/search-failures.test.tsx @@ -16,6 +16,8 @@ const mock = vi.hoisted(() => ({ release_year?: number; track_count?: number; duration_ms?: number; + source_id?: string; + explicit?: boolean; }[]; tracks: { id: string; title: string }[]; warnings?: string[]; @@ -289,6 +291,21 @@ it("shows each search warning once across loaded pages", () => { expect(html).toContain("Local search is rebuilding."); }); +it("folds a release's explicit and clean copies into one tile", () => { + mock.query.data = { + albums: [ + { id: "tidal:1", source_id: "1", title: "Powiedz To", release_year: 2026, track_count: 1, duration_ms: 180_000, explicit: true }, + { id: "tidal:2", source_id: "2", title: "Powiedz To", release_year: 2026, track_count: 1, duration_ms: 180_000 }, + { id: "tidal:3", source_id: "3", title: "LP", release_year: 2025, track_count: 12, duration_ms: 2_700_000 }, + ], + tracks: [], + }; + const html = markup(); + expect(html.split("Powiedz To (Single)")).toHaveLength(2); + expect(html).toContain("LP (Album)"); + expect(html).toContain("2 releases"); +}); + it("opens a TIDAL release by its TIDAL id", () => { mock.query.data = { albums: [{ id: "tidal:9", title: "LP", track_count: 12, duration_ms: 2_700_000 }], @@ -298,5 +315,8 @@ it("opens a TIDAL release by its TIDAL id", () => { markup(); mock.openRelease?.({ id: "tidal:9", source: "tidal", source_id: "9" }); mock.openRelease?.({ id: "tidal:10", source: "tidal" }); - expect(mock.push.mock.calls.map(([route]) => route.params)).toEqual([{ id: "9" }, { id: "10" }]); + expect(mock.push.mock.calls.map(([route]) => route.params)).toEqual([ + { id: "9", artist: "123" }, + { id: "10", artist: "123" }, + ]); }); diff --git a/mobile/tests/tidal-album-versions.test.tsx b/mobile/tests/tidal-album-versions.test.tsx new file mode 100644 index 00000000..75c18ed5 --- /dev/null +++ b/mobile/tests/tidal-album-versions.test.tsx @@ -0,0 +1,172 @@ +import React, { type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, expect, it, vi } from "vitest"; +import TidalAlbumDetailScreen from "../app/(tabs)/(library,browse)/tidal-albums/[id]"; + +type Album = { id: string; title: string; artist_id?: string; tracks: { id: string; title: string }[] }; +const mock = vi.hoisted(() => ({ + params: {} as { id: string; artist?: string }, + album: {} as { data?: Album; isPlaceholderData?: boolean }, + // TIDAL artist responses by id; the artist queries asked for, by id. + artists: {} as Record, + artistQueries: [] as { id?: string; enabled?: boolean }[], + setParams: vi.fn(), + segments: undefined as + | undefined + | { options: { value: string; label: string }[]; value: string; onChange: (v: string) => void }, + pending: undefined as boolean | undefined, +})); +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) =>
{children}
, + Text: ({ children }: { children?: ReactNode }) => {children}, + Pressable: ({ children }: { children?: ReactNode }) => , + PixelRatio: { get: () => 3 }, +})); +vi.mock("@tanstack/react-query", () => ({ + keepPreviousData: () => undefined, + useQueryClient: () => ({ setQueryData: vi.fn() }), + useQuery: ({ queryKey, enabled }: { queryKey: unknown[]; enabled?: boolean }) => { + if (queryKey[2] === "tidal-album") return { ...mock.album, refetch: vi.fn() }; + const id = queryKey[3] as string | undefined; + mock.artistQueries.push({ id, enabled }); + return { data: enabled && id ? mock.artists[id] : undefined }; + }, +})); +vi.mock("expo-router", () => ({ + Stack: { Screen: () => null }, + useLocalSearchParams: () => mock.params, + useRouter: () => ({ setParams: mock.setParams }), +})); +vi.mock("expo-symbols", () => ({ SymbolView: () => null })); +vi.mock("expo-haptics", () => ({ selectionAsync: vi.fn() })); +// The pure album, label and artwork helpers are the real ones. +vi.mock("@music-library/core", async (importOriginal) => ({ + ...(await importOriginal()), + api: {}, + useAuth: () => ({ me: { id: "user" } }), +})); +vi.mock("@shopify/flash-list", () => ({ + FlashList: ({ + data, + ListHeaderComponent, + renderItem, + }: { + data: unknown[]; + ListHeaderComponent: ReactNode; + renderItem: (args: { item: unknown; index: number }) => ReactNode; + }) => ( +
+ {ListHeaderComponent} + {data.map((item, index) => ( +
{renderItem({ item, index })}
+ ))} +
+ ), +})); +vi.mock("../components/album-header", () => ({ + ALBUM_ART_SIZE: 220, + AlbumHeader: ({ title, versions, pending }: { title: string; versions?: ReactNode; pending?: boolean }) => { + mock.pending = pending; + return ( +
+ {title} + {versions} +
+ ); + }, +})); +vi.mock("../components/glass-segmented-control", () => ({ + GlassSegmentedControl: (props: NonNullable) => { + mock.segments = props; + return ; + }, +})); +vi.mock("../components/optional-swift-ui", () => ({ getOptionalSwiftUI: () => null })); +vi.mock("../lib/action-menu", () => ({ showActionMenu: vi.fn() })); +vi.mock("../components/track-row", () => ({ + TrackRow: ({ track }: { track: { title: string } }) => {track.title}, +})); +vi.mock("../components/empty-state", () => ({ + EmptyState: () => Loading, + retryAction: () => undefined, +})); +vi.mock("../components/dock/dock-context", () => ({ + useBottomDockInset: () => 0, + useDockScrollHandler: () => ({}), +})); +vi.mock("../context/player", () => ({ + usePlayTrack: () => vi.fn(), + usePlayerPlayback: () => ({ shuffle: false }), +})); +vi.mock("../lib/use-play-queue", () => ({ + usePlayQueue: () => vi.fn(), + listPlaybackQueue: () => [], +})); +vi.mock("../lib/album-download", () => ({ useAlbumDownloadAction: () => undefined })); +vi.mock("../theme/theme", () => ({ + useTheme: () => ({ space: { lg: 24, md: 16 }, color: { bg: "black" } }), +})); + +const single = (sourceId: string, explicit: boolean) => ({ + id: `tidal:${sourceId}`, + source_id: sourceId, + title: "Powiedz To", + release_year: 2026, + track_count: 1, + duration_ms: 180_000, + explicit, +}); + +beforeEach(() => { + mock.params = { id: "1" }; + mock.album = { + data: { id: "tidal:1", title: "Powiedz To", tracks: [{ id: "t1", title: "Powiedz To" }] }, + }; + mock.artists = { + "77": { albums: [single("1", true), single("2", false), { ...single("3", false), title: "LP" }] }, + }; + mock.artistQueries = []; + mock.setParams.mockReset(); + mock.segments = undefined; + mock.pending = undefined; +}); +const markup = () => renderToStaticMarkup(); + +it("switches between versions listed by the artist it was opened from", () => { + mock.params = { id: "2", artist: "77" }; + const html = markup(); + expect(html).toContain("Explicit | Clean"); + expect(mock.segments?.value).toBe("2"); + mock.segments?.onChange("1"); + expect(mock.setParams).toHaveBeenCalledWith({ id: "1", artist: "77" }); +}); + +it("asks the album's own artist when it wasn't opened from one", () => { + mock.album.data!.artist_id = "77"; + markup(); + expect(mock.artistQueries.at(-1)).toEqual({ id: "77", enabled: true }); + expect(mock.segments?.value).toBe("1"); + mock.segments?.onChange("2"); + expect(mock.setParams).toHaveBeenCalledWith({ id: "2", artist: "77" }); +}); + +it("shows no switcher for an album without other versions", () => { + mock.params = { id: "3", artist: "77" }; + markup(); + expect(mock.segments).toBeUndefined(); +}); + +it("shows no switcher without an artist to ask", () => { + markup(); + expect(mock.artistQueries.at(-1)?.enabled).toBe(false); + expect(mock.segments).toBeUndefined(); +}); + +it("keeps the page up, buttons waiting, while the picked version loads", () => { + mock.params = { id: "2", artist: "77" }; + mock.album.isPlaceholderData = true; + const html = markup(); + expect(mock.pending).toBe(true); + expect(mock.segments?.value).toBe("2"); + expect(html).toContain("Powiedz To"); +}); From e993522f3fcd8949c89cab46ede7126bf9b5f7e5 Mon Sep 17 00:00:00 2001 From: esson <55195336+githubesson@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:23:27 +0000 Subject: [PATCH 2/5] Album versions: keep the picker through a failed or offline switch Review fixes: - A version that can't load, because it failed or is waiting offline, shows the error with the picker kept above it, rather than the previous version dimmed for good (offline) or a page with no way back (failed). - Dimmed rows are hidden from VoiceOver too, and every row keeps one wrapper, so a switch no longer remounts them. - The menu's tick moves by remounting the two items it moves between, rather than clearing a native prop. - Tests cover the menu past three versions, the keep-previous wiring, the failed and offline switches, and AlbumHeader's disabled buttons. Co-Authored-By: Claude Opus 5.5 --- .../(library,browse)/tidal-albums/[id].tsx | 66 ++++++++---- .../library/release-version-picker.tsx | 4 +- mobile/tests/album-header.test.tsx | 60 +++++++++++ mobile/tests/tidal-album-versions.test.tsx | 102 +++++++++++++++--- 4 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 mobile/tests/album-header.test.tsx diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 9791337c..0131cafc 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx @@ -104,16 +104,19 @@ export default function TidalAlbumDetailScreen() { onQueuedChanged, ); - // The previous version's tracks stay dimmed until the picked one loads. + // The previous version's tracks stay dimmed, out of reach of touch and + // VoiceOver, until the picked one loads. const renderItem = useCallback( - ({ item }: ListRenderItemInfo) => - switching ? ( - - - - ) : ( + ({ item }: ListRenderItemInfo) => ( + - ), + + ), [onTrackPress, switching], ); @@ -155,33 +158,54 @@ export default function TidalAlbumDetailScreen() { ]); if (albumQuery.isLoading) return ; - if (albumQuery.isError || !albumQuery.data) { - return ( - - ); - } + // A version that can't load (failed, or waiting offline) shows the error, + // with the picker kept above it so the other versions stay a tap away. + const failed = + albumQuery.isError || + !albumQuery.data || + (switching && albumQuery.isPaused); + const failure = ( + + ); + if (failed && !versionPicker) return failure; return ( <> + {versionPicker} + + ) : ( + header + ) + } + ListEmptyComponent={failed ? failure : undefined} contentInsetAdjustmentBehavior="automatic" style={{ backgroundColor: theme.color.bg }} contentContainerStyle={{ paddingBottom: dockInset + 24 }} diff --git a/mobile/components/library/release-version-picker.tsx b/mobile/components/library/release-version-picker.tsx index d0b21c7f..e7b96f71 100644 --- a/mobile/components/library/release-version-picker.tsx +++ b/mobile/components/library/release-version-picker.tsx @@ -90,8 +90,10 @@ export function ReleaseVersionPicker({ ]} > {options.map((option) => ( + // Keyed by whether it's ticked, so the tick moves by remounting + // the two items rather than clearing a native prop. pick(option.value)} diff --git a/mobile/tests/album-header.test.tsx b/mobile/tests/album-header.test.tsx new file mode 100644 index 00000000..e937fc7c --- /dev/null +++ b/mobile/tests/album-header.test.tsx @@ -0,0 +1,60 @@ +import React, { type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vitest"; +import { AlbumHeader } from "../components/album-header"; + +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) =>
{children}
, + Text: ({ children }: { children?: ReactNode }) => {children}, + Pressable: ({ + children, + disabled, + style, + }: { + children?: ReactNode; + disabled?: boolean; + style: (state: { pressed: boolean }) => { opacity: number }; + }) => ( + + ), +})); +vi.mock("expo-image", () => ({ Image: () => null })); +vi.mock("expo-symbols", () => ({ SymbolView: () => null })); +vi.mock("expo-haptics", () => ({ + impactAsync: vi.fn(), + selectionAsync: vi.fn(), + ImpactFeedbackStyle: { Medium: "medium" }, +})); +vi.mock("../theme/theme", () => ({ + useTheme: () => ({ + space: { sm: 8, md: 16, lg: 24, xl: 32 }, + radius: { md: 12, lg: 16 }, + color: {}, + }), +})); + +const render = (pending?: boolean) => + renderToStaticMarkup( + {}} + secondaryAction={{ label: "Download album", onPress: () => {} }} + versions={} + pending={pending} + />, + ); + +it("puts the version switcher above Play", () => { + const html = render(); + expect(html.indexOf("Explicit | Clean")).toBeLessThan(html.indexOf("Play")); + expect(html).not.toContain("disabled"); +}); + +it("disables Play and the album download while another version loads", () => { + const html = render(true); + expect(html.match(/, + Pressable: ({ + children, + onPress, + accessibilityLabel, + }: { + children?: ReactNode; + onPress?: () => void; + accessibilityLabel?: string; + }) => { + if (accessibilityLabel && onPress) mock.presses[accessibilityLabel] = onPress; + return ; + }, PixelRatio: { get: () => 3 }, })); vi.mock("@tanstack/react-query", () => ({ - keepPreviousData: () => undefined, + keepPreviousData: mock.keepPreviousData, useQueryClient: () => ({ setQueryData: vi.fn() }), - useQuery: ({ queryKey, enabled }: { queryKey: unknown[]; enabled?: boolean }) => { - if (queryKey[2] === "tidal-album") return { ...mock.album, refetch: vi.fn() }; + useQuery: (options: { queryKey: unknown[]; enabled?: boolean }) => { + const { queryKey, enabled } = options; + if (queryKey[2] === "tidal-album") { + mock.albumOptions = options; + return { ...mock.album, isFetching: false, refetch: vi.fn() }; + } const id = queryKey[3] as string | undefined; mock.artistQueries.push({ id, enabled }); return { data: enabled && id ? mock.artists[id] : undefined }; @@ -49,17 +75,19 @@ vi.mock("@shopify/flash-list", () => ({ FlashList: ({ data, ListHeaderComponent, + ListEmptyComponent, renderItem, }: { data: unknown[]; ListHeaderComponent: ReactNode; + ListEmptyComponent?: ReactNode; renderItem: (args: { item: unknown; index: number }) => ReactNode; }) => (
{ListHeaderComponent} - {data.map((item, index) => ( -
{renderItem({ item, index })}
- ))} + {data.length + ? data.map((item, index) =>
{renderItem({ item, index })}
) + : ListEmptyComponent}
), })); @@ -82,12 +110,14 @@ vi.mock("../components/glass-segmented-control", () => ({ }, })); vi.mock("../components/optional-swift-ui", () => ({ getOptionalSwiftUI: () => null })); -vi.mock("../lib/action-menu", () => ({ showActionMenu: vi.fn() })); +vi.mock("../lib/action-menu", () => ({ showActionMenu: mock.showActionMenu })); vi.mock("../components/track-row", () => ({ TrackRow: ({ track }: { track: { title: string } }) => {track.title}, })); vi.mock("../components/empty-state", () => ({ - EmptyState: () => Loading, + EmptyState: ({ message, loading }: { message?: string; loading?: boolean }) => ( + {loading ? "Loading" : message} + ), retryAction: () => undefined, })); vi.mock("../components/dock/dock-context", () => ({ @@ -107,7 +137,7 @@ vi.mock("../theme/theme", () => ({ useTheme: () => ({ space: { lg: 24, md: 16 }, color: { bg: "black" } }), })); -const single = (sourceId: string, explicit: boolean) => ({ +const single = (sourceId: string, explicit: boolean, dolby_atmos = false) => ({ id: `tidal:${sourceId}`, source_id: sourceId, title: "Powiedz To", @@ -115,6 +145,7 @@ const single = (sourceId: string, explicit: boolean) => ({ track_count: 1, duration_ms: 180_000, explicit, + dolby_atmos, }); beforeEach(() => { @@ -126,6 +157,9 @@ beforeEach(() => { "77": { albums: [single("1", true), single("2", false), { ...single("3", false), title: "LP" }] }, }; mock.artistQueries = []; + mock.albumOptions = undefined; + mock.presses = {}; + mock.showActionMenu.mockReset(); mock.setParams.mockReset(); mock.segments = undefined; mock.pending = undefined; @@ -162,11 +196,51 @@ it("shows no switcher without an artist to ask", () => { expect(mock.segments).toBeUndefined(); }); -it("keeps the page up, buttons waiting, while the picked version loads", () => { +it("keeps the previous version up, its rows hidden and buttons waiting, while the picked one loads", () => { mock.params = { id: "2", artist: "77" }; + expect(markup()).not.toContain("aria-hidden"); + expect(mock.albumOptions?.placeholderData).toBe(mock.keepPreviousData); mock.album.isPlaceholderData = true; const html = markup(); expect(mock.pending).toBe(true); expect(mock.segments?.value).toBe("2"); - expect(html).toContain("Powiedz To"); + expect(html).toContain('aria-hidden="true">Powiedz To'); + expect(html).not.toContain("Couldn't load TIDAL album."); +}); + +it("shows the error with the picker when the picked version can't load offline", () => { + mock.params = { id: "2", artist: "77" }; + Object.assign(mock.album, { isPlaceholderData: true, isPaused: true }); + const html = markup(); + expect(html).toContain("Couldn't load TIDAL album."); + expect(html).not.toContain("Powiedz To"); + mock.segments?.onChange("1"); + expect(mock.setParams).toHaveBeenCalledWith({ id: "1", artist: "77" }); +}); + +it("shows the error with the picker when the picked version fails", () => { + mock.params = { id: "2", artist: "77" }; + mock.album = { data: undefined, isError: true }; + const html = markup(); + expect(html).toContain("Couldn't load TIDAL album."); + expect(mock.segments?.value).toBe("2"); +}); + +it("lists four or more versions in a menu", () => { + mock.params = { id: "2", artist: "77" }; + mock.artists["77"].albums.push(single("4", true, true), single("5", false, true)); + markup(); + expect(mock.segments).toBeUndefined(); + mock.presses["Version: Clean"](); + const [{ items }] = mock.showActionMenu.mock.calls[0] as [ + { items: { label: string; disabled: boolean; onPress: () => void }[] }, + ]; + expect(items.map((item) => [item.label, item.disabled])).toEqual([ + ["Stereo · Explicit · 1 track · 2026", false], + ["Stereo · Clean · 1 track · 2026", true], + ["Dolby Atmos · Explicit · 1 track · 2026", false], + ["Dolby Atmos · Clean · 1 track · 2026", false], + ]); + items[2].onPress(); + expect(mock.setParams).toHaveBeenCalledWith({ id: "4", artist: "77" }); }); From 4638103b4a83bfff3d0bac52086c3cfb169d7deb Mon Sep 17 00:00:00 2001 From: esson <55195336+githubesson@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:24:47 +0000 Subject: [PATCH 3/5] Album versions: a menu when labels are too long for segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (CodeRabbit): three versions such as Stereo | Dolby Atmos · Explicit | Dolby Atmos · Clean cut both Atmos labels to "Dolby Atmos…" in a third of a phone's width. Segments now need every label to fit (12 characters); otherwise the versions go in the menu, which names each one in full. Co-Authored-By: Claude Opus 5.5 --- .../components/library/release-version-picker.tsx | 12 +++++++++--- mobile/tests/tidal-album-versions.test.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/mobile/components/library/release-version-picker.tsx b/mobile/components/library/release-version-picker.tsx index e7b96f71..c291ae85 100644 --- a/mobile/components/library/release-version-picker.tsx +++ b/mobile/components/library/release-version-picker.tsx @@ -17,10 +17,13 @@ import { } from "../optional-swift-ui"; const MENU_HEIGHT = 36; +// What a third of a 375 pt phone's segmented control shows in full. Longer +// labels ("Dolby Atmos · Explicit") would cut to the same "Dolby Atmos…". +const SEGMENT_LABEL_MAX = 12; /** - * Switches between a release's versions: segments up to three, a menu past - * that, which lists each version's full description. + * Switches between a release's versions: segments for up to three short + * labels, else a menu listing each version's full description. */ export function ReleaseVersionPicker({ versions, @@ -39,7 +42,10 @@ export function ReleaseVersionPicker({ label: labels[i].label, description: labels[i].description, })); - if (options.length <= 3) { + if ( + options.length <= 3 && + options.every((option) => option.label.length <= SEGMENT_LABEL_MAX) + ) { return ( ); diff --git a/mobile/tests/tidal-album-versions.test.tsx b/mobile/tests/tidal-album-versions.test.tsx index 94d319f8..a75e5433 100644 --- a/mobile/tests/tidal-album-versions.test.tsx +++ b/mobile/tests/tidal-album-versions.test.tsx @@ -226,6 +226,21 @@ it("shows the error with the picker when the picked version fails", () => { expect(mock.segments?.value).toBe("2"); }); +it("lists versions in a menu when their labels are too long for segments", () => { + mock.params = { id: "2", artist: "77" }; + // Stereo | Dolby Atmos · Explicit | Dolby Atmos · Clean + mock.artists["77"].albums.splice(0, 1, single("4", true, true), single("5", false, true)); + markup(); + expect(mock.segments).toBeUndefined(); + mock.presses["Version: Stereo"](); + const [{ items }] = mock.showActionMenu.mock.calls[0] as [{ items: { label: string }[] }]; + expect(items.map((item) => item.label)).toEqual([ + "Stereo · Clean · 1 track · 2026", + "Dolby Atmos · Explicit · 1 track · 2026", + "Dolby Atmos · Clean · 1 track · 2026", + ]); +}); + it("lists four or more versions in a menu", () => { mock.params = { id: "2", artist: "77" }; mock.artists["77"].albums.push(single("4", true, true), single("5", false, true)); From 27744d2a32f1462bdd85a74906e4f5637e3b8609 Mon Sep 17 00:00:00 2001 From: esson <55195336+githubesson@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:31:59 +0000 Subject: [PATCH 4/5] Album versions: retrying a failed version stays on the error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: - "Try again" on a version that failed put its query back to pending, so the previous version's tracks flashed up dimmed until it failed again. A switch to a version that has already failed now stays on the error, reading "Retrying…". - The SwiftUI menu's ticked item is disabled, as the action-sheet fallback's is. Co-Authored-By: Claude Opus 5.5 --- .../(library,browse)/tidal-albums/[id].tsx | 4 +++- .../library/release-version-picker.tsx | 2 ++ mobile/tests/tidal-album-versions.test.tsx | 19 +++++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 0131cafc..3c16ebfa 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx @@ -160,10 +160,12 @@ export default function TidalAlbumDetailScreen() { if (albumQuery.isLoading) return ; // A version that can't load (failed, or waiting offline) shows the error, // with the picker kept above it so the other versions stay a tap away. + // Retrying one that failed stays on the error rather than flashing the + // previous version back up. const failed = albumQuery.isError || !albumQuery.data || - (switching && albumQuery.isPaused); + (switching && (albumQuery.isPaused || albumQuery.errorUpdateCount > 0)); const failure = ( pick(option.value)} /> ))} diff --git a/mobile/tests/tidal-album-versions.test.tsx b/mobile/tests/tidal-album-versions.test.tsx index a75e5433..a20e2a73 100644 --- a/mobile/tests/tidal-album-versions.test.tsx +++ b/mobile/tests/tidal-album-versions.test.tsx @@ -6,7 +6,13 @@ import TidalAlbumDetailScreen from "../app/(tabs)/(library,browse)/tidal-albums/ type Album = { id: string; title: string; artist_id?: string; tracks: { id: string; title: string }[] }; const mock = vi.hoisted(() => ({ params: {} as { id: string; artist?: string }, - album: {} as { data?: Album; isPlaceholderData?: boolean; isPaused?: boolean; isError?: boolean }, + album: {} as { + data?: Album; + isPlaceholderData?: boolean; + isPaused?: boolean; + isError?: boolean; + errorUpdateCount?: number; + }, albumOptions: undefined as Record | undefined, keepPreviousData: vi.fn(), // What each labelled button does when pressed. @@ -51,7 +57,7 @@ vi.mock("@tanstack/react-query", () => ({ const { queryKey, enabled } = options; if (queryKey[2] === "tidal-album") { mock.albumOptions = options; - return { ...mock.album, isFetching: false, refetch: vi.fn() }; + return { errorUpdateCount: 0, isFetching: false, ...mock.album, refetch: vi.fn() }; } const id = queryKey[3] as string | undefined; mock.artistQueries.push({ id, enabled }); @@ -226,6 +232,15 @@ it("shows the error with the picker when the picked version fails", () => { expect(mock.segments?.value).toBe("2"); }); +it("stays on the error while a failed version is retried", () => { + mock.params = { id: "2", artist: "77" }; + Object.assign(mock.album, { isPlaceholderData: true, errorUpdateCount: 1 }); + const html = markup(); + expect(html).toContain("Couldn't load TIDAL album."); + expect(html).not.toContain("Powiedz To"); + expect(mock.segments?.value).toBe("2"); +}); + it("lists versions in a menu when their labels are too long for segments", () => { mock.params = { id: "2", artist: "77" }; // Stereo | Dolby Atmos · Explicit | Dolby Atmos · Clean From b2bb54ecd8e0c61d61d53150f7a11d31c7a68747 Mon Sep 17 00:00:00 2001 From: esson <55195336+githubesson@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:40:15 +0000 Subject: [PATCH 5/5] Album versions: a version that failed earlier loads clean when picked again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix: a query's error count survives in the cache (gcTime is 7 days), so picking a version that had failed once, even when it now loaded fine, showed the error and "Retrying…" until it arrived. Picking a version whose cached state is a failure with no data now removes that query first: the pick starts at zero and shows the previous version dimmed, while "Try again" on the error still keeps the count. Co-Authored-By: Claude Opus 5.5 --- .../(library,browse)/tidal-albums/[id].tsx | 20 +++++++---- mobile/tests/tidal-album-versions.test.tsx | 34 +++++++++++++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 3c16ebfa..7fb14240 100644 --- a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx +++ b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx @@ -68,25 +68,32 @@ export default function TidalAlbumDetailScreen() { const switching = albumQuery.isPlaceholderData; const versionsArtist = artist || albumQuery.data?.artist_id || undefined; const versions = useReleaseVersions(versionsArtist, id); + const queryClient = useQueryClient(); // Like a tab: picking a version replaces this page rather than pushing one. // The artist listing the versions goes along, so the next version's page // has them from its first frame even when this one only learned it on load. + // A version that failed earlier starts over, so its load isn't taken for a + // retry of the failure (see `failed`). const versionPicker = useMemo( () => versions && id && versionsArtist ? ( - router.setParams({ id: version, artist: versionsArtist }) - } + onPick={(version) => { + const queryKey = qk.tidalAlbum(userId, version); + const state = queryClient.getQueryState(queryKey); + if (state?.status === "error" && state.data === undefined) { + queryClient.removeQueries({ queryKey, exact: true }); + } + router.setParams({ id: version, artist: versionsArtist }); + }} /> ) : undefined, - [versions, id, versionsArtist, router], + [versions, id, versionsArtist, router, queryClient, userId], ); const play = usePlayTrack(); const { shuffle } = usePlayerPlayback(); - const queryClient = useQueryClient(); const { refetch } = albumQuery; const onQueuedChanged = useCallback( (queued: number) => { @@ -161,7 +168,8 @@ export default function TidalAlbumDetailScreen() { // A version that can't load (failed, or waiting offline) shows the error, // with the picker kept above it so the other versions stay a tap away. // Retrying one that failed stays on the error rather than flashing the - // previous version back up. + // previous version back up: its query keeps the error count, which a pick + // clears. const failed = albumQuery.isError || !albumQuery.data || diff --git a/mobile/tests/tidal-album-versions.test.tsx b/mobile/tests/tidal-album-versions.test.tsx index a20e2a73..07d24812 100644 --- a/mobile/tests/tidal-album-versions.test.tsx +++ b/mobile/tests/tidal-album-versions.test.tsx @@ -18,6 +18,9 @@ const mock = vi.hoisted(() => ({ // What each labelled button does when pressed. presses: {} as Record void>, showActionMenu: vi.fn(), + // Album query states by TIDAL id, and the calls that change the cache. + albumStates: {} as Record, + cache: [] as unknown[], // TIDAL artist responses by id; the artist queries asked for, by id. artists: {} as Record, artistQueries: [] as { id?: string; enabled?: boolean }[], @@ -52,7 +55,11 @@ vi.mock("react-native", () => ({ })); vi.mock("@tanstack/react-query", () => ({ keepPreviousData: mock.keepPreviousData, - useQueryClient: () => ({ setQueryData: vi.fn() }), + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryState: (queryKey: unknown[]) => mock.albumStates[queryKey[3] as string], + removeQueries: (filters: unknown) => mock.cache.push(["remove", filters]), + }), useQuery: (options: { queryKey: unknown[]; enabled?: boolean }) => { const { queryKey, enabled } = options; if (queryKey[2] === "tidal-album") { @@ -67,7 +74,12 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("expo-router", () => ({ Stack: { Screen: () => null }, useLocalSearchParams: () => mock.params, - useRouter: () => ({ setParams: mock.setParams }), + useRouter: () => ({ + setParams: (params: unknown) => { + mock.cache.push(["setParams", params]); + mock.setParams(params); + }, + }), })); vi.mock("expo-symbols", () => ({ SymbolView: () => null })); vi.mock("expo-haptics", () => ({ selectionAsync: vi.fn() })); @@ -164,6 +176,8 @@ beforeEach(() => { }; mock.artistQueries = []; mock.albumOptions = undefined; + mock.albumStates = {}; + mock.cache = []; mock.presses = {}; mock.showActionMenu.mockReset(); mock.setParams.mockReset(); @@ -241,6 +255,22 @@ it("stays on the error while a failed version is retried", () => { expect(mock.segments?.value).toBe("2"); }); +it("starts a version that failed earlier over when it's picked again", () => { + mock.params = { id: "2", artist: "77" }; + mock.albumStates = { "1": { status: "error" }, "2": { status: "success", data: {} } }; + markup(); + mock.segments?.onChange("1"); + expect(mock.cache).toEqual([ + ["remove", { queryKey: ["user", "user", "tidal-album", "1"], exact: true }], + ["setParams", { id: "1", artist: "77" }], + ]); + mock.cache = []; + mock.params = { id: "1", artist: "77" }; + markup(); + mock.segments?.onChange("2"); + expect(mock.cache).toEqual([["setParams", { id: "2", artist: "77" }]]); +}); + it("lists versions in a menu when their labels are too long for segments", () => { mock.params = { id: "2", artist: "77" }; // Stereo | Dolby Atmos · Explicit | Dolby Atmos · Clean