WIP: Working on player
This commit is contained in:
0
web/src/App.tsx
Normal file → Executable file
0
web/src/App.tsx
Normal file → Executable file
0
web/src/api/annotations.ts
Normal file → Executable file
0
web/src/api/annotations.ts
Normal file → Executable file
0
web/src/api/auth.ts
Normal file → Executable file
0
web/src/api/auth.ts
Normal file → Executable file
0
web/src/api/bands.ts
Normal file → Executable file
0
web/src/api/bands.ts
Normal file → Executable file
0
web/src/api/client.ts
Normal file → Executable file
0
web/src/api/client.ts
Normal file → Executable file
0
web/src/api/invites.ts
Normal file → Executable file
0
web/src/api/invites.ts
Normal file → Executable file
0
web/src/components/AppShell.tsx
Normal file → Executable file
0
web/src/components/AppShell.tsx
Normal file → Executable file
0
web/src/components/BottomNavBar.tsx
Normal file → Executable file
0
web/src/components/BottomNavBar.tsx
Normal file → Executable file
0
web/src/components/InviteManagement.tsx
Normal file → Executable file
0
web/src/components/InviteManagement.tsx
Normal file → Executable file
0
web/src/components/MiniPlayer.tsx
Normal file → Executable file
0
web/src/components/MiniPlayer.tsx
Normal file → Executable file
0
web/src/components/ResponsiveLayout.tsx
Normal file → Executable file
0
web/src/components/ResponsiveLayout.tsx
Normal file → Executable file
0
web/src/components/Sidebar.tsx
Normal file → Executable file
0
web/src/components/Sidebar.tsx
Normal file → Executable file
0
web/src/components/TopBar.tsx
Normal file → Executable file
0
web/src/components/TopBar.tsx
Normal file → Executable file
21
web/src/hooks/useWaveform.ts
Normal file → Executable file
21
web/src/hooks/useWaveform.ts
Normal file → Executable file
@@ -45,25 +45,16 @@ export function useWaveform(
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
console.debug('useWaveform: container ref is null, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.url || options.url === 'null' || options.url === 'undefined') {
|
||||
console.debug('useWaveform: invalid URL, skipping initialization', { url: options.url });
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('useWaveform: initializing audio service', {
|
||||
url: options.url,
|
||||
songId: options.songId,
|
||||
bandId: options.bandId,
|
||||
containerExists: !!containerRef.current
|
||||
});
|
||||
|
||||
const initializeAudio = async () => {
|
||||
try {
|
||||
console.debug('useWaveform: using audio service instance');
|
||||
|
||||
|
||||
await audioService.initialize(containerRef.current!, options.url!);
|
||||
|
||||
@@ -105,7 +96,7 @@ export function useWaveform(
|
||||
globalBandId === options.bandId &&
|
||||
globalIsPlaying) {
|
||||
|
||||
console.debug('useWaveform: restoring playback state');
|
||||
|
||||
|
||||
// Wait a moment for the waveform to be ready
|
||||
setTimeout(() => {
|
||||
@@ -120,7 +111,7 @@ export function useWaveform(
|
||||
options.onReady?.(audioService.getDuration());
|
||||
|
||||
return () => {
|
||||
console.debug('useWaveform: cleanup');
|
||||
|
||||
unsubscribe();
|
||||
// Note: We don't cleanup the audio service here to maintain persistence
|
||||
// audioService.cleanup();
|
||||
@@ -138,7 +129,7 @@ export function useWaveform(
|
||||
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
|
||||
|
||||
const play = () => {
|
||||
console.debug('useWaveform.play called');
|
||||
|
||||
try {
|
||||
audioService.play();
|
||||
} catch (error) {
|
||||
@@ -147,7 +138,7 @@ export function useWaveform(
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
console.debug('useWaveform.pause called');
|
||||
|
||||
try {
|
||||
audioService.pause();
|
||||
} catch (error) {
|
||||
@@ -156,7 +147,7 @@ export function useWaveform(
|
||||
};
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
console.debug('useWaveform.seekTo called', { time });
|
||||
|
||||
try {
|
||||
if (isReady && isFinite(time)) {
|
||||
audioService.seekTo(time);
|
||||
|
||||
0
web/src/hooks/useWebSocket.ts
Normal file → Executable file
0
web/src/hooks/useWebSocket.ts
Normal file → Executable file
0
web/src/index.css
Normal file → Executable file
0
web/src/index.css
Normal file → Executable file
0
web/src/main.tsx
Normal file → Executable file
0
web/src/main.tsx
Normal file → Executable file
0
web/src/pages/BandPage.test.tsx
Normal file → Executable file
0
web/src/pages/BandPage.test.tsx
Normal file → Executable file
165
web/src/pages/BandPage.tsx
Normal file → Executable file
165
web/src/pages/BandPage.tsx
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBand } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
|
||||
@@ -43,13 +43,6 @@ function formatDateLabel(iso: string): string {
|
||||
|
||||
export function BandPage() {
|
||||
const { bandId } = useParams<{ bandId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
||||
const [librarySearch, setLibrarySearch] = useState("");
|
||||
const [activePill, setActivePill] = useState<FilterPill>("all");
|
||||
|
||||
@@ -91,75 +84,6 @@ export function BandPage() {
|
||||
});
|
||||
}, [unattributedSongs, librarySearch, activePill]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||
setShowCreate(false);
|
||||
setNewTitle("");
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
|
||||
});
|
||||
|
||||
async function startScan() {
|
||||
if (scanning || !bandId) return;
|
||||
setScanning(true);
|
||||
setScanMsg(null);
|
||||
setScanProgress("Starting scan…");
|
||||
|
||||
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
if (!resp.ok || !resp.body) {
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
throw new Error(text || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(line); } catch { continue; }
|
||||
|
||||
if (event.type === "progress") {
|
||||
setScanProgress(event.message as string);
|
||||
} else if (event.type === "song" || event.type === "session") {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
|
||||
} else if (event.type === "done") {
|
||||
const s = event.stats as { found: number; imported: number; skipped: number };
|
||||
if (s.imported > 0) {
|
||||
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
|
||||
} else if (s.found === 0) {
|
||||
setScanMsg("No audio files found.");
|
||||
} else {
|
||||
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
|
||||
}
|
||||
setTimeout(() => setScanMsg(null), 6000);
|
||||
} else if (event.type === "error") {
|
||||
setScanMsg(`Scan error: ${event.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setScanMsg(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setScanProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
|
||||
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||
@@ -206,41 +130,6 @@ export function BandPage() {
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={startScan}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid rgba(255,255,255,0.09)",
|
||||
borderRadius: 6,
|
||||
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
|
||||
cursor: scanning ? "default" : "pointer",
|
||||
padding: "5px 12px",
|
||||
fontSize: 12,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
||||
style={{
|
||||
background: "rgba(232,162,42,0.14)",
|
||||
border: "1px solid rgba(232,162,42,0.28)",
|
||||
borderRadius: 6,
|
||||
color: "#e8a22a",
|
||||
cursor: "pointer",
|
||||
padding: "5px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
+ Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
@@ -271,56 +160,6 @@ export function BandPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scan feedback ─────────────────────────────────────── */}
|
||||
{scanning && scanProgress && (
|
||||
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
|
||||
<div style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
|
||||
{scanProgress}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scanMsg && (
|
||||
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
|
||||
<div style={{ background: "rgba(61,200,120,0.06)", border: "1px solid rgba(61,200,120,0.25)", borderRadius: 8, color: "#4dba85", fontSize: 12, padding: "8px 14px" }}>
|
||||
{scanMsg}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── New song / upload form ─────────────────────────────── */}
|
||||
{showCreate && (
|
||||
<div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
|
||||
<div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 18 }}>
|
||||
{error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}>
|
||||
Song title
|
||||
</label>
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", marginBottom: 12, fontSize: 14, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newTitle}
|
||||
style={{ background: "rgba(232,162,42,0.14)", border: "1px solid rgba(232,162,42,0.28)", borderRadius: 6, color: "#e8a22a", cursor: newTitle ? "pointer" : "default", padding: "7px 18px", fontWeight: 600, fontSize: 13, fontFamily: "inherit", opacity: newTitle ? 1 : 0.4 }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setError(null); }}
|
||||
style={{ background: "none", border: "1px solid rgba(255,255,255,0.09)", borderRadius: 6, color: "rgba(255,255,255,0.42)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontFamily: "inherit" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scrollable content ────────────────────────────────── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
|
||||
|
||||
@@ -441,7 +280,7 @@ export function BandPage() {
|
||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
|
||||
{librarySearch
|
||||
? "No results match your search."
|
||||
: "No sessions yet. Scan Nextcloud or create a song to get started."}
|
||||
: "No sessions yet. Go to Storage settings to scan your Nextcloud folder."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
0
web/src/pages/BandSettingsPage.test.md
Normal file → Executable file
0
web/src/pages/BandSettingsPage.test.md
Normal file → Executable file
0
web/src/pages/BandSettingsPage.test.tsx
Normal file → Executable file
0
web/src/pages/BandSettingsPage.test.tsx
Normal file → Executable file
94
web/src/pages/BandSettingsPage.tsx
Normal file → Executable file
94
web/src/pages/BandSettingsPage.tsx
Normal file → Executable file
@@ -419,6 +419,68 @@ function StoragePanel({
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [folderInput, setFolderInput] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
||||
|
||||
async function startScan() {
|
||||
if (scanning) return;
|
||||
setScanning(true);
|
||||
setScanMsg(null);
|
||||
setScanProgress("Starting scan…");
|
||||
|
||||
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
if (!resp.ok || !resp.body) {
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
throw new Error(text || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(line); } catch { continue; }
|
||||
|
||||
if (event.type === "progress") {
|
||||
setScanProgress(event.message as string);
|
||||
} else if (event.type === "song" || event.type === "session") {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
|
||||
} else if (event.type === "done") {
|
||||
const s = event.stats as { found: number; imported: number; skipped: number };
|
||||
if (s.imported > 0) {
|
||||
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
|
||||
} else if (s.found === 0) {
|
||||
setScanMsg("No audio files found.");
|
||||
} else {
|
||||
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
|
||||
}
|
||||
setTimeout(() => setScanMsg(null), 6000);
|
||||
} else if (event.type === "error") {
|
||||
setScanMsg(`Scan error: ${event.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setScanMsg(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setScanProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
|
||||
@@ -538,6 +600,38 @@ function StoragePanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scan action */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button
|
||||
onClick={startScan}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
background: scanning ? "transparent" : "rgba(61,200,120,0.08)",
|
||||
border: `1px solid ${scanning ? "rgba(255,255,255,0.07)" : "rgba(61,200,120,0.25)"}`,
|
||||
borderRadius: 6,
|
||||
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
|
||||
cursor: scanning ? "default" : "pointer",
|
||||
padding: "6px 14px",
|
||||
fontSize: 12,
|
||||
fontFamily: "inherit",
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scanning && scanProgress && (
|
||||
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
|
||||
{scanProgress}
|
||||
</div>
|
||||
)}
|
||||
{scanMsg && (
|
||||
<div style={{ marginTop: 10, background: "rgba(61,200,120,0.06)", border: "1px solid rgba(61,200,120,0.25)", borderRadius: 8, color: "#4dba85", fontSize: 12, padding: "8px 14px" }}>
|
||||
{scanMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
0
web/src/pages/HomePage.tsx
Normal file → Executable file
0
web/src/pages/HomePage.tsx
Normal file → Executable file
0
web/src/pages/InvitePage.tsx
Normal file → Executable file
0
web/src/pages/InvitePage.tsx
Normal file → Executable file
0
web/src/pages/LoginPage.tsx
Normal file → Executable file
0
web/src/pages/LoginPage.tsx
Normal file → Executable file
0
web/src/pages/SessionPage.tsx
Normal file → Executable file
0
web/src/pages/SessionPage.tsx
Normal file → Executable file
0
web/src/pages/SettingsPage.tsx
Normal file → Executable file
0
web/src/pages/SettingsPage.tsx
Normal file → Executable file
0
web/src/pages/SongPage.tsx
Normal file → Executable file
0
web/src/pages/SongPage.tsx
Normal file → Executable file
5
web/src/services/audioService.ts
Normal file → Executable file
5
web/src/services/audioService.ts
Normal file → Executable file
@@ -31,7 +31,7 @@ class AudioService {
|
||||
private readonly PLAY_DEBOUNCE_MS: number = 100;
|
||||
private lastSeekTime: number = 0;
|
||||
private readonly SEEK_DEBOUNCE_MS: number = 200;
|
||||
private logLevel: LogLevel = LogLevel.WARN;
|
||||
private logLevel: LogLevel = LogLevel.ERROR;
|
||||
private playbackAttempts: number = 0;
|
||||
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
|
||||
|
||||
@@ -203,17 +203,14 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
||||
const playerStore = usePlayerStore.getState();
|
||||
|
||||
ws.on("play", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: play event');
|
||||
playerStore.batchUpdate({ isPlaying: true });
|
||||
});
|
||||
|
||||
ws.on("pause", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: pause event');
|
||||
playerStore.batchUpdate({ isPlaying: false });
|
||||
});
|
||||
|
||||
ws.on("finish", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: finish event');
|
||||
playerStore.batchUpdate({ isPlaying: false });
|
||||
});
|
||||
|
||||
|
||||
0
web/src/services/audioService.ts.backup2
Normal file → Executable file
0
web/src/services/audioService.ts.backup2
Normal file → Executable file
0
web/src/stores/playerStore.ts
Normal file → Executable file
0
web/src/stores/playerStore.ts
Normal file → Executable file
0
web/src/test/helpers.tsx
Normal file → Executable file
0
web/src/test/helpers.tsx
Normal file → Executable file
0
web/src/test/setup.ts
Normal file → Executable file
0
web/src/test/setup.ts
Normal file → Executable file
0
web/src/theme.ts
Normal file → Executable file
0
web/src/theme.ts
Normal file → Executable file
0
web/src/types/invite.ts
Normal file → Executable file
0
web/src/types/invite.ts
Normal file → Executable file
0
web/src/utils.ts
Normal file → Executable file
0
web/src/utils.ts
Normal file → Executable file
Reference in New Issue
Block a user