diff --git a/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx b/mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx index 235b92bf..7fb14240 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,9 +65,35 @@ 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); + 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 ? ( + { + 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, queryClient, userId], + ); const play = usePlayTrack(); const { shuffle } = usePlayerPlayback(); - const queryClient = useQueryClient(); const { refetch } = albumQuery; const onQueuedChanged = useCallback( (queued: number) => { @@ -73,11 +111,20 @@ export default function TidalAlbumDetailScreen() { onQueuedChanged, ); + // The previous version's tracks stay dimmed, out of reach of touch and + // VoiceOver, until the picked one loads. const renderItem = useCallback( ({ item }: ListRenderItemInfo) => ( - + + + ), - [onTrackPress], + [onTrackPress, switching], ); const keyExtractor = useCallback((track: TrackListItem) => track.id, []); @@ -103,37 +150,72 @@ 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) { - 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: its query keeps the error count, which a pick + // clears. + const failed = + albumQuery.isError || + !albumQuery.data || + (switching && (albumQuery.isPaused || albumQuery.errorUpdateCount > 0)); + 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/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 && + options.every((option) => option.label.length <= SEGMENT_LABEL_MAX) + ) { + 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) => ( + // Keyed by whether it's ticked, so the tick moves by remounting + // the two items rather than clearing a native prop. + 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/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(/; + }, + PixelRatio: { get: () => 3 }, +})); +vi.mock("@tanstack/react-query", () => ({ + keepPreviousData: mock.keepPreviousData, + 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") { + mock.albumOptions = options; + return { errorUpdateCount: 0, isFetching: false, ...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: (params: unknown) => { + mock.cache.push(["setParams", params]); + mock.setParams(params); + }, + }), +})); +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, + ListEmptyComponent, + renderItem, + }: { + data: unknown[]; + ListHeaderComponent: ReactNode; + ListEmptyComponent?: ReactNode; + renderItem: (args: { item: unknown; index: number }) => ReactNode; + }) => ( +
+ {ListHeaderComponent} + {data.length + ? data.map((item, index) =>
{renderItem({ item, index })}
) + : ListEmptyComponent} +
+ ), +})); +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: mock.showActionMenu })); +vi.mock("../components/track-row", () => ({ + TrackRow: ({ track }: { track: { title: string } }) => {track.title}, +})); +vi.mock("../components/empty-state", () => ({ + EmptyState: ({ message, loading }: { message?: string; loading?: boolean }) => ( + {loading ? "Loading" : message} + ), + 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, dolby_atmos = false) => ({ + id: `tidal:${sourceId}`, + source_id: sourceId, + title: "Powiedz To", + release_year: 2026, + track_count: 1, + duration_ms: 180_000, + explicit, + dolby_atmos, +}); + +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.albumOptions = undefined; + mock.albumStates = {}; + mock.cache = []; + mock.presses = {}; + mock.showActionMenu.mockReset(); + 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 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('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("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("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 + 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)); + 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" }); +});