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
50 changes: 27 additions & 23 deletions mobile/app/(tabs)/(playlists)/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
View,
} from "react-native";
import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list";
import ReorderableList, {
import {
useIsActive,
useReorderableDrag,
type ReorderableListReorderEvent,
Expand All @@ -21,7 +21,6 @@ import {
api,
compareSortableTracks,
errorMessage,
movePlaylistEntry,
playlistEntryToTrack as entryToTrack,
playlistAutoDownloadRefetchMs,
playlistPermissions,
Expand Down Expand Up @@ -65,7 +64,13 @@ import {
SwipeableTrackRow,
type SwipeRowProps,
} from "../../../components/swipe/swipeable-track-row";
import { SteadyReorderableList } from "../../../components/steady-reorderable-list";
import { qk } from "../../../lib/query-keys";
import {
numberRepeatedKeys,
playlistAfterDrop,
rowKeyOrIndex,
} from "../../../lib/reorder-drop";
import { listPlaybackQueue, usePlayQueue } from "../../../lib/use-play-queue";
import { useTrackSort } from "../../../lib/use-track-sort";
import {
Expand Down Expand Up @@ -127,14 +132,16 @@ export default function PlaylistDetailScreen() {
enabled: !!userId && !!id,
});

const [reorderMode, setReorderMode] = useState(false);
const tracksQuery = useQuery({
queryKey: playlistTracksQueryKey,
queryFn: ({ signal }) => api.listPlaylistTracks(id!, { signal }),
enabled: !!userId && !!id,
// While auto-download saves tracks, refetch so rows flip to their
// library copies; after a failure, look again when its retry is due.
// Not while reordering: rows swapped under a drag move the wrong one.
refetchInterval: (query) =>
playlistQuery.data?.tidal_auto_download
playlistQuery.data?.tidal_auto_download && !reorderMode
? playlistAutoDownloadRefetchMs(query.state.data)
: false,
});
Expand All @@ -157,7 +164,6 @@ export default function PlaylistDetailScreen() {
// server list. The override is tied to the snapshot it was made from, so the
// next fetched snapshot replaces it without an effect having to reset it.
const [override, setOverride] = useState<TracksOverride | null>(null);
const [reorderMode, setReorderMode] = useState(false);
const { sortKey, sortAsc, onSelectSort, onToggleSortDirection, resetSort } =
useTrackSort();
const serverData = tracksQuery.data;
Expand Down Expand Up @@ -280,15 +286,16 @@ export default function PlaylistDetailScreen() {
if (!canEdit && reorderMode) setReorderMode(false);
}, [canEdit, reorderMode]);

const rowModels = useMemo<PlaylistTrackRowModel[]>(
() =>
localTracks.map((entry) => ({
key: playlistTrackKey(entry),
entry,
track: entryToTrack(entry),
})),
[localTracks],
);
const rowModels = useMemo<PlaylistTrackRowModel[]>(() => {
// One add can save a track twice with the same timestamp and adder, and
// a drop is matched to its row by key.
const keys = numberRepeatedKeys(localTracks.map(playlistTrackKey));
return localTracks.map((entry, i) => ({
key: keys[i],
entry,
track: entryToTrack(entry),
}));
}, [localTracks]);
// What the list actually shows; the play queue follows this order too.
const displayModels = useMemo<PlaylistTrackRowModel[]>(
() =>
Expand Down Expand Up @@ -340,9 +347,11 @@ export default function PlaylistDetailScreen() {

const onReorder = useCallback(
({ from, to }: ReorderableListReorderEvent) => {
if (!serverData) return;
// The list replays a drop onto `rowModels`, which mirror `localTracks`;
// an index outside them is ignored rather than saved.
const next = playlistAfterDrop(localTracks, from, to);
if (!serverData || !next) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const next = movePlaylistEntry(localTracks, from, to);
const edit = { base: serverData, tracks: next };
setOverride(edit);
reorderTracks(
Expand Down Expand Up @@ -566,11 +575,6 @@ export default function PlaylistDetailScreen() {
queuedForServer,
]);

const keyExtractor = useCallback(
(item: PlaylistTrackRowModel) => item.key,
[],
);

const renderDraggableItem = useCallback(
({ item }: { item: PlaylistTrackRowModel }) => (
<DraggablePlaylistRow
Expand Down Expand Up @@ -665,11 +669,11 @@ export default function PlaylistDetailScreen() {
}}
/>
{showReorderMode ? (
<ReorderableList<PlaylistTrackRowModel>
<SteadyReorderableList<PlaylistTrackRowModel>
{...TRACK_LIST_PERFORMANCE_PROPS}
data={rowModels}
onReorder={onReorder}
keyExtractor={keyExtractor}
keyExtractor={rowKeyOrIndex}
renderItem={renderDraggableItem}
ListHeaderComponent={header}
// No automatic inset here: the drag math reads the raw scroll
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand All @@ -690,7 +694,7 @@ export default function PlaylistDetailScreen() {
{...dockScroll}
data={displayModels}
renderItem={renderReadOnlyItem}
keyExtractor={keyExtractor}
keyExtractor={rowKeyOrIndex}
ListHeaderComponent={header}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: theme.color.bg }}
Expand Down
96 changes: 96 additions & 0 deletions mobile/components/steady-reorderable-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import ReorderableList, {
type ReorderableListProps,
type ReorderableListReorderEvent,
} from "react-native-reorderable-list";
import { scheduleOnRN } from "react-native-worklets";
import { rebaseDrop } from "../lib/reorder-drop";

/** The library's default `animationDuration`: how long a dropped row takes to settle. */
const DROP_ANIMATION_MS = 200;
/** Slack after the drop animation for the library's onReorder to reach JS. */
export const LET_GO_AFTER_DROP_MS = 300;

type SteadyReorderableListProps<T> = Omit<
ReorderableListProps<T>,
"keyExtractor" | "onDragStart" | "onDragEnd"
> & {
keyExtractor: (item: T, index: number) => string;
};

/**
* A ReorderableList whose rows hold still from the start of a drag until its
* drop is handled.
*
* The library reports a drop as indices into the rows it showed when the
* drag began, and before that reads `keyExtractor(data[i])` for each index
* in between without checking it against the current data. New data
* mid-drag (a refetch) then moved the wrong row, and a shorter list crashed
* the app (Sentry LUMEN-4). So the list keeps the rows it started the drag
* on, and `onReorder` gets the drop replayed onto the latest `data`, which
* the list shows from then on. A drop whose row is gone reports nothing.
*/
export function SteadyReorderableList<T>({
data,
keyExtractor,
onReorder,
...props
}: SteadyReorderableListProps<T>) {
const [held, setHeld] = useState<T[] | null>(null);
const shown = held ?? data;
const letGoMs = (props.animationDuration ?? DROP_ANIMATION_MS) + LET_GO_AFTER_DROP_MS;
// The drag callbacks run from the UI thread, and the drop from the closure
// the list captured when the finger lifted, so they read the latest render
// from here.
const latest = useRef({ shown, data, keyExtractor, onReorder, letGoMs });
useLayoutEffect(() => {
latest.current = { shown, data, keyExtractor, onReorder, letGoMs };
});
// The rows the current drag's indices refer to, kept even if the hold
// lets go before its drop arrives.
const dragRows = useRef<T[] | null>(null);
const letGoTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(letGoTimer.current), []);

const hold = useCallback(() => {
clearTimeout(letGoTimer.current);
dragRows.current = latest.current.shown;
setHeld(dragRows.current);
}, []);
// The library calls onReorder once the dropped row has settled, except for
// a row put back where it was, or a finger lifted exactly on the new slot.
// Let go a little after the drop animation unless the drop came first.
const settle = useCallback(() => {
clearTimeout(letGoTimer.current);
letGoTimer.current = setTimeout(() => setHeld(null), latest.current.letGoMs);
}, []);
const onDragStart = useCallback(() => {
"worklet";
scheduleOnRN(hold);
}, [hold]);
const onDragEnd = useCallback(() => {
"worklet";
scheduleOnRN(settle);
}, [settle]);
const handleReorder = useCallback(({ from, to }: ReorderableListReorderEvent) => {
clearTimeout(letGoTimer.current);
const { shown: dragged, data: current, keyExtractor: key, onReorder: report } =
latest.current;
const rows = dragRows.current ?? dragged;
dragRows.current = null;
setHeld(null);
const move = rebaseDrop(rows.map(key), current.map(key), from, to);
if (move) report(move);
}, []);

return (
<ReorderableList<T>
{...props}
data={shown}
keyExtractor={keyExtractor}
onReorder={handleReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
);
}
83 changes: 83 additions & 0 deletions mobile/lib/reorder-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { movePlaylistEntry } from "@music-library/core";

/**
* Drops from react-native-reorderable-list, checked against the rows a list
* holds when they land.
*
* The list reports a drop as indices into the rows it showed when the drag
* began, one drop animation after the finger lifts. A refetch can replace
* those rows in the meantime, so the indices may point at other rows, or past
* the end of a shorter list.
*/

export type ReorderMove = { from: number; to: number };

/** Whether `index` is a slot in `list`. */
export function isIndexIn(list: readonly unknown[], index: number): boolean {
return Number.isInteger(index) && index >= 0 && index < list.length;
}

/**
* A list key for a row that may be missing. The list library reads keys by
* index, and a stale index past the end of the data hands it `undefined`.
*/
export function rowKeyOrIndex(row: { key: string } | undefined, index: number): string {
return row?.key ?? String(index);
}

/**
* `keys` with each repeat numbered in list order ("k", "k#1", "k#2"), so rows
* that share a key still get distinct ones.
*/
export function numberRepeatedKeys(keys: readonly string[]): string[] {
const seen = new Map<string, number>();
return keys.map((key) => {
const count = seen.get(key) ?? 0;
seen.set(key, count + 1);
return count === 0 ? key : `${key}#${count}`;
});
}

/**
* The move a drop makes on `current`, where `from` and `to` index `dragged`
* (the row keys the drag began on). The dragged row goes under the row it
* was dropped under; when that row is gone, or the drop was at the top, it
* goes above the row it was dropped above. Null when there's nothing to
* move: an index outside `dragged`, a dragged row that's gone, no neighbour
* left to place it by, or a row already where the drop puts it. Keys must
* be unique (see `numberRepeatedKeys`).
*/
export function rebaseDrop(
dragged: readonly string[],
current: readonly string[],
from: number,
to: number,
): ReorderMove | null {
if (from === to || !isIndexIn(dragged, from) || !isIndexIn(dragged, to)) return null;
const fromNow = current.indexOf(dragged[from]);
if (fromNow < 0) return null;
// Both lists without the dragged row: the drop puts it between
// others[to - 1] and others[to], and the move's `to` indexes `rest` the
// same way (out first, then back in).
const others = dragged.filter((_, i) => i !== from);
const rest = current.filter((_, i) => i !== fromNow);
const below = to > 0 ? rest.indexOf(others[to - 1]) : -1;
const above = below < 0 && to < others.length ? rest.indexOf(others[to]) : -1;
const toNow = below >= 0 ? below + 1 : above;
if (toNow < 0 || toNow === fromNow) return null;
return { from: fromNow, to: toNow };
}

/**
* The playlist after moving the row at `from` to `to`, or null unless both
* are distinct slots in `entries`: a reorder built from an index outside the
* list would send the server an undefined track id.
*/
export function playlistAfterDrop<T extends { position: number }>(
entries: readonly T[],
from: number,
to: number,
): T[] | null {
if (from === to || !isIndexIn(entries, from) || !isIndexIn(entries, to)) return null;
return movePlaylistEntry(entries, from, to);
}
Loading
Loading