From 2125f379a267c144da21d066908d3efc61463a92 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 24 Feb 2026 04:08:31 +0200 Subject: [PATCH] nightshift: rename LanguageFilter "persian" to "original" The LanguageFilter type hardcoded "persian" as the value for showing original/source text, but the app supports multiple source languages (Korean, Arabic, Hebrew, etc.). Renamed to "original" for clarity. Includes backward compatibility: stored "persian" preferences (Convex DB and localStorage) are normalized to "original" on read. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/songPageDefaults.test.ts | 109 +-- src/components/LyricsDisplay.test.tsx | 401 ++++++----- src/components/LyricsDisplay.tsx | 193 ++--- src/hooks/useAnonymousProgress.ts | 34 +- src/routes/song.$songId.tsx | 958 +++++++++++++++---------- 5 files changed, 1005 insertions(+), 690 deletions(-) diff --git a/src/__tests__/songPageDefaults.test.ts b/src/__tests__/songPageDefaults.test.ts index 4ac64cc..98d5d42 100644 --- a/src/__tests__/songPageDefaults.test.ts +++ b/src/__tests__/songPageDefaults.test.ts @@ -1,66 +1,83 @@ -import { describe, it, expect } from 'vitest' -import * as fs from 'fs' -import * as path from 'path' +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; /** * Unit test to verify that the song page initializes with the correct default values * for playback modes and uses video as single audio source. */ -describe('Song Page Default State', () => { - const songPagePath = path.join(__dirname, '..', 'routes', 'song.$songId.tsx') - const songPageContent = fs.readFileSync(songPagePath, 'utf-8') +describe("Song Page Default State", () => { + const songPagePath = path.join(__dirname, "..", "routes", "song.$songId.tsx"); + const songPageContent = fs.readFileSync(songPagePath, "utf-8"); it('initializes playback mode to "fluid" always (better UX on page load)', () => { - expect(songPageContent).toContain('useState("fluid")') - }) + expect(songPageContent).toContain('useState("fluid")'); + }); - it('initializes video muted state from user preferences with true fallback', () => { - const videoMutedRegex = /const\s+\[isVideoMuted,\s*setIsVideoMuted\]\s*=\s*useState\s*\(\s*userPreferences\?\.videoMuted\s*\?\?\s*true\s*\)/ - expect(songPageContent).toMatch(videoMutedRegex) - }) + it("initializes video muted state from user preferences with true fallback", () => { + const videoMutedRegex = + /const\s+\[isVideoMuted,\s*setIsVideoMuted\]\s*=\s*useState\s*\(\s*userPreferences\?\.videoMuted\s*\?\?\s*true[\s,]*\)/; + expect(songPageContent).toMatch(videoMutedRegex); + }); - it('passes autoplay prop to LocalVideoPlayer when in fluid mode and preferences are applied', () => { - expect(songPageContent).toContain('autoplay={preferencesApplied && playbackMode === "fluid"}') - }) + it("passes autoplay prop to LocalVideoPlayer when in fluid mode and preferences are applied", () => { + expect(songPageContent).toContain( + 'autoplay={preferencesApplied && playbackMode === "fluid"}', + ); + }); - it('has three playback modes: single, loop, and fluid', () => { - expect(songPageContent).toContain('type PlaybackMode = "single" | "loop" | "fluid"') - }) + it("has three playback modes: single, loop, and fluid", () => { + expect(songPageContent).toContain( + 'type PlaybackMode = "single" | "loop" | "fluid"', + ); + }); - it('uses video as single audio source (no useAudioPreloader on main page)', () => { + it("uses video as single audio source (no useAudioPreloader on main page)", () => { // The simplified architecture should NOT use useAudioPreloader on the main page - expect(songPageContent).not.toContain('useAudioPreloader(audioSnippets') - }) + expect(songPageContent).not.toContain("useAudioPreloader(audioSnippets"); + }); - it('handles loop mode by seeking video back to line start', () => { + it("handles loop mode by seeking video back to line start", () => { // Uses seekTo helper which calls playerRef.current?.seekTo - expect(songPageContent).toContain('seekTo(currentLine.startTime)') - }) + expect(songPageContent).toContain("seekTo(currentLine.startTime)"); + }); - it('handles single mode by pausing video at line end', () => { - expect(songPageContent).toContain('playerRef.current?.pause()') - }) -}) + it("handles single mode by pausing video at line end", () => { + expect(songPageContent).toContain("playerRef.current?.pause()"); + }); +}); -describe('LocalVideoPlayer Auto-Play', () => { - const localVideoPlayerPath = path.join(__dirname, '..', 'components', 'LocalVideoPlayer.tsx') - const localVideoPlayerContent = fs.readFileSync(localVideoPlayerPath, 'utf-8') +describe("LocalVideoPlayer Auto-Play", () => { + const localVideoPlayerPath = path.join( + __dirname, + "..", + "components", + "LocalVideoPlayer.tsx", + ); + const localVideoPlayerContent = fs.readFileSync( + localVideoPlayerPath, + "utf-8", + ); - it('supports autoplay prop', () => { - expect(localVideoPlayerContent).toContain('autoplay?: boolean') - }) + it("supports autoplay prop", () => { + expect(localVideoPlayerContent).toContain("autoplay?: boolean"); + }); - it('uses a ref to prevent multiple auto-play triggers', () => { - expect(localVideoPlayerContent).toContain('hasAttemptedAutoplayRef') - expect(localVideoPlayerContent).toContain('hasAttemptedAutoplayRef.current = true') - }) + it("uses a ref to prevent multiple auto-play triggers", () => { + expect(localVideoPlayerContent).toContain("hasAttemptedAutoplayRef"); + expect(localVideoPlayerContent).toContain( + "hasAttemptedAutoplayRef.current = true", + ); + }); - it('shows play button overlay when autoplay is blocked', () => { - expect(localVideoPlayerContent).toContain('showPlayButton') - expect(localVideoPlayerContent).toContain('Click to play') - }) + it("shows play button overlay when autoplay is blocked", () => { + expect(localVideoPlayerContent).toContain("showPlayButton"); + expect(localVideoPlayerContent).toContain("Click to play"); + }); - it('handles autoplay attempt in canplay event', () => { - expect(localVideoPlayerContent).toContain('autoplay && !hasAttemptedAutoplayRef.current') - }) -}) + it("handles autoplay attempt in canplay event", () => { + expect(localVideoPlayerContent).toContain( + "autoplay && !hasAttemptedAutoplayRef.current", + ); + }); +}); diff --git a/src/components/LyricsDisplay.test.tsx b/src/components/LyricsDisplay.test.tsx index 3490d04..4ee601c 100644 --- a/src/components/LyricsDisplay.test.tsx +++ b/src/components/LyricsDisplay.test.tsx @@ -1,60 +1,60 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import LyricsDisplay from './LyricsDisplay' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import LyricsDisplay from "./LyricsDisplay"; // Mock the convexQuery function -vi.mock('@convex-dev/react-query', () => ({ +vi.mock("@convex-dev/react-query", () => ({ convexQuery: vi.fn(() => ({ - queryKey: ['lyrics', 'test-song-id'], + queryKey: ["lyrics", "test-song-id"], queryFn: vi.fn(), })), -})) +})); // Mock useSuspenseQuery to return mock lyrics data -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, useSuspenseQuery: vi.fn(() => ({ data: [ { - _id: 'lyric1', - songId: 'test-song-id', + _id: "lyric1", + songId: "test-song-id", lineNumber: 1, startTime: 14.81, endTime: 17.46, - original: 'برای توی کوچه رقصیدن', - transliteration: 'Barāye tūye kūche raqsidan', - hebrew: 'בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן', - english: 'For dancing in the alley', + original: "برای توی کوچه رقصیدن", + transliteration: "Barāye tūye kūche raqsidan", + hebrew: "בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן", + english: "For dancing in the alley", }, { - _id: 'lyric2', - songId: 'test-song-id', + _id: "lyric2", + songId: "test-song-id", lineNumber: 2, startTime: 17.46, endTime: 20.91, - original: 'برای ترسیدن به وقت بوسیدن', - transliteration: 'Barāye tarsidan be vaqt-e būsidan', - hebrew: 'בָּרָאיֶה טַרְסִידַן בֶּה וַקְטֶה בּוּסִידַן', - english: 'For being afraid at the moment of kissing', + original: "برای ترسیدن به وقت بوسیدن", + transliteration: "Barāye tarsidan be vaqt-e būsidan", + hebrew: "בָּרָאיֶה טַרְסִידַן בֶּה וַקְטֶה בּוּסִידַן", + english: "For being afraid at the moment of kissing", }, { - _id: 'lyric3', - songId: 'test-song-id', + _id: "lyric3", + songId: "test-song-id", lineNumber: 3, startTime: 20.91, endTime: 24.63, - original: 'برای خواهرم خواهرت خواهرامون', - transliteration: 'Barāye khāharam khāharet khāharāmūn', + original: "برای خواهرم خواهرت خواهرامون", + transliteration: "Barāye khāharam khāharet khāharāmūn", // No Hebrew for this line to test optional field - english: 'For my sister, your sister, our sisters', + english: "For my sister, your sister, our sisters", }, ], })), - } -}) + }; +}); // Create a wrapper with QueryClientProvider function createWrapper() { @@ -64,225 +64,290 @@ function createWrapper() { retry: false, }, }, - }) + }); return function Wrapper({ children }: { children: React.ReactNode }) { return ( {children} - ) - } + ); + }; } -describe('LyricsDisplay', () => { +describe("LyricsDisplay", () => { beforeEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - it('renders all language versions by default', () => { - render(, { + it("renders all language versions by default", () => { + render(, { wrapper: createWrapper(), - }) + }); // Check Persian text is rendered - expect(screen.getByText('برای توی کوچه رقصیدن')).toBeInTheDocument() + expect(screen.getByText("برای توی کوچه رقصیدن")).toBeInTheDocument(); // Check transliteration is rendered - expect(screen.getByText('Barāye tūye kūche raqsidan')).toBeInTheDocument() + expect(screen.getByText("Barāye tūye kūche raqsidan")).toBeInTheDocument(); // Check Hebrew is rendered (for lines that have it) - expect(screen.getByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).toBeInTheDocument() + expect( + screen.getByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).toBeInTheDocument(); // Check English is rendered - expect(screen.getByText('For dancing in the alley')).toBeInTheDocument() - }) + expect(screen.getByText("For dancing in the alley")).toBeInTheDocument(); + }); - it('renders multiple lines sorted by lineNumber', () => { - render(, { + it("renders multiple lines sorted by lineNumber", () => { + render(, { wrapper: createWrapper(), - }) + }); // All 3 lines should be rendered - expect(screen.getByText('For dancing in the alley')).toBeInTheDocument() - expect(screen.getByText('For being afraid at the moment of kissing')).toBeInTheDocument() - expect(screen.getByText('For my sister, your sister, our sisters')).toBeInTheDocument() - }) - - it('calls onLineClick with startTime and index when line is clicked', () => { - const onLineClick = vi.fn() + expect(screen.getByText("For dancing in the alley")).toBeInTheDocument(); + expect( + screen.getByText("For being afraid at the moment of kissing"), + ).toBeInTheDocument(); + expect( + screen.getByText("For my sister, your sister, our sisters"), + ).toBeInTheDocument(); + }); + + it("calls onLineClick with startTime and index when line is clicked", () => { + const onLineClick = vi.fn(); render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // Click the first line - const firstLine = screen.getByText('For dancing in the alley').closest('button') + const firstLine = screen + .getByText("For dancing in the alley") + .closest("button"); if (firstLine) { - fireEvent.click(firstLine) + fireEvent.click(firstLine); } - expect(onLineClick).toHaveBeenCalledWith(14.81, 0) - }) + expect(onLineClick).toHaveBeenCalledWith(14.81, 0); + }); - it('applies active highlight class to active line', () => { + it("applies active highlight class to active line", () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // The second line (index 1) should have the highlight class on the outer container div - const button = screen.getByText('For being afraid at the moment of kissing').closest('button') - const lineContainer = button?.parentElement - expect(lineContainer).toHaveClass('bg-primary/10') - expect(lineContainer).toHaveClass('ring-1') - expect(lineContainer).toHaveClass('ring-primary/20') - }) - - it('applies click animation class to clicked line', () => { + const button = screen + .getByText("For being afraid at the moment of kissing") + .closest("button"); + const lineContainer = button?.parentElement; + expect(lineContainer).toHaveClass("bg-primary/10"); + expect(lineContainer).toHaveClass("ring-1"); + expect(lineContainer).toHaveClass("ring-primary/20"); + }); + + it("applies click animation class to clicked line", () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // The click animation class is on the outer container div, not the button - const button = screen.getByText('For dancing in the alley').closest('button') - const lineContainer = button?.parentElement - expect(lineContainer).toHaveClass('scale-[0.98]') - expect(lineContainer).toHaveClass('bg-primary/20') - }) - - describe('Language Filter', () => { - it('shows only Persian when filter is "persian"', () => { + const button = screen + .getByText("For dancing in the alley") + .closest("button"); + const lineContainer = button?.parentElement; + expect(lineContainer).toHaveClass("scale-[0.98]"); + expect(lineContainer).toHaveClass("bg-primary/20"); + }); + + describe("Language Filter", () => { + it('shows only original text when filter is "original"', () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); - // Persian should be visible - expect(screen.getByText('برای توی کوچه رقصیدن')).toBeInTheDocument() + // Original text should be visible + expect(screen.getByText("برای توی کوچه رقصیدن")).toBeInTheDocument(); // Other languages should NOT be visible - expect(screen.queryByText('Barāye tūye kūche raqsidan')).not.toBeInTheDocument() - expect(screen.queryByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).not.toBeInTheDocument() - expect(screen.queryByText('For dancing in the alley')).not.toBeInTheDocument() - }) + expect( + screen.queryByText("Barāye tūye kūche raqsidan"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("For dancing in the alley"), + ).not.toBeInTheDocument(); + }); it('shows only Transliteration when filter is "transliteration"', () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // Transliteration should be visible - expect(screen.getByText('Barāye tūye kūche raqsidan')).toBeInTheDocument() + expect( + screen.getByText("Barāye tūye kūche raqsidan"), + ).toBeInTheDocument(); // Other languages should NOT be visible - expect(screen.queryByText('برای توی کوچه رقصیدن')).not.toBeInTheDocument() - expect(screen.queryByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).not.toBeInTheDocument() - expect(screen.queryByText('For dancing in the alley')).not.toBeInTheDocument() - }) + expect( + screen.queryByText("برای توی کوچه رقصیدن"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("For dancing in the alley"), + ).not.toBeInTheDocument(); + }); it('shows only Hebrew when filter is "hebrew"', () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // Hebrew should be visible (for lines that have it) - expect(screen.getByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).toBeInTheDocument() + expect( + screen.getByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).toBeInTheDocument(); // Other languages should NOT be visible - expect(screen.queryByText('برای توی کوچه رقصیدن')).not.toBeInTheDocument() - expect(screen.queryByText('Barāye tūye kūche raqsidan')).not.toBeInTheDocument() - expect(screen.queryByText('For dancing in the alley')).not.toBeInTheDocument() - }) + expect( + screen.queryByText("برای توی کوچه رقصیدن"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Barāye tūye kūche raqsidan"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("For dancing in the alley"), + ).not.toBeInTheDocument(); + }); it('shows only English when filter is "english"', () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // English should be visible - expect(screen.getByText('For dancing in the alley')).toBeInTheDocument() + expect(screen.getByText("For dancing in the alley")).toBeInTheDocument(); // Other languages should NOT be visible - expect(screen.queryByText('برای توی کوچه رقصیدن')).not.toBeInTheDocument() - expect(screen.queryByText('Barāye tūye kūche raqsidan')).not.toBeInTheDocument() - expect(screen.queryByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).not.toBeInTheDocument() - }) + expect( + screen.queryByText("برای توی کوچه رقصیدن"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Barāye tūye kūche raqsidan"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).not.toBeInTheDocument(); + }); it('shows all languages when filter is "all"', () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // All languages should be visible - expect(screen.getByText('برای توی کوچه رقصیدن')).toBeInTheDocument() - expect(screen.getByText('Barāye tūye kūche raqsidan')).toBeInTheDocument() - expect(screen.getByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן')).toBeInTheDocument() - expect(screen.getByText('For dancing in the alley')).toBeInTheDocument() - }) - }) - - it('handles lines without Hebrew text gracefully', () => { + expect(screen.getByText("برای توی کوچه رقصیدن")).toBeInTheDocument(); + expect( + screen.getByText("Barāye tūye kūche raqsidan"), + ).toBeInTheDocument(); + expect( + screen.getByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"), + ).toBeInTheDocument(); + expect(screen.getByText("For dancing in the alley")).toBeInTheDocument(); + }); + }); + + it("handles lines without Hebrew text gracefully", () => { render( - , - { wrapper: createWrapper() } - ) + , + { wrapper: createWrapper() }, + ); // Line 3 has no Hebrew - should still render other languages - expect(screen.getByText('برای خواهرم خواهرت خواهرامون')).toBeInTheDocument() - expect(screen.getByText('Barāye khāharam khāharet khāharāmūn')).toBeInTheDocument() - expect(screen.getByText('For my sister, your sister, our sisters')).toBeInTheDocument() - }) - - it('applies correct RTL direction to Persian text', () => { - render(, { + expect( + screen.getByText("برای خواهرم خواهرت خواهرامون"), + ).toBeInTheDocument(); + expect( + screen.getByText("Barāye khāharam khāharet khāharāmūn"), + ).toBeInTheDocument(); + expect( + screen.getByText("For my sister, your sister, our sisters"), + ).toBeInTheDocument(); + }); + + it("applies correct RTL direction to Persian text", () => { + render(, { wrapper: createWrapper(), - }) + }); - const persianText = screen.getByText('برای توی کوچه رقصیدن') - expect(persianText).toHaveAttribute('dir', 'rtl') - expect(persianText).toHaveClass('text-right') - }) + const persianText = screen.getByText("برای توی کوچه رقصیدن"); + expect(persianText).toHaveAttribute("dir", "rtl"); + expect(persianText).toHaveClass("text-right"); + }); - it('applies correct RTL direction to Hebrew text', () => { - render(, { + it("applies correct RTL direction to Hebrew text", () => { + render(, { wrapper: createWrapper(), - }) + }); - const hebrewText = screen.getByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן') - expect(hebrewText).toHaveAttribute('dir', 'rtl') - expect(hebrewText).toHaveClass('text-right') - }) + const hebrewText = screen.getByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"); + expect(hebrewText).toHaveAttribute("dir", "rtl"); + expect(hebrewText).toHaveClass("text-right"); + }); - it('applies italic and green color to transliteration', () => { - render(, { + it("applies italic and green color to transliteration", () => { + render(, { wrapper: createWrapper(), - }) + }); - const translitText = screen.getByText('Barāye tūye kūche raqsidan') - expect(translitText).toHaveClass('italic') - expect(translitText).toHaveClass('text-emerald-500') - }) + const translitText = screen.getByText("Barāye tūye kūche raqsidan"); + expect(translitText).toHaveClass("italic"); + expect(translitText).toHaveClass("text-emerald-500"); + }); - it('applies blue color to Hebrew text', () => { - render(, { + it("applies blue color to Hebrew text", () => { + render(, { wrapper: createWrapper(), - }) + }); - const hebrewText = screen.getByText('בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן') - expect(hebrewText).toHaveClass('text-blue-500') - }) + const hebrewText = screen.getByText("בָּרָאיֶה טוּיֶה כּוּצֶ׳ה רַקְסִידַן"); + expect(hebrewText).toHaveClass("text-blue-500"); + }); - it('applies gray color to English text', () => { - render(, { + it("applies gray color to English text", () => { + render(, { wrapper: createWrapper(), - }) + }); - const englishText = screen.getByText('For dancing in the alley') - expect(englishText).toHaveClass('text-gray-400') - }) -}) + const englishText = screen.getByText("For dancing in the alley"); + expect(englishText).toHaveClass("text-gray-400"); + }); +}); diff --git a/src/components/LyricsDisplay.tsx b/src/components/LyricsDisplay.tsx index d4c81e8..e2f5c50 100644 --- a/src/components/LyricsDisplay.tsx +++ b/src/components/LyricsDisplay.tsx @@ -18,7 +18,12 @@ export interface LyricLine { english: string; } -export type LanguageFilter = "all" | "persian" | "transliteration" | "hebrew" | "english"; +export type LanguageFilter = + | "all" + | "original" + | "transliteration" + | "hebrew" + | "english"; interface LyricsDisplayProps { songId: Id<"songs">; @@ -51,27 +56,27 @@ export default function LyricsDisplay({ }: LyricsDisplayProps) { const isOriginalRTL = isRTLLanguage(sourceLanguage || "persian"); const { data: lyrics } = useSuspenseQuery( - convexQuery(api.lyrics.getBySong, { songId }) + convexQuery(api.lyrics.getBySong, { songId }), ); // Sort lyrics by lineNumber to ensure correct order const sortedLyrics = [...(lyrics || [])].sort( - (a, b) => a.lineNumber - b.lineNumber + (a, b) => a.lineNumber - b.lineNumber, ) as LyricLine[]; // Create lookup maps for efficient state checking const lineLearnedMap = new Map( - lineProgress.map(lp => [lp.lineNumber, lp.learned]) + lineProgress.map((lp) => [lp.lineNumber, lp.learned]), ); // Determine visual state for each line - const getLineState = (line: LyricLine): 'default' | 'learned' => { + const getLineState = (line: LyricLine): "default" | "learned" => { // Check if line is explicitly marked as learned if (lineLearnedMap.get(line.lineNumber)) { - return 'learned'; + return "learned"; } - - return 'default'; + + return "default"; }; // Refs for each line to enable auto-scroll @@ -91,98 +96,94 @@ export default function LyricsDisplay({
{sortedLyrics.map((line, index) => { const lineState = getLineState(line); - const isLearned = lineState === 'learned'; - + const isLearned = lineState === "learned"; + return ( -
{ - lineRefs.current[index] = el as HTMLButtonElement | null; - }} - className={`flex min-h-11 items-start gap-2 rounded-lg p-3 transition-all duration-150 ${ - activeLineIndex === index - ? "bg-primary/10 ring-1 ring-primary/20" - : "" - } ${ - clickedLineIndex === index - ? "scale-[0.98] bg-primary/20" - : "" - } ${ - // Visual state styling - lineState === 'learned' - ? "border-l-4 border-l-emerald-500" - : "" - }`} - > - {/* Checkbox - left side, sticky position */} - - - {/* Main content - clickable to play audio */} - - - {/* Info button - opens word breakdown modal */} - -
- ) + {/* Checkbox - left side, sticky position */} + + + {/* Main content - clickable to play audio */} + + + {/* Info button - opens word breakdown modal */} + +
+ ); })} ); diff --git a/src/hooks/useAnonymousProgress.ts b/src/hooks/useAnonymousProgress.ts index e7bfd07..0be2c05 100644 --- a/src/hooks/useAnonymousProgress.ts +++ b/src/hooks/useAnonymousProgress.ts @@ -213,7 +213,11 @@ function validateProgress(data: unknown, visitorId: string): AnonymousProgress { playbackSpeed: typeof p.playbackSpeed === "number" ? p.playbackSpeed : 1.0, languageFilter: - typeof p.languageFilter === "string" ? p.languageFilter : "all", + typeof p.languageFilter === "string" + ? p.languageFilter === "persian" + ? "original" + : p.languageFilter + : "all", playbackMode: typeof p.playbackMode === "string" ? p.playbackMode : "auto", videoMuted: typeof p.videoMuted === "boolean" ? p.videoMuted : false, @@ -254,7 +258,7 @@ export function readProgress(): AnonymousProgress { } catch { // JSON parse error or localStorage error - reset to empty state console.warn( - "[useAnonymousProgress] Corrupted localStorage data, resetting to empty state" + "[useAnonymousProgress] Corrupted localStorage data, resetting to empty state", ); const empty = createEmptyProgress(visitorId); try { @@ -323,7 +327,7 @@ export function isWordLearned(persian: string): boolean { export function setWordLearned( persian: string, learned: boolean, - wordId?: string + wordId?: string, ): void { const progress = readProgress(); const existing = progress.wordProgress.find((w) => w.persian === persian); @@ -419,11 +423,11 @@ export function getLearnedWordsCount(): number { */ export function getLineProgress( songId: string, - lineNumber: number + lineNumber: number, ): LineProgressItem | undefined { const progress = readProgress(); return progress.lineProgress.find( - (l) => l.songId === songId && l.lineNumber === lineNumber + (l) => l.songId === songId && l.lineNumber === lineNumber, ); } @@ -441,11 +445,11 @@ export function isLineLearned(songId: string, lineNumber: number): boolean { export function setLineLearned( songId: string, lineNumber: number, - learned: boolean + learned: boolean, ): void { const progress = readProgress(); const existing = progress.lineProgress.find( - (l) => l.songId === songId && l.lineNumber === lineNumber + (l) => l.songId === songId && l.lineNumber === lineNumber, ); if (existing) { @@ -486,9 +490,7 @@ export function getLearnedLinesCount(): number { /** * Get song progress for a specific song */ -export function getSongProgress( - songId: string -): SongProgressItem | undefined { +export function getSongProgress(songId: string): SongProgressItem | undefined { const progress = readProgress(); return progress.songProgress.find((s) => s.songId === songId); } @@ -498,7 +500,7 @@ export function getSongProgress( */ export function updateSongProgress( songId: string, - listenTimeSeconds: number + listenTimeSeconds: number, ): void { const progress = readProgress(); const existing = progress.songProgress.find((s) => s.songId === songId); @@ -534,7 +536,7 @@ function getTodayDate(): string { export function logPractice( practiceSeconds: number, wordsLearned: number = 0, - linesCompleted: number = 0 + linesCompleted: number = 0, ): void { const progress = readProgress(); const today = getTodayDate(); @@ -627,7 +629,7 @@ export function getPreferences(): UserPreferences { * Update user preferences */ export function updatePreferences( - updates: Partial + updates: Partial, ): UserPreferences { const progress = readProgress(); progress.preferences = { ...progress.preferences, ...updates }; @@ -709,7 +711,7 @@ export function useAnonymousProgress() { setWordLearned(persian, learned, wordId); triggerUpdate(); }, - [triggerUpdate] + [triggerUpdate], ); const setLineLearnedLocal = useCallback( @@ -717,7 +719,7 @@ export function useAnonymousProgress() { setLineLearned(songId, lineNumber, learned); triggerUpdate(); }, - [triggerUpdate] + [triggerUpdate], ); const updatePreferencesLocal = useCallback( @@ -726,7 +728,7 @@ export function useAnonymousProgress() { triggerUpdate(); return result; }, - [triggerUpdate] + [triggerUpdate], ); return { diff --git a/src/routes/song.$songId.tsx b/src/routes/song.$songId.tsx index f0a6480..53119b7 100644 --- a/src/routes/song.$songId.tsx +++ b/src/routes/song.$songId.tsx @@ -1,12 +1,24 @@ import { createFileRoute, Link, notFound } from "@tanstack/react-router"; -import { Suspense, useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { + Suspense, + useRef, + useState, + useCallback, + useEffect, + useMemo, +} from "react"; import { convexQuery } from "@convex-dev/react-query"; import { useSuspenseQuery } from "@tanstack/react-query"; import { ErrorBoundary } from "react-error-boundary"; import { api } from "@convex/_generated/api"; import { Id } from "@convex/_generated/dataModel"; -import LocalVideoPlayer, { LocalVideoPlayerHandle } from "../components/LocalVideoPlayer"; -import LyricsDisplay, { LanguageFilter, LyricLine } from "../components/LyricsDisplay"; +import LocalVideoPlayer, { + LocalVideoPlayerHandle, +} from "../components/LocalVideoPlayer"; +import LyricsDisplay, { + LanguageFilter, + LyricLine, +} from "../components/LyricsDisplay"; import WordInfoModal, { ModalLyricLine } from "../components/WordInfoModal"; import { useConvexMutation } from "@convex-dev/react-query"; import { useProgress } from "../hooks/useProgress"; @@ -18,7 +30,17 @@ import { SelectTrigger, SelectValue, } from "../components/ui/select"; -import { Repeat, Languages, Video, Play, Square, Waves, Pause, ChevronDown, ChevronUp } from "lucide-react"; +import { + Repeat, + Languages, + Video, + Play, + Square, + Waves, + Pause, + ChevronDown, + ChevronUp, +} from "lucide-react"; import { WishlistButton } from "../components/WishlistButton"; import { AnonymousProgressBanner } from "../components/AnonymousProgressBanner"; import { getLanguageDisplayName } from "../components/dashboard/LanguageChip"; @@ -27,6 +49,19 @@ import { isRTLLanguage } from "../components/LanguageFlag"; // Playback modes - ALL modes use video as audio source type PlaybackMode = "single" | "loop" | "fluid"; +// Normalize legacy "persian" filter value to "original" (backward compat) +function normalizeLanguageFilter(filter: string | undefined): LanguageFilter { + if (filter === "persian") return "original"; + if ( + filter === "original" || + filter === "transliteration" || + filter === "hebrew" || + filter === "english" + ) + return filter; + return "all"; +} + export const Route = createFileRoute("/song/$songId")({ component: SongPage, }); @@ -75,10 +110,10 @@ interface SongPageContentProps { function SongPageContent({ songId }: SongPageContentProps) { const { data: song } = useSuspenseQuery( - convexQuery(api.songs.getById, { id: songId }) + convexQuery(api.songs.getById, { id: songId }), ); const { data: lyrics } = useSuspenseQuery( - convexQuery(api.lyrics.getBySong, { songId }) + convexQuery(api.lyrics.getBySong, { songId }), ); // Unified progress hook - routes to localStorage for anonymous, Convex for authenticated @@ -93,12 +128,14 @@ function SongPageContent({ songId }: SongPageContentProps) { // For authenticated users, get line progress from Convex // For anonymous users, we'll get it from the useProgress hook const { data: lineProgressFromConvex } = useSuspenseQuery( - convexQuery(api.songProgress.getLineProgressByUserSong, { songId }) + convexQuery(api.songProgress.getLineProgressByUserSong, { songId }), ); // Optimistic toggle state for instant UI feedback // Maps lineNumber -> optimistic learned state (true/false) - const [optimisticToggles, setOptimisticToggles] = useState>(new Map()); + const [optimisticToggles, setOptimisticToggles] = useState< + Map + >(new Map()); // Build line progress array for LyricsDisplay - combine Convex data (authenticated) with local data (anonymous) // Note: For anonymous users, we include `progress` in deps to trigger recompute when localStorage changes @@ -139,7 +176,7 @@ function SongPageContent({ songId }: SongPageContentProps) { // Add new optimistic entry progressMap.set(lineNumber, { _id: `optimistic-${songId}-${lineNumber}`, - visitorId: 'authenticated', + visitorId: "authenticated", songId: songId as Id<"songs">, lineNumber, learned: true, @@ -151,15 +188,21 @@ function SongPageContent({ songId }: SongPageContentProps) { } else { // Build from anonymous localStorage data const learnedLines = progress.getLearnedLinesForSong(songId); - return learnedLines.map(lineNumber => ({ + return learnedLines.map((lineNumber) => ({ _id: `anon-${songId}-${lineNumber}`, - visitorId: 'anonymous', + visitorId: "anonymous", songId: songId as Id<"songs">, lineNumber, learned: true, })); } - }, [isAuthenticated, lineProgressFromConvex, progress, songId, optimisticToggles]); + }, [ + isAuthenticated, + lineProgressFromConvex, + progress, + songId, + optimisticToggles, + ]); // Clear optimistic state only when server confirms our expected state // This prevents flicker when Convex pushes stale data before mutation completes @@ -168,7 +211,10 @@ function SongPageContent({ songId }: SongPageContentProps) { // Only clear optimistic entries where server state matches what we expected const serverStateMap = new Map( - lineProgressFromConvex.map((p: typeof lineProgressFromConvex[0]) => [p.lineNumber, p.learned]) + lineProgressFromConvex.map((p: (typeof lineProgressFromConvex)[0]) => [ + p.lineNumber, + p.learned, + ]), ); let hasMatchingEntries = false; @@ -182,7 +228,7 @@ function SongPageContent({ songId }: SongPageContentProps) { // Only clear if at least one optimistic entry matches server state if (hasMatchingEntries) { - setOptimisticToggles(prev => { + setOptimisticToggles((prev) => { const next = new Map(prev); for (const [lineNumber, optimisticLearned] of prev) { const serverLearned = serverStateMap.get(lineNumber) ?? false; @@ -197,23 +243,33 @@ function SongPageContent({ songId }: SongPageContentProps) { // Load user preferences const { data: userPreferences } = useSuspenseQuery( - convexQuery(api.userPreferences.getUserPreferences, {}) + convexQuery(api.userPreferences.getUserPreferences, {}), + ); + const updatePreferencesMutation = useConvexMutation( + api.userPreferences.updatePreferences, ); - const updatePreferencesMutation = useConvexMutation(api.userPreferences.updatePreferences); // Sort lyrics by lineNumber const sortedLyrics = useMemo( () => [...(lyrics || [])].sort((a, b) => a.lineNumber - b.lineNumber), - [lyrics] + [lyrics], ); // Practice tracking const logPracticeMutation = useConvexMutation(api.practiceLog.logPractice); - const recordLineCompletionMutation = useConvexMutation(api.songProgress.recordLineCompletion); - const toggleLineLearnedMutation = useConvexMutation(api.songProgress.toggleLineLearned); - const toggleWordLearnedMutation = useConvexMutation(api.wordProgress.toggleLearned); + const recordLineCompletionMutation = useConvexMutation( + api.songProgress.recordLineCompletion, + ); + const toggleLineLearnedMutation = useConvexMutation( + api.songProgress.toggleLineLearned, + ); + const toggleWordLearnedMutation = useConvexMutation( + api.wordProgress.toggleLearned, + ); - const [sessionCompletedLines, setSessionCompletedLines] = useState>(new Set()); + const [sessionCompletedLines, setSessionCompletedLines] = useState< + Set + >(new Set()); // Video player ref const playerRef = useRef(null); @@ -228,30 +284,36 @@ function SongPageContent({ songId }: SongPageContentProps) { const practiceSecondsRef = useRef(0); // Current line being played (for Loop/Single modes) - const [currentLineIndex, setCurrentLineIndex] = useState(undefined); + const [currentLineIndex, setCurrentLineIndex] = useState( + undefined, + ); // Active line (highlighted in UI based on video time) - const [activeLineIndex, setActiveLineIndex] = useState(undefined); + const [activeLineIndex, setActiveLineIndex] = useState( + undefined, + ); // Click animation - const [clickedLineIndex, setClickedLineIndex] = useState(undefined); + const [clickedLineIndex, setClickedLineIndex] = useState( + undefined, + ); // Playback mode - always start in fluid mode const [playbackMode, setPlaybackMode] = useState("fluid"); // Playback speed const [playbackSpeed, setPlaybackSpeed] = useState( - userPreferences?.playbackSpeed?.toString() || "1" + userPreferences?.playbackSpeed?.toString() || "1", ); // Language filter const [languageFilter, setLanguageFilter] = useState( - (userPreferences?.languageFilter as LanguageFilter) || "all" + normalizeLanguageFilter(userPreferences?.languageFilter), ); // Video mute state const [isVideoMuted, setIsVideoMuted] = useState( - userPreferences?.videoMuted ?? true + userPreferences?.videoMuted ?? true, ); // Video error state @@ -266,7 +328,7 @@ function SongPageContent({ songId }: SongPageContentProps) { // Mobile video collapsed state const [isVideoCollapsed, setIsVideoCollapsed] = useState( - userPreferences?.videoCollapsed ?? true + userPreferences?.videoCollapsed ?? true, ); // Track if preferences have been applied @@ -277,8 +339,8 @@ function SongPageContent({ songId }: SongPageContentProps) { useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); }, []); // Activity tracking for practice time @@ -292,18 +354,18 @@ function SongPageContent({ songId }: SongPageContentProps) { }; // Track various activity types - window.addEventListener('mousemove', updateActivity); - window.addEventListener('mousedown', updateActivity); - window.addEventListener('keydown', updateActivity); - window.addEventListener('touchstart', updateActivity); - window.addEventListener('scroll', updateActivity, true); + window.addEventListener("mousemove", updateActivity); + window.addEventListener("mousedown", updateActivity); + window.addEventListener("keydown", updateActivity); + window.addEventListener("touchstart", updateActivity); + window.addEventListener("scroll", updateActivity, true); return () => { - window.removeEventListener('mousemove', updateActivity); - window.removeEventListener('mousedown', updateActivity); - window.removeEventListener('keydown', updateActivity); - window.removeEventListener('touchstart', updateActivity); - window.removeEventListener('scroll', updateActivity, true); + window.removeEventListener("mousemove", updateActivity); + window.removeEventListener("mousedown", updateActivity); + window.removeEventListener("keydown", updateActivity); + window.removeEventListener("touchstart", updateActivity); + window.removeEventListener("scroll", updateActivity, true); }; }, []); @@ -350,7 +412,9 @@ function SongPageContent({ songId }: SongPageContentProps) { useEffect(() => { if (userPreferences) { setPlaybackSpeed(userPreferences.playbackSpeed?.toString() || "1"); - setLanguageFilter((userPreferences.languageFilter as LanguageFilter) || "all"); + setLanguageFilter( + normalizeLanguageFilter(userPreferences.languageFilter), + ); setIsVideoMuted(userPreferences.videoMuted ?? true); setIsVideoCollapsed(userPreferences.videoCollapsed ?? true); setPreferencesApplied(true); @@ -369,72 +433,106 @@ function SongPageContent({ songId }: SongPageContentProps) { }, [isAuthenticated, preferencesApplied]); // Preference persistence functions - const persistPlaybackSpeed = useCallback((speed: string) => { - if (!isAuthenticated) return; - updatePreferencesMutation({ playbackSpeed: parseFloat(speed) }); - }, [updatePreferencesMutation, isAuthenticated]); - - const persistLanguageFilter = useCallback((filter: LanguageFilter) => { - if (!isAuthenticated) return; - updatePreferencesMutation({ languageFilter: filter }); - }, [updatePreferencesMutation, isAuthenticated]); - - const persistPlaybackMode = useCallback((mode: PlaybackMode) => { - if (!isAuthenticated) return; - updatePreferencesMutation({ playbackMode: mode }); - }, [updatePreferencesMutation, isAuthenticated]); - - const persistVideoMuted = useCallback((muted: boolean) => { - if (!isAuthenticated) return; - updatePreferencesMutation({ videoMuted: muted }); - }, [updatePreferencesMutation, isAuthenticated]); - - const persistVideoCollapsed = useCallback((collapsed: boolean) => { - if (!isAuthenticated) return; - updatePreferencesMutation({ videoCollapsed: collapsed }); - }, [updatePreferencesMutation, isAuthenticated]); + const persistPlaybackSpeed = useCallback( + (speed: string) => { + if (!isAuthenticated) return; + updatePreferencesMutation({ playbackSpeed: parseFloat(speed) }); + }, + [updatePreferencesMutation, isAuthenticated], + ); + + const persistLanguageFilter = useCallback( + (filter: LanguageFilter) => { + if (!isAuthenticated) return; + updatePreferencesMutation({ languageFilter: filter }); + }, + [updatePreferencesMutation, isAuthenticated], + ); + + const persistPlaybackMode = useCallback( + (mode: PlaybackMode) => { + if (!isAuthenticated) return; + updatePreferencesMutation({ playbackMode: mode }); + }, + [updatePreferencesMutation, isAuthenticated], + ); + + const persistVideoMuted = useCallback( + (muted: boolean) => { + if (!isAuthenticated) return; + updatePreferencesMutation({ videoMuted: muted }); + }, + [updatePreferencesMutation, isAuthenticated], + ); + + const persistVideoCollapsed = useCallback( + (collapsed: boolean) => { + if (!isAuthenticated) return; + updatePreferencesMutation({ videoCollapsed: collapsed }); + }, + [updatePreferencesMutation, isAuthenticated], + ); // Handle speed change - applies to video playback rate - const handleSpeedChange = useCallback((speed: string) => { - setPlaybackSpeed(speed); - playerRef.current?.setPlaybackRate(parseFloat(speed)); - persistPlaybackSpeed(speed); - }, [persistPlaybackSpeed]); + const handleSpeedChange = useCallback( + (speed: string) => { + setPlaybackSpeed(speed); + playerRef.current?.setPlaybackRate(parseFloat(speed)); + persistPlaybackSpeed(speed); + }, + [persistPlaybackSpeed], + ); // Handle playback mode change - const handlePlaybackModeChange = useCallback((mode: PlaybackMode) => { - setPlaybackMode(mode); + const handlePlaybackModeChange = useCallback( + (mode: PlaybackMode) => { + setPlaybackMode(mode); - if (mode === "fluid") { - // Fluid mode: unmute video, continue playing - setIsVideoMuted(false); - persistVideoMuted(false); - playerRef.current?.play(); - if (isMobile) { - setIsVideoCollapsed(false); - persistVideoCollapsed(false); - } - } else { - // Loop/Single mode: start from current line, active line, or first line - const lineIndexToPlay = currentLineIndex ?? activeLineIndex ?? 0; - if (sortedLyrics[lineIndexToPlay]) { - setCurrentLineIndex(lineIndexToPlay); - setActiveLineIndex(lineIndexToPlay); - isSeekingRef.current = true; - playerRef.current?.seekTo(sortedLyrics[lineIndexToPlay].startTime); - setTimeout(() => { isSeekingRef.current = false; }, 200); + if (mode === "fluid") { + // Fluid mode: unmute video, continue playing + setIsVideoMuted(false); + persistVideoMuted(false); playerRef.current?.play(); + if (isMobile) { + setIsVideoCollapsed(false); + persistVideoCollapsed(false); + } + } else { + // Loop/Single mode: start from current line, active line, or first line + const lineIndexToPlay = currentLineIndex ?? activeLineIndex ?? 0; + if (sortedLyrics[lineIndexToPlay]) { + setCurrentLineIndex(lineIndexToPlay); + setActiveLineIndex(lineIndexToPlay); + isSeekingRef.current = true; + playerRef.current?.seekTo(sortedLyrics[lineIndexToPlay].startTime); + setTimeout(() => { + isSeekingRef.current = false; + }, 200); + playerRef.current?.play(); + } } - } - persistPlaybackMode(mode); - }, [activeLineIndex, currentLineIndex, sortedLyrics, isMobile, persistPlaybackMode, persistVideoMuted, persistVideoCollapsed]); + persistPlaybackMode(mode); + }, + [ + activeLineIndex, + currentLineIndex, + sortedLyrics, + isMobile, + persistPlaybackMode, + persistVideoMuted, + persistVideoCollapsed, + ], + ); // Handle language filter change - const handleLanguageFilterChange = useCallback((filter: LanguageFilter) => { - setLanguageFilter(filter); - persistLanguageFilter(filter); - }, [persistLanguageFilter]); + const handleLanguageFilterChange = useCallback( + (filter: LanguageFilter) => { + setLanguageFilter(filter); + persistLanguageFilter(filter); + }, + [persistLanguageFilter], + ); // Handle video collapsed toggle const handleVideoCollapsedToggle = useCallback(() => { @@ -444,21 +542,27 @@ function SongPageContent({ songId }: SongPageContentProps) { }, [isVideoCollapsed, persistVideoCollapsed]); // Handle video mute change - const handleVideoMuteChange = useCallback((muted: boolean) => { - setIsVideoMuted(muted); - persistVideoMuted(muted); - }, [persistVideoMuted]); + const handleVideoMuteChange = useCallback( + (muted: boolean) => { + setIsVideoMuted(muted); + persistVideoMuted(muted); + }, + [persistVideoMuted], + ); // Handle video error const handleVideoError = useCallback((error: string) => { setVideoError(error); - console.warn('Local video error:', error); + console.warn("Local video error:", error); }, []); // Handle video state change - const handleVideoStateChange = useCallback((state: 'playing' | 'paused' | 'ended') => { - setIsVideoPlaying(state === 'playing'); - }, []); + const handleVideoStateChange = useCallback( + (state: "playing" | "paused" | "ended") => { + setIsVideoPlaying(state === "playing"); + }, + [], + ); // Toggle pause/play const togglePlayPause = useCallback(() => { @@ -479,14 +583,24 @@ function SongPageContent({ songId }: SongPageContentProps) { const handleLineClick = useCallback( (startTime: number, lineIndex: number) => { // Record completion of previous line in Loop mode - if (currentLineIndex !== undefined && currentLineIndex !== lineIndex && isAuthenticated && songId) { + if ( + currentLineIndex !== undefined && + currentLineIndex !== lineIndex && + isAuthenticated && + songId + ) { const previousLineNumber = sortedLyrics[currentLineIndex]?.lineNumber; - if (previousLineNumber !== undefined && !sessionCompletedLines.has(previousLineNumber)) { + if ( + previousLineNumber !== undefined && + !sessionCompletedLines.has(previousLineNumber) + ) { recordLineCompletionMutation({ songId: songId as Id<"songs">, - lineNumber: previousLineNumber + lineNumber: previousLineNumber, }); - setSessionCompletedLines(prev => new Set(prev).add(previousLineNumber)); + setSessionCompletedLines((prev) => + new Set(prev).add(previousLineNumber), + ); } } @@ -500,7 +614,9 @@ function SongPageContent({ songId }: SongPageContentProps) { // Seek and play video with guard isSeekingRef.current = true; playerRef.current?.seekTo(startTime); - setTimeout(() => { isSeekingRef.current = false; }, 200); + setTimeout(() => { + isSeekingRef.current = false; + }, 200); playerRef.current?.play(); // If in Fluid mode and video was muted, unmute it @@ -509,7 +625,18 @@ function SongPageContent({ songId }: SongPageContentProps) { persistVideoMuted(false); } }, - [triggerClickAnimation, sortedLyrics, playbackMode, isVideoMuted, persistVideoMuted, currentLineIndex, isAuthenticated, songId, sessionCompletedLines, recordLineCompletionMutation] + [ + triggerClickAnimation, + sortedLyrics, + playbackMode, + isVideoMuted, + persistVideoMuted, + currentLineIndex, + isAuthenticated, + songId, + sessionCompletedLines, + recordLineCompletionMutation, + ], ); // Helper to seek with guard @@ -538,7 +665,10 @@ function SongPageContent({ songId }: SongPageContentProps) { const currentLine = sortedLyrics[currentLineIndex]; if (currentLine) { // Check if we've passed the end of the current line (with small buffer) - if (currentTime >= currentLine.endTime - 0.05 && !isLoopingRef.current) { + if ( + currentTime >= currentLine.endTime - 0.05 && + !isLoopingRef.current + ) { if (playbackMode === "loop") { // Prevent multiple loop triggers isLoopingRef.current = true; @@ -566,12 +696,17 @@ function SongPageContent({ songId }: SongPageContentProps) { playerRef.current?.pause(); // Record line completion - if (songId && !sessionCompletedLines.has(currentLine.lineNumber)) { + if ( + songId && + !sessionCompletedLines.has(currentLine.lineNumber) + ) { recordLineCompletionMutation({ songId: songId as Id<"songs">, - lineNumber: currentLine.lineNumber + lineNumber: currentLine.lineNumber, }); - setSessionCompletedLines(prev => new Set(prev).add(currentLine.lineNumber)); + setSessionCompletedLines((prev) => + new Set(prev).add(currentLine.lineNumber), + ); } } } @@ -579,7 +714,7 @@ function SongPageContent({ songId }: SongPageContentProps) { } else { // Fluid mode: highlight follows video time const lineIndex = sortedLyrics.findIndex( - (line) => currentTime >= line.startTime && currentTime < line.endTime + (line) => currentTime >= line.startTime && currentTime < line.endTime, ); if (lineIndex !== -1 && lineIndex !== activeLineIndex) { @@ -587,25 +722,41 @@ function SongPageContent({ songId }: SongPageContentProps) { } } }, - [sortedLyrics, activeLineIndex, playbackMode, currentLineIndex, isAuthenticated, songId, sessionCompletedLines, logPracticeMutation, recordLineCompletionMutation, seekTo] + [ + sortedLyrics, + activeLineIndex, + playbackMode, + currentLineIndex, + isAuthenticated, + songId, + sessionCompletedLines, + logPracticeMutation, + recordLineCompletionMutation, + seekTo, + ], ); // Handle opening word info modal - const handleLineInfoClick = useCallback((line: LyricLine) => { - playerRef.current?.pause(); - - const fullLyricData = sortedLyrics.find(l => l.lineNumber === line.lineNumber); - - setSelectedLine({ - lineNumber: line.lineNumber, - original: line.original, - transliteration: line.transliteration, - hebrew: line.hebrew, - english: line.english, - audioSnippetUrl: fullLyricData?.audioSnippetUrl, - }); - setWordModalOpen(true); - }, [sortedLyrics]); + const handleLineInfoClick = useCallback( + (line: LyricLine) => { + playerRef.current?.pause(); + + const fullLyricData = sortedLyrics.find( + (l) => l.lineNumber === line.lineNumber, + ); + + setSelectedLine({ + lineNumber: line.lineNumber, + original: line.original, + transliteration: line.transliteration, + hebrew: line.hebrew, + english: line.english, + audioSnippetUrl: fullLyricData?.audioSnippetUrl, + }); + setWordModalOpen(true); + }, + [sortedLyrics], + ); // Handle closing word info modal const handleWordModalClose = useCallback(() => { @@ -613,77 +764,104 @@ function SongPageContent({ songId }: SongPageContentProps) { // Resume playback if (selectedLine) { - const lineIndex = sortedLyrics.findIndex(l => l.lineNumber === selectedLine.lineNumber); + const lineIndex = sortedLyrics.findIndex( + (l) => l.lineNumber === selectedLine.lineNumber, + ); if (lineIndex !== -1) { setCurrentLineIndex(lineIndex); setActiveLineIndex(lineIndex); isSeekingRef.current = true; playerRef.current?.seekTo(sortedLyrics[lineIndex].startTime); - setTimeout(() => { isSeekingRef.current = false; }, 200); + setTimeout(() => { + isSeekingRef.current = false; + }, 200); playerRef.current?.play(); } } }, [selectedLine, sortedLyrics]); // Handle checkbox toggle for line learned state - const handleLineCheckboxClick = useCallback((lineNumber: number) => { - if (isAuthenticated) { - // Optimistic update: determine current state and toggle it - const currentProgress = lineProgressFromConvex?.find((p: typeof lineProgressFromConvex[0]) => p.lineNumber === lineNumber); - const currentlyLearned = optimisticToggles.has(lineNumber) - ? optimisticToggles.get(lineNumber) - : currentProgress?.learned ?? false; - const newLearnedState = !currentlyLearned; - - // Set optimistic state immediately for instant UI feedback - setOptimisticToggles(prev => { - const next = new Map(prev); - next.set(lineNumber, newLearnedState); - return next; - }); + const handleLineCheckboxClick = useCallback( + (lineNumber: number) => { + if (isAuthenticated) { + // Optimistic update: determine current state and toggle it + const currentProgress = lineProgressFromConvex?.find( + (p: (typeof lineProgressFromConvex)[0]) => + p.lineNumber === lineNumber, + ); + const currentlyLearned = optimisticToggles.has(lineNumber) + ? optimisticToggles.get(lineNumber) + : (currentProgress?.learned ?? false); + const newLearnedState = !currentlyLearned; + + // Set optimistic state immediately for instant UI feedback + setOptimisticToggles((prev) => { + const next = new Map(prev); + next.set(lineNumber, newLearnedState); + return next; + }); - // Authenticated: use Convex mutation - toggleLineLearnedMutation({ songId, lineNumber }); - } else { - // Anonymous: use localStorage via useProgress hook (already instant) - toggleLineLearnedFn(songId, lineNumber); - } - }, [isAuthenticated, toggleLineLearnedMutation, songId, toggleLineLearnedFn, lineProgressFromConvex, optimisticToggles]); + // Authenticated: use Convex mutation + toggleLineLearnedMutation({ songId, lineNumber }); + } else { + // Anonymous: use localStorage via useProgress hook (already instant) + toggleLineLearnedFn(songId, lineNumber); + } + }, + [ + isAuthenticated, + toggleLineLearnedMutation, + songId, + toggleLineLearnedFn, + lineProgressFromConvex, + optimisticToggles, + ], + ); // Handle word learned toggle - const handleToggleWordLearned = useCallback((wordId: Id<"words">, persian: string) => { - if (isAuthenticated) { - // Authenticated: use Convex mutation - toggleWordLearnedMutation({ wordId, persian }).then((newLearnedState) => { - if (newLearnedState) { - logPracticeMutation({ eventType: "word_learned", value: 1 }); - } - }); - } else { - // Anonymous: use localStorage via useProgress hook - toggleWordLearnedFn(persian, wordId); - } - }, [isAuthenticated, toggleWordLearnedMutation, logPracticeMutation, toggleWordLearnedFn]); + const handleToggleWordLearned = useCallback( + (wordId: Id<"words">, persian: string) => { + if (isAuthenticated) { + // Authenticated: use Convex mutation + toggleWordLearnedMutation({ wordId, persian }).then( + (newLearnedState) => { + if (newLearnedState) { + logPracticeMutation({ eventType: "word_learned", value: 1 }); + } + }, + ); + } else { + // Anonymous: use localStorage via useProgress hook + toggleWordLearnedFn(persian, wordId); + } + }, + [ + isAuthenticated, + toggleWordLearnedMutation, + logPracticeMutation, + toggleWordLearnedFn, + ], + ); // Spacebar for pause/play useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Space' && event.target === document.body) { + if (event.code === "Space" && event.target === document.body) { event.preventDefault(); togglePlayPause(); } }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [togglePlayPause]); // On desktop, prevent page scroll - only lyrics should scroll useEffect(() => { if (!isMobile) { - document.body.style.overflow = 'hidden'; + document.body.style.overflow = "hidden"; return () => { - document.body.style.overflow = ''; + document.body.style.overflow = ""; }; } }, [isMobile]); @@ -696,208 +874,260 @@ function SongPageContent({ songId }: SongPageContentProps) {
{/* LEFT: Video section */}
- {/* Mobile: Collapsible Header - use div instead of button to allow nested buttons */} - {isMobile && ( -
e.key === 'Enter' && handleVideoCollapsedToggle()} - className="w-full flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700 hover:bg-gray-700/50 transition-colors cursor-pointer" - > -
-