chore(mana): music + apps/mukke aus unified-App entfernen

Mukke ist seit 2026-05-19 als Standalone-App live (mukke.mana.how +
mukke-api.mana.how, Repo git.mana.how/till/mukke) mit umfassenderem
Feature-Set (Studio, Wavesurfer, Lyrics-Sync, Beats, LRC/SRT/JSON-
Export, ID3-Extract, S3-Streaming). Modul + alte Landing können raus.

Entfernt:
- apps/mana/apps/web/src/routes/(app)/music/ (alle 6 Routes)
- apps/mana/apps/web/src/lib/modules/music/ (Stores, Queries,
  Collections, Tools, Types, Views, Components)
- apps/mana/apps/web/src/lib/i18n/locales/music/ (DE/EN/ES/FR/IT)
- apps/mana/apps/web/src/lib/search/providers/music.ts
- apps/mana/apps/web/src/lib/components/dashboard/widgets/MusicLibraryWidget.svelte
- apps/mukke/ (alte Landing + shared types-Package — Standalone hat
  beides selbst; VISUALIZER_CONCEPT.md + ALTERNATIVES.md vorab nach
  mukke/docs/ ins Standalone-Repo migriert)

Aktualisiert (Music-Refs raus):
- module-registry.ts (musicModuleConfig)
- module-registry.test.ts (music-Tabellen-Expectation)
- cross-app-queries.ts (useMusicStats + MusicStats-Interface)
- tools/init.ts (musicTools-Init)
- search/providers/index.ts (registerLazy 'music')
- app-registry/apps.ts (registerApp 'music' + MusicNotes-Icon-Import)
- packages/shared-branding/src/mana-apps.ts (music-Eintrag)
- hooks.server.ts (Allowlist)
- types/dashboard.ts (WidgetType 'music-library' + RequiredBackend)
- types/dashboard.test.ts (Erwartung 'music-library')
- stores/dashboard.svelte.ts (Widget-Default-Liste)
- splitscreen/registry.ts
- components/dashboard/widget-registry.ts

NICHT angefasst (mit Absicht):
- data/database.ts db.version(1).stores — Schema-Snapshot ist frozen
  (gleiche Konvention wie für cards/quotes). Tabellen (songs,
  mukkePlaylists, playlistSongs, mukkeProjects, markers, songTags)
  bleiben im IndexedDB-Schema, werden aber nicht mehr beschrieben.
  Bei Bedarf später ein db.version(N) mit `songs: null` etc. nachschieben.
- modules/events/discovery/types.ts 'music' (Event-Kategorie, generisch)
- data/time-blocks/types.ts 'music' (TimeBlock-Kategorie, generisch)
- shared-ai/tools/schemas.ts 'music' (Event-Discovery-Enum)
- packages/shared-branding/src/app-icons.ts APP_ICONS.music (für
  Native-PNG-Generator, harmlos)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-19 15:20:13 +02:00
parent d2bfaf1b2a
commit f9159741a0
59 changed files with 0 additions and 4753 deletions

View file

@ -144,7 +144,6 @@ const APP_SUBDOMAINS = new Set([
'storage',
'presi',
'photos',
'music',
'picture',
'calc',
'inventory',

View file

@ -23,7 +23,6 @@ import {
Clock,
Quotes,
Image,
MusicNotes,
Camera,
HardDrives,
Presentation,
@ -601,17 +600,6 @@ registerApp({
},
});
registerApp({
id: 'music',
name: 'Music',
color: '#F97316',
icon: MusicNotes,
views: {
list: { load: () => import('$lib/modules/music/ListView.svelte') },
detail: { load: () => import('$lib/modules/music/views/DetailView.svelte') },
},
});
registerApp({
id: 'photos',
name: 'Photos',

View file

@ -20,7 +20,6 @@ import QuoteWidget from './widgets/QuoteWidget.svelte';
import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte';
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
// Phase 4: Unified app widgets (direct Dexie queries, internal routing)
@ -49,7 +48,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'picture-recent': PictureRecentWidget,
'clock-timers': ClockTimersWidget,
'storage-usage': StorageUsageWidget,
'music-library': MusicLibraryWidget,
'presi-decks': PresiDecksWidget,
'active-timer': ActiveTimerWidget,
'day-timeline': DayTimelineWidget,

View file

@ -1,71 +0,0 @@
<script lang="ts">
/**
* MusicLibraryWidget - Music library stats (local-first)
*/
import { _ } from 'svelte-i18n';
import { useMusicStats } from '$lib/data/cross-app-queries';
const stats = useMusicStats();
function formatDuration(seconds?: number): string {
if (!seconds) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎵</span>
{$_('dashboard.widgets.music.title')}
</h3>
</div>
{#if stats.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<div class="mb-3 flex gap-4 text-sm">
<div>
<span class="font-semibold">{stats.value.totalSongs}</span>
<span class="text-muted-foreground"> Songs</span>
</div>
<div>
<span class="font-semibold">{stats.value.totalPlaylists}</span>
<span class="text-muted-foreground"> Playlists</span>
</div>
<div>
<span class="font-semibold">{stats.value.favoriteCount}</span>
<span class="text-muted-foreground"></span>
</div>
</div>
{#if stats.value.recentSongs.length > 0}
<div class="space-y-1">
{#each stats.value.recentSongs as song (song.id)}
<div class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-hover">
<span class="text-sm">🎵</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{song.title}</p>
{#if song.artist}
<p class="truncate text-xs text-muted-foreground">{song.artist}</p>
{/if}
</div>
{#if song.duration}
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDuration(song.duration)}
</span>
{/if}
</div>
{/each}
</div>
{/if}
<p class="mt-2 text-center text-xs text-muted-foreground">Music</p>
{/if}
</div>

View file

@ -17,7 +17,6 @@ import type { LocalFavorite } from '$lib/modules/quotes/types';
import type { LocalImage } from '$lib/modules/picture/types';
import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
// ─── Todo Queries ───────────────────────────────────────────
@ -222,41 +221,6 @@ export function useStorageStats() {
);
}
// ─── Music Queries ──────────────────────────────────────────
interface MusicStats {
totalSongs: number;
totalPlaylists: number;
favoriteCount: number;
recentSongs: LocalSong[];
}
/** Music library stats + recent songs. */
export function useMusicStats() {
return useLiveQueryWithDefault(
async (): Promise<MusicStats> => {
const songs = await db.table<LocalSong>('songs').toArray();
const playlists = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
const activeSongs = songs.filter((s) => !s.deletedAt);
const activePlaylists = playlists.filter((p) => !p.deletedAt);
// title is encrypted on disk; the dashboard widget renders it
// for the recent-songs list, so decrypt the small slice we
// surface (counts only need plaintext flags).
const recentRaw = activeSongs
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
const recent = await decryptRecords('songs', recentRaw);
return {
totalSongs: activeSongs.length,
totalPlaylists: activePlaylists.length,
favoriteCount: activeSongs.filter((s) => s.favorite).length,
recentSongs: recent,
};
},
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as LocalSong[] }
);
}
// ─── Presi Queries ──────────────────────────────────────────
/** Recent presentation decks. */

View file

@ -200,7 +200,6 @@ describe('module-registry — snapshot', () => {
chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'],
picture: ['images', 'boards', 'boardItems', 'imageTags'],
quotes: ['quotesFavorites', 'quotesLists', 'quotesListTags', 'customQuotes'],
music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
storage: ['files', 'storageFolders', 'fileTags'],
presi: ['presiDecks', 'slides', 'presiDeckTags'],
inventory: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],

View file

@ -57,7 +57,6 @@ import { contactsModuleConfig } from '$lib/modules/contacts/module.config';
import { chatModuleConfig } from '$lib/modules/chat/module.config';
import { pictureModuleConfig } from '$lib/modules/picture/module.config';
import { quotesModuleConfig } from '$lib/modules/quotes/module.config';
import { musicModuleConfig } from '$lib/modules/music/module.config';
import { storageModuleConfig } from '$lib/modules/storage/module.config';
import { presiModuleConfig } from '$lib/modules/presi/module.config';
import { inventoryModuleConfig } from '$lib/modules/inventory/module.config';
@ -110,7 +109,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
chatModuleConfig,
pictureModuleConfig,
quotesModuleConfig,
musicModuleConfig,
storageModuleConfig,
presiModuleConfig,
inventoryModuleConfig,

View file

@ -17,7 +17,6 @@ import { financeTools } from '$lib/modules/finance/tools';
import { dreamsTools } from '$lib/modules/dreams/tools';
import { timesTools } from '$lib/modules/times/tools';
import { socialEventsTools } from '$lib/modules/events/tools';
import { musicTools } from '$lib/modules/music/tools';
import { storageTools } from '$lib/modules/storage/tools';
import { chatTools } from '$lib/modules/chat/tools';
import { skilltreeTools } from '$lib/modules/skilltree/tools';
@ -63,7 +62,6 @@ export function initTools(): void {
registerTools(dreamsTools);
registerTools(timesTools);
registerTools(socialEventsTools);
registerTools(musicTools);
registerTools(storageTools);
registerTools(chatTools);
registerTools(skilltreeTools);

View file

@ -1,20 +0,0 @@
{
"detail": {
"not_found": "Song nicht gefunden",
"confirm_delete": "Song wirklich löschen?",
"toast_deleted": "Song gelöscht",
"placeholder_title": "Titel...",
"title_fallback": "Ohne Titel",
"prop_artist": "Künstler",
"prop_artist_placeholder": "Unbekannt",
"prop_album": "Album",
"prop_genre": "Genre",
"prop_year": "Jahr",
"prop_bpm": "BPM",
"prop_duration": "Dauer",
"prop_play_count": "Wiedergaben",
"meta_created": "Erstellt: {date}",
"meta_updated": "Bearbeitet: {date}",
"meta_last_played": "Zuletzt gehört: {date}"
}
}

View file

@ -1,20 +0,0 @@
{
"detail": {
"not_found": "Song not found",
"confirm_delete": "Really delete this song?",
"toast_deleted": "Song deleted",
"placeholder_title": "Title...",
"title_fallback": "Untitled",
"prop_artist": "Artist",
"prop_artist_placeholder": "Unknown",
"prop_album": "Album",
"prop_genre": "Genre",
"prop_year": "Year",
"prop_bpm": "BPM",
"prop_duration": "Duration",
"prop_play_count": "Plays",
"meta_created": "Created: {date}",
"meta_updated": "Edited: {date}",
"meta_last_played": "Last played: {date}"
}
}

View file

@ -1,20 +0,0 @@
{
"detail": {
"not_found": "Canción no encontrada",
"confirm_delete": "¿Eliminar realmente esta canción?",
"toast_deleted": "Canción eliminada",
"placeholder_title": "Título...",
"title_fallback": "Sin título",
"prop_artist": "Artista",
"prop_artist_placeholder": "Desconocido",
"prop_album": "Álbum",
"prop_genre": "Género",
"prop_year": "Año",
"prop_bpm": "BPM",
"prop_duration": "Duración",
"prop_play_count": "Reproducciones",
"meta_created": "Creado: {date}",
"meta_updated": "Editado: {date}",
"meta_last_played": "Última escucha: {date}"
}
}

View file

@ -1,20 +0,0 @@
{
"detail": {
"not_found": "Morceau introuvable",
"confirm_delete": "Vraiment supprimer ce morceau ?",
"toast_deleted": "Morceau supprimé",
"placeholder_title": "Titre...",
"title_fallback": "Sans titre",
"prop_artist": "Artiste",
"prop_artist_placeholder": "Inconnu",
"prop_album": "Album",
"prop_genre": "Genre",
"prop_year": "Année",
"prop_bpm": "BPM",
"prop_duration": "Durée",
"prop_play_count": "Lectures",
"meta_created": "Créé : {date}",
"meta_updated": "Modifié : {date}",
"meta_last_played": "Dernière écoute : {date}"
}
}

View file

@ -1,20 +0,0 @@
{
"detail": {
"not_found": "Brano non trovato",
"confirm_delete": "Eliminare davvero questo brano?",
"toast_deleted": "Brano eliminato",
"placeholder_title": "Titolo...",
"title_fallback": "Senza titolo",
"prop_artist": "Artista",
"prop_artist_placeholder": "Sconosciuto",
"prop_album": "Album",
"prop_genre": "Genere",
"prop_year": "Anno",
"prop_bpm": "BPM",
"prop_duration": "Durata",
"prop_play_count": "Riproduzioni",
"meta_created": "Creato: {date}",
"meta_updated": "Modificato: {date}",
"meta_last_played": "Ultimo ascolto: {date}"
}
}

View file

@ -1,400 +0,0 @@
<!--
Music — Workbench ListView
Song library with recent plays, drag-to-upload for audio files.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import { UploadSimple, Check, X, SpinnerGap } from '@mana/shared-icons';
import type { LocalSong, LocalPlaylist } from './types';
import type { ViewProps } from '$lib/app-registry';
import { libraryStore } from './stores/library.svelte';
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
let { navigate }: ViewProps = $props();
const songsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalSong>('songs').toArray();
const visible = all.filter((s) => !s.deletedAt);
return decryptRecords('songs', visible);
}, [] as LocalSong[]);
const playlistsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalPlaylist>('playlists').toArray();
return all.filter((p) => !p.deletedAt);
}, [] as LocalPlaylist[]);
const songs = $derived(songsQuery.value);
const playlists = $derived(playlistsQuery.value);
const recentlyPlayed = $derived(
[...songs]
.filter((s) => s.lastPlayedAt)
.sort((a, b) => (b.lastPlayedAt ?? '').localeCompare(a.lastPlayedAt ?? ''))
.slice(0, 10)
);
const favorites = $derived(songs.filter((s) => s.favorite));
function formatDuration(sec?: number | null): string {
if (!sec) return '--:--';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
// ─── Upload State ────────────────────────────────────────
let dragActive = $state(false);
let fileInput: HTMLInputElement;
interface UploadFile {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
let uploadFiles = $state<UploadFile[]>([]);
let uploading = $state(false);
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragActive = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragActive = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragActive = false;
if (e.dataTransfer?.files) {
addFiles(Array.from(e.dataTransfer.files));
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) {
addFiles(Array.from(input.files));
input.value = '';
}
}
function addFiles(files: File[]) {
const audioFiles = files.filter((f) => f.type.startsWith('audio/'));
if (audioFiles.length === 0) return;
const newFiles: UploadFile[] = audioFiles.map((file) => ({
file,
status: 'pending',
}));
uploadFiles = [...uploadFiles, ...newFiles];
uploadAll();
}
/** Extract duration in seconds from an audio file via a temporary Audio element. */
function getAudioDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
audio.preload = 'metadata';
audio.onloadedmetadata = () => {
const dur = Number.isFinite(audio.duration) ? Math.round(audio.duration) : null;
URL.revokeObjectURL(url);
resolve(dur);
};
audio.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
audio.src = url;
});
}
async function uploadAll() {
if (uploading) return;
uploading = true;
const token = await authStore.getValidToken();
for (let i = 0; i < uploadFiles.length; i++) {
if (uploadFiles[i]!.status !== 'pending') continue;
uploadFiles[i]!.status = 'uploading';
try {
if (!token) throw new Error('Nicht eingeloggt');
// 1. Get presigned upload URL from mana-api
const res = await fetch(`${getManaApiUrl()}/api/v1/music/songs/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ filename: uploadFiles[i]!.file.name }),
});
if (!res.ok) throw new Error('Upload-URL konnte nicht erstellt werden');
const { song, uploadUrl } = (await res.json()) as {
song: { id: string; title: string; storagePath: string };
uploadUrl: string;
};
// 2. Upload file directly to S3/MinIO via presigned URL
const putRes = await fetch(uploadUrl, {
method: 'PUT',
body: uploadFiles[i]!.file,
headers: { 'Content-Type': uploadFiles[i]!.file.type || 'audio/mpeg' },
});
if (!putRes.ok) throw new Error('Datei-Upload fehlgeschlagen');
// 3. Extract duration from the audio file
const duration = await getAudioDuration(uploadFiles[i]!.file);
// 4. Create local IndexedDB record
const now = new Date().toISOString();
await libraryStore.insert({
id: song.id,
title: song.title,
storagePath: song.storagePath,
duration,
favorite: false,
playCount: 0,
fileSize: uploadFiles[i]!.file.size,
createdAt: now,
} as LocalSong);
uploadFiles[i]!.status = 'success';
} catch (e) {
uploadFiles[i]!.status = 'error';
uploadFiles[i]!.error = e instanceof Error ? e.message : 'Upload fehlgeschlagen';
}
}
uploading = false;
// Clear successful uploads after a delay
setTimeout(() => {
uploadFiles = uploadFiles.filter((f) => f.status !== 'success');
}, 2000);
}
function removeUpload(index: number) {
uploadFiles = uploadFiles.filter((_, i) => i !== index);
}
</script>
<div
class="music-list-view"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="application"
>
<input
bind:this={fileInput}
type="file"
accept="audio/*"
multiple
class="hidden"
onchange={handleFileSelect}
/>
<!-- Drop Overlay -->
{#if dragActive}
<div class="drop-overlay">
<UploadSimple size={32} weight="bold" />
<span>Musik ablegen</span>
</div>
{/if}
<BaseListView items={recentlyPlayed} getKey={(s) => s.id} emptyTitle="Noch nichts gehört">
{#snippet header()}
<span>{songs.length} Songs</span>
<span>{playlists.length} Playlists</span>
<span>{favorites.length} Favoriten</span>
{/snippet}
{#snippet toolbar()}
<!-- Upload button + file status -->
<div class="upload-section">
<button class="upload-btn" onclick={() => fileInput.click()}>
<UploadSimple size={14} />
<span>Musik hochladen</span>
</button>
{#if uploadFiles.length > 0}
<div class="upload-list">
{#each uploadFiles as uf, i (uf.file.name + i)}
<div
class="upload-item"
class:success={uf.status === 'success'}
class:error={uf.status === 'error'}
>
<span class="upload-name">{uf.file.name}</span>
{#if uf.status === 'uploading'}
<SpinnerGap size={12} class="spinner" />
{:else if uf.status === 'success'}
<Check size={12} />
{:else if uf.status === 'error'}
<button class="upload-remove" onclick={() => removeUpload(i)} title={uf.error}>
<X size={12} />
</button>
{:else}
<button class="upload-remove" onclick={() => removeUpload(i)}>
<X size={12} />
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/snippet}
{#snippet listHeader()}
<h3 class="mb-2 text-xs font-medium text-muted-foreground">Zuletzt gehört</h3>
{/snippet}
{#snippet item(song)}
<button
onclick={() =>
navigate('detail', {
songId: song.id,
_siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId',
})}
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50 cursor-pointer text-left"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-xs text-muted-foreground"
>
&#9835;
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-foreground">{song.title}</p>
<p class="truncate text-xs text-muted-foreground">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-muted-foreground/70">{formatDuration(song.duration)}</span>
</button>
{/snippet}
</BaseListView>
</div>
<style>
.music-list-view {
position: relative;
height: 100%;
}
.drop-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: hsl(var(--color-primary) / 0.12);
border: 2px dashed hsl(var(--color-primary));
border-radius: 0.5rem;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
font-weight: 600;
pointer-events: none;
}
.upload-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0 1rem;
margin-bottom: 0.5rem;
}
.upload-btn {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.upload-btn:hover {
border-color: hsl(var(--color-border-strong));
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface-hover));
}
.upload-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.upload-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-muted) / 0.5);
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.upload-item.success {
color: hsl(var(--color-success));
}
.upload-item.error {
color: hsl(var(--color-error));
}
.upload-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-remove {
display: flex;
align-items: center;
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
opacity: 0.6;
transition: opacity 0.15s;
}
.upload-remove:hover {
opacity: 1;
}
:global(.upload-section .spinner) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,41 +0,0 @@
/**
* Music module collection accessors and guest seed data.
*
* Dexie table names kept as mukkePlaylists/mukkeProjects for backward compat.
*/
import { db } from '$lib/data/database';
import type {
LocalSong,
LocalPlaylist,
LocalPlaylistSong,
LocalProject,
LocalMarker,
} from './types';
// ─── Collection Accessors ──────────────────────────────────
export const songTable = db.table<LocalSong>('songs');
export const musicPlaylistTable = db.table<LocalPlaylist>('mukkePlaylists');
export const playlistSongTable = db.table<LocalPlaylistSong>('playlistSongs');
export const musicProjectTable = db.table<LocalProject>('mukkeProjects');
export const markerTable = db.table<LocalMarker>('markers');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_PLAYLIST_ID = 'demo-favorites';
export const MUSIC_GUEST_SEED = {
songs: [] as Record<string, unknown>[],
mukkePlaylists: [
{
id: DEMO_PLAYLIST_ID,
name: 'Meine Favoriten',
description: 'Deine Lieblingssongs.',
coverArtPath: null,
},
],
playlistSongs: [] as Record<string, unknown>[],
mukkeProjects: [] as Record<string, unknown>[],
markers: [] as Record<string, unknown>[],
};

View file

@ -1,52 +0,0 @@
/**
* Music module barrel exports.
*/
export { libraryStore } from './stores/library.svelte';
export { playlistsStore } from './stores/playlists.svelte';
export { projectsStore } from './stores/projects.svelte';
export { playerStore } from './stores/player.svelte';
export {
useAllSongs,
useAllPlaylists,
useAllPlaylistSongs,
useAllProjects,
useMarkersByBeat,
toSong,
toPlaylist,
toProject,
searchSongs,
filterFavorites,
filterByArtist,
filterByAlbum,
filterByGenre,
getPlaylistSongs,
groupByArtist,
groupByAlbum,
groupByGenre,
computeStats,
formatDuration,
} from './queries';
export {
songTable,
musicPlaylistTable,
playlistSongTable,
musicProjectTable,
markerTable,
MUSIC_GUEST_SEED,
} from './collections';
export type {
LocalSong,
LocalPlaylist,
LocalPlaylistSong,
LocalProject,
LocalMarker,
Song,
Playlist,
Project,
Album,
Artist,
Genre,
LibraryStats,
RepeatMode,
} from './types';

View file

@ -1,13 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const musicModuleConfig: ModuleConfig = {
appId: 'music',
tables: [
{ name: 'songs' },
{ name: 'mukkePlaylists', syncName: 'playlists' },
{ name: 'playlistSongs' },
{ name: 'mukkeProjects', syncName: 'projects' },
{ name: 'markers' },
{ name: 'songTags' },
],
};

View file

@ -1,255 +0,0 @@
/**
* Reactive queries & pure helpers for Music uses Dexie liveQuery on the unified DB.
*/
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalSong,
LocalPlaylist,
LocalPlaylistSong,
LocalProject,
LocalMarker,
Song,
Playlist,
Project,
Album,
Artist,
Genre,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toSong(local: LocalSong): Song {
return {
id: local.id,
title: local.title,
artist: local.artist ?? null,
album: local.album ?? null,
albumArtist: local.albumArtist ?? null,
genre: local.genre ?? null,
trackNumber: local.trackNumber ?? null,
year: local.year ?? null,
duration: local.duration ?? null,
storagePath: local.storagePath,
coverArtPath: local.coverArtPath ?? null,
fileSize: local.fileSize ?? null,
bpm: local.bpm ?? null,
favorite: local.favorite,
playCount: local.playCount,
lastPlayedAt: local.lastPlayedAt ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toPlaylist(local: LocalPlaylist): Playlist {
return {
id: local.id,
name: local.name,
description: local.description ?? null,
coverArtPath: local.coverArtPath ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toProject(local: LocalProject): Project {
return {
id: local.id,
title: local.title,
description: local.description ?? null,
songId: local.songId ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All songs, sorted by title. */
export function useAllSongs() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalSong, string>('music', 'songs').toArray();
const visible = locals.filter((s) => !s.deletedAt);
// title is encrypted on disk; sort needs the plaintext value.
const decrypted = await decryptRecords('songs', visible);
return decrypted.map(toSong).sort((a, b) => a.title.localeCompare(b.title));
}, []);
}
/** All playlists, sorted by name. */
export function useAllPlaylists() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalPlaylist, string>(
'music',
'mukkePlaylists'
).toArray();
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('mukkePlaylists', visible);
return decrypted.map(toPlaylist).sort((a, b) => a.name.localeCompare(b.name));
}, []);
}
/** All playlist-song associations. */
export function useAllPlaylistSongs() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalPlaylistSong, string>(
'music',
'playlistSongs'
).toArray();
return locals.filter((ps) => !ps.deletedAt);
}, []);
}
/** All projects, sorted by title. */
export function useAllProjects() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalProject, string>('music', 'mukkeProjects').toArray();
return locals
.filter((p) => !p.deletedAt)
.map(toProject)
.sort((a, b) => a.title.localeCompare(b.title));
}, []);
}
/** All markers for a given beat ID. */
export function useMarkersByBeat(beatId: string) {
return useScopedLiveQuery(async () => {
const locals = await db.table<LocalMarker>('markers').where('beatId').equals(beatId).toArray();
return locals.filter((m) => !m.deletedAt).sort((a, b) => a.startTime - b.startTime);
}, []);
}
// ─── Pure Filter Functions ─────────────────────────────────
/** Filter songs by search query across title, artist, album. */
export function searchSongs(songs: Song[], query: string): Song[] {
if (!query.trim()) return songs;
const search = query.toLowerCase().trim();
return songs.filter(
(s) =>
s.title?.toLowerCase().includes(search) ||
s.artist?.toLowerCase().includes(search) ||
s.album?.toLowerCase().includes(search)
);
}
/** Filter songs to favorites only. */
export function filterFavorites(songs: Song[]): Song[] {
return songs.filter((s) => s.favorite);
}
/** Filter songs by artist. */
export function filterByArtist(songs: Song[], artist: string): Song[] {
if (!artist) return songs;
return songs.filter((s) => s.artist === artist);
}
/** Filter songs by album. */
export function filterByAlbum(songs: Song[], album: string): Song[] {
if (!album) return songs;
return songs.filter((s) => s.album === album);
}
/** Filter songs by genre. */
export function filterByGenre(songs: Song[], genre: string): Song[] {
if (!genre) return songs;
return songs.filter((s) => s.genre === genre);
}
/** Get songs for a playlist, sorted by sortOrder. */
export function getPlaylistSongs(
songs: Song[],
playlistSongs: LocalPlaylistSong[],
playlistId: string
): Song[] {
const psForPlaylist = playlistSongs
.filter((ps) => ps.playlistId === playlistId)
.sort((a, b) => a.sortOrder - b.sortOrder);
return psForPlaylist
.map((ps) => songs.find((s) => s.id === ps.songId))
.filter((s): s is Song => !!s);
}
/** Group songs by artist. */
export function groupByArtist(songs: Song[]): Album[] {
const map = new Map<string, { songCount: number; albumCount: number }>();
const artistAlbums = new Map<string, Set<string>>();
for (const s of songs) {
const key = s.artist || 'Unknown';
if (!map.has(key)) {
map.set(key, { songCount: 0, albumCount: 0 });
artistAlbums.set(key, new Set());
}
map.get(key)!.songCount++;
if (s.album) artistAlbums.get(key)!.add(s.album);
}
return Array.from(map.entries()).map(([artist, data]) => ({
album: artist,
albumArtist: artist,
year: null,
coverArtPath: null,
songCount: data.songCount,
}));
}
/** Group songs by album. */
export function groupByAlbum(songs: Song[]): Album[] {
const albumMap = new Map<string, Album>();
for (const s of songs) {
const key = s.album || 'Unknown Album';
if (!albumMap.has(key)) {
albumMap.set(key, {
album: key,
albumArtist: s.albumArtist || s.artist || 'Unknown',
year: s.year ?? null,
coverArtPath: s.coverArtPath ?? null,
songCount: 0,
});
}
albumMap.get(key)!.songCount++;
}
return Array.from(albumMap.values());
}
/** Group songs by genre. */
export function groupByGenre(songs: Song[]): Genre[] {
const genreMap = new Map<string, number>();
for (const s of songs) {
const key = s.genre || 'Unknown';
genreMap.set(key, (genreMap.get(key) || 0) + 1);
}
return Array.from(genreMap.entries()).map(([genre, songCount]) => ({ genre, songCount }));
}
/** Compute library stats from songs. */
export function computeStats(songs: Song[]): {
totalSongs: number;
totalArtists: number;
totalAlbums: number;
totalGenres: number;
totalDuration: number;
totalPlays: number;
} {
const artists = new Set(songs.map((s) => s.artist).filter(Boolean));
const albums = new Set(songs.map((s) => s.album).filter(Boolean));
const genres = new Set(songs.map((s) => s.genre).filter(Boolean));
return {
totalSongs: songs.length,
totalArtists: artists.size,
totalAlbums: albums.size,
totalGenres: genres.size,
totalDuration: songs.reduce((sum, s) => sum + (s.duration || 0), 0),
totalPlays: songs.reduce((sum, s) => sum + (s.playCount || 0), 0),
};
}
/** Format duration in seconds to m:ss. */
export function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return '0:00';
return Math.floor(seconds / 60) + ':' + String(Math.floor(seconds % 60)).padStart(2, '0');
}

View file

@ -1,93 +0,0 @@
/**
* Library Store Mutations for songs
*
* Reads come from liveQuery hooks in queries.ts.
* Handles toggle favorite, delete, update metadata.
*/
import { songTable } from '../collections';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalSong } from '../types';
export const libraryStore = {
/** Toggle favorite — writes to IndexedDB instantly. */
async toggleFavorite(id: string) {
const local = await songTable.get(id);
if (local) {
const newState = !local.favorite;
await songTable.update(id, {
favorite: newState,
});
MusicEvents.songFavorited(newState);
}
},
/** Increment play count and create a listening TimeBlock. */
async incrementPlayCount(id: string) {
const local = await songTable.get(id);
if (local) {
const now = new Date().toISOString();
await songTable.update(id, {
playCount: (local.playCount || 0) + 1,
lastPlayedAt: now,
});
const decrypted = await decryptRecord('songs', { ...local });
const title = decrypted?.title ?? 'Song';
const artist = decrypted?.artist;
const endDate = local.duration
? new Date(Date.now() + local.duration * 1000).toISOString()
: now;
await createBlock({
startDate: now,
endDate,
kind: 'logged',
type: 'listening',
sourceModule: 'music',
sourceId: id,
title: artist ? `${title}${artist}` : title,
color: '#d946ef',
});
MusicEvents.songPlayed();
}
},
/** Update song metadata. */
async updateMetadata(
id: string,
data: Partial<
Pick<
LocalSong,
'title' | 'artist' | 'album' | 'albumArtist' | 'genre' | 'trackNumber' | 'year' | 'bpm'
>
>
) {
const diff: Record<string, unknown> = {
...data,
};
await encryptRecord('songs', diff);
await songTable.update(id, diff);
},
/** Soft-delete a song. */
async delete(id: string) {
const now = new Date().toISOString();
await songTable.update(id, { deletedAt: now });
MusicEvents.songDeleted();
},
/** Insert a song (e.g., after upload). */
async insert(song: LocalSong) {
await encryptRecord('songs', song);
await songTable.add(song);
emitDomainEvent('SongAdded', 'music', 'songs', song.id, {
songId: song.id,
title: (song.title as string) ?? '',
});
},
};

View file

@ -1,275 +0,0 @@
/**
* Player Store Audio playback state management
*
* Manages the HTML5 Audio element, queue, shuffle, repeat modes.
* This is a runtime-only store (no IndexedDB persistence).
*/
import type { Song, RepeatMode } from '../types';
interface PlayerState {
currentSong: Song | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
repeatMode: RepeatMode;
shuffleOn: boolean;
queue: Song[];
originalQueue: Song[];
currentIndex: number;
showFullPlayer: boolean;
error: string | null;
}
function shuffleArray<T>(arr: T[], keepIndex: number): T[] {
const result = [...arr];
if (keepIndex >= 0 && keepIndex < result.length) {
[result[0], result[keepIndex]] = [result[keepIndex], result[0]];
}
for (let i = result.length - 1; i > 1; i--) {
const j = 1 + Math.floor(Math.random() * i);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function createPlayerStore() {
let state = $state<PlayerState>({
currentSong: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
repeatMode: 'off',
shuffleOn: false,
queue: [],
originalQueue: [],
currentIndex: 0,
showFullPlayer: false,
error: null,
});
let audio: HTMLAudioElement | null = null;
if (typeof window !== 'undefined') {
audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.addEventListener('timeupdate', () => {
state.currentTime = audio!.currentTime;
});
audio.addEventListener('loadedmetadata', () => {
state.duration = audio!.duration;
});
audio.addEventListener('ended', () => {
handleNext();
});
audio.addEventListener('error', () => {
state.error = 'Audiodatei konnte nicht geladen werden';
state.isPlaying = false;
});
}
function getNextIndex(): number | null {
if (state.queue.length === 0) return null;
if (state.repeatMode === 'one') return state.currentIndex;
if (state.currentIndex < state.queue.length - 1) return state.currentIndex + 1;
if (state.repeatMode === 'all') return 0;
return null;
}
function getPreviousIndex(): number | null {
if (state.queue.length === 0) return null;
if (state.repeatMode === 'one') return state.currentIndex;
if (state.currentIndex > 0) return state.currentIndex - 1;
if (state.repeatMode === 'all') return state.queue.length - 1;
return null;
}
function updateMediaSession(song: Song) {
if (typeof navigator !== 'undefined' && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: song.title,
artist: song.artist || 'Unknown',
album: song.album || '',
});
navigator.mediaSession.setActionHandler('play', () => store.togglePlay());
navigator.mediaSession.setActionHandler('pause', () => store.togglePlay());
navigator.mediaSession.setActionHandler('nexttrack', () => store.nextSong());
navigator.mediaSession.setActionHandler('previoustrack', () => store.previousSong());
}
}
async function loadAndPlay(song: Song) {
if (!audio) return;
state.currentSong = song;
state.currentTime = 0;
state.duration = 0;
state.error = null;
// NOTE: In the unified app, audio URLs would come from the music backend
// via presigned S3 download URLs. For now, playback requires the backend.
// The store manages queue/state regardless.
try {
// Audio URL would be set here from backend
state.isPlaying = false;
updateMediaSession(song);
} catch (e) {
state.isPlaying = false;
state.error = 'Song konnte nicht abgespielt werden.';
}
}
function handleNext() {
const nextIdx = getNextIndex();
if (nextIdx !== null) {
state.currentIndex = nextIdx;
loadAndPlay(state.queue[nextIdx]);
} else {
state.isPlaying = false;
if (audio) audio.pause();
}
}
const store = {
get currentSong() {
return state.currentSong;
},
get isPlaying() {
return state.isPlaying;
},
get currentTime() {
return state.currentTime;
},
get duration() {
return state.duration;
},
get volume() {
return state.volume;
},
get repeatMode() {
return state.repeatMode;
},
get shuffleOn() {
return state.shuffleOn;
},
get queue() {
return state.queue;
},
get currentIndex() {
return state.currentIndex;
},
get showFullPlayer() {
return state.showFullPlayer;
},
get error() {
return state.error;
},
async playSong(song: Song, queue?: Song[], startIndex?: number) {
if (queue) {
state.originalQueue = [...queue];
state.queue = [...queue];
state.currentIndex = startIndex ?? 0;
if (state.shuffleOn) {
state.queue = shuffleArray(state.queue, state.currentIndex);
state.currentIndex = 0;
}
}
await loadAndPlay(song);
},
togglePlay() {
if (!audio || !state.currentSong) return;
if (state.isPlaying) {
audio.pause();
state.isPlaying = false;
} else {
audio.play();
state.isPlaying = true;
}
},
seekTo(time: number) {
if (!audio) return;
audio.currentTime = time;
state.currentTime = time;
},
setVolume(vol: number) {
if (!audio) return;
const clamped = Math.max(0, Math.min(1, vol));
audio.volume = clamped;
state.volume = clamped;
},
nextSong() {
handleNext();
},
previousSong() {
if (state.currentTime > 3) {
store.seekTo(0);
return;
}
const prevIdx = getPreviousIndex();
if (prevIdx !== null) {
state.currentIndex = prevIdx;
loadAndPlay(state.queue[prevIdx]);
}
},
toggleShuffle() {
state.shuffleOn = !state.shuffleOn;
if (state.shuffleOn) {
state.queue = shuffleArray(state.queue, state.currentIndex);
state.currentIndex = 0;
} else {
const currentSong = state.queue[state.currentIndex];
state.queue = [...state.originalQueue];
const idx = state.queue.findIndex((s) => s.id === currentSong?.id);
state.currentIndex = idx >= 0 ? idx : 0;
}
},
toggleRepeat() {
const modes: RepeatMode[] = ['off', 'all', 'one'];
const currentIdx = modes.indexOf(state.repeatMode);
state.repeatMode = modes[(currentIdx + 1) % modes.length];
},
toggleFullPlayer() {
state.showFullPlayer = !state.showFullPlayer;
},
clearQueue() {
if (audio) {
audio.pause();
audio.src = '';
}
state.currentSong = null;
state.isPlaying = false;
state.currentTime = 0;
state.duration = 0;
state.queue = [];
state.originalQueue = [];
state.currentIndex = 0;
state.showFullPlayer = false;
state.error = null;
},
clearError() {
state.error = null;
},
getAudioElement(): HTMLAudioElement | null {
return audio;
},
};
return store;
}
export const playerStore = createPlayerStore();

View file

@ -1,93 +0,0 @@
/**
* Playlists Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* Handles playlist CRUD and song associations.
*/
import { db } from '$lib/data/database';
import { musicPlaylistTable, playlistSongTable } from '../collections';
import { toPlaylist } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalPlaylist, LocalPlaylistSong } from '../types';
export const playlistsStore = {
/** Create a new playlist. */
async create(name: string, description?: string) {
const newLocal: LocalPlaylist = {
id: crypto.randomUUID(),
name,
description: description ?? null,
coverArtPath: null,
};
// Snapshot the plaintext for the return value before encryptRecord
// mutates `newLocal` in place — UI consumers expect plaintext.
const plaintextSnapshot = toPlaylist({ ...newLocal });
await encryptRecord('mukkePlaylists', newLocal);
await musicPlaylistTable.add(newLocal);
MusicEvents.playlistCreated();
return plaintextSnapshot;
},
/** Update a playlist. */
async update(id: string, data: Partial<Pick<LocalPlaylist, 'name' | 'description'>>) {
const diff: Record<string, unknown> = {
...data,
};
await encryptRecord('mukkePlaylists', diff);
await musicPlaylistTable.update(id, diff);
},
/** Soft-delete a playlist and its song associations atomically. */
async delete(id: string) {
const now = new Date().toISOString();
// Atomic cascade: playlist + playlistSongs in one Dexie transaction.
await db.transaction('rw', musicPlaylistTable, playlistSongTable, async () => {
await musicPlaylistTable.update(id, { deletedAt: now });
const allPS = await playlistSongTable.where('playlistId').equals(id).toArray();
for (const ps of allPS) {
await playlistSongTable.update(ps.id, { deletedAt: now });
}
});
MusicEvents.playlistDeleted();
},
/** Add a song to a playlist. */
async addSong(playlistId: string, songId: string) {
const existing = await playlistSongTable.where('playlistId').equals(playlistId).toArray();
const maxSort = existing
.filter((ps) => !ps.deletedAt)
.reduce((max, ps) => Math.max(max, ps.sortOrder), -1);
const newPS: LocalPlaylistSong = {
id: crypto.randomUUID(),
playlistId,
songId,
sortOrder: maxSort + 1,
};
await playlistSongTable.add(newPS);
},
/** Remove a song from a playlist. */
async removeSong(playlistId: string, songId: string) {
const allPS = await playlistSongTable.where('playlistId').equals(playlistId).toArray();
const toRemove = allPS.find((ps) => ps.songId === songId && !ps.deletedAt);
if (toRemove) {
const now = new Date().toISOString();
await playlistSongTable.update(toRemove.id, { deletedAt: now });
}
},
/** Reorder songs in a playlist. */
async reorderSongs(playlistId: string, songIds: string[]) {
const allPS = await playlistSongTable.where('playlistId').equals(playlistId).toArray();
const now = new Date().toISOString();
for (let i = 0; i < songIds.length; i++) {
const ps = allPS.find((p) => p.songId === songIds[i] && !p.deletedAt);
if (ps) {
await playlistSongTable.update(ps.id, { sortOrder: i });
}
}
},
};

View file

@ -1,40 +0,0 @@
/**
* Projects Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* Handles project CRUD.
*/
import { musicProjectTable } from '../collections';
import { toProject } from '../queries';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalProject } from '../types';
export const projectsStore = {
/** Create a new project. */
async create(data: { title: string; description?: string; songId?: string }) {
const newLocal: LocalProject = {
id: crypto.randomUUID(),
title: data.title,
description: data.description ?? null,
songId: data.songId ?? null,
};
await musicProjectTable.add(newLocal);
MusicEvents.projectCreated();
return toProject(newLocal);
},
/** Update a project. */
async update(id: string, data: Partial<Pick<LocalProject, 'title' | 'description' | 'songId'>>) {
await musicProjectTable.update(id, {
...data,
});
},
/** Soft-delete a project. */
async delete(id: string) {
const now = new Date().toISOString();
await musicProjectTable.update(id, { deletedAt: now });
MusicEvents.projectDeleted();
},
};

View file

@ -1,19 +0,0 @@
/**
* Music Tags Uses shared global tags + module-specific junction table.
*/
import { db } from '$lib/data/database';
import { createTagLinkOps } from '@mana/shared-stores';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
} from '@mana/shared-stores';
export const songTagOps = createTagLinkOps({
table: () => db.table('songTags'),
entityIdField: 'songId',
});

View file

@ -1,17 +0,0 @@
import type { ModuleTool } from '$lib/data/tools/types';
export const musicTools: ModuleTool[] = [
{
name: 'create_playlist',
module: 'music',
description: 'Erstellt eine neue Playlist',
parameters: [
{ name: 'name', type: 'string', description: 'Name der Playlist', required: true },
],
async execute(params) {
const { playlistsStore } = await import('./stores/playlists.svelte');
const playlist = await playlistsStore.create(params.name as string);
return { success: true, data: playlist, message: `Playlist "${params.name}" erstellt` };
},
},
];

View file

@ -1,122 +0,0 @@
/**
* Music module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';
export interface LocalSong extends BaseRecord {
title: string;
artist?: string | null;
album?: string | null;
albumArtist?: string | null;
genre?: string | null;
trackNumber?: number | null;
year?: number | null;
duration?: number | null;
storagePath: string;
coverArtPath?: string | null;
fileSize?: number | null;
bpm?: number | null;
favorite: boolean;
playCount: number;
lastPlayedAt?: string | null;
}
export interface LocalPlaylist extends BaseRecord {
name: string;
description?: string | null;
coverArtPath?: string | null;
}
export interface LocalPlaylistSong extends BaseRecord {
playlistId: string;
songId: string;
sortOrder: number;
}
export interface LocalProject extends BaseRecord {
title: string;
description?: string | null;
songId?: string | null;
}
export interface LocalMarker extends BaseRecord {
beatId: string;
type: 'verse' | 'hook' | 'bridge' | 'intro' | 'outro' | 'drop' | 'breakdown' | 'custom';
label?: string | null;
startTime: number;
endTime?: number | null;
color?: string | null;
sortOrder: number;
}
// ─── View Types ────────────────────────────────────────────
export interface Song {
id: string;
title: string;
artist?: string | null;
album?: string | null;
albumArtist?: string | null;
genre?: string | null;
trackNumber?: number | null;
year?: number | null;
duration?: number | null;
storagePath: string;
coverArtPath?: string | null;
fileSize?: number | null;
bpm?: number | null;
favorite: boolean;
playCount: number;
lastPlayedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface Playlist {
id: string;
name: string;
description?: string | null;
coverArtPath?: string | null;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
title: string;
description?: string | null;
songId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface Album {
album: string;
albumArtist: string;
year: number | null;
coverArtPath: string | null;
songCount: number;
}
export interface Artist {
artist: string;
songCount: number;
albumCount: number;
}
export interface Genre {
genre: string;
songCount: number;
}
export interface LibraryStats {
totalSongs: number;
totalArtists: number;
totalAlbums: number;
totalGenres: number;
totalDuration: number;
totalPlays: number;
}
export type RepeatMode = 'off' | 'all' | 'one';

View file

@ -1,183 +0,0 @@
<!--
Music — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { libraryStore } from '../stores/library.svelte';
import { Heart } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalSong } from '../types';
import { _ } from 'svelte-i18n';
let { params, goBack }: ViewProps = $props();
let songId = $derived(params.songId as string);
let editTitle = $state('');
let editArtist = $state('');
let editAlbum = $state('');
let editGenre = $state('');
let editYear = $state<number | null>(null);
let editBpm = $state<number | null>(null);
const detail = useDetailEntity<LocalSong>({
id: () => songId,
table: 'songs',
decrypt: true,
onLoad: (val) => {
editTitle = val.title;
editArtist = val.artist ?? '';
editAlbum = val.album ?? '';
editGenre = val.genre ?? '';
editYear = val.year ?? null;
editBpm = val.bpm ?? null;
},
});
async function saveField() {
detail.blur();
await libraryStore.updateMetadata(songId, {
title: editTitle.trim() || detail.entity?.title || $_('music.detail.title_fallback'),
artist: editArtist.trim() || undefined,
album: editAlbum.trim() || undefined,
genre: editGenre.trim() || undefined,
year: editYear,
bpm: editBpm,
});
}
async function toggleFavorite() {
await libraryStore.toggleFavorite(songId);
}
function formatDuration(sec?: number | null): string {
if (!sec) return '--:--';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
</script>
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel={$_('music.detail.not_found')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('music.detail.confirm_delete')}
onConfirmDelete={() =>
detail.deleteWithUndo({
label: $_('music.detail.toast_deleted'),
delete: () => libraryStore.delete(songId),
goBack,
})}
>
{#snippet body(song)}
<div class="title-row">
<input
class="title-input"
bind:value={editTitle}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('music.detail.placeholder_title')}
/>
<button class="fav-btn" class:active={song.favorite} onclick={toggleFavorite}>
<Heart size={18} weight={song.favorite ? 'fill' : 'regular'} />
</button>
</div>
<div class="properties">
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_artist')}</span>
<input
class="prop-input"
bind:value={editArtist}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('music.detail.prop_artist_placeholder')}
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_album')}</span>
<input
class="prop-input"
bind:value={editAlbum}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_genre')}</span>
<input
class="prop-input"
bind:value={editGenre}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_year')}</span>
<input
type="number"
class="prop-input"
bind:value={editYear}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_bpm')}</span>
<input
type="number"
class="prop-input"
bind:value={editBpm}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_duration')}</span>
<span class="prop-value">{formatDuration(song.duration)}</span>
</div>
<div class="prop-row">
<span class="prop-label">{$_('music.detail.prop_play_count')}</span>
<span class="prop-value">{song.playCount}</span>
</div>
</div>
<div class="meta">
<span
>{$_('music.detail.meta_created', {
values: { date: formatDate(new Date(song.createdAt ?? '')) },
})}</span
>
{#if song.updatedAt}
<span
>{$_('music.detail.meta_updated', {
values: { date: formatDate(new Date(song.updatedAt)) },
})}</span
>
{/if}
{#if song.lastPlayedAt}
<span
>{$_('music.detail.meta_last_played', {
values: { date: formatDate(new Date(song.lastPlayedAt)) },
})}</span
>
{/if}
</div>
{/snippet}
</DetailViewShell>

View file

@ -23,7 +23,6 @@ export function registerAllProviders(registry: SearchRegistry): void {
// 'cards': dekommissioniert 2026-05-08 — Cards eigenständig auf cardecky.mana.how.
registry.registerLazy('picture', () => import('./picture').then((m) => m.pictureSearchProvider));
registry.registerLazy('presi', () => import('./presi').then((m) => m.presiSearchProvider));
registry.registerLazy('music', () => import('./music').then((m) => m.musicSearchProvider));
registry.registerLazy('quotes', () => import('./quotes').then((m) => m.quotesSearchProvider));
registry.registerLazy('clock', () => import('./clock').then((m) => m.clockSearchProvider));
}

View file

@ -1,109 +0,0 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
const app = getManaApp('music');
export const musicSearchProvider: SearchProvider = {
appId: 'music',
appName: 'Music',
appIcon: app?.icon,
appColor: app?.color,
searchableTypes: ['song', 'playlist', 'project'],
async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search songs. title is encrypted at rest; the scorer needs
// plaintext to do substring matching against the user query.
const rawSongs = await db.table('songs').toArray();
const visibleSongs = rawSongs.filter((s) => !s.deletedAt);
const songs = await decryptRecords('songs', visibleSongs);
for (const song of songs) {
const { score, matchedField } = scoreRecord(
[
{ name: 'title', value: song.title, weight: 1.0 },
{ name: 'artist', value: song.artist, weight: 0.8 },
{ name: 'album', value: song.album, weight: 0.6 },
{ name: 'genre', value: song.genre, weight: 0.4 },
],
query
);
if (score > 0) {
results.push({
id: song.id,
type: 'song',
appId: 'music',
title: song.title,
subtitle: [song.artist, song.album].filter(Boolean).join(' · ') || undefined,
appIcon: app?.icon,
appColor: app?.color,
href: '/music/library',
score,
matchedField,
});
}
}
// Search playlists (Dexie table name kept for backward compat).
// name + description are encrypted at rest.
const rawPlaylists = await db.table('mukkePlaylists').toArray();
const visiblePlaylists = rawPlaylists.filter((p) => !p.deletedAt);
const playlists = await decryptRecords('mukkePlaylists', visiblePlaylists);
for (const pl of playlists) {
const { score, matchedField } = scoreRecord(
[
{ name: 'name', value: pl.name, weight: 1.0 },
{ name: 'description', value: pl.description, weight: 0.7 },
],
query
);
if (score > 0) {
results.push({
id: pl.id,
type: 'playlist',
appId: 'music',
title: pl.name,
subtitle: truncateSubtitle(pl.description) || 'Playlist',
appIcon: app?.icon,
appColor: app?.color,
href: `/music/playlists/${pl.id}`,
score,
matchedField,
});
}
}
// Search projects (Dexie table name kept for backward compat)
const projects = await db.table('mukkeProjects').toArray();
for (const proj of projects) {
if (proj.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'title', value: proj.title, weight: 1.0 },
{ name: 'description', value: proj.description, weight: 0.7 },
],
query
);
if (score > 0) {
results.push({
id: proj.id,
type: 'project',
appId: 'music',
title: proj.title,
subtitle: truncateSubtitle(proj.description) || 'Projekt',
appIcon: app?.icon,
appColor: app?.color,
href: `/music/projects`,
score,
matchedField,
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, limit);
},
};

View file

@ -14,7 +14,6 @@ const SPLIT_APP_ID_LIST = [
'picture',
'cards',
'quotes',
'music',
'storage',
'presi',
'inventory',

View file

@ -233,7 +233,6 @@ export const dashboardStore = {
'chat-recent',
'contacts-favorites',
'quotes-quote',
'music-library',
'presi-decks',
] as WidgetType[]
).filter((type) => {

View file

@ -49,7 +49,6 @@ describe('WIDGET_REGISTRY', () => {
'cards',
'times',
'storage',
'music',
'presi',
'mana-auth',
'period',
@ -75,7 +74,6 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('picture-recent');
expect(types).toContain('clock-timers');
expect(types).toContain('storage-usage');
expect(types).toContain('music-library');
expect(types).toContain('presi-decks');
});

View file

@ -21,7 +21,6 @@ export type WidgetType =
| 'picture-recent' // Picture API: recent generations
| 'clock-timers' // Clock: active timers and alarms
| 'storage-usage' // Storage: file storage stats
| 'music-library' // Music: music library stats
| 'presi-decks' // Presi: recent presentations
| 'active-timer' // Times: running timer
| 'day-timeline' // TimeBlocks: chronological day timeline
@ -125,7 +124,6 @@ export interface WidgetMeta {
| 'picture'
| 'cards'
| 'storage'
| 'music'
| 'presi'
| 'times'
| 'period'
@ -244,15 +242,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'storage',
},
{
type: 'music-library',
nameKey: 'dashboard.widgets.music.title',
descriptionKey: 'dashboard.widgets.music.description',
icon: '🎵',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'music',
},
{
type: 'presi-decks',
nameKey: 'dashboard.widgets.presi.title',

View file

@ -1,26 +0,0 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllSongs,
useAllPlaylists,
useAllPlaylistSongs,
useAllProjects,
} from '$lib/modules/music/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes
const allSongs = useAllSongs();
const allPlaylists = useAllPlaylists();
const allPlaylistSongs = useAllPlaylistSongs();
const allProjects = useAllProjects();
// Provide data to child components via Svelte context
setContext('songs', allSongs);
setContext('playlists', allPlaylists);
setContext('playlistSongs', allPlaylistSongs);
setContext('projects', allProjects);
</script>
{@render children()}

View file

@ -1,138 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import { computeStats } from '$lib/modules/music/queries';
import type { Song, Playlist, Project } from '$lib/modules/music/types';
import { MusicNote, Plus, Playlist as PlaylistIcon, Note } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
const projectsCtx: { readonly value: Project[] } = getContext('projects');
let stats = $derived(computeStats(songsCtx.value));
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Music - Mana</title>
</svelte:head>
<RoutePage appId="music">
<div class="space-y-8">
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">Music</h1>
<!-- Quick Stats -->
<section>
<h2
class="mb-4 text-sm font-medium uppercase tracking-wide text-[hsl(var(--color-muted-foreground))]"
>
Bibliothek
</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Songs</p>
<p class="text-2xl font-bold text-[hsl(var(--color-foreground))]">{stats.totalSongs}</p>
</div>
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Alben</p>
<p class="text-2xl font-bold text-[hsl(var(--color-foreground))]">{stats.totalAlbums}</p>
</div>
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Kunstler</p>
<p class="text-2xl font-bold text-[hsl(var(--color-foreground))]">{stats.totalArtists}</p>
</div>
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Genres</p>
<p class="text-2xl font-bold text-[hsl(var(--color-foreground))]">{stats.totalGenres}</p>
</div>
</div>
</section>
<!-- Quick Actions -->
<section>
<h2
class="mb-4 text-sm font-medium uppercase tracking-wide text-[hsl(var(--color-muted-foreground))]"
>
Schnellzugriff
</h2>
<div class="flex flex-wrap gap-3">
<a
href="/music/library"
class="inline-flex items-center gap-2 rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
>
<MusicNote size={20} />
Bibliothek
</a>
<a
href="/music/playlists"
class="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--color-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<PlaylistIcon size={20} />
Playlists
</a>
<a
href="/music/projects"
class="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--color-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<Plus size={20} />
Projekte
</a>
</div>
</section>
<!-- Recent Projects -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2
class="text-sm font-medium uppercase tracking-wide text-[hsl(var(--color-muted-foreground))]"
>
Letzte Projekte
</h2>
<a href="/music/projects" class="text-sm text-[hsl(var(--color-primary))] hover:underline">
Alle anzeigen
</a>
</div>
{#if projectsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--color-border))] py-12"
>
<Note size={40} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Noch keine Projekte</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each projectsCtx.value.slice(0, 6) as project (project.id)}
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary)/0.3)]"
>
<h3 class="font-medium text-[hsl(var(--color-foreground))]">{project.title}</h3>
{#if project.description}
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))] line-clamp-2">
{project.description}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
Aktualisiert {formatDate(project.updatedAt)}
</p>
</div>
{/each}
</div>
{/if}
</section>
</div>
</RoutePage>

View file

@ -1,251 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import { libraryStore } from '$lib/modules/music/stores/library.svelte';
import { playerStore } from '$lib/modules/music/stores/player.svelte';
import {
searchSongs,
filterFavorites,
groupByAlbum,
groupByGenre,
formatDuration,
} from '$lib/modules/music/queries';
import type { Song } from '$lib/modules/music/types';
import { RoutePage } from '$lib/components/shell';
import {
MusicNote,
Heart,
Play,
Pause,
Trash,
MagnifyingGlass,
ArrowLeft,
} from '@mana/shared-icons';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const tabs = ['songs', 'albums', 'genres'] as const;
type Tab = (typeof tabs)[number];
let activeTab = $state<Tab>('songs');
let searchQuery = $state('');
let filteredSongs = $derived(searchSongs(songsCtx.value, searchQuery));
let albums = $derived(groupByAlbum(songsCtx.value));
let genres = $derived(groupByGenre(songsCtx.value));
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, filteredSongs, index);
}
async function handleToggleFavorite(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
await libraryStore.toggleFavorite(id);
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
const song = songsCtx.value.find((s) => s.id === id);
if (confirm(`"${song?.title}" wirklich loschen?`)) {
await libraryStore.delete(id);
}
}
</script>
<svelte:head>
<title>Bibliothek - Music - Mana</title>
</svelte:head>
<RoutePage appId="music" backHref="/music">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a
href="/music"
class="rounded-lg p-1.5 text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">Bibliothek</h1>
</div>
<!-- Tab Bar -->
<div class="flex max-w-md rounded-lg bg-[hsl(var(--color-muted))] p-1">
{#each tabs as tab}
<button
onclick={() => (activeTab = tab)}
class="flex-1 rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors {activeTab ===
tab
? 'bg-[hsl(var(--color-primary))] text-[hsl(var(--color-primary-foreground))]'
: 'text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]'}"
>
{tab === 'songs' ? 'Songs' : tab === 'albums' ? 'Alben' : 'Genres'}
</button>
{/each}
</div>
<!-- Search (songs tab only) -->
{#if activeTab === 'songs'}
<div class="relative">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--color-muted-foreground))]"
/>
<input
type="text"
placeholder="Songs durchsuchen..."
bind:value={searchQuery}
class="w-full rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] py-2.5 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
/>
</div>
{/if}
<!-- Songs Tab -->
{#if activeTab === 'songs'}
{#if filteredSongs.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<MusicNote size={48} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="text-[hsl(var(--color-muted-foreground))]">
{searchQuery ? 'Keine Songs gefunden' : 'Noch keine Songs in deiner Bibliothek'}
</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]"
>
<!-- Header -->
<div
class="grid grid-cols-[40px_1fr_1fr_80px_40px_40px] gap-4 border-b border-[hsl(var(--color-border))] px-4 py-3 text-xs font-medium uppercase tracking-wide text-[hsl(var(--color-muted-foreground))]"
>
<span></span>
<span>Titel</span>
<span>Kunstler</span>
<span class="text-right">Dauer</span>
<span></span>
<span></span>
</div>
<!-- Song rows -->
{#each filteredSongs as song, index (song.id)}
<div
role="button"
tabindex="0"
onclick={() => handlePlaySong(song, index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlaySong(song, index);
}
}}
class="group grid grid-cols-[40px_1fr_1fr_80px_40px_40px] items-center gap-4 px-4 py-3 transition-colors hover:bg-[hsl(var(--color-muted))] {playerStore
.currentSong?.id === song.id
? 'bg-[hsl(var(--color-primary)/0.05)]'
: ''}"
>
<div
class="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded bg-[hsl(var(--color-muted))]"
>
<MusicNote size={20} class="text-[hsl(var(--color-muted-foreground))]" />
{#if playerStore.currentSong?.id === song.id && playerStore.isPlaying}
<div
class="absolute inset-0 flex items-center justify-center rounded bg-black/40"
>
<Pause size={20} weight="fill" class="text-white" />
</div>
{:else}
<div
class="absolute inset-0 hidden items-center justify-center rounded bg-black/40 group-hover:flex"
>
<Play size={20} weight="fill" class="text-white" />
</div>
{/if}
</div>
<span
class="truncate font-medium {playerStore.currentSong?.id === song.id
? 'text-[hsl(var(--color-primary))]'
: 'text-[hsl(var(--color-foreground))]'}"
>
{song.title}
</span>
<span class="truncate text-[hsl(var(--color-muted-foreground))]">
{song.artist ?? 'Unbekannt'}
</span>
<span class="text-right text-sm text-[hsl(var(--color-muted-foreground))]">
{formatDuration(song.duration)}
</span>
<button
onclick={(e) => handleToggleFavorite(song.id, e)}
class="transition-colors {song.favorite
? 'text-red-500'
: 'text-[hsl(var(--color-muted-foreground))] hover:text-red-500'}"
>
<Heart size={16} weight={song.favorite ? 'fill' : 'regular'} />
</button>
<button
onclick={(e) => handleDelete(song.id, e)}
class="text-[hsl(var(--color-muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Albums Tab -->
{#if activeTab === 'albums'}
{#if albums.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<p class="text-[hsl(var(--color-muted-foreground))]">Keine Alben gefunden</p>
</div>
{:else}
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{#each albums as album}
<div
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<div
class="mb-3 flex aspect-square items-center justify-center rounded-lg bg-[hsl(var(--color-muted))]"
>
<MusicNote size={48} class="text-[hsl(var(--color-muted-foreground))]" />
</div>
<h3 class="truncate font-medium text-[hsl(var(--color-foreground))]">
{album.album}
</h3>
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
{album.songCount}
{album.songCount === 1 ? 'Song' : 'Songs'}
</p>
</div>
{/each}
</div>
{/if}
{/if}
<!-- Genres Tab -->
{#if activeTab === 'genres'}
{#if genres.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<p class="text-[hsl(var(--color-muted-foreground))]">Keine Genres gefunden</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]"
>
{#each genres as genre}
<div
class="flex items-center justify-between border-b border-[hsl(var(--color-border))] px-4 py-3 last:border-b-0"
>
<span class="font-medium text-[hsl(var(--color-foreground))]">{genre.genre}</span>
<span class="text-sm text-[hsl(var(--color-muted-foreground))]">
{genre.songCount}
{genre.songCount === 1 ? 'Song' : 'Songs'}
</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</RoutePage>

View file

@ -1,182 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { playlistsStore } from '$lib/modules/music/stores/playlists.svelte';
import type { Playlist } from '$lib/modules/music/types';
import { RoutePage } from '$lib/components/shell';
import {
ArrowLeft,
Plus,
Trash,
MusicNote,
Playlist as PlaylistIcon,
X,
} from '@mana/shared-icons';
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
let showCreateModal = $state(false);
let newName = $state('');
let newDescription = $state('');
let isCreating = $state(false);
async function handleCreate() {
if (!newName.trim()) return;
isCreating = true;
try {
await playlistsStore.create(newName.trim(), newDescription.trim() || undefined);
newName = '';
newDescription = '';
showCreateModal = false;
} catch (e) {
console.error('Failed to create playlist:', e);
}
isCreating = false;
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Playlist wirklich loschen?')) return;
await playlistsStore.delete(id);
}
</script>
<svelte:head>
<title>Playlists - Music - Mana</title>
</svelte:head>
<RoutePage appId="music" backHref="/music">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/music"
class="rounded-lg p-1.5 text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">Playlists</h1>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
>
<Plus size={16} />
Neue Playlist
</button>
</div>
{#if playlistsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--color-border))] py-16"
>
<PlaylistIcon size={48} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="mb-3 text-[hsl(var(--color-muted-foreground))]">Noch keine Playlists</p>
<button
onclick={() => (showCreateModal = true)}
class="text-sm text-[hsl(var(--color-primary))] hover:underline"
>
Erste Playlist erstellen
</button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each playlistsCtx.value as playlist (playlist.id)}
<a
href="/music/playlists/{playlist.id}"
class="group relative rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary)/0.3)]"
>
<div
class="mb-3 flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-[hsl(var(--color-muted))]"
>
<MusicNote size={48} class="text-[hsl(var(--color-muted-foreground))]" />
</div>
<h3
class="truncate font-medium text-[hsl(var(--color-foreground))] group-hover:text-[hsl(var(--color-primary))]"
>
{playlist.name}
</h3>
{#if playlist.description}
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))] line-clamp-1">
{playlist.description}
</p>
{/if}
<button
onclick={(e) => handleDelete(playlist.id, e)}
class="absolute right-3 top-3 rounded-lg bg-[hsl(var(--color-card)/0.8)] p-1.5 text-[hsl(var(--color-muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create Playlist Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-md rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-[hsl(var(--color-foreground))]">Neue Playlist</h2>
<button
onclick={() => (showCreateModal = false)}
class="rounded p-1 text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="mb-4">
<label for="pl-name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="pl-name"
type="text"
bind:value={newName}
placeholder="Meine Playlist"
required
class="w-full rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
/>
</div>
<div class="mb-6">
<label for="pl-desc" class="mb-1 block text-sm font-medium"
>Beschreibung (optional)</label
>
<textarea
id="pl-desc"
bind:value={newDescription}
placeholder="Beschreibe deine Playlist..."
rows="3"
class="w-full resize-none rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newName.trim() || isCreating}
class="rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{isCreating ? $_('common.creating') : $_('common.create')}
</button>
</div>
</form>
</div>
</div>
{/if}
</RoutePage>

View file

@ -1,230 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { playlistsStore } from '$lib/modules/music/stores/playlists.svelte';
import { playerStore } from '$lib/modules/music/stores/player.svelte';
import { getPlaylistSongs, formatDuration } from '$lib/modules/music/queries';
import type { Song, Playlist, LocalPlaylistSong } from '$lib/modules/music/types';
import {
ArrowLeft,
Play,
Pause,
Trash,
MusicNote,
PencilSimple,
Check,
X,
ShareNetwork,
} from '@mana/shared-icons';
import { ShareModal } from '@mana/shared-uload';
import { RoutePage } from '$lib/components/shell';
const songsCtx: { readonly value: Song[] } = getContext('songs');
const playlistsCtx: { readonly value: Playlist[] } = getContext('playlists');
const playlistSongsCtx: { readonly value: LocalPlaylistSong[] } = getContext('playlistSongs');
const playlistId = $derived($page.params.id ?? '');
const playlist = $derived(playlistsCtx.value.find((p) => p.id === playlistId));
const songs = $derived(getPlaylistSongs(songsCtx.value, playlistSongsCtx.value, playlistId));
let isEditingName = $state(false);
let editName = $state('');
let showShare = $state(false);
let shareUrl = $derived(
`${typeof window !== 'undefined' ? window.location.origin : ''}/music/playlists/${playlistId}`
);
function startEdit() {
editName = playlist?.name ?? '';
isEditingName = true;
}
async function saveName() {
if (editName.trim()) {
await playlistsStore.update(playlistId, { name: editName.trim() });
}
isEditingName = false;
}
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, songs, index);
}
function handlePlayAll() {
if (songs.length > 0) {
playerStore.playSong(songs[0], songs, 0);
}
}
async function handleRemoveSong(songId: string, e: Event) {
e.stopPropagation();
await playlistsStore.removeSong(playlistId, songId);
}
async function handleDeletePlaylist() {
if (confirm('Playlist wirklich loschen?')) {
await playlistsStore.delete(playlistId);
goto('/music/playlists');
}
}
</script>
<svelte:head>
<title>{playlist?.name || 'Playlist'} - Music - Mana</title>
</svelte:head>
<RoutePage appId="music" backHref="/music/playlists" title="Playlist">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/music/playlists"
class="rounded-lg p-1.5 text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<ArrowLeft size={20} />
</a>
<div>
{#if isEditingName}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editName}
onkeydown={(e) => e.key === 'Enter' && saveName()}
class="rounded border border-[hsl(var(--color-border))] bg-transparent px-2 py-1 text-xl font-bold focus:outline-none focus:ring-1 focus:ring-[hsl(var(--color-primary))]"
/>
<button onclick={saveName} class="text-[hsl(var(--color-primary))]">
<Check size={18} />
</button>
<button
onclick={() => (isEditingName = false)}
class="text-[hsl(var(--color-muted-foreground))]"
>
<X size={18} />
</button>
</div>
{:else}
<button onclick={startEdit} class="group flex items-center gap-2">
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">
{playlist?.name || 'Playlist'}
</h1>
<PencilSimple
size={16}
class="text-[hsl(var(--color-muted-foreground))] opacity-0 group-hover:opacity-100"
/>
</button>
{/if}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
{songs.length}
{songs.length === 1 ? 'Song' : 'Songs'}
</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if songs.length > 0}
<button
onclick={handlePlayAll}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
>
<Play size={16} weight="fill" />
Alle abspielen
</button>
{/if}
<button
onclick={() => (showShare = true)}
class="rounded-lg p-2 text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
title="Kurzlink teilen"
>
<ShareNetwork size={20} />
</button>
<button
onclick={handleDeletePlaylist}
class="rounded-lg p-2 text-[hsl(var(--color-muted-foreground))] hover:text-red-500"
title="Playlist loschen"
>
<Trash size={20} />
</button>
</div>
</div>
<!-- Songs -->
{#if songs.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<MusicNote size={48} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="text-[hsl(var(--color-muted-foreground))]">Keine Songs in dieser Playlist</p>
</div>
{:else}
<div
class="overflow-hidden rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))]"
>
{#each songs as song, index (song.id)}
<div
role="button"
tabindex="0"
onclick={() => handlePlaySong(song, index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlaySong(song, index);
}
}}
class="group flex items-center gap-4 border-b border-[hsl(var(--color-border))] px-4 py-3 transition-colors last:border-b-0 hover:bg-[hsl(var(--color-muted))] {playerStore
.currentSong?.id === song.id
? 'bg-[hsl(var(--color-primary)/0.05)]'
: ''}"
>
<div
class="relative flex h-10 w-10 shrink-0 items-center justify-center rounded bg-[hsl(var(--color-muted))]"
>
<MusicNote size={20} class="text-[hsl(var(--color-muted-foreground))]" />
{#if playerStore.currentSong?.id === song.id && playerStore.isPlaying}
<div class="absolute inset-0 flex items-center justify-center rounded bg-black/40">
<Pause size={20} weight="fill" class="text-white" />
</div>
{:else}
<div
class="absolute inset-0 hidden items-center justify-center rounded bg-black/40 group-hover:flex"
>
<Play size={20} weight="fill" class="text-white" />
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p
class="truncate font-medium {playerStore.currentSong?.id === song.id
? 'text-[hsl(var(--color-primary))]'
: 'text-[hsl(var(--color-foreground))]'}"
>
{song.title}
</p>
<p class="truncate text-sm text-[hsl(var(--color-muted-foreground))]">
{song.artist ?? 'Unbekannt'}
</p>
</div>
<span class="text-sm text-[hsl(var(--color-muted-foreground))]">
{formatDuration(song.duration)}
</span>
<button
onclick={(e) => handleRemoveSong(song.id, e)}
class="rounded p-1 text-[hsl(var(--color-muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
title="Aus Playlist entfernen"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Share Modal (uLoad integration) -->
<ShareModal
visible={showShare}
onClose={() => (showShare = false)}
url={shareUrl}
title={playlist?.name ?? 'Playlist'}
source="music"
description="{songs.length} {songs.length === 1 ? 'Song' : 'Songs'}"
/>
</RoutePage>

View file

@ -1,181 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { projectsStore } from '$lib/modules/music/stores/projects.svelte';
import type { Project } from '$lib/modules/music/types';
import { ArrowLeft, Plus, Trash, Note, X } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const projectsCtx: { readonly value: Project[] } = getContext('projects');
let showCreateModal = $state(false);
let newTitle = $state('');
let newDescription = $state('');
let isCreating = $state(false);
async function handleCreate() {
if (!newTitle.trim()) return;
isCreating = true;
try {
await projectsStore.create({
title: newTitle.trim(),
description: newDescription.trim() || undefined,
});
newTitle = '';
newDescription = '';
showCreateModal = false;
} catch (e) {
console.error('Failed to create project:', e);
}
isCreating = false;
}
async function handleDelete(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Projekt wirklich loschen?')) return;
await projectsStore.delete(id);
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Projekte - Music - Mana</title>
</svelte:head>
<RoutePage appId="music" backHref="/music">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/music"
class="rounded-lg p-1.5 text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
<ArrowLeft size={20} />
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">Projekte</h1>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
>
<Plus size={16} />
Neues Projekt
</button>
</div>
{#if projectsCtx.value.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--color-border))] py-16"
>
<Note size={48} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="mb-3 text-[hsl(var(--color-muted-foreground))]">Noch keine Projekte</p>
<button
onclick={() => (showCreateModal = true)}
class="text-sm text-[hsl(var(--color-primary))] hover:underline"
>
Erstes Projekt erstellen
</button>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each projectsCtx.value as project (project.id)}
<div
class="group rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 transition-colors hover:border-[hsl(var(--color-primary)/0.3)]"
>
<div class="flex items-start justify-between">
<h3 class="font-medium text-[hsl(var(--color-foreground))]">{project.title}</h3>
<button
onclick={(e) => handleDelete(project.id, e)}
class="rounded p-1 text-[hsl(var(--color-muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{#if project.description}
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))] line-clamp-2">
{project.description}
</p>
{/if}
<p class="mt-2 text-xs text-[hsl(var(--color-muted-foreground))]">
Aktualisiert {formatDate(project.updatedAt)}
</p>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Project Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
class="w-full max-w-md rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-6"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-[hsl(var(--color-foreground))]">Neues Projekt</h2>
<button
onclick={() => (showCreateModal = false)}
class="rounded p-1 text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="mb-4">
<label for="proj-title" class="mb-1 block text-sm font-medium">Titel</label>
<input
id="proj-title"
type="text"
bind:value={newTitle}
placeholder="Mein Projekt"
required
class="w-full rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
/>
</div>
<div class="mb-6">
<label for="proj-desc" class="mb-1 block text-sm font-medium"
>Beschreibung (optional)</label
>
<textarea
id="proj-desc"
bind:value={newDescription}
placeholder="Beschreibe dein Projekt..."
rows="3"
class="w-full resize-none rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-background))] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
></textarea>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newTitle.trim() || isCreating}
class="rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{isCreating ? $_('common.creating') : $_('common.create')}
</button>
</div>
</form>
</div>
</div>
{/if}
</RoutePage>

View file

@ -1,191 +0,0 @@
# Mukke - Music Workspace
Mukke is a web application for managing your music library, playing tracks, and creating synchronized lyrics. It combines a music player with a beat/lyrics editor featuring waveform visualization, BPM detection, timestamp markers, and exports to multiple formats.
## Architecture
```
apps/mukke/
├── apps/
│ ├── backend/ # Hono/Bun server (port 3010)
│ ├── web/ # SvelteKit app (port 5180)
│ └── landing/ # Astro marketing page
├── packages/
│ └── shared/ # Shared types (@mukke/shared)
└── package.json
```
## Quick Start
```bash
# Start with full database setup
pnpm dev:mukke:full
# Or start components individually
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm --filter @mukke/server dev # Server on port 3010
pnpm --filter @mukke/web dev # Web on port 5180
pnpm --filter @mukke/landing dev # Landing page
```
## Backend API Endpoints
### Songs (Library)
- `POST /songs/upload` - Upload song and get presigned URL
- `GET /songs` - List user's songs (with sort/filter)
- `GET /songs/:id` - Get song details
- `PUT /songs/:id` - Update song metadata
- `PUT /songs/:id/favorite` - Toggle favorite
- `PUT /songs/:id/play` - Increment play count
- `DELETE /songs/:id` - Delete song
- `GET /songs/search?q=` - Search songs
- `POST /songs/:id/extract-metadata` - Extract ID3 tags from file into DB (+ cover art to S3)
- `POST /songs/:id/write-tags` - Write DB metadata as ID3 tags back into MP3 file
- `GET /songs/:id/cover-url` - Get presigned URL for cover art
### Playlists
- `GET /playlists` - List user's playlists
- `POST /playlists` - Create playlist
- `GET /playlists/:id` - Get playlist with songs
- `PUT /playlists/:id` - Update playlist
- `DELETE /playlists/:id` - Delete playlist
- `POST /playlists/:id/songs` - Add song to playlist
- `DELETE /playlists/:id/songs/:songId` - Remove song
- `PUT /playlists/:id/songs/reorder` - Reorder songs
### Library (Aggregates)
- `GET /library/albums` - Get albums (grouped)
- `GET /library/artists` - Get artists (grouped)
- `GET /library/genres` - Get genres (grouped)
- `GET /library/stats` - Library statistics
### Projects (Editor)
- `GET /projects` - List user's projects
- `GET /projects/:id` - Get project with beat and lyrics
- `POST /projects` - Create project
- `POST /projects/from-song/:songId` - Create project from library song
- `PUT /projects/:id` - Update project
- `DELETE /projects/:id` - Delete project
### Beats
- `GET /beats/project/:projectId` - Get beat for project
- `GET /beats/:id` - Get beat with markers
- `GET /beats/:id/download-url` - Get presigned download URL
- `POST /beats/upload` - Create beat and get upload URL
- `PUT /beats/:id/metadata` - Update BPM, duration, waveform data
- `DELETE /beats/:id` - Delete beat
### Markers
- `GET /markers/beat/:beatId` - Get markers for beat
- `POST /markers` - Create marker
- `POST /markers/bulk` - Bulk create markers
- `PUT /markers/:id` - Update marker
- `PUT /markers/bulk` - Bulk update markers
- `DELETE /markers/:id` - Delete marker
### Lyrics
- `GET /lyrics/project/:projectId` - Get lyrics with synced lines
- `POST /lyrics/project/:projectId` - Create/update lyrics content
- `POST /lyrics/:id/sync` - Sync line timestamps
### Export
- `GET /export/:projectId?format=lrc|srt|json` - Export project
## Database Schema
```typescript
// songs - Music library
{ id, userId, title, artist, album, albumArtist, genre, trackNumber, year, duration,
storagePath, coverArtPath, fileSize, bpm, favorite, playCount, lastPlayedAt, addedAt, updatedAt }
// playlists - User playlists
{ id, userId, name, description, coverArtPath, createdAt, updatedAt }
// playlist_songs - Playlist-Song join table
{ id, playlistId, songId, sortOrder, addedAt }
// projects - Editor projects
{ id, userId, title, description, songId, createdAt, updatedAt }
// beats - Audio files for editor
{ id, projectId, storagePath, filename, duration, bpm, bpmConfidence, waveformData }
// markers - Section markers
{ id, beatId, type, label, startTime, endTime, color, sortOrder }
// lyrics - Full lyrics text
{ id, projectId, content }
// lyric_lines - Synced lines
{ id, lyricsId, lineNumber, text, startTime, endTime }
```
## Supported Audio Formats
Playback uses HTML5 Audio (browser-native codec support). Upload accepts any `audio/*` MIME type.
| Format | Extensions | Browser Playback | Notes |
|--------|-----------|-----------------|-------|
| MP3 | `.mp3` | All browsers | ID3 tag read/write supported |
| WAV | `.wav` | All browsers | Uncompressed PCM |
| OGG Vorbis | `.ogg` | Chrome, Firefox, Edge | No Safari support |
| FLAC | `.flac` | All modern browsers | Lossless |
| AAC/M4A | `.aac`, `.m4a` | All browsers | Common iOS format |
| OPUS | `.opus` | Chrome, Firefox, Edge | Best quality/size ratio |
| WebM | `.webm` | Chrome, Firefox, Edge | Container format |
| AIFF | `.aiff`, `.aif` | Safari, Chrome | Common macOS format |
| WMA | `.wma` | Edge only | Legacy Windows format |
| ALAC | `.alac` | Safari | Apple Lossless |
| APE | `.ape` | None natively | Monkey's Audio (upload/metadata only) |
| WavPack | `.wv` | None natively | Hybrid lossless (upload/metadata only) |
| DSF/DFF | `.dsf`, `.dff` | None natively | DSD audio (upload/metadata only) |
**Note:** Formats without native browser playback can be uploaded and have metadata extracted (via `music-metadata`), but require server-side transcoding for playback (not yet implemented).
## Key Technologies
| Component | Technology |
|-----------|------------|
| Frontend | SvelteKit 2, Svelte 5, Tailwind CSS 4 |
| Waveform | wavesurfer.js 7.x |
| BPM Detection | Web Audio API (peak detection) |
| Metadata | music-metadata (server-side) |
| Backend | Hono + Bun, Drizzle ORM |
| Database | PostgreSQL |
| Storage | MinIO (S3-compatible) |
| Auth | mana-core-auth |
## Environment Variables
### Backend (.env)
```
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mukke
MANA_AUTH_URL=http://localhost:3001
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=mukke-storage
```
### Web (.env)
```
PUBLIC_MANA_AUTH_URL=http://localhost:3001
PUBLIC_BACKEND_URL=http://localhost:3010
```
## Development Commands
```bash
# Database
pnpm --filter @mukke/server db:push # Push schema
pnpm --filter @mukke/server db:studio # Open Drizzle Studio
# Type checking
pnpm --filter @mukke/server type-check
pnpm --filter @mukke/web type-check
# Build
pnpm --filter @mukke/server build
pnpm --filter @mukke/web build
```

View file

@ -1,7 +0,0 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://mukke.app',
integrations: [sitemap()],
});

View file

@ -1,20 +0,0 @@
{
"name": "@mukke/landing",
"type": "module",
"version": "0.2.0",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/sitemap": "^3.3.0",
"@mana/shared-landing-ui": "workspace:*",
"astro": "^5.1.1",
"typescript": "^5.7.2"
}
}

View file

@ -1,61 +0,0 @@
---
import Analytics from '@mana/shared-landing-ui/atoms/Analytics.astro';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="description"
content="Mukke - Your music workspace. Upload tracks, manage your library, write lyrics, and play your music."
/>
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- Umami Analytics -->
{
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
<script
defer
src="https://stats.mana.how/script.js"
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
/>
)
}
<title>{title}</title>
<style is:global>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
scroll-behavior: smooth;
}
</style>
</head>
<body>
<slot />
<Analytics />
</body>
</html>

View file

@ -1,212 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Mukke - Music Workspace">
<main class="min-h-screen bg-gradient-to-b from-gray-900 to-black text-white">
<!-- Hero Section -->
<section class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-purple-600/20"></div>
<div class="max-w-6xl mx-auto px-4 py-24 relative">
<div class="text-center">
<h1 class="text-5xl md:text-7xl font-bold mb-6">
<span class="text-blue-400">Mukke</span>
</h1>
<p class="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto">
Your music workspace. Upload tracks, manage your library, write and sync lyrics, and
play your music.
</p>
<div class="flex items-center justify-center gap-4">
<a
href="https://app.mukke.app"
class="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg"
>
Get Started Free
</a>
<a
href="#features"
class="px-8 py-4 border border-gray-600 text-white rounded-lg hover:bg-white/10 transition-colors font-semibold text-lg"
>
Learn More
</a>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-24 bg-gray-900/50">
<div class="max-w-6xl mx-auto px-4">
<h2 class="text-3xl md:text-4xl font-bold text-center mb-16">
Everything You Need for Lyric Sync
</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="w-14 h-14 bg-blue-600/20 rounded-lg flex items-center justify-center mb-6">
<svg
class="w-7 h-7 text-blue-400"
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"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">Waveform Editor</h3>
<p class="text-gray-400">
Visualize your audio with an interactive waveform. Zoom, scroll, and navigate with
precision.
</p>
</div>
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div
class="w-14 h-14 bg-purple-600/20 rounded-lg flex items-center justify-center mb-6"
>
<svg
class="w-7 h-7 text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">BPM Detection</h3>
<p class="text-gray-400">
Automatic tempo detection helps you sync lyrics to the beat with snap-to-beat
functionality.
</p>
</div>
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="w-14 h-14 bg-green-600/20 rounded-lg flex items-center justify-center mb-6">
<svg
class="w-7 h-7 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">Part Markers</h3>
<p class="text-gray-400">
Mark verses, hooks, bridges, and more. Organize your song structure visually.
</p>
</div>
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="w-14 h-14 bg-red-600/20 rounded-lg flex items-center justify-center mb-6">
<svg
class="w-7 h-7 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">Live Sync Recording</h3>
<p class="text-gray-400">
Record timestamps in real-time as the song plays. Just tap to sync each line.
</p>
</div>
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div
class="w-14 h-14 bg-yellow-600/20 rounded-lg flex items-center justify-center mb-6"
>
<svg
class="w-7 h-7 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">Karaoke Preview</h3>
<p class="text-gray-400">
Preview your synced lyrics in real-time with smooth karaoke-style highlighting.
</p>
</div>
<div class="p-8 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="w-14 h-14 bg-cyan-600/20 rounded-lg flex items-center justify-center mb-6">
<svg
class="w-7 h-7 text-cyan-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</div>
<h3 class="text-xl font-semibold mb-3">Multiple Exports</h3>
<p class="text-gray-400">
Export to LRC, SRT, JSON, or generate karaoke videos for social media.
</p>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-24">
<div class="max-w-4xl mx-auto px-4 text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6">Ready to Create?</h2>
<p class="text-xl text-gray-400 mb-8">
Start syncing your lyrics today. Free to use, no credit card required.
</p>
<a
href="https://app.mukke.app"
class="inline-block px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg"
>
Start Creating
</a>
</div>
</section>
<!-- Footer -->
<footer class="py-8 border-t border-gray-800">
<div class="max-w-6xl mx-auto px-4 text-center text-gray-500">
<p>&copy; {new Date().getFullYear()} Mukke. Part of the Mana ecosystem.</p>
</div>
</footer>
</main>
</Layout>

View file

@ -1,6 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true
}
}

View file

@ -1,417 +0,0 @@
# Mukke Visualizer Alternativen & Umsetzungswege
Unabhängige Übersicht aller Optionen für ein Musik-Visualisierungs-System im Browser.
---
## Rendering-Technologien im Vergleich
### A) Canvas 2D (Vanilla)
```
Audio → AnalyserNode → getByteFrequencyData() → Canvas 2D → requestAnimationFrame
```
| | |
|---|---|
| **Bundle** | 0 KB (Browser-nativ) |
| **Performance** | Gut für <500 Elemente, CPU-gebunden |
| **Lernkurve** | Niedrig |
| **Best für** | Bars, Waveforms, einfache Geometrien |
| **Limitierung** | Kein GPU, kein 3D, kein Blur/Glow nativ |
### B) PixiJS v8 (GPU-beschleunigtes 2D)
```
Audio → AnalyserNode → PixiJS Sprites/Particles → WebGL2/WebGPU
```
| | |
|---|---|
| **Bundle** | ~100 KB (modular) |
| **Performance** | Exzellent, GPU-beschleunigt, 100k+ Partikel möglich |
| **Lernkurve** | Mittel |
| **Best für** | Partikel-Systeme, Sprite-basierte Animationen, performante 2D-Effekte |
| **Limitierung** | Kein 3D |
| **Besonderheit** | v8 hat reaktives Rendering (nur geänderte Elemente werden neu gezeichnet) |
### C) Three.js (3D WebGL)
```
Audio → AnalyserNode → FFT als Uniform/Texture → Three.js Scene → Custom Shaders
```
| | |
|---|---|
| **Bundle** | ~150 KB (tree-shakeable) |
| **Performance** | Exzellent (GPU) |
| **Lernkurve** | Hoch |
| **Best für** | 3D-Wellenformen, Mesh-Displacement, Partikel, Postprocessing |
| **Limitierung** | Overkill für einfache 2D-Visualisierungen |
| **Ökosystem** | Riesig GSAP-Integration, Shader Park Plugin, tausende Beispiele |
### D) Raw WebGL/WebGPU + GLSL Shaders
```
Audio → AnalyserNode → FFT als Textur (512x2) → Fragment Shader
```
| | |
|---|---|
| **Bundle** | ~5 KB (glslCanvas) oder 0 KB (eigener Loader) |
| **Performance** | Maximal (rein GPU) |
| **Lernkurve** | Sehr hoch (GLSL) |
| **Best für** | Generative Kunst, Shadertoy-artige Effekte, maximale visuelle Qualität |
| **Limitierung** | GLSL-Kenntnisse nötig, schwer zu debuggen |
| **Vorteil** | Tausende Shadertoy-Presets sind direkt portierbar |
### E) Babylon.js 8.0
```
Audio → Babylon Audio Engine (built-in) → 3D Scene → GLSL/WGSL Shaders
```
| | |
|---|---|
| **Bundle** | ~300 KB+ (tree-shakeable) |
| **Performance** | Exzellent (WebGPU-Unterstützung) |
| **Lernkurve** | Hoch |
| **Best für** | Wenn Audio-Engine und 3D-Rendering aus einer Hand kommen sollen |
| **Besonderheit** | Einzige große 3D-Engine mit eingebauter Audio-Engine und Visualizer-Integration |
| **Limitierung** | Sehr groß, kleinere Community als Three.js |
### F) p5.js + p5.sound
```
Audio → p5.FFT / p5.Amplitude → p5 draw() Loop → Canvas
```
| | |
|---|---|
| **Bundle** | ~1 MB (mit p5.sound) |
| **Performance** | Mäßig (nicht für Production optimiert) |
| **Lernkurve** | Sehr niedrig (beginner-friendly) |
| **Best für** | Prototyping, User-generierte Visualisierungen, Lern-Kontext |
| **Limitierung** | Monolithisch, nicht tree-shakeable, Performance-Ceiling |
| **Vorteil** | Riesige Community, tausende Tutorials, ideal als "User-Coding-Sprache" |
---
## Fertige Visualizer-Lösungen
### audiomotion-analyzer
| | |
|---|---|
| **Was** | Plug-and-Play Spektrum-Analyzer |
| **Bundle** | Klein, 0 Dependencies |
| **Features** | Log/Linear/Bark/Mel-Skalen, LED-Bars, Radial, Mirror, A/B/C/D-Weighting |
| **Lizenz** | AGPL v3+ (Copyleft problematisch für SaaS!) |
| **Bewertung** | Bestes Aufwand/Ergebnis-Verhältnis für Spektrum-Analysen, aber Lizenz beachten |
### Butterchurn (Milkdrop WebGL Port)
| | |
|---|---|
| **Was** | Winamp/Milkdrop-Visualizer im Browser |
| **Bundle** | Mittel + Preset-Bibliothek |
| **Features** | Tausende Community-Presets, mathematische Preset-Sprache → GLSL-Compilation |
| **Lizenz** | MIT |
| **Bewertung** | Sofort beeindruckende Visuals, riesige Preset-Library, aber eigene DSL statt JS/GLSL |
### wavesurfer.js (bereits in Mukke)
| | |
|---|---|
| **Was** | Waveform-Player mit Plugins |
| **Relevante Plugins** | Spectrogram, Regions (schon genutzt) |
| **Bewertung** | Gut für Editor-Kontext, nicht für Fullscreen-Visualisierungen geeignet |
---
## User-Generated Visualizer: Plattform-Ansätze
### Weg 1: Code-Editor (JS/Canvas)
User schreibt eine `render(ctx, audioData)` Funktion.
```
┌──────────────────┐ ┌───────────────┐
│ Code Editor │ → │ Sandboxed │ → Canvas Output
│ (CodeMirror 6) │ │ Execution │
└──────────────────┘ └───────────────┘
```
| Pro | Contra |
|-----|--------|
| Volle Kontrolle | Programmierkenntnisse nötig |
| KI kann Code generieren | Sicherheits-Sandboxing nötig |
| Bekanntes Paradigma (Shadertoy, Processing) | |
**Sandboxing-Optionen:**
| Methode | Sicherheit | Performance | Empfehlung |
|---------|-----------|-------------|------------|
| `new Function()` + Proxy-Scope | Schwach | Beste | Nur für eigenen Code / vertrauenswürdige Nutzer |
| **Sandboxed iframe** (`allow-scripts`, kein `allow-same-origin`) | **Stark** | Gut | **Best Practice für User-Code** |
| Web Worker + OffscreenCanvas | Mittel | Gut | Gute Alternative wenn kein DOM nötig |
| iframe + Worker (doppelt) | Sehr stark | Gut | Maximum Security |
| SES/Compartments (TC39 Proposal) | Stark | Gut | Zukunftssicher, aber noch Stage 1 |
### Weg 2: Shader-Editor (GLSL)
User schreibt einen Fragment Shader. Audio-Daten kommen als Uniforms/Textur.
```glsl
uniform float u_bass, u_mid, u_high, u_volume;
uniform sampler2D u_fft; // 512x2 Textur (Row 0: FFT, Row 1: Waveform)
uniform float u_time;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float freq = texture2D(u_fft, vec2(uv.x, 0.0)).r;
// ... Shader-Logik
}
```
| Pro | Contra |
|-----|--------|
| GPU-nativ, maximale Performance | GLSL-Kenntnisse nötig |
| Shadertoy-Presets direkt portierbar | Schwerer zu debuggen |
| Shader-Code ist von Natur aus sandboxed (GPU) | Nur Pixel-Output, kein DOM |
| Tausende Beispiele online | |
### Weg 3: Node-basierter Visual Editor (wie cables.gl)
Visuelle Programmierung durch Verbinden von Nodes.
```
[FFT Input] → [Split Bands] → [Scale] → [Circle Generator] → [Color Map] → [Output]
[Beat Detector]
```
| Pro | Contra |
|-----|--------|
| Keine Programmierkenntnisse nötig | Hoher Implementierungsaufwand |
| Visuell verständlich | Performance-Overhead durch Graph-Traversal |
| Composable, wiederverwendbar | Komplexe UI zu bauen |
| cables.gl ist MIT-lizenziert und einbettbar | |
### Weg 4: Deklaratives DSL (wie Hydra)
Ketten-Syntax, inspiriert von Modular-Synthese:
```javascript
osc(10, 0.1, () => bass * 2)
.color(1.0, 0.3, () => mid)
.rotate(() => time * 0.1)
.modulate(noise(3), () => high * 0.5)
.out()
```
| Pro | Contra |
|-----|--------|
| Extrem kompakt, ausdrucksstark | Eigene DSL = eigenes Ökosystem |
| Live-Coding geeignet | Weniger flexibel als freier Code |
| Audio-reaktiv by Design | Lernkurve für DSL-Syntax |
| Hydra ist Open Source (MIT) | |
### Weg 5: Prompt-basiert (KI-generiert)
User beschreibt in Sprache, KI generiert den Code.
```
User: "Nordlichter die auf den Bass reagieren, grün-lila Farbverlauf"
→ KI generiert Canvas 2D oder GLSL Code
→ Live Preview
→ User iteriert per Prompt
```
| Pro | Contra |
|-----|--------|
| Keine Programmierkenntnisse nötig | KI-Output muss validiert werden |
| Niedrigste Einstiegshürde | Weniger Kontrolle |
| Kombinierbar mit jedem Code-Ansatz | LLM-Kosten / Latenz |
| mana-llm Service existiert bereits | |
---
## Audio-Analyse: Alternativen zum AnalyserNode
### Meyda (Feature Extraction)
| | |
|---|---|
| **Bundle** | ~20 KB |
| **Features** | RMS, Spectral Centroid, Rolloff, Flatness, MFCC, Chroma, Loudness, ZCR |
| **Vorteil** | Musikalisch sinnvollere Features als rohe FFT-Daten |
| **Anwendung** | Beat-Detection aus RMS-Peaks, Genre-Erkennung, Stimmungsanalyse |
| **Integration** | `Meyda.createMeydaAnalyzer({ source, featureExtractors: ['rms', 'spectralCentroid'] })` |
### essentia.js (WASM Music Information Retrieval)
| | |
|---|---|
| **Bundle** | 800 KB 2 MB (WASM) |
| **Features** | BPM, Beat-Tracking, Key Detection, Chord Recognition, Melody Extraction, Pitch |
| **Vorteil** | Akademisch fundiert, vollständige MIR-Toolbox |
| **Limitierung** | Groß, API noch nicht stabil |
### AudioWorklet (Custom DSP)
| | |
|---|---|
| **Bundle** | 0 KB (Browser-nativ) |
| **Features** | Sample-genaue Analyse auf dem Audio-Thread |
| **Vorteil** | Niedrigste Latenz, volle Kontrolle |
| **Limitierung** | Muss in separater Worklet-Datei leben, FFT selbst implementieren |
### Empfehlung nach Anwendungsfall
| Bedarf | Lösung |
|--------|--------|
| Frequency Bars / Waveform | `AnalyserNode` (reicht völlig) |
| Beat-Erkennung (einfach) | `AnalyserNode` + RMS-Peak-Detection |
| Beat-Erkennung (präzise) | **Meyda** (spectral flux + onset detection) |
| BPM / Key / Chord | **essentia.js** (wenn Größe akzeptabel) |
| Echtzeit-Feature-Extraction | **Meyda** |
| Sample-genaues Processing | **AudioWorklet** |
---
## Performance-Strategien
### requestAnimationFrame (Standard)
```typescript
function loop() {
analyser.getByteFrequencyData(dataArray); // Reuse array!
drawVisualization(dataArray);
requestAnimationFrame(loop);
}
```
- Synced mit Display-Refresh (60/120 Hz)
- Reicht für 95% der Fälle
### OffscreenCanvas + Worker (Heavy Rendering)
```typescript
// Main Thread
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen, audioData }, [offscreen]);
// Worker
self.onmessage = ({ data }) => {
const ctx = data.canvas.getContext('2d');
// Render here doesn't block main thread
};
```
- ~4x weniger Main-Thread-Blockade
- Browser-Support: alle modernen Browser
- Lohnt sich erst bei >10ms Renderzeit pro Frame
### Canvas-Layering
```html
<canvas id="static-bg" /> <!-- Hintergrund, selten aktualisiert -->
<canvas id="visualization" /> <!-- Hauptvisualisierung, 60fps -->
<canvas id="ui-overlay" /> <!-- UI-Elemente, nur bei Interaktion -->
```
- Vermeidet Neuzeichnen von statischen Elementen
- Besonders effektiv für Canvas 2D
### WebGL: FFT als Textur
```typescript
// Einmal pro Frame: FFT-Daten als 512x1 Textur hochladen
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 512, 1, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, fftData);
// GPU macht den Rest im Shader
```
- Gesamte Visualisierung auf GPU
- Ideal für Shadertoy-artige Effekte
---
## Gegenüberstellung: 5 Architektur-Strategien
### Strategie 1: "Keep it Simple" Canvas 2D Only
```
AnalyserNode → Canvas 2D → Built-in Visualizers (Svelte Components)
```
- 0 Dependencies
- 5-8 handgeschriebene Visualisierungen
- Keine User-Erweiterbarkeit
- **Aufwand: S** | **Visueller Impact: Mittel**
### Strategie 2: "Butterchurn Integration"
```
AnalyserNode → Butterchurn (WebGL) → 1000+ Milkdrop-Presets
```
- 1 Dependency (MIT)
- Sofort beeindruckend mit tausenden Presets
- Eigene Presets möglich (aber eigene DSL)
- **Aufwand: S** | **Visueller Impact: Sehr hoch**
### Strategie 3: "PixiJS Powerhouse" GPU 2D + Partikel
```
AnalyserNode → PixiJS v8 → GPU-beschleunigte 2D + Custom Shaders
```
- 1 Dependency (~100 KB)
- Partikel-Systeme, Shader-Effekte, Sprites
- Gute Balance aus Aufwand und Ergebnis
- **Aufwand: M** | **Visueller Impact: Hoch**
### Strategie 4: "Creator Platform" Code-Editor + Sandboxing
```
AnalyserNode → Sandboxed iframe → User Code (Canvas/GLSL) + KI-Generierung
```
- CodeMirror 6 + iframe Sandbox
- User erstellen und teilen Visualisierungen
- KI-Assistent generiert Code
- Built-in Library als Startpunkt
- **Aufwand: L** | **Visueller Impact: Unbegrenzt**
### Strategie 5: "Hybrid Maximum" Best of All
```
Built-ins (Canvas 2D) + Butterchurn (Milkdrop) + Custom (Sandboxed iframe + GLSL)
```
- Sofortige Vielfalt durch Butterchurn-Presets
- Eigene handgemachte Visualisierungen für Marken-Identität
- User-Erweiterbarkeit für Power-User
- **Aufwand: L-XL** | **Visueller Impact: Maximal**
---
## Empfehlung: Entscheidungsmatrix
| Kriterium | Canvas 2D | Butterchurn | PixiJS | Creator Platform | Hybrid |
|-----------|:---------:|:-----------:|:------:|:----------------:|:------:|
| Time-to-Value | ★★★ | ★★★ | ★★ | ★ | ★★ |
| Visuelle Qualität | ★★ | ★★★ | ★★★ | ★★★ | ★★★ |
| Erweiterbarkeit | ★ | ★★ | ★★ | ★★★ | ★★★ |
| Bundle Size | ★★★ | ★★ | ★★ | ★★ | ★ |
| Wartbarkeit | ★★★ | ★★ | ★★ | ★ | ★ |
| Unique Selling Point | ★ | ★★ | ★★ | ★★★ | ★★★ |
**Quick Win:** Butterchurn einbinden → sofort 1000+ Presets, MIT-Lizenz, wenig Aufwand.
**Langfristig differenzierend:** Creator Platform mit KI → kein anderer Music Player bietet das.
**Pragmatischer Mittelweg:** Canvas 2D Built-ins + Butterchurn als "Classic Mode" + optionaler Shader-Editor für Power-User.

View file

@ -1,490 +0,0 @@
# Mukke Visualizer System Konzept
## Vision
Ein erweiterbares Visualisierungs-Framework, das:
- Viele eingebaute Visualisierungen mitbringt (Frequency Bars, Circular, Particles, Waveform, etc.)
- Nutzern ermöglicht, eigene Visualisierungen zu erstellen (Code-Editor oder KI-generiert)
- Visualisierungen als "Community Presets" teilbar macht
- Sich nahtlos in den bestehenden Player (FullPlayer, MiniPlayer, Fullscreen-Modus) integriert
---
## Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────┐
│ Player UI │
│ FullPlayer │ MiniPlayer │ Fullscreen Visualizer │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ VisualizerRenderer.svelte │
│ Wählt den aktiven Renderer, übergibt AudioData + Config│
└────────────────────────┬────────────────────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Built-in │ │ Built-in │ │ Custom │
│ Renderer │ │ Renderer │ │ Renderer │
│ (Svelte) │ │ (Svelte) │ │ (Sandboxed)│
└────────────┘ └────────────┘ └────────────┘
│ │ │
└──────────────┼──────────────┘
┌─────────────────────────────────────────────────────────┐
│ Audio Analyzer (Singleton) │
│ AnalyserNode → frequencyData, timeDomainData, volume │
└─────────────────────────────────────────────────────────┘
```
---
## 1. Audio Data Layer
### Aktueller Stand
`src/lib/visualizer/analyzer.ts` liefert bereits:
- `getFrequencyData()``Uint8Array` (Frequenzbänder 0-255)
- `getFrequencyBinCount()` → Anzahl der Bins
### Erweiterung
```typescript
// src/lib/visualizer/audio-data.ts
export interface AudioData {
/** Frequency spectrum (0-255 per bin) */
frequency: Uint8Array;
/** Time domain waveform (-128 to 127, centered at 128) */
timeDomain: Uint8Array;
/** Number of frequency bins */
binCount: number;
/** Current RMS volume (0-1) */
volume: number;
/** Current bass energy (0-1) average of lowest 1/8 bins */
bass: number;
/** Current mid energy (0-1) */
mid: number;
/** Current high energy (0-1) */
high: number;
/** Beat detected this frame */
beat: boolean;
/** Current playback time in seconds */
currentTime: number;
/** Song duration in seconds */
duration: number;
/** Song BPM (if available) */
bpm: number | null;
}
```
Die `AudioData`-Struktur wird einmal pro Frame berechnet und an alle aktiven Renderer weitergegeben. So müssen Visualisierungen selbst keine Audio-Analyse machen.
---
## 2. Visualizer Registry
### Discriminated Union Pattern (wie Picture App Board Items)
```typescript
// src/lib/visualizer/types.ts
export interface VisualizerMeta {
id: string;
name: string;
description: string;
author: string;
thumbnail?: string; // Preview-Bild oder generiert
category: VisualizerCategory;
tags: string[];
version: string;
}
export type VisualizerCategory =
| 'spectrum' // Frequency-basiert (Bars, Circular)
| 'waveform' // Wellenform-basiert
| 'particles' // Partikel-Systeme
| 'geometric' // Geometrische Muster
| 'abstract' // Abstrakte/generative Kunst
| 'custom'; // User-created
export interface VisualizerConfig {
[key: string]: unknown;
}
export interface VisualizerDefinition<C extends VisualizerConfig = VisualizerConfig> {
meta: VisualizerMeta;
/** Default-Konfiguration */
defaultConfig: C;
/** JSON-Schema für die Config (für UI-Generierung) */
configSchema: ConfigSchema;
/** Rendering-Typ */
type: 'builtin' | 'custom-canvas' | 'custom-shader';
}
```
### Registry Store
```typescript
// src/lib/visualizer/registry.svelte.ts
function createVisualizerRegistry() {
let visualizers = $state<Map<string, VisualizerDefinition>>(new Map());
let activeId = $state<string>('frequency-bars');
let activeConfig = $state<VisualizerConfig>({});
return {
get all() { return [...visualizers.values()]; },
get active() { return visualizers.get(activeId) ?? null; },
get activeConfig() { return activeConfig; },
register(def: VisualizerDefinition) { ... },
unregister(id: string) { ... },
setActive(id: string) { ... },
updateConfig(patch: Partial<VisualizerConfig>) { ... },
getByCategory(cat: VisualizerCategory) { ... },
};
}
```
---
## 3. Built-in Visualisierungen
### Phase 1 (implementiert)
| ID | Name | Beschreibung |
|----|------|-------------|
| `frequency-bars` | Frequency Bars | Klassische EQ-Balken, konfigurierbar (Anzahl, Farbe, Mirror) |
### Phase 2 (Editor-Verbesserungen)
| ID | Name | Beschreibung |
|----|------|-------------|
| `beat-grid` | Beat Grid | BPM-basiertes Raster über Waveform |
| `energy-heatmap` | Energy Heatmap | Waveform eingefärbt nach Lautstärke |
### Phase 3 (Premium)
| ID | Name | Beschreibung |
|----|------|-------------|
| `circular-spectrum` | Circular Spectrum | Kreisförmig, Album-Art in der Mitte, Frequenz als Strahlen |
| `particles` | Particle Flow | Partikel reagieren auf Bass/Transients |
| `waveform-3d` | 3D Waveform | Scrollende 3D-Wellenform (Terrain-Stil) |
| `blob` | Reactive Blob | Organische Form, die zum Beat atmet |
| `bars-circular` | Circular Bars | Bars im Kreis angeordnet |
| `spectrum-wave` | Spectrum Wave | Smooth-kurvige Frequenzlinie |
| `stereo-field` | Stereo Field | L/R Panorama-Visualisierung |
| `kaleidoscope` | Kaleidoscope | Symmetrische Muster aus Frequenzdaten |
---
## 4. Rendering-Ansätze
### Option A: Svelte Components (Built-ins)
Jede Built-in-Visualisierung ist eine Svelte-Komponente mit standardisiertem Interface:
```svelte
<script lang="ts">
import type { AudioData, VisualizerConfig } from '$lib/visualizer/types';
interface Props {
audioData: AudioData;
config: VisualizerConfig;
width: number;
height: number;
}
let { audioData, config, width, height }: Props = $props();
// Rendering-Logik mit Canvas 2D, SVG, oder WebGL
</script>
<canvas bind:this={canvas} {width} {height}></canvas>
```
**Pro:** Volle Svelte-Reaktivität, Tree-Shakeable, einfach zu warten
**Contra:** Nicht dynamisch ladbar für User-Visualisierungen
### Option B: Canvas 2D Render Functions (Custom)
User-Visualisierungen als reine Render-Funktionen:
```typescript
interface CustomVisualizerFn {
setup?: (ctx: CanvasRenderingContext2D, width: number, height: number) => void;
render: (ctx: CanvasRenderingContext2D, data: AudioData, config: VisualizerConfig,
width: number, height: number) => void;
destroy?: () => void;
}
```
**Pro:** Einfach, sicher (nur Canvas-Zugriff), leicht per Code-Editor erstellbar
**Contra:** Nur 2D, kein DOM-Zugriff
### Option C: WebGL/Shader (Advanced)
GLSL Fragment Shaders für GPU-beschleunigte Visualisierungen:
```glsl
// User schreibt nur den Fragment Shader
uniform float u_time;
uniform float u_bass;
uniform float u_mid;
uniform float u_high;
uniform float u_volume;
uniform sampler2D u_frequency; // Frequenzdaten als Textur
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// ... Shader-Code
gl_FragColor = vec4(color, 1.0);
}
```
**Pro:** GPU-beschleunigt, visuell beeindruckend, Shadertoy-kompatibel
**Contra:** Steile Lernkurve, schwer zu debuggen
### Empfehlung: Hybrid-Ansatz
| Typ | Technologie | Verwendung |
|-----|------------|------------|
| Built-in | Svelte + Canvas 2D | Alle mitgelieferten Visualisierungen |
| Custom Canvas | Sandboxed Canvas 2D Function | User-erstellte 2D-Visualisierungen |
| Custom Shader | WebGL Fragment Shader | User-erstellte GPU-Visualisierungen |
---
## 5. Custom Visualizer System
### User-Workflow
1. **Galerie öffnen** → Alle verfügbaren Visualisierungen als Grid mit Live-Preview
2. **"Create New"** → Code-Editor öffnet sich
3. **Template wählen** → Startercode für Canvas 2D oder Shader
4. **Code schreiben** → Live-Preview neben dem Editor
5. **KI-Assistent** → "Erstelle eine Visualisierung die..." → Code wird generiert
6. **Speichern** → In der persönlichen Bibliothek
7. **Teilen** → Als Community Preset veröffentlichen (optional)
### Code-Editor Integration
```
┌─────────────────────────────────────────────────────┐
│ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ │ │ │ │
│ │ Code Editor │ │ Live Preview │ │
│ │ (Monaco/CM6) │ │ (Canvas) │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ └─────────────────────┘ └──────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐│
│ │ Config Panel: [barCount: 32] [color: #ff0] ││
│ └──────────────────────────────────────────────────┘│
│ [💾 Save] [▶ Test with Audio] [🤖 Ask AI] [📤 Share]│
└─────────────────────────────────────────────────────┘
```
### Sandboxing (Sicherheit)
Custom Code läuft **nicht** direkt im Hauptthread:
```
Option 1: new Function() mit Whitelist
- Kein Zugriff auf window, document, fetch, etc.
- Nur ctx (Canvas), data (AudioData), config erreichbar
- Einfach, performant, leichte Einschränkungen
Option 2: Web Worker + OffscreenCanvas
- Code läuft in isoliertem Worker
- Rendert auf OffscreenCanvas, wird in Hauptthread übertragen
- Sicherer, aber komplexer und nicht alle Browser unterstützen OffscreenCanvas
Option 3: iframe Sandbox
- Maximale Isolation
- Overhead durch postMessage-Kommunikation
- Overkill für Canvas-Rendering
```
**Empfehlung:** Option 1 (`new Function()`) für Canvas 2D, direkte WebGL-Ausführung für Shaders (Shader-Code ist von Natur aus sandboxed auf der GPU).
### Datenbank-Schema
```typescript
// Erweiterung der DB (Backend)
visualizers: {
id: uuid,
userId: uuid,
meta: jsonb, // VisualizerMeta
config: jsonb, // Default VisualizerConfig
configSchema: jsonb, // JSON Schema für Config-UI
code: text, // Render-Function oder Shader-Code
type: enum('canvas-2d', 'shader'),
isPublic: boolean,
likes: integer,
createdAt: timestamp,
updatedAt: timestamp,
}
```
---
## 6. Visualizer Galerie UI
```
┌─────────────────────────────────────────────────────────┐
│ 🎵 Visualisierungen [+ Create New] │
│ │
│ [All] [Spectrum] [Waveform] [Particles] [Community] │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ▓▓▓▓▓▓▓▓ │ │ ◉ ))) ) │ │ · · · · │ │
│ │ ▓▓▓▓▓▓▓▓ │ │ ◉ )))) ) │ │· · · · │ │
│ │ Freq Bars │ │ Circular │ │ Particles │ │
│ │ ★ Built-in│ │ ★ Built-in│ │ ★ Built-in│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ~~~~~~~~ │ │ ╱╲╱╲╱╲╱╲ │ │ ▒▒▒▒▒▒▒▒ │ │
│ │ ~~~~~~~~ │ │ ╱╲╱╲╱╲╱╲ │ │ ▒▒▒▒▒▒▒▒ │ │
│ │ Waveform │ │ Kaleido │ │ My Custom │ │
│ │ ★ Built-in│ │ ★ Built-in│ │ 👤 You │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Config Panel (pro Visualisierung)
Jede Visualisierung definiert ein `configSchema`, aus dem automatisch UI generiert wird:
```typescript
const configSchema: ConfigSchema = {
barCount: { type: 'range', min: 8, max: 128, step: 1, label: 'Bar Count' },
color: { type: 'color', label: 'Color' },
mirror: { type: 'toggle', label: 'Mirror' },
sensitivity: { type: 'range', min: 0, max: 2, step: 0.1, label: 'Sensitivity' },
colorMode: { type: 'select', options: ['gradient', 'solid', 'rainbow'], label: 'Color Mode' },
};
```
---
## 7. KI-Integration
### Prompt-basierte Erstellung
User beschreibt die gewünschte Visualisierung in natürlicher Sprache:
> "Erstelle einen Visualizer der wie Nordlichter aussieht.
> Die Farben sollen zwischen Grün und Lila wechseln, basierend auf den Bässen."
→ KI generiert Canvas 2D oder Shader Code
→ Live-Preview zeigt das Ergebnis sofort
→ User kann iterieren ("mach die Bewegung schneller", "füge Sterne hinzu")
### System Prompt für KI
```
Du bist ein Musik-Visualisierungs-Experte. Generiere eine render()-Funktion:
function render(ctx, data, config, width, height) {
// ctx: CanvasRenderingContext2D
// data: { frequency, timeDomain, volume, bass, mid, high, beat, currentTime, bpm }
// config: User-konfigurierbare Werte
// width, height: Canvas-Dimensionen
}
Regeln:
- Lösche den Canvas mit ctx.clearRect(0, 0, width, height)
- Nutze data.frequency (Uint8Array, 0-255) für Spektrumdaten
- data.bass/mid/high sind normalisiert (0-1)
- data.beat ist true wenn ein Beat erkannt wurde
- Halte den Code performant (60fps)
- Gib auch ein configSchema-Objekt zurück
```
### Integration über mana-llm Service
```
User Input → mana-llm → Code generiert → Sandbox → Live Preview
```
Nutzt den bestehenden `services/mana-llm` als LLM-Abstraktionsschicht.
---
## 8. Fullscreen Visualizer Mode
Neuer Fullscreen-Modus für immersive Erfahrung:
- Visualisierung füllt den gesamten Bildschirm
- Minimale Transport-Controls (transparent overlay, auto-hide)
- Song-Info eingeblendet bei Track-Wechsel (fade in/out)
- Keyboard-Shortcuts (Space = Play/Pause, Esc = Exit, N = Next Viz)
- Screensaver-Modus: Wechselt automatisch zwischen Visualisierungen
---
## 9. Datei-Struktur (geplant)
```
src/lib/visualizer/
├── analyzer.ts # Audio Analyzer (existiert)
├── audio-data.ts # AudioData Interface + Berechnung
├── types.ts # Alle Type-Definitionen
├── registry.svelte.ts # Visualizer Registry Store
├── sandbox.ts # Custom Code Sandboxing
├── FrequencyBars.svelte # Built-in (existiert)
├── renderers/
│ ├── CircularSpectrum.svelte # Kreisförmiges Spektrum
│ ├── ParticleFlow.svelte # Partikel-System
│ ├── Waveform3D.svelte # 3D Waveform
│ ├── ReactiveBlob.svelte # Organische Form
│ ├── CircularBars.svelte # Bars im Kreis
│ ├── SpectrumWave.svelte # Glatte Frequenzkurve
│ ├── StereoField.svelte # Stereo-Analyse
│ └── Kaleidoscope.svelte # Kaleidoskop-Muster
├── components/
│ ├── VisualizerRenderer.svelte # Router: wählt aktiven Renderer
│ ├── VisualizerGallery.svelte # Galerie-Ansicht
│ ├── VisualizerConfig.svelte # Auto-generiertes Config-Panel
│ ├── VisualizerEditor.svelte # Code-Editor für Custom Viz
│ ├── VisualizerPreview.svelte # Live-Preview Canvas
│ └── FullscreenVisualizer.svelte# Fullscreen-Modus
└── templates/
├── canvas-2d-starter.ts # Template für Canvas 2D
└── shader-starter.glsl # Template für GLSL Shader
```
---
## 10. Implementierungs-Reihenfolge
| Phase | Was | Aufwand |
|-------|-----|---------|
| **1** ✅ | Frequency Bars + Analyzer (done) | — |
| **2** | AudioData erweitern (bass/mid/high/beat), Registry Store, VisualizerRenderer | S |
| **3** | 3-4 weitere Built-in Renderer (Circular, Particles, Blob, Wave) | M |
| **4** | Galerie UI + Config Panel + Fullscreen Mode | M |
| **5** | Custom Visualizer: Sandbox + Code-Editor + Templates | L |
| **6** | KI-Integration (mana-llm) für Code-Generierung | M |
| **7** | Community: Teilen, Likes, öffentliche Galerie | L |
| **8** | Backend: visualizers-Tabelle, CRUD API, User-Presets | M |
---
## 11. Technologie-Entscheidungen
| Bereich | Entscheidung | Begründung |
|---------|-------------|------------|
| Built-in Rendering | Canvas 2D | Performant, einfach, kein Dep overhead |
| GPU-Effekte | Raw WebGL (kein Three.js) | Vermeidet ~150kb Dependency für Shader-only |
| Code Editor | CodeMirror 6 | Leichtgewichtig, Svelte-freundlich, besser als Monaco für embedded |
| Sandboxing | `new Function()` mit Whitelist | Guter Kompromiss aus Sicherheit und Performance |
| State | Svelte 5 Runes Store | Konsistent mit Rest der App |
| Persistenz | JSONB in PostgreSQL | Flexibel, keine Migrationen bei Config-Änderungen |
| KI | mana-llm Service | Bereits vorhanden, LLM-agnostisch |

View file

@ -1,8 +0,0 @@
{
"name": "mukke",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "pnpm run --filter=@mukke/* --parallel dev"
}
}

View file

@ -1,17 +0,0 @@
{
"name": "@mukke/shared",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -1 +0,0 @@
export * from './types';

View file

@ -1,40 +0,0 @@
export type TranscriptionStatus = 'none' | 'pending' | 'completed' | 'failed';
export interface Beat {
id: string;
projectId: string;
storagePath: string;
filename?: string | null;
duration?: number | null;
bpm?: number | null;
bpmConfidence?: number | null;
waveformData?: WaveformData | null;
// STT Transcription fields
transcriptionStatus?: TranscriptionStatus | null;
transcriptionError?: string | null;
transcribedAt?: Date | null;
createdAt: Date;
}
export interface WaveformData {
peaks: number[];
sampleRate: number;
duration: number;
}
export interface CreateBeatDto {
projectId: string;
filename: string;
}
export interface UpdateBeatDto {
bpm?: number;
bpmConfidence?: number;
duration?: number;
waveformData?: WaveformData;
}
export interface BeatUploadResponse {
beat: Beat;
uploadUrl: string;
}

View file

@ -1,57 +0,0 @@
export type ExportFormat = 'lrc' | 'srt' | 'json' | 'video';
export interface ExportOptions {
format: ExportFormat;
includeMarkers?: boolean;
videoOptions?: VideoExportOptions;
}
export interface VideoExportOptions {
width: number;
height: number;
fps: number;
backgroundColor: string;
textColor: string;
highlightColor: string;
fontFamily: string;
fontSize: number;
}
export interface LrcExportResult {
content: string;
filename: string;
}
export interface SrtExportResult {
content: string;
filename: string;
}
export interface JsonExportResult {
data: JsonExportData;
filename: string;
}
export interface JsonExportData {
project: {
id: string;
title: string;
description?: string;
};
beat: {
bpm?: number;
duration?: number;
};
markers: Array<{
type: string;
label?: string;
startTime: number;
endTime?: number;
}>;
lyrics: Array<{
lineNumber: number;
text: string;
startTime?: number;
endTime?: number;
}>;
}

View file

@ -1,7 +0,0 @@
export * from './project';
export * from './beat';
export * from './marker';
export * from './lyrics';
export * from './export';
export * from './song';
export * from './playlist';

View file

@ -1,55 +0,0 @@
export interface Lyrics {
id: string;
projectId: string;
content?: string | null;
}
export interface LyricLine {
id: string;
lyricsId: string;
lineNumber: number;
text: string;
startTime?: number | null;
endTime?: number | null;
}
export interface CreateLyricsDto {
projectId: string;
content?: string;
}
export interface UpdateLyricsDto {
content?: string;
}
export interface CreateLyricLineDto {
lyricsId: string;
lineNumber: number;
text: string;
startTime?: number;
endTime?: number;
}
export interface UpdateLyricLineDto {
text?: string;
startTime?: number;
endTime?: number;
}
export interface SyncedLyrics {
lines: SyncedLine[];
}
export interface SyncedLine {
lineNumber: number;
text: string;
startTime: number;
endTime?: number;
words?: SyncedWord[];
}
export interface SyncedWord {
word: string;
startTime: number;
endTime: number;
}

View file

@ -1,49 +0,0 @@
export type MarkerType =
| 'verse'
| 'hook'
| 'bridge'
| 'intro'
| 'outro'
| 'drop'
| 'breakdown'
| 'custom';
export interface Marker {
id: string;
beatId: string;
type: MarkerType;
label?: string | null;
startTime: number;
endTime?: number | null;
color?: string | null;
sortOrder?: number | null;
}
export interface CreateMarkerDto {
beatId: string;
type: MarkerType;
label?: string;
startTime: number;
endTime?: number;
color?: string;
}
export interface UpdateMarkerDto {
type?: MarkerType;
label?: string;
startTime?: number;
endTime?: number;
color?: string;
sortOrder?: number;
}
export const MARKER_COLORS: Record<MarkerType, string> = {
verse: '#3B82F6', // blue
hook: '#EF4444', // red
bridge: '#8B5CF6', // purple
intro: '#22C55E', // green
outro: '#F97316', // orange
drop: '#EC4899', // pink
breakdown: '#14B8A6', // teal
custom: '#6B7280', // gray
};

View file

@ -1,25 +0,0 @@
import type { Song } from './song';
export interface Playlist {
id: string;
userId: string;
name: string;
description: string | null;
coverArtPath: string | null;
createdAt: string;
updatedAt: string;
}
export interface PlaylistWithSongs extends Playlist {
songs: Song[];
}
export interface CreatePlaylistDto {
name: string;
description?: string;
}
export interface UpdatePlaylistDto {
name?: string;
description?: string;
}

View file

@ -1,18 +0,0 @@
export interface Project {
id: string;
userId: string;
title: string;
description?: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateProjectDto {
title: string;
description?: string;
}
export interface UpdateProjectDto {
title?: string;
description?: string;
}

View file

@ -1,77 +0,0 @@
export interface Song {
id: string;
userId: string;
title: string;
artist: string | null;
album: string | null;
albumArtist: string | null;
genre: string | null;
trackNumber: number | null;
year: number | null;
month: number | null;
day: number | null;
duration: number | null;
storagePath: string;
coverArtPath: string | null;
fileSize: number | null;
bpm: number | null;
favorite: boolean;
playCount: number;
lastPlayedAt: string | null;
addedAt: string;
updatedAt: string;
}
export interface Album {
album: string;
albumArtist: string | null;
year: number | null;
coverArtPath: string | null;
songCount: number;
}
export interface Artist {
artist: string;
songCount: number;
albumCount: number;
}
export interface Genre {
genre: string;
songCount: number;
}
export interface LibraryStats {
totalSongs: number;
totalArtists: number;
totalAlbums: number;
totalGenres: number;
totalDuration: number | null;
totalPlays: number;
}
export type SortField = 'title' | 'artist' | 'album' | 'addedAt' | 'playCount';
export type SortDirection = 'asc' | 'desc';
export interface CreateSongDto {
title: string;
artist?: string;
album?: string;
albumArtist?: string;
genre?: string;
trackNumber?: number;
year?: number;
month?: number;
day?: number;
bpm?: number;
}
export interface UpdateSongDto extends Partial<CreateSongDto> {
duration?: number;
fileSize?: number;
}
export interface SongUploadResponse {
song: Song;
uploadUrl: string;
}

View file

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -447,23 +447,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'music',
name: 'Music',
description: {
de: 'Musikproduktion',
en: 'Music Production',
},
longDescription: {
de: 'Erstelle und verwalte Songs, Playlists und Musikprojekte mit Markern und Arrangements.',
en: 'Create and manage songs, playlists, and music projects with markers and arrangements.',
},
icon: APP_ICONS.music,
color: '#ec4899',
comingSoon: false,
status: 'beta',
requiredTier: 'guest',
},
{
id: 'photos',
name: 'Photos',