diff --git a/apps/lightwrite/apps/backend/src/beat/beat.controller.ts b/apps/lightwrite/apps/backend/src/beat/beat.controller.ts index 3771ab125..f74b9ef07 100644 --- a/apps/lightwrite/apps/backend/src/beat/beat.controller.ts +++ b/apps/lightwrite/apps/backend/src/beat/beat.controller.ts @@ -11,14 +11,50 @@ import { } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { BeatService } from './beat.service'; -import { CreateBeatUploadDto, UpdateBeatMetadataDto } from './dto/beat.dto'; +import { CreateBeatUploadDto, UpdateBeatMetadataDto, UseLibraryBeatDto } from './dto/beat.dto'; @Controller('beats') -@UseGuards(JwtAuthGuard) export class BeatController { constructor(private readonly beatService: BeatService) {} + // ==================== Library Beats (Public) ==================== + + @Get('library') + async getLibraryBeats() { + const beats = await this.beatService.getLibraryBeats(); + return { beats }; + } + + @Get('library/:id') + async getLibraryBeat(@Param('id', ParseUUIDPipe) id: string) { + const beat = await this.beatService.getLibraryBeatById(id); + if (!beat) { + return { beat: null }; + } + return { beat }; + } + + @Get('library/:id/download-url') + async getLibraryBeatDownloadUrl(@Param('id', ParseUUIDPipe) id: string) { + const url = await this.beatService.getLibraryBeatDownloadUrl(id); + return { url }; + } + + @Post('library/:id/use') + @UseGuards(JwtAuthGuard) + async useLibraryBeat( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UseLibraryBeatDto + ) { + const beat = await this.beatService.useLibraryBeat(id, dto.projectId, user.userId); + return { beat }; + } + + // ==================== User Beats (Protected) ==================== + @Get('project/:projectId') + @UseGuards(JwtAuthGuard) async findByProject( @CurrentUser() user: CurrentUserData, @Param('projectId', ParseUUIDPipe) projectId: string @@ -29,6 +65,7 @@ export class BeatController { } @Get(':id') + @UseGuards(JwtAuthGuard) async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const beat = await this.beatService.findByIdOrThrow(id); await this.beatService.verifyProjectOwnership(beat.projectId, user.userId); @@ -37,6 +74,7 @@ export class BeatController { } @Get(':id/download-url') + @UseGuards(JwtAuthGuard) async getDownloadUrl( @CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string @@ -46,12 +84,14 @@ export class BeatController { } @Post('upload') + @UseGuards(JwtAuthGuard) async createUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBeatUploadDto) { const result = await this.beatService.createUploadUrl(dto.projectId, user.userId, dto.filename); return result; } @Put(':id/metadata') + @UseGuards(JwtAuthGuard) async updateMetadata( @CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string, @@ -62,6 +102,7 @@ export class BeatController { } @Delete(':id') + @UseGuards(JwtAuthGuard) async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { await this.beatService.delete(id, user.userId); return { success: true }; diff --git a/apps/lightwrite/apps/backend/src/beat/beat.service.ts b/apps/lightwrite/apps/backend/src/beat/beat.service.ts index a514b0cab..b167f1e9b 100644 --- a/apps/lightwrite/apps/backend/src/beat/beat.service.ts +++ b/apps/lightwrite/apps/backend/src/beat/beat.service.ts @@ -2,8 +2,8 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from '@nes import { eq, and } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; -import { beats, projects, markers } from '../db/schema'; -import type { Beat, Marker } from '../db/schema'; +import { beats, projects, markers, libraryBeats } from '../db/schema'; +import type { Beat, Marker, LibraryBeat } from '../db/schema'; import { createLightWriteStorage, generateUserFileKey, @@ -127,4 +127,56 @@ export class BeatService { async getMarkersForBeat(beatId: string): Promise { return this.db.select().from(markers).where(eq(markers.beatId, beatId)); } + + // ==================== Library Beats ==================== + + async getLibraryBeats(): Promise { + return this.db + .select() + .from(libraryBeats) + .where(eq(libraryBeats.isActive, true)) + .orderBy(libraryBeats.title); + } + + async getLibraryBeatById(id: string): Promise { + const [beat] = await this.db.select().from(libraryBeats).where(eq(libraryBeats.id, id)); + return beat || null; + } + + async getLibraryBeatDownloadUrl(id: string): Promise { + const beat = await this.getLibraryBeatById(id); + if (!beat) { + throw new NotFoundException('Library beat not found'); + } + return this.storage.getDownloadUrl(beat.storagePath, { expiresIn: 3600 }); + } + + async useLibraryBeat(libraryBeatId: string, projectId: string, userId: string): Promise { + await this.verifyProjectOwnership(projectId, userId); + + // Check if beat already exists for this project + const existingBeat = await this.findByProjectId(projectId); + if (existingBeat) { + throw new BadRequestException('Beat already exists for this project. Delete it first.'); + } + + const libraryBeat = await this.getLibraryBeatById(libraryBeatId); + if (!libraryBeat) { + throw new NotFoundException('Library beat not found'); + } + + // Create beat record referencing the same storage path + const [beat] = await this.db + .insert(beats) + .values({ + projectId, + storagePath: libraryBeat.storagePath, + filename: `${libraryBeat.title}${libraryBeat.artist ? ` - ${libraryBeat.artist}` : ''}.mp3`, + duration: libraryBeat.duration, + bpm: libraryBeat.bpm, + }) + .returning(); + + return beat; + } } diff --git a/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts b/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts index 894391bb4..c32925b5b 100644 --- a/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts +++ b/apps/lightwrite/apps/backend/src/beat/dto/beat.dto.ts @@ -10,6 +10,12 @@ export class CreateBeatUploadDto { filename!: string; } +export class UseLibraryBeatDto { + @IsUUID() + @IsNotEmpty() + projectId!: string; +} + export class UpdateBeatMetadataDto { @IsNumber() @IsOptional() diff --git a/apps/lightwrite/apps/backend/src/db/schema/index.ts b/apps/lightwrite/apps/backend/src/db/schema/index.ts index 13b0571a6..078101c6f 100644 --- a/apps/lightwrite/apps/backend/src/db/schema/index.ts +++ b/apps/lightwrite/apps/backend/src/db/schema/index.ts @@ -2,3 +2,4 @@ export * from './projects.schema'; export * from './beats.schema'; export * from './markers.schema'; export * from './lyrics.schema'; +export * from './library-beats.schema'; diff --git a/apps/lightwrite/apps/backend/src/db/schema/library-beats.schema.ts b/apps/lightwrite/apps/backend/src/db/schema/library-beats.schema.ts new file mode 100644 index 000000000..e500a85e0 --- /dev/null +++ b/apps/lightwrite/apps/backend/src/db/schema/library-beats.schema.ts @@ -0,0 +1,24 @@ +import { pgTable, uuid, text, timestamp, varchar, real, boolean } from 'drizzle-orm/pg-core'; + +/** + * Library beats are free beats available to all users. + * They are pre-uploaded by admins and can be used in any project. + */ +export const libraryBeats = pgTable('library_beats', { + id: uuid('id').primaryKey().defaultRandom(), + title: varchar('title', { length: 255 }).notNull(), + artist: varchar('artist', { length: 255 }), + genre: varchar('genre', { length: 100 }), + bpm: real('bpm'), + duration: real('duration'), + storagePath: text('storage_path').notNull(), + previewUrl: text('preview_url'), + license: varchar('license', { length: 100 }).default('free'), + isActive: boolean('is_active').default(true), + tags: text('tags').array(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type LibraryBeat = typeof libraryBeats.$inferSelect; +export type NewLibraryBeat = typeof libraryBeats.$inferInsert; diff --git a/apps/lightwrite/apps/web/src/app.css b/apps/lightwrite/apps/web/src/app.css index db85d8754..9755be955 100644 --- a/apps/lightwrite/apps/web/src/app.css +++ b/apps/lightwrite/apps/web/src/app.css @@ -119,3 +119,50 @@ background: var(--color-primary); border-radius: 50%; } + +/* Mobile responsive waveform */ +@media (max-width: 767px) { + .waveform-container { + height: 80px; + } + + .timeline-ruler { + height: 20px; + } + + .timeline-marker span { + font-size: 8px; + } +} + +/* Touch-friendly range inputs */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-runnable-track { + background: hsl(var(--color-surface-hover)); + border-radius: 4px; + height: 8px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: hsl(var(--color-primary)); + height: 16px; + width: 16px; + border-radius: 50%; + margin-top: -4px; +} + +@media (max-width: 767px) { + input[type="range"]::-webkit-slider-thumb { + height: 20px; + width: 20px; + margin-top: -6px; + } +} diff --git a/apps/lightwrite/apps/web/src/lib/components/BeatLibrary.svelte b/apps/lightwrite/apps/web/src/lib/components/BeatLibrary.svelte new file mode 100644 index 000000000..733b4e40d --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/components/BeatLibrary.svelte @@ -0,0 +1,225 @@ + + +
+ {#if isLoading} +
+
+
+ {:else if error} +
+

{error}

+ +
+ {:else if beats.length === 0} +
+
+ + + +
+

No beats available in the library yet.

+

Upload your own beat instead.

+
+ {:else} +
+ {#each beats as beat} +
+ + + + +
+

{beat.title}

+
+ {#if beat.artist} + {beat.artist} + {/if} + {#if beat.genre} + + {beat.genre} + + {/if} + {#if beat.bpm} + {beat.bpm} BPM + {/if} + {formatDuration(beat.duration)} +
+ {#if beat.tags && beat.tags.length > 0} +
+ {#each beat.tags.slice(0, 3) as tag} + + {tag} + + {/each} + {#if beat.tags.length > 3} + + +{beat.tags.length - 3} more + + {/if} +
+ {/if} +
+ + + +
+ {/each} +
+ {/if} +
diff --git a/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte b/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte index 7678fabcc..ba121c509 100644 --- a/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte +++ b/apps/lightwrite/apps/web/src/lib/components/BeatUploader.svelte @@ -2,6 +2,7 @@ import { projectStore } from '$lib/stores/project.svelte'; import { audioStore } from '$lib/stores/audio.svelte'; import { detectBpmFromFile } from '$lib/utils/bpm-detector'; + import BeatLibrary from './BeatLibrary.svelte'; interface Props { projectId: string; @@ -10,6 +11,9 @@ let { projectId, onUploadComplete }: Props = $props(); + type Tab = 'upload' | 'library'; + let activeTab = $state('upload'); + let isUploading = $state(false); let isDetectingBpm = $state(false); let uploadProgress = $state(0); @@ -90,59 +94,17 @@ } -
- - - {#if isUploading} -
-
- {#if isDetectingBpm} - - - - {:else} -
- {/if} -
-

- {isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'} -

-
-
-
-
- {:else} -
+{:else} + +
+ +
+ {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+ + + {Math.round(editorStore.zoom * 100)}% + + +
+ + + {#if audioStore.bpm} +
+ {audioStore.bpm} BPM +
+ {/if} +
+{/if} diff --git a/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte b/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte index 6b513211d..317919b21 100644 --- a/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte +++ b/apps/lightwrite/apps/web/src/lib/components/WaveformEditor.svelte @@ -5,6 +5,7 @@ import { audioStore } from '$lib/stores/audio.svelte'; import { projectStore } from '$lib/stores/project.svelte'; import { editorStore } from '$lib/stores/editor.svelte'; + import { theme } from '$lib/stores/theme.svelte'; import { MARKER_COLORS, type Marker, type MarkerType } from '@lightwrite/shared'; let containerRef: HTMLDivElement; @@ -19,16 +20,26 @@ let { audioUrl, onTimeUpdate, onSeek }: Props = $props(); + // Get theme-aware colors + function getWaveformColors() { + return { + waveColor: theme.isDark ? '#9CA3AF' : '#6B7280', + progressColor: theme.isDark ? '#60A5FA' : '#3B82F6', + cursorColor: '#EF4444', + }; + } + onMount(() => { if (!containerRef) return; regionsPlugin = RegionsPlugin.create(); + const colors = getWaveformColors(); wavesurfer = WaveSurfer.create({ container: containerRef, - waveColor: '#6B7280', - progressColor: '#3B82F6', - cursorColor: '#EF4444', + waveColor: colors.waveColor, + progressColor: colors.progressColor, + cursorColor: colors.cursorColor, cursorWidth: 2, height: 128, normalize: true, @@ -104,6 +115,19 @@ } }); + // Watch for theme changes and update waveform colors + $effect(() => { + // Reference isDark to track changes + const isDark = theme.isDark; + if (wavesurfer) { + const colors = getWaveformColors(); + wavesurfer.setOptions({ + waveColor: colors.waveColor, + progressColor: colors.progressColor, + }); + } + }); + // Watch for marker changes and sync regions $effect(() => { const markers = projectStore.currentMarkers; @@ -166,6 +190,38 @@ }); } } + + export function toggleLoop(markerId: string) { + if (!regionsPlugin || !wavesurfer) return; + + const region = regionsPlugin.getRegions().find((r) => r.id === `marker-${markerId}`); + if (!region) return; + + if (editorStore.loopRegionId === markerId) { + // Disable loop + editorStore.setLoopRegion(null); + } else { + // Enable loop and start playback from region start + editorStore.setLoopRegion(markerId); + wavesurfer.setTime(region.start); + wavesurfer.play(); + } + } + + // Watch for loop region and handle looping + $effect(() => { + if (!wavesurfer || !regionsPlugin || !editorStore.loopRegionId) return; + + const region = regionsPlugin + .getRegions() + .find((r) => r.id === `marker-${editorStore.loopRegionId}`); + if (!region) return; + + // Check if we've reached the end of the loop region + if (audioStore.currentTime >= region.end && editorStore.isLooping) { + wavesurfer.setTime(region.start); + } + });
diff --git a/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts index b888d43a0..cffad38f5 100644 --- a/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts +++ b/apps/lightwrite/apps/web/src/lib/stores/editor.svelte.ts @@ -16,6 +16,8 @@ interface EditorState { showWaveform: boolean; showMarkers: boolean; showLyrics: boolean; + loopRegionId: string | null; + isLooping: boolean; } function createEditorStore() { @@ -32,6 +34,8 @@ function createEditorStore() { showWaveform: true, showMarkers: true, showLyrics: true, + loopRegionId: null, + isLooping: false, }); return { @@ -71,6 +75,12 @@ function createEditorStore() { get showLyrics() { return state.showLyrics; }, + get loopRegionId() { + return state.loopRegionId; + }, + get isLooping() { + return state.isLooping; + }, setMode(mode: EditorMode) { state.mode = mode; @@ -128,6 +138,11 @@ function createEditorStore() { state.showLyrics = !state.showLyrics; }, + setLoopRegion(markerId: string | null) { + state.loopRegionId = markerId; + state.isLooping = markerId !== null; + }, + reset() { state.mode = 'edit'; state.syncMode = 'line'; @@ -136,6 +151,8 @@ function createEditorStore() { state.isRecordingTimestamps = false; state.zoom = 1; state.scrollPosition = 0; + state.loopRegionId = null; + state.isLooping = false; }, }; } diff --git a/apps/lightwrite/apps/web/src/lib/stores/theme.svelte.ts b/apps/lightwrite/apps/web/src/lib/stores/theme.svelte.ts new file mode 100644 index 000000000..67b4ac098 --- /dev/null +++ b/apps/lightwrite/apps/web/src/lib/stores/theme.svelte.ts @@ -0,0 +1,15 @@ +import { createThemeStore, type HSLValue, type ThemeVariant } from '@manacore/shared-theme'; + +/** + * LightWrite theme store + * + * Uses blue primary color matching the waveform progress color + */ +export const theme = createThemeStore({ + appId: 'lightwrite', + defaultVariant: 'ocean' as ThemeVariant, + primaryColor: { + light: '217 91% 60%' as HSLValue, // Blue #3b82f6 + dark: '217 91% 60%' as HSLValue, + }, +}); diff --git a/apps/lightwrite/apps/web/src/routes/+layout.svelte b/apps/lightwrite/apps/web/src/routes/+layout.svelte index 4430f8bff..5dddb98cc 100644 --- a/apps/lightwrite/apps/web/src/routes/+layout.svelte +++ b/apps/lightwrite/apps/web/src/routes/+layout.svelte @@ -1,16 +1,24 @@ {#if loading} diff --git a/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte b/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte index 4accee458..90626cbbf 100644 --- a/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte +++ b/apps/lightwrite/apps/web/src/routes/editor/[id]/+page.svelte @@ -6,17 +6,123 @@ import { projectStore } from '$lib/stores/project.svelte'; import { audioStore } from '$lib/stores/audio.svelte'; import { editorStore } from '$lib/stores/editor.svelte'; + import { MARKER_COLORS } from '@lightwrite/shared'; import WaveformEditor from '$lib/components/WaveformEditor.svelte'; import PlaybackControls from '$lib/components/PlaybackControls.svelte'; import LyricsEditor from '$lib/components/LyricsEditor.svelte'; import MarkerTimeline from '$lib/components/MarkerTimeline.svelte'; import KaraokePreview from '$lib/components/KaraokePreview.svelte'; import BeatUploader from '$lib/components/BeatUploader.svelte'; + import { ThemeToggle } from '@manacore/shared-theme-ui'; + import { theme } from '$lib/stores/theme.svelte'; let waveformEditor: WaveformEditor; let showExportMenu = $state(false); let isExporting = $state(false); + // Mobile responsive state + let isMobile = $state(false); + let mobileTab: 'lyrics' | 'preview' = $state('lyrics'); + + // Listen for resize events + $effect(() => { + if (typeof window === 'undefined') return; + + const checkMobile = () => { + isMobile = window.innerWidth < 768; + }; + checkMobile(); + + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }); + + // Keyboard shortcuts handler + function handleKeydown(e: KeyboardEvent) { + // Ignore if typing in input fields + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) { + return; + } + + // Ignore if no audio loaded + if (!audioStore.isLoaded) return; + + switch (e.code) { + case 'Space': + e.preventDefault(); + waveformEditor?.playPause(); + break; + + case 'ArrowLeft': + e.preventDefault(); + const skipBack = e.shiftKey ? 1 : 5; + waveformEditor?.seekTo(Math.max(0, audioStore.currentTime - skipBack)); + break; + + case 'ArrowRight': + e.preventDefault(); + const skipForward = e.shiftKey ? 1 : 5; + waveformEditor?.seekTo(Math.min(audioStore.duration, audioStore.currentTime + skipForward)); + break; + + case 'Home': + e.preventDefault(); + waveformEditor?.seekTo(0); + break; + + case 'End': + e.preventDefault(); + waveformEditor?.seekTo(audioStore.duration); + break; + + case 'KeyM': + e.preventDefault(); + if (projectStore.currentBeat) { + projectStore.createMarker(projectStore.currentBeat.id, { + type: editorStore.markerTypeToCreate, + startTime: audioStore.currentTime, + endTime: audioStore.currentTime + 4, + color: MARKER_COLORS[editorStore.markerTypeToCreate], + }); + } + break; + + case 'KeyL': + e.preventDefault(); + if (editorStore.selectedMarkerId) { + waveformEditor?.toggleLoop(editorStore.selectedMarkerId); + } + break; + + case 'Escape': + e.preventDefault(); + editorStore.selectMarker(null); + editorStore.selectLine(null); + editorStore.setLoopRegion(null); + break; + + case 'Equal': + case 'NumpadAdd': + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleZoomIn(); + } + break; + + case 'Minus': + case 'NumpadSubtract': + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleZoomOut(); + } + break; + } + } + $effect(() => { const id = $page.params.id; if (id && authStore.isAuthenticated) { @@ -148,13 +254,18 @@ {projectStore.currentProject?.title || 'Editor'} - LightWrite + +
-
-
- - +
+
+ + -
-

+
+

{projectStore.currentProject?.title || 'Loading...'}

- {#if projectStore.currentProject?.description} -

+ {#if projectStore.currentProject?.description && !isMobile} +

{projectStore.currentProject.description}

{/if}

-
+
+ + +
{#if showExportMenu} @@ -246,12 +360,14 @@
-
+
{#if projectStore.currentBeat} -
+
-
- +
+ - {projectStore.currentBeat.filename} + {projectStore.currentBeat.filename}
-
@@ -272,7 +391,13 @@ onSeek={handleSeek} /> - + {#if !isMobile} + waveformEditor?.toggleLoop(markerId)} + /> + {/if}
{:else} @@ -291,31 +417,69 @@
-
- -
- -
+ {#if isMobile} + +
+ +
+ + +
- -
- {#if editorStore.mode === 'preview'} - - {:else} -
-
-

Switch to Preview mode to see karaoke animation

- -
-
- {/if} + +
+ {#if mobileTab === 'lyrics'} + + {:else} + + {/if} +
-
+ {:else} + +
+ +
+ +
+ + +
+ {#if editorStore.mode === 'preview'} + + {:else} +
+
+

Switch to Preview mode to see karaoke animation

+ +
+
+ {/if} +
+
+ {/if}
{/if}