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
57 changes: 57 additions & 0 deletions __tests__/deck-pointer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { deckMatchesPointer, reconstructPlaylistUrl } from '../lib/deck-pointer';
import type { Deck } from '../lib/types';

const deckWith = (sourceUrl: string): Deck => ({
id: 'deck-1',
name: 'Test Deck',
sourceUrl,
createdAt: 1_700_000_000_000,
cards: [],
});

describe('reconstructPlaylistUrl', () => {
test('returns canonical Deezer playlist URL for the DZ provider', () => {
expect(reconstructPlaylistUrl({ provider: 'DZ', playlistId: '123' })).toBe(
'https://www.deezer.com/playlist/123',
);
});

test('returns null for an unsupported provider tag', () => {
expect(reconstructPlaylistUrl({ provider: 'SP', playlistId: '123' })).toBeNull();
});

test('returns null for the AM provider tag (Apple Music — not yet supported)', () => {
expect(reconstructPlaylistUrl({ provider: 'AM', playlistId: 'pl.abc' })).toBeNull();
});

test('returns null for the YT provider tag (YouTube Music — not yet supported)', () => {
expect(reconstructPlaylistUrl({ provider: 'YT', playlistId: 'PLxyz' })).toBeNull();
});
});

describe('deckMatchesPointer', () => {
test('returns true when the deck source URL parses to the same Deezer playlist id', () => {
const deck = deckWith('https://www.deezer.com/playlist/123');
expect(deckMatchesPointer(deck, { provider: 'DZ', playlistId: '123' })).toBe(true);
});

test('returns true regardless of locale segment in the saved deck URL', () => {
const deck = deckWith('https://www.deezer.com/en/playlist/123');
expect(deckMatchesPointer(deck, { provider: 'DZ', playlistId: '123' })).toBe(true);
});

test("returns false when the deck's playlist id differs from the pointer", () => {
const deck = deckWith('https://www.deezer.com/playlist/999');
expect(deckMatchesPointer(deck, { provider: 'DZ', playlistId: '123' })).toBe(false);
});

test("returns false when the deck's source URL is not a Deezer playlist URL", () => {
const deck = deckWith('https://example.com/some-foreign-source');
expect(deckMatchesPointer(deck, { provider: 'DZ', playlistId: '123' })).toBe(false);
});

test('returns false for an unsupported provider regardless of deck contents', () => {
const deck = deckWith('https://www.deezer.com/playlist/123');
expect(deckMatchesPointer(deck, { provider: 'SP', playlistId: '123' })).toBe(false);
});
});
4 changes: 4 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export default function RootLayout() {
}}
/>
<Stack.Screen name="playback" options={{ title: '' }} />
<Stack.Screen
name="import"
options={{ title: '', headerBackVisible: false, gestureEnabled: false }}
/>
</Stack>
<StatusBar style="light" />
</SafeAreaProvider>
Expand Down
190 changes: 190 additions & 0 deletions app/import.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

import { Text } from '@/components/ui/text';
import type { DeckPointer } from '@/lib/deck-pointer';
import { reconstructPlaylistUrl } from '@/lib/deck-pointer';
import { markDeclined } from '@/lib/import-session';
import { extractFromDeezerUrl } from '@/lib/playlist-extractor';
import { tokens } from '@/theme/tokens';

type ScreenState =
| { kind: 'loading' }
| { kind: 'unsupported' }
| { kind: 'error' }
| { kind: 'prompt'; name: string; trackCount: number };

const ERROR_MESSAGE =
"Couldn't load this deck — the source playlist may have been removed.";
const UNSUPPORTED_MESSAGE =
"Couldn't load this deck — provider not supported.";

export default function ImportScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ provider?: string; playlistId?: string }>();
const provider = typeof params.provider === 'string' ? params.provider : '';
const playlistId = typeof params.playlistId === 'string' ? params.playlistId : '';
const pointer: DeckPointer = { provider, playlistId };
const reconstructed = reconstructPlaylistUrl(pointer);

const [state, setState] = useState<ScreenState>(() =>
reconstructed === null ? { kind: 'unsupported' } : { kind: 'loading' },
);

// Strict mode / Fast Refresh fires effects twice in dev. Avoid double-fetch.
const startedRef = useRef(false);

useEffect(() => {
if (startedRef.current) return;
if (reconstructed === null) return;
startedRef.current = true;

let cancelled = false;
void (async () => {
try {
const result = await extractFromDeezerUrl(reconstructed);
if (cancelled) return;
setState({
kind: 'prompt',
name: result.name,
trackCount: result.tracks.length,
});
} catch {
if (cancelled) return;
setState({ kind: 'error' });
}
})();

return () => {
cancelled = true;
};
}, [reconstructed]);

const onImport = (): void => {
if (reconstructed === null) return;
router.replace({ pathname: '/resolving', params: { url: reconstructed } });
};

const onNotNow = (): void => {
markDeclined(pointer);
router.replace('/');
};

const onBack = (): void => {
router.replace('/');
};

return (
<SafeAreaView edges={['top', 'left', 'right', 'bottom']} className="flex-1 bg-navy900">
<View className="flex-1 px-6 py-8">
{state.kind === 'loading' && <LoadingView />}
{state.kind === 'unsupported' && (
<ErrorView message={UNSUPPORTED_MESSAGE} onBack={onBack} />
)}
{state.kind === 'error' && (
<ErrorView message={ERROR_MESSAGE} onBack={onBack} />
)}
{state.kind === 'prompt' && (
<PromptView
name={state.name}
trackCount={state.trackCount}
onImport={onImport}
onNotNow={onNotNow}
/>
)}
</View>
</SafeAreaView>
);
}

function LoadingView(): React.ReactElement {
return (
<View className="flex-1 items-center justify-center gap-4">
<ActivityIndicator color={tokens.colors.lime} />
<Text className="font-body text-foreground text-base">
Looking up the deck…
</Text>
</View>
);
}

function ErrorView({
message,
onBack,
}: {
message: string;
onBack: () => void;
}): React.ReactElement {
return (
<View className="flex-1 items-center justify-center gap-6">
<Text className="font-display text-foreground text-2xl text-center leading-snug">
{message}
</Text>
<Pressable
onPress={onBack}
role="button"
accessibilityLabel="Back to Library"
className="rounded-full bg-primary px-6 py-3 active:bg-primary/90"
>
<Text className="font-body text-primary-foreground text-base font-bold">
Back to Library
</Text>
</Pressable>
</View>
);
}

function PromptView({
name,
trackCount,
onImport,
onNotNow,
}: {
name: string;
trackCount: number;
onImport: () => void;
onNotNow: () => void;
}): React.ReactElement {
return (
<View className="flex-1 justify-between">
<View className="gap-3 pt-8">
<Text className="font-body text-navy200 text-sm uppercase tracking-wide">
Import this deck?
</Text>
<Text
className="font-display text-foreground text-4xl leading-tight"
numberOfLines={3}
>
{name}
</Text>
<Text className="font-body text-muted-foreground text-base">
{trackCount === 1 ? '1 track' : `${trackCount} tracks`}
</Text>
</View>
<View className="gap-3">
<Pressable
onPress={onImport}
role="button"
accessibilityLabel="Import this deck"
className="items-center justify-center rounded-full bg-primary px-6 py-4 active:bg-primary/90"
>
<Text className="font-body text-primary-foreground text-base font-bold">
Import this deck
</Text>
</Pressable>
<Pressable
onPress={onNotNow}
role="button"
accessibilityLabel="Not now"
className="items-center justify-center rounded-full border border-border px-6 py-4 active:bg-navy700"
>
<Text className="font-body text-foreground text-base font-bold">
Not now
</Text>
</Pressable>
</View>
</View>
);
}
80 changes: 71 additions & 9 deletions app/playback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
// - playback timing (position, isPlaying, didFinish)
//
// It is NOT allowed to know — and therefore must NOT look up — the card's
// artist, title, year, album, or cover art. It does not query the local
// DeckLibrary, because doing so would put identifying strings one render
// away from the UI. That's the whole game: scan -> audio -> physical reveal.
// artist, title, year, album, or cover art. The only DeckLibrary access
// permitted is a *boolean* existence check at end-of-round to decide whether
// to route to the import-prompt screen (per Issue #13 / ADR-0013). That check
// must NEVER feed deck or track strings into UI state on this screen.
//
// The audio API is touched only through `AntiSpoilerAudioPlayer`. Direct
// `expo-av` imports here would be both an ADR-0009 violation and an ESLint
Expand All @@ -33,7 +34,14 @@ import {
type PlaybackStatus,
} from '@/lib/anti-spoiler-audio-player';
import { deezerAudioProvider, type AudioProviderError } from '@/lib/audio-provider';
import { parseCardCode } from '@/lib/card-code-validator';
import { parseCardCode, type CardCode } from '@/lib/card-code-validator';
import { deckLibrary } from '@/lib/deck-library';
import {
deckMatchesPointer,
reconstructPlaylistUrl,
type DeckPointer,
} from '@/lib/deck-pointer';
import { wasDeclined } from '@/lib/import-session';
import { tokens } from '@/theme/tokens';

/**
Expand Down Expand Up @@ -62,6 +70,26 @@ export default function PlaybackScreen() {
// Player and abort flag held in refs so we don't re-create them per render.
const playerRef = useRef<AntiSpoilerAudioPlayer | null>(null);
const cancelledRef = useRef(false);
// Round-end can be triggered both by natural `onEnded` and by the End-round
// button. Route exactly once so we don't double-navigate.
const routedRef = useRef(false);

const routeAfterRound = useCallback(async (): Promise<void> => {
if (routedRef.current) return;
routedRef.current = true;
cancelledRef.current = true;
void playerRef.current?.stop();

const next = await chooseNextRoute(parsed);
if (next.kind === 'scan') {
router.replace('/scan');
return;
}
router.replace({
pathname: '/import',
params: { provider: next.pointer.provider, playlistId: next.pointer.playlistId },
});
}, [parsed, router]);

// Defensive: a working Scanner never navigates here with an invalid code,
// but the screen still has to handle it gracefully.
Expand Down Expand Up @@ -104,6 +132,7 @@ export default function PlaybackScreen() {
const onEnded = () => {
if (cancelledRef.current) return;
setPhase({ kind: 'ended' });
void routeAfterRound();
};

void (async () => {
Expand All @@ -129,7 +158,7 @@ export default function PlaybackScreen() {
void player.stop();
playerRef.current = null;
};
}, [parsed]);
}, [parsed, routeAfterRound]);

const onTogglePause = useCallback(async () => {
const player = playerRef.current;
Expand All @@ -144,10 +173,8 @@ export default function PlaybackScreen() {
}, [phase]);

const onEndRound = useCallback(() => {
cancelledRef.current = true;
void playerRef.current?.stop();
router.replace('/scan');
}, [router]);
void routeAfterRound();
}, [routeAfterRound]);

return (
<SafeAreaView edges={['top', 'left', 'right', 'bottom']} className="flex-1 bg-navy900">
Expand Down Expand Up @@ -380,3 +407,38 @@ function messageFor(e: unknown): string {
}
return 'Audio failed to start. Try again.';
}

type NextRoute =
/** Card had no deck pointer, the deck is already in the Library, or the
* user already declined this pointer this session. Go back to scan. */
| { kind: 'scan' }
/** Card carried an unknown, supported pointer not yet declined. Offer
* import. */
| { kind: 'import'; pointer: DeckPointer };

/**
* Decide where Playback should route after the round ends. Pure routing
* decision: this function reads the local DeckLibrary to test for a match,
* but never returns or surfaces deck/track metadata to the caller — only the
* pointer the screen already carries. Errors fall through to `scan` so a
* misbehaving DeckLibrary can never trap the user on the playback screen.
*/
async function chooseNextRoute(parsed: CardCode | null): Promise<NextRoute> {
if (parsed === null) return { kind: 'scan' };
if (parsed.provider === undefined || parsed.playlistId === undefined) {
return { kind: 'scan' };
}
const pointer: DeckPointer = {
provider: parsed.provider,
playlistId: parsed.playlistId,
};
if (reconstructPlaylistUrl(pointer) === null) return { kind: 'scan' };
if (wasDeclined(pointer)) return { kind: 'scan' };
try {
const decks = await deckLibrary.list();
if (decks.some((d) => deckMatchesPointer(d, pointer))) return { kind: 'scan' };
} catch {
return { kind: 'scan' };
}
return { kind: 'import', pointer };
}
Loading
Loading