Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 103 additions & 21 deletions mobile/app/(tabs)/(library,browse)/tidal-albums/[id].tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -35,14 +41,20 @@ 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;

const albumQuery = useQuery({
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,
Expand All @@ -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 ? (
<ReleaseVersionPicker
versions={versions}
current={id}
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, queryClient, userId],
);
const play = usePlayTrack();
const { shuffle } = usePlayerPlayback();
const queryClient = useQueryClient();
const { refetch } = albumQuery;
const onQueuedChanged = useCallback(
(queued: number) => {
Expand All @@ -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<TrackListItem>) => (
<TrackRow track={item} onPress={onTrackPress} />
<View
pointerEvents={switching ? "none" : "auto"}
accessibilityElementsHidden={switching}
importantForAccessibility={switching ? "no-hide-descendants" : "auto"}
style={switching ? { opacity: 0.4 } : undefined}
>
<TrackRow track={item} onPress={onTrackPress} />
</View>
),
[onTrackPress],
[onTrackPress, switching],
);

const keyExtractor = useCallback((track: TrackListItem) => track.id, []);
Expand All @@ -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 <EmptyState fill loading />;
if (albumQuery.isError || !albumQuery.data) {
return (
<EmptyState
fill
selectable
message="Couldn't load TIDAL album."
action={retryAction(albumQuery)}
/>
);
}
// 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 = (
<EmptyState
fill={!versionPicker}
selectable
message="Couldn't load TIDAL album."
action={retryAction(albumQuery)}
/>
);
if (failed && !versionPicker) return failure;

return (
<>
<Stack.Screen
options={{
title: displayText(albumQuery.data.title),
title: failed
? "TIDAL album"
: displayText(albumQuery.data?.title ?? ""),
headerLargeTitle: false,
}}
/>
<FlashList
{...TRACK_FLASH_LIST_PERFORMANCE_PROPS}
{...dockScroll}
data={tracks}
data={failed ? [] : tracks}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={header}
extraData={switching}
ListHeaderComponent={
failed ? (
<View
style={{
paddingHorizontal: theme.space.lg,
paddingTop: theme.space.md,
}}
>
{versionPicker}
</View>
) : (
header
)
}
ListEmptyComponent={failed ? failure : undefined}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: theme.color.bg }}
contentContainerStyle={{ paddingBottom: dockInset + 24 }}
Expand Down
9 changes: 6 additions & 3 deletions mobile/app/(tabs)/(library,browse)/tidal-artists/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@music-library/core";
import {
ARTIST_POPULAR_PREVIEW_COUNT,
foldReleaseVersions,
tidalArtistReleases,
type ArtistRelease,
} from "@music-library/core/artist-releases";
Expand Down Expand Up @@ -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(
Expand All @@ -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<TrackListItem>) => (
Expand Down
21 changes: 17 additions & 4 deletions mobile/components/album-header.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand All @@ -26,6 +31,8 @@ export function AlbumHeader({
metadata,
onPlay,
secondaryAction,
versions,
pending = false,
}: Props) {
const theme = useTheme();
return (
Expand Down Expand Up @@ -93,8 +100,14 @@ export function AlbumHeader({
</Text>
</View>
</View>
{versions ? (
<View style={{ marginBottom: theme.space.md }}>{versions}</View>
) : null}
{!!onPlay ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: pending }}
disabled={pending}
onPress={() => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onPlay();
Expand All @@ -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",
})}
>
Expand All @@ -131,8 +144,8 @@ export function AlbumHeader({
<Pressable
accessibilityRole="button"
accessibilityLabel={secondaryAction.label}
accessibilityState={{ disabled: !!secondaryAction.disabled }}
disabled={secondaryAction.disabled}
accessibilityState={{ disabled: pending || !!secondaryAction.disabled }}
disabled={pending || secondaryAction.disabled}
onPress={() => {
void Haptics.selectionAsync();
secondaryAction.onPress();
Expand All @@ -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",
})}
>
Expand Down
4 changes: 4 additions & 0 deletions mobile/components/glass-segmented-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export function GlassSegmentedControl<T extends string>({
style={styles.item}
>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={{
color: active ? colors.active : colors.inactive,
fontSize: 15,
Expand Down Expand Up @@ -135,5 +138,6 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 6,
},
});
Loading
Loading