mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 00:41:26 +02:00
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:
parent
d2bfaf1b2a
commit
f9159741a0
59 changed files with 0 additions and 4753 deletions
|
|
@ -144,7 +144,6 @@ const APP_SUBDOMAINS = new Set([
|
|||
'storage',
|
||||
'presi',
|
||||
'photos',
|
||||
'music',
|
||||
'picture',
|
||||
'calc',
|
||||
'inventory',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
♫
|
||||
</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>
|
||||
|
|
@ -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>[],
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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) ?? '',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -14,7 +14,6 @@ const SPLIT_APP_ID_LIST = [
|
|||
'picture',
|
||||
'cards',
|
||||
'quotes',
|
||||
'music',
|
||||
'storage',
|
||||
'presi',
|
||||
'inventory',
|
||||
|
|
|
|||
|
|
@ -233,7 +233,6 @@ export const dashboardStore = {
|
|||
'chat-recent',
|
||||
'contacts-favorites',
|
||||
'quotes-quote',
|
||||
'music-library',
|
||||
'presi-decks',
|
||||
] as WidgetType[]
|
||||
).filter((type) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://mukke.app',
|
||||
integrations: [sitemap()],
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>© {new Date().getFullYear()} Mukke. Part of the Mana ecosystem.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</Layout>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"name": "mukke",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@mukke/* --parallel dev"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './types';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue