mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
✨ feat(lightwrite): add UX improvements and beat library
Phase 1-5 implementation: - Keyboard shortcuts (Space, arrows, M, L, +/-) - Dark/Light mode with theme store - Loop regions for practice sections - Mobile responsive view with tabs - Beat library with preview and use functionality Backend: - Add library_beats schema - Add library beat endpoints (GET list, GET download-url, POST use) Frontend: - BeatLibrary component with preview player - BeatUploader tabs (Upload/Library) - Theme-aware waveform colors - Compact mobile playback controls Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b5d7524c77
commit
6e7b671a73
15 changed files with 998 additions and 209 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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<Marker[]> {
|
||||
return this.db.select().from(markers).where(eq(markers.beatId, beatId));
|
||||
}
|
||||
|
||||
// ==================== Library Beats ====================
|
||||
|
||||
async getLibraryBeats(): Promise<LibraryBeat[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(libraryBeats)
|
||||
.where(eq(libraryBeats.isActive, true))
|
||||
.orderBy(libraryBeats.title);
|
||||
}
|
||||
|
||||
async getLibraryBeatById(id: string): Promise<LibraryBeat | null> {
|
||||
const [beat] = await this.db.select().from(libraryBeats).where(eq(libraryBeats.id, id));
|
||||
return beat || null;
|
||||
}
|
||||
|
||||
async getLibraryBeatDownloadUrl(id: string): Promise<string> {
|
||||
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<Beat> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export class CreateBeatUploadDto {
|
|||
filename!: string;
|
||||
}
|
||||
|
||||
export class UseLibraryBeatDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
projectId!: string;
|
||||
}
|
||||
|
||||
export class UpdateBeatMetadataDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
225
apps/lightwrite/apps/web/src/lib/components/BeatLibrary.svelte
Normal file
225
apps/lightwrite/apps/web/src/lib/components/BeatLibrary.svelte
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface LibraryBeat {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
genre: string | null;
|
||||
bpm: number | null;
|
||||
duration: number | null;
|
||||
tags: string[] | null;
|
||||
license: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
onSelectBeat?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, onSelectBeat }: Props = $props();
|
||||
|
||||
let beats = $state<LibraryBeat[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let isUsing = $state<string | null>(null);
|
||||
let previewingBeat = $state<string | null>(null);
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
const backendUrl =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__) ||
|
||||
'http://localhost:3010';
|
||||
|
||||
onMount(async () => {
|
||||
await loadLibraryBeats();
|
||||
});
|
||||
|
||||
async function loadLibraryBeats() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library`);
|
||||
if (!response.ok) throw new Error('Failed to load library');
|
||||
const data = await response.json();
|
||||
beats = data.beats;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load beat library';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUseBeat(beatId: string) {
|
||||
isUsing = beatId;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library/${beatId}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authStore.getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to use beat');
|
||||
}
|
||||
|
||||
onSelectBeat?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to add beat to project';
|
||||
} finally {
|
||||
isUsing = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreview(beatId: string) {
|
||||
if (previewingBeat === beatId) {
|
||||
// Stop preview
|
||||
audioElement?.pause();
|
||||
audioElement = null;
|
||||
previewingBeat = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing preview
|
||||
audioElement?.pause();
|
||||
previewingBeat = beatId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/beats/library/${beatId}/download-url`);
|
||||
if (!response.ok) throw new Error('Failed to get preview URL');
|
||||
const data = await response.json();
|
||||
|
||||
audioElement = new Audio(data.url);
|
||||
audioElement.play();
|
||||
audioElement.onended = () => {
|
||||
previewingBeat = null;
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Preview error:', err);
|
||||
previewingBeat = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '--:--';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<button onclick={loadLibraryBeats} class="px-4 py-2 text-primary hover:underline">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{:else if beats.length === 0}
|
||||
<div class="text-center py-12 text-foreground-secondary">
|
||||
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>No beats available in the library yet.</p>
|
||||
<p class="text-sm mt-2">Upload your own beat instead.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3">
|
||||
{#each beats as beat}
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<!-- Preview button -->
|
||||
<button
|
||||
onclick={() => togglePreview(beat.id)}
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors shrink-0"
|
||||
aria-label={previewingBeat === beat.id ? 'Stop preview' : 'Play preview'}
|
||||
>
|
||||
{#if previewingBeat === beat.id}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Beat info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium truncate">{beat.title}</h3>
|
||||
<div class="flex items-center gap-3 text-sm text-foreground-secondary">
|
||||
{#if beat.artist}
|
||||
<span>{beat.artist}</span>
|
||||
{/if}
|
||||
{#if beat.genre}
|
||||
<span class="px-2 py-0.5 bg-surface-active rounded-full text-xs">
|
||||
{beat.genre}
|
||||
</span>
|
||||
{/if}
|
||||
{#if beat.bpm}
|
||||
<span>{beat.bpm} BPM</span>
|
||||
{/if}
|
||||
<span>{formatDuration(beat.duration)}</span>
|
||||
</div>
|
||||
{#if beat.tags && beat.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each beat.tags.slice(0, 3) as tag}
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{#if beat.tags.length > 3}
|
||||
<span class="text-xs text-foreground-secondary">
|
||||
+{beat.tags.length - 3} more
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Use button -->
|
||||
<button
|
||||
onclick={() => handleUseBeat(beat.id)}
|
||||
disabled={isUsing !== null}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors shrink-0 text-sm"
|
||||
>
|
||||
{#if isUsing === beat.id}
|
||||
<span class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Adding...
|
||||
</span>
|
||||
{:else}
|
||||
Use Beat
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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<Tab>('upload');
|
||||
|
||||
let isUploading = $state(false);
|
||||
let isDetectingBpm = $state(false);
|
||||
let uploadProgress = $state(0);
|
||||
|
|
@ -90,59 +94,17 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed border-border rounded-lg p-8 text-center transition-colors hover:border-primary"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedExtensions}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="beat-upload"
|
||||
/>
|
||||
|
||||
{#if isUploading}
|
||||
<div class="space-y-4">
|
||||
<div class="w-16 h-16 mx-auto">
|
||||
{#if isDetectingBpm}
|
||||
<svg
|
||||
class="w-full h-full text-primary animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-foreground-secondary">
|
||||
{isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'}
|
||||
</p>
|
||||
<div class="w-full max-w-xs mx-auto h-2 bg-surface-hover rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<label for="beat-upload" class="cursor-pointer block">
|
||||
<div class="w-16 h-16 mx-auto mb-4 text-foreground-secondary">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<div class="space-y-4">
|
||||
<!-- Tab Switcher -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
onclick={() => (activeTab = 'upload')}
|
||||
class="flex-1 py-3 px-4 text-sm font-medium transition-colors relative {activeTab === 'upload'
|
||||
? 'text-primary'
|
||||
: 'text-foreground-secondary hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
@ -150,14 +112,112 @@
|
|||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-2">Upload a Beat</p>
|
||||
<p class="text-foreground-secondary text-sm">Drag & drop or click to select an audio file</p>
|
||||
<p class="text-foreground-secondary text-xs mt-2">Supported formats: MP3, WAV, OGG</p>
|
||||
</label>
|
||||
{/if}
|
||||
Upload
|
||||
</span>
|
||||
{#if activeTab === 'upload'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'library')}
|
||||
class="flex-1 py-3 px-4 text-sm font-medium transition-colors relative {activeTab ===
|
||||
'library'
|
||||
? 'text-primary'
|
||||
: 'text-foreground-secondary hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
Library
|
||||
</span>
|
||||
{#if activeTab === 'library'}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="text-red-500 mt-4 text-sm">{errorMessage}</p>
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'upload'}
|
||||
<div
|
||||
class="border-2 border-dashed border-border rounded-lg p-8 text-center transition-colors hover:border-primary"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedExtensions}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="beat-upload"
|
||||
/>
|
||||
|
||||
{#if isUploading}
|
||||
<div class="space-y-4">
|
||||
<div class="w-16 h-16 mx-auto">
|
||||
{#if isDetectingBpm}
|
||||
<svg
|
||||
class="w-full h-full text-primary animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-foreground-secondary">
|
||||
{isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'}
|
||||
</p>
|
||||
<div class="w-full max-w-xs mx-auto h-2 bg-surface-hover rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<label for="beat-upload" class="cursor-pointer block">
|
||||
<div class="w-16 h-16 mx-auto mb-4 text-foreground-secondary">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-2">Upload a Beat</p>
|
||||
<p class="text-foreground-secondary text-sm">
|
||||
Drag & drop or click to select an audio file
|
||||
</p>
|
||||
<p class="text-foreground-secondary text-xs mt-2">Supported formats: MP3, WAV, OGG</p>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="text-red-500 mt-4 text-sm">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<BeatLibrary {projectId} onSelectBeat={onUploadComplete} />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@
|
|||
interface Props {
|
||||
onMarkerClick?: (markerId: string) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onToggleLoop?: (markerId: string) => void;
|
||||
}
|
||||
|
||||
let { onMarkerClick, onSeek }: Props = $props();
|
||||
let { onMarkerClick, onSeek, onToggleLoop }: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement;
|
||||
|
||||
|
|
@ -165,6 +166,24 @@
|
|||
selectedMarker.endTime || selectedMarker.startTime
|
||||
).toFixed(2)}s
|
||||
</span>
|
||||
<button
|
||||
onclick={() => onToggleLoop?.(selectedMarker.id)}
|
||||
class="p-1 rounded transition-colors {editorStore.loopRegionId === selectedMarker.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-foreground-secondary hover:bg-surface-active'}"
|
||||
title={editorStore.loopRegionId === selectedMarker.id
|
||||
? 'Stop Loop (L)'
|
||||
: 'Loop Region (L)'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDeleteMarker(selectedMarker.id)}
|
||||
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
onSeek?: (time: number) => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { onPlay, onPause, onSeek, onZoomIn, onZoomOut }: Props = $props();
|
||||
let { onPlay, onPause, onSeek, onZoomIn, onZoomOut, compact = false }: Props = $props();
|
||||
|
||||
function handlePlayPause() {
|
||||
if (audioStore.isPlaying) {
|
||||
|
|
@ -38,113 +39,166 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4 p-4 bg-surface rounded-lg">
|
||||
<!-- Time display -->
|
||||
<div class="text-sm font-mono text-foreground-secondary min-w-[100px]">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</div>
|
||||
|
||||
<!-- Playback controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={skipBackward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip backward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if compact}
|
||||
<!-- Compact mobile layout -->
|
||||
<div class="flex items-center gap-2 p-2 bg-surface rounded-lg">
|
||||
<!-- Play/Pause button -->
|
||||
<button
|
||||
onclick={handlePlayPause}
|
||||
class="p-3 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors"
|
||||
class="p-2 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip forward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Seek slider -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 100}
|
||||
step="0.1"
|
||||
value={audioStore.currentTime}
|
||||
oninput={handleSeek}
|
||||
class="w-full h-2 bg-surface-hover rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs text-foreground-secondary min-w-[40px] text-center">
|
||||
{Math.round(editorStore.zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- BPM display -->
|
||||
{#if audioStore.bpm}
|
||||
<div class="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
{audioStore.bpm} BPM
|
||||
<!-- Time display -->
|
||||
<div class="text-xs font-mono text-foreground-secondary shrink-0">
|
||||
{formatTime(audioStore.currentTime)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seek slider -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 100}
|
||||
step="0.1"
|
||||
value={audioStore.currentTime}
|
||||
oninput={handleSeek}
|
||||
class="w-full h-1.5 bg-surface-hover rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-xs font-mono text-foreground-secondary shrink-0">
|
||||
{formatTime(audioStore.duration)}
|
||||
</div>
|
||||
|
||||
<!-- BPM display (compact) -->
|
||||
{#if audioStore.bpm}
|
||||
<div class="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-medium shrink-0">
|
||||
{audioStore.bpm}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop layout -->
|
||||
<div class="flex items-center gap-4 p-4 bg-surface rounded-lg">
|
||||
<!-- Time display -->
|
||||
<div class="text-sm font-mono text-foreground-secondary min-w-[100px]">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</div>
|
||||
|
||||
<!-- Playback controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={skipBackward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip backward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handlePlayPause}
|
||||
class="p-3 bg-primary text-white rounded-full hover:bg-primary-hover transition-colors"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Skip forward 5 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Seek slider -->
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 100}
|
||||
step="0.1"
|
||||
value={audioStore.currentTime}
|
||||
oninput={handleSeek}
|
||||
class="w-full h-2 bg-surface-hover rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs text-foreground-secondary min-w-[40px] text-center">
|
||||
{Math.round(editorStore.zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- BPM display -->
|
||||
{#if audioStore.bpm}
|
||||
<div class="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
{audioStore.bpm} BPM
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="waveform-container" bind:this={containerRef}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
15
apps/lightwrite/apps/web/src/lib/stores/theme.svelte.ts
Normal file
15
apps/lightwrite/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,16 +1,24 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let cleanupTheme: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme first to prevent flash
|
||||
cleanupTheme = theme.initialize();
|
||||
await authStore.initialize();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupTheme?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<title>{projectStore.currentProject?.title || 'Editor'} - LightWrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-border bg-surface shrink-0">
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/" class="text-foreground-secondary hover:text-foreground transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="px-3 md:px-4 py-2 md:py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 md:gap-4 min-w-0">
|
||||
<a
|
||||
href="/"
|
||||
class="text-foreground-secondary hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<svg class="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
@ -163,25 +274,28 @@
|
|||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="font-semibold">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-semibold text-sm md:text-base truncate">
|
||||
{projectStore.currentProject?.title || 'Loading...'}
|
||||
</h1>
|
||||
{#if projectStore.currentProject?.description}
|
||||
<p class="text-sm text-foreground-secondary">
|
||||
{#if projectStore.currentProject?.description && !isMobile}
|
||||
<p class="text-sm text-foreground-secondary truncate">
|
||||
{projectStore.currentProject.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
<!-- Theme toggle -->
|
||||
<ThemeToggle {theme} showTooltip size={isMobile ? 16 : 20} />
|
||||
|
||||
<!-- Export dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showExportMenu = !showExportMenu)}
|
||||
disabled={isExporting || !projectStore.currentLyrics}
|
||||
class="px-4 py-2 bg-surface-hover hover:bg-surface-active rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
class="px-2 md:px-4 py-1.5 md:py-2 bg-surface-hover hover:bg-surface-active rounded-lg transition-colors flex items-center gap-1 md:gap-2 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{#if isExporting}
|
||||
<div
|
||||
|
|
@ -197,7 +311,7 @@
|
|||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Export
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
|
||||
{#if showExportMenu}
|
||||
|
|
@ -246,12 +360,14 @@
|
|||
<!-- Main editor layout -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Waveform section -->
|
||||
<div class="shrink-0 p-4 border-b border-border">
|
||||
<div class="shrink-0 p-2 md:p-4 border-b border-border">
|
||||
{#if projectStore.currentBeat}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2 md:space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs md:text-sm text-foreground-secondary truncate"
|
||||
>
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
@ -259,10 +375,13 @@
|
|||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{projectStore.currentBeat.filename}
|
||||
<span class="truncate">{projectStore.currentBeat.filename}</span>
|
||||
</div>
|
||||
<button onclick={handleDeleteBeat} class="text-sm text-red-500 hover:text-red-600">
|
||||
Remove Beat
|
||||
<button
|
||||
onclick={handleDeleteBeat}
|
||||
class="text-xs md:text-sm text-red-500 hover:text-red-600 shrink-0"
|
||||
>
|
||||
{isMobile ? 'Remove' : 'Remove Beat'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -272,7 +391,13 @@
|
|||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
<MarkerTimeline onMarkerClick={handleMarkerClick} onSeek={handleSeek} />
|
||||
{#if !isMobile}
|
||||
<MarkerTimeline
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onSeek={handleSeek}
|
||||
onToggleLoop={(markerId) => waveformEditor?.toggleLoop(markerId)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PlaybackControls
|
||||
onPlay={handlePlay}
|
||||
|
|
@ -280,6 +405,7 @@
|
|||
onSeek={handleSeek}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
compact={isMobile}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -291,31 +417,69 @@
|
|||
</div>
|
||||
|
||||
<!-- Lyrics and preview section -->
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<!-- Lyrics editor -->
|
||||
<div class="w-1/2 border-r border-border overflow-hidden">
|
||||
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
|
||||
</div>
|
||||
{#if isMobile}
|
||||
<!-- Mobile: Tab-based layout -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-border bg-surface shrink-0">
|
||||
<button
|
||||
onclick={() => (mobileTab = 'lyrics')}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {mobileTab === 'lyrics'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-foreground-secondary'}"
|
||||
>
|
||||
Lyrics
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
mobileTab = 'preview';
|
||||
editorStore.setMode('preview');
|
||||
}}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {mobileTab === 'preview'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-foreground-secondary'}"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Karaoke preview -->
|
||||
<div class="w-1/2 overflow-hidden">
|
||||
{#if editorStore.mode === 'preview'}
|
||||
<KaraokePreview />
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-foreground-secondary">
|
||||
<div class="text-center">
|
||||
<p>Switch to Preview mode to see karaoke animation</p>
|
||||
<button
|
||||
onclick={() => editorStore.setMode('preview')}
|
||||
class="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover"
|
||||
>
|
||||
Preview Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{#if mobileTab === 'lyrics'}
|
||||
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
|
||||
{:else}
|
||||
<KaraokePreview />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop: Side-by-side layout -->
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<!-- Lyrics editor -->
|
||||
<div class="w-1/2 border-r border-border overflow-hidden">
|
||||
<LyricsEditor onLineClick={handleLineClick} onSyncLine={handleSyncLine} />
|
||||
</div>
|
||||
|
||||
<!-- Karaoke preview -->
|
||||
<div class="w-1/2 overflow-hidden">
|
||||
{#if editorStore.mode === 'preview'}
|
||||
<KaraokePreview />
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-foreground-secondary">
|
||||
<div class="text-center">
|
||||
<p>Switch to Preview mode to see karaoke animation</p>
|
||||
<button
|
||||
onclick={() => editorStore.setMode('preview')}
|
||||
class="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover"
|
||||
>
|
||||
Preview Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue