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
34 changes: 31 additions & 3 deletions src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ import { SlideshowComposition } from '../composition'
import { StoryboardGrid } from './StoryboardGrid'
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { SoundtrackPanel } from './SoundtrackPanel'
import { JamendoPanel } from './JamendoPanel'
import { SlideSettingsDialog } from './SlideSettingsDialog'
import { TitleSlideDialog } from './TitleSlideDialog'
import { reconcileSlides, slidesToJson } from './slidePersistence'
import type { JamendoAttribution, JamendoTrack } from '../jamendo/types'
import { downloadTrack, sanitizeFilename } from '../jamendo'
import './App.css'

const FPS = 30
Expand All @@ -45,6 +48,7 @@ export function App() {
const [globalSettings, setGlobalSettings] = useState<GlobalSettings>(DEFAULT_GLOBAL_SETTINGS)
const [slides, setSlides] = useState<Slide[]>([])
const [soundtrackFilename, setSoundtrackFilename] = useState<string | null>(null)
const [soundtrackAttribution, setSoundtrackAttribution] = useState<JamendoAttribution | null>(null)
const [themeName, setThemeName] = useState<ThemeName | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -90,9 +94,9 @@ export function App() {
if (!handle) return
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current)
autosaveTimerRef.current = setTimeout(() => {
saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName)).catch(console.error)
saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution)).catch(console.error)
}, AUTOSAVE_DELAY)
}, [globalSettings, slides, soundtrackFilename, themeName])
}, [globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName])

const loadFolder = useCallback(
async (handle: FileSystemDirectoryHandle, savedData?: SlideshowJson) => {
Expand All @@ -108,6 +112,7 @@ export function App() {
savedSoundtrack && nextAudioTracks.some((track) => track.filename === savedSoundtrack)
? savedSoundtrack
: null
const restoredAttribution = validSoundtrack ? (savedData?.soundtrackAttribution ?? null) : null
let finalSlides: Slide[] = savedData ? reconcileSlides(enumerated, savedData) : enumerated
// Apply global duration only to media slides not already in savedData (new files added to folder).
const savedFilenames = new Set(
Expand All @@ -126,6 +131,7 @@ export function App() {
setThemeName(restoredThemeName)
setSlides(finalSlides)
setSoundtrackFilename(validSoundtrack)
setSoundtrackAttribution(restoredAttribution)
setFolderOpen(true)
setSelectedSlideId(null)
} catch (e) {
Expand Down Expand Up @@ -229,6 +235,22 @@ export function App() {
setSlides(prev => prev.map(s => (s.id === id && isTitleSlide(s) ? { ...s, ...updates } : s)))
}, [])

const handleJamendoAdd = useCallback(async (track: JamendoTrack, attribution: JamendoAttribution) => {
const handle = dirHandleRef.current
if (!handle) return
const filename = sanitizeFilename(`${track.artistName} - ${track.name}`)
await downloadTrack(track.audioUrl, filename, handle)
try {
const nextAudioTracks = await enumerateAudioTracks(handle)
pendingAudioRevokeRef.current = latestAudioTracksRef.current
setAudioTracks(nextAudioTracks)
setSoundtrackFilename(filename)
setSoundtrackAttribution(attribution)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to refresh audio tracks')
}
}, [])

const handleStartFresh = useCallback(() => {
setCorruptError(null)
}, [])
Expand Down Expand Up @@ -311,9 +333,15 @@ export function App() {
/>
<SoundtrackPanel
audioTracks={audioTracks}
onChange={setSoundtrackFilename}
onChange={filename => { setSoundtrackFilename(filename); setSoundtrackAttribution(null) }}
soundtrackFilename={soundtrackFilename}
/>
{import.meta.env.VITE_JAMENDO_CLIENT_ID && (
<JamendoPanel
clientId={import.meta.env.VITE_JAMENDO_CLIENT_ID}
onAdd={handleJamendoAdd}
/>
)}
<div className="sidebar-actions">
<button onClick={handleAddTitleSlide} className="add-title-btn">
+ Add Title Slide
Expand Down
150 changes: 150 additions & 0 deletions src/editor-shell/JamendoPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
import type { JamendoAttribution, JamendoTrack } from '../jamendo/types'
import { searchTracks } from '../jamendo/client'

type SearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'results'; tracks: JamendoTrack[] }
| { status: 'empty' }
| { status: 'error'; message: string }

type Props = {
clientId: string
onAdd: (track: JamendoTrack, attribution: JamendoAttribution) => Promise<void>
}

function formatDuration(secs: number): string {
const m = Math.floor(secs / 60)
const s = String(secs % 60).padStart(2, '0')
return `${m}:${s}`
}

export function JamendoPanel({ clientId, onAdd }: Props) {
const [query, setQuery] = useState('')
const [state, setState] = useState<SearchState>({ status: 'idle' })
const [previewId, setPreviewId] = useState<string | null>(null)
const [addingId, setAddingId] = useState<string | null>(null)
const [addError, setAddError] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)

useEffect(() => {
return () => { audioRef.current?.pause() }
}, [])

async function handleSearch(e: React.FormEvent) {
e.preventDefault()
if (!query.trim()) return
setState({ status: 'loading' })
try {
const tracks = await searchTracks(query.trim(), clientId)
setState(tracks.length === 0 ? { status: 'empty' } : { status: 'results', tracks })
} catch (err) {
setState({ status: 'error', message: err instanceof Error ? err.message : 'Search failed' })
}
}

function togglePreview(track: JamendoTrack) {
if (previewId === track.id) {
audioRef.current?.pause()
setPreviewId(null)
} else {
if (audioRef.current) {
audioRef.current.onended = null
audioRef.current.pause()
}
const audio = new Audio(track.audioUrl)
audio.onended = () => setPreviewId(null)
audio.play().catch(() => setPreviewId(null))
audioRef.current = audio
setPreviewId(track.id)
}
}

async function handleAdd(track: JamendoTrack) {
setAddingId(track.id)
setAddError(null)
audioRef.current?.pause()
setPreviewId(null)
const attribution: JamendoAttribution = {
jamendoId: track.id,
name: track.name,
artist: track.artistName,
licenseUrl: track.licenseUrl,
}
try {
await onAdd(track, attribution)
} catch (err) {
setAddError(err instanceof Error ? err.message : 'Failed to add track')
} finally {
setAddingId(null)
}
}

return (
<div className="settings-panel jamendo-panel">
<h2 className="settings-title">Find Music (Jamendo)</h2>

<form className="jamendo-search-form" onSubmit={handleSearch}>
<input
className="jamendo-search-input"
type="text"
placeholder="Search by mood, genre, or keyword…"
value={query}
onChange={e => setQuery(e.target.value)}
aria-label="Search Jamendo"
/>
<button className="jamendo-search-btn" type="submit" disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Searching…' : 'Search'}
</button>
</form>

{state.status === 'results' && (
<ul className="jamendo-results" aria-label="Search results">
{state.tracks.map(track => (
<li key={track.id} className="jamendo-track">
<div className="jamendo-track-info">
<span className="jamendo-track-name">{track.name}</span>
<span className="jamendo-track-artist">{track.artistName}</span>
<span className="jamendo-track-duration">{formatDuration(track.durationSecs)}</span>
</div>
<div className="jamendo-track-actions">
<button
className="jamendo-preview-btn"
onClick={() => togglePreview(track)}
aria-label={previewId === track.id ? `Stop preview of ${track.name}` : `Preview ${track.name}`}
>
{previewId === track.id ? 'Stop' : 'Preview'}
</button>
<button
className="jamendo-add-btn"
onClick={() => handleAdd(track)}
disabled={addingId === track.id}
aria-label={`Add ${track.name} to project`}
>
{addingId === track.id ? 'Adding…' : 'Add'}
</button>
</div>
</li>
))}
</ul>
)}

{addError && (
<p className="jamendo-error">{addError}</p>
)}

{state.status === 'empty' && (
<p className="jamendo-empty">
No results found. Try different keywords, or drop an audio file into your project folder.
</p>
)}

{state.status === 'error' && (
<p className="jamendo-error">
{state.message} — or drop an audio file into your project folder instead.
</p>
)}
</div>
)
}
17 changes: 17 additions & 0 deletions src/editor-shell/slidePersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { DEFAULT_GLOBAL_SETTINGS } from '../timeline-core'
import type { MediaSlide } from '../timeline-core/types'
import { SCHEMA_VERSION } from '../project-store'
import type { JamendoAttribution } from '../jamendo/types'
import { slidesToJson } from './slidePersistence'

function makeSlide(filename: string): MediaSlide {
Expand Down Expand Up @@ -36,4 +37,20 @@ describe('slidesToJson', () => {
const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null)
expect(json.themeName).toBeUndefined()
})

it('includes soundtrackAttribution when provided', () => {
const attribution: JamendoAttribution = {
jamendoId: '123',
name: 'Great Track',
artist: 'Cool Artist',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
}
const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, attribution)
expect(json.soundtrackAttribution).toEqual(attribution)
})

it('omits soundtrackAttribution when null', () => {
const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, null)
expect(json.soundtrackAttribution).toBeUndefined()
})
})
3 changes: 3 additions & 0 deletions src/editor-shell/slidePersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MediaSlide, Slide } from '../timeline-core/types'
import { isTitleSlide } from '../timeline-core/types'
import type { GlobalSettings, ThemeName } from '../timeline-core'
import { SCHEMA_VERSION, type SlideshowJson } from '../project-store'
import type { JamendoAttribution } from '../jamendo/types'

export function reconcileSlides(enumerated: MediaSlide[], saved: SlideshowJson): Slide[] {
const byFilename = new Map(enumerated.map((slide) => [slide.filename, slide]))
Expand Down Expand Up @@ -32,12 +33,14 @@ export function slidesToJson(
slides: Slide[],
soundtrackFilename?: string | null,
themeName?: ThemeName | null,
soundtrackAttribution?: JamendoAttribution | null,
): SlideshowJson {
return {
globalSettings,
schemaVersion: SCHEMA_VERSION,
...(soundtrackFilename ? { soundtrackFilename } : {}),
...(themeName ? { themeName } : {}),
...(soundtrackAttribution ? { soundtrackAttribution } : {}),
slides: slides.map(slide => {
if (isTitleSlide(slide)) {
return {
Expand Down
4 changes: 4 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_JAMENDO_CLIENT_ID?: string
}

// File System Access API — not yet fully in lib.dom.d.ts
interface Window {
showDirectoryPicker(options?: { mode?: 'read' | 'readwrite' }): Promise<FileSystemDirectoryHandle>
Expand Down
85 changes: 85 additions & 0 deletions src/jamendo/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { searchTracks } from './client'

const CLIENT_ID = 'test_client'

const TRACK_FIXTURE = {
id: '1234',
name: 'Cool Track',
artist_name: 'Cool Artist',
duration: 240,
audio: 'https://mp3l.jamendo.com/?trackid=1234&format=mp32',
license_ccurl: 'https://creativecommons.org/licenses/by/4.0/',
}

function mockFetch(body: unknown, status = 200) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}))
}

afterEach(() => vi.restoreAllMocks())

describe('searchTracks — happy path', () => {
it('returns parsed tracks for a successful query', async () => {
mockFetch({
headers: { code: 0, error_message: '' },
results: [TRACK_FIXTURE],
})

const tracks = await searchTracks('jazz', CLIENT_ID)

expect(tracks).toHaveLength(1)
expect(tracks[0]).toEqual({
id: '1234',
name: 'Cool Track',
artistName: 'Cool Artist',
durationSecs: 240,
audioUrl: 'https://mp3l.jamendo.com/?trackid=1234&format=mp32',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
})
})

it('includes client_id and search term in the request URL', async () => {
const fakeFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ headers: { code: 0 }, results: [] }),
})
vi.stubGlobal('fetch', fakeFetch)

await searchTracks('ambient', CLIENT_ID)

const calledUrl: string = fakeFetch.mock.calls[0][0]
expect(calledUrl).toContain('client_id=test_client')
expect(calledUrl).toContain('search=ambient')
})

it('returns empty array when results is empty', async () => {
mockFetch({ headers: { code: 0 }, results: [] })
const tracks = await searchTracks('xyzunknown', CLIENT_ID)
expect(tracks).toEqual([])
})
})

describe('searchTracks — error handling (AC3)', () => {
it('throws a JamendoError on HTTP error', async () => {
mockFetch({ headers: { code: 0 }, results: [] }, 500)
await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow()
})

it('throws a JamendoError when API code is non-zero', async () => {
mockFetch({
headers: { code: 8, error_message: 'Invalid client_id.' },
results: [],
})
await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow('Invalid client_id.')
})

it('throws when fetch itself rejects (network failure)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(searchTracks('jazz', CLIENT_ID)).rejects.toThrow()
})
})
Loading
Loading