+
- {/* ── Main content ──────────────────────────────────────────────── */}
-
+ {/* ── Main content ── */}
+
{children}
);
}
-
-function SectionLabel({
- children,
- style,
-}: {
- children: React.ReactNode;
- style?: React.CSSProperties;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/web/src/index.css b/web/src/index.css
index d6d0850..12647e0 100755
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -16,23 +16,30 @@ input, textarea, button, select {
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */
:root {
- --bg: #0f0f12;
+ /* v2 dark-space palette */
+ --bg: #0c0e1a;
+ --bg-card: #10131f;
+ --bg-raised: #151828;
+ --bg-hover: #1a1e30;
--bg-subtle: rgba(255,255,255,0.025);
--bg-inset: rgba(255,255,255,0.04);
- --border: rgba(255,255,255,0.08);
- --border-subtle: rgba(255,255,255,0.05);
- --text: #eeeef2;
- --text-muted: rgba(255,255,255,0.35);
- --text-subtle: rgba(255,255,255,0.22);
- --accent: #e8a22a;
- --accent-hover: #f0b740;
- --accent-bg: rgba(232,162,42,0.1);
- --accent-border: rgba(232,162,42,0.28);
- --accent-fg: #0f0f12;
- --teal: #4dba85;
- --teal-bg: rgba(61,200,120,0.1);
- --danger: #e07070;
- --danger-bg: rgba(220,80,80,0.1);
+ --border: rgba(255,255,255,0.06);
+ --border-bright: rgba(255,255,255,0.12);
+ --border-subtle: rgba(255,255,255,0.04);
+ --text: #e8e9f0;
+ --text-muted: rgba(232,233,240,0.55);
+ --text-subtle: rgba(232,233,240,0.28);
+ /* Violet accent */
+ --accent: #8b5cf6;
+ --accent-light: #a78bfa;
+ --accent-hover: #9f70f8;
+ --accent-bg: rgba(139,92,246,0.12);
+ --accent-border: rgba(139,92,246,0.3);
+ --accent-fg: #ffffff;
+ --teal: #34d399;
+ --teal-bg: rgba(52,211,153,0.1);
+ --danger: #f43f5e;
+ --danger-bg: rgba(244,63,94,0.1);
}
/* ── Responsive Layout ──────────────────────────────────────────────────── */
diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx
index 5f37057..afb4514 100755
--- a/web/src/pages/BandPage.tsx
+++ b/web/src/pages/BandPage.tsx
@@ -1,50 +1,44 @@
-import { useState, useMemo } from "react";
-import { useParams, Link } from "react-router-dom";
+import { useState, useEffect } from "react";
+import { useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getBand } from "../api/bands";
-import { api } from "../api/client";
+import { LibraryPanel } from "../components/LibraryPanel";
+import { PlayerPanel } from "../components/PlayerPanel";
-interface SongSummary {
- id: string;
- title: string;
- status: string;
- tags: string[];
- global_key: string | null;
- global_bpm: number | null;
- version_count: number;
+// ── Empty player state ────────────────────────────────────────────────────────
+
+function EmptyPlayer() {
+ return (
+
+
+
+ Select a track from the library to start listening
+
+
+ );
}
-interface SessionSummary {
- id: string;
- date: string;
- label: string | null;
- recording_count: number;
-}
-
-type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented";
-
-const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"];
-
-function formatDate(iso: string): string {
- const d = new Date(iso.slice(0, 10) + "T12:00:00");
- return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
-}
-
-function formatDateLabel(iso: string): string {
- const d = new Date(iso.slice(0, 10) + "T12:00:00");
- const today = new Date();
- today.setHours(12, 0, 0, 0);
- const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
- if (diffDays === 0) {
- return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
- }
- return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
-}
+// ── BandPage ──────────────────────────────────────────────────────────────────
export function BandPage() {
const { bandId } = useParams<{ bandId: string }>();
- const [librarySearch, setLibrarySearch] = useState("");
- const [activePill, setActivePill] = useState
("all");
+ const [searchParams, setSearchParams] = useSearchParams();
+ // selectedSongId is kept in URL as ?song= so deep-links and browser back work
+ const selectedSongId = searchParams.get("song");
+
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 900);
+ check();
+ window.addEventListener("resize", check);
+ return () => window.removeEventListener("resize", check);
+ }, []);
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
@@ -52,239 +46,63 @@ export function BandPage() {
enabled: !!bandId,
});
- const { data: sessions } = useQuery({
- queryKey: ["sessions", bandId],
- queryFn: () => api.get(`/bands/${bandId}/sessions`),
- enabled: !!bandId,
- });
+ function selectSong(songId: string) {
+ setSearchParams({ song: songId }, { replace: false });
+ }
- const { data: unattributedSongs } = useQuery({
- queryKey: ["songs-unattributed", bandId],
- queryFn: () => api.get(`/bands/${bandId}/songs/search?unattributed=true`),
- enabled: !!bandId,
- });
+ function clearSong() {
+ setSearchParams({}, { replace: false });
+ }
- const filteredSessions = useMemo(() => {
- return (sessions ?? []).filter((s) => {
- if (!librarySearch) return true;
- const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
- return haystack.includes(librarySearch.toLowerCase());
- });
- }, [sessions, librarySearch]);
+ if (isLoading) return Loading…
;
+ if (!band || !bandId) return Band not found
;
- const filteredUnattributed = useMemo(() => {
- return (unattributedSongs ?? []).filter((song) => {
- const matchesSearch =
- !librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase());
- const matchesPill =
- activePill === "all" ||
- activePill === "commented" ||
- song.tags.some((t) => t.toLowerCase() === activePill);
- return matchesSearch && matchesPill;
- });
- }, [unattributedSongs, librarySearch, activePill]);
+ // ── Mobile: show library OR player, not both ──────────────────────────────
+ if (isMobile) {
+ if (selectedSongId) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ );
+ }
- if (isLoading) return Loading...
;
- if (!band) return Band not found
;
-
- const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
+ // ── Desktop: three-panel (Sidebar is handled by AppShell, we add Library + Player) ──
return (
-
+
+
- {/* ── Header ─────────────────────────────────────────────── */}
-
- {/* Title row + search + actions */}
-
-
- Library
-
-
- {/* Search input */}
-
-
-
setLibrarySearch(e.target.value)}
- placeholder="Search recordings, comments…"
- style={{
- width: "100%",
- padding: "7px 12px 7px 30px",
- background: "rgba(255,255,255,0.05)",
- border: "1px solid rgba(255,255,255,0.08)",
- borderRadius: 7,
- color: "#e2e2e8",
- fontSize: 13,
- fontFamily: "inherit",
- outline: "none",
- boxSizing: "border-box",
- }}
- onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")}
- onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
- />
-
-
-
- {/* Filter pills */}
-
- {PILLS.map((pill) => {
- const active = activePill === pill;
- return (
- setActivePill(pill)}
- style={{
- padding: "3px 10px",
- borderRadius: 20,
- cursor: "pointer",
- border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
- background: active ? "rgba(232,162,42,0.1)" : "transparent",
- color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
- fontSize: 11,
- fontFamily: "inherit",
- transition: "all 0.12s",
- textTransform: "capitalize",
- }}
- >
- {pill}
-
- );
- })}
-
-
-
- {/* ── Scrollable content ────────────────────────────────── */}
-
-
- {/* Sessions — one date group per session */}
- {filteredSessions.map((s) => (
-
- {/* Date group header */}
-
-
- {formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""}
-
-
-
- {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
-
-
-
- {/* Session row */}
-
{
- (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
- (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
- (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
- }}
- >
- {/* Session name */}
-
- {s.label ?? formatDate(s.date)}
-
-
- {/* Recording count */}
-
- {s.recording_count}
-
-
-
- ))}
-
- {/* Unattributed recordings */}
- {filteredUnattributed.length > 0 && (
-
0 ? 28 : 18 }}>
- {/* Section header */}
-
-
- Unattributed
-
-
-
- {filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
-
-
-
-
- {filteredUnattributed.map((song) => (
-
{
- (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
- (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
- (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
- }}
- >
-
-
- {song.title}
-
-
- {song.tags.map((t) => (
-
- {t}
-
- ))}
- {song.global_key && (
-
- {song.global_key}
-
- )}
- {song.global_bpm && (
-
- {song.global_bpm.toFixed(0)} BPM
-
- )}
-
-
-
-
- {song.version_count} ver{song.version_count !== 1 ? "s" : ""}
-
-
- ))}
-
-
- )}
-
- {/* Empty state */}
- {!hasResults && (
-
- {librarySearch
- ? "No results match your search."
- : "No sessions yet. Go to Storage settings to scan your Nextcloud folder."}
-
- )}
-
+ {selectedSongId ? (
+
+ ) : (
+
+ )}
);
}
-
diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx
index 72af380..ae6d40f 100755
--- a/web/src/pages/SongPage.tsx
+++ b/web/src/pages/SongPage.tsx
@@ -1,1030 +1,23 @@
-import { useRef, useState, useCallback, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { api } from "../api/client";
-import type { MemberRead } from "../api/auth";
-import { useWaveform } from "../hooks/useWaveform";
-
-// ── Types ─────────────────────────────────────────────────────────────────────
-
-interface SongRead {
- id: string;
- band_id: string;
- session_id: string | null;
- title: string;
- status: string;
- tags: string[];
- global_key: string | null;
- global_bpm: number | null;
- version_count: number;
-}
-
-interface SongVersion {
- id: string;
- version_number: number;
- label: string | null;
- analysis_status: string;
-}
-
-interface SongComment {
- id: string;
- song_id: string;
- body: string;
- author_id: string;
- author_name: string;
- author_avatar_url: string | null;
- timestamp: number | null;
- tag: string | null;
- created_at: string;
-}
-
-interface SessionSong {
- id: string;
- title: string;
- status: string;
- tags: string[];
-}
-
-interface SessionDetail {
- id: string;
- band_id: string;
- date: string;
- label: string | null;
- songs: SessionSong[];
-}
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-function formatTime(seconds: number): string {
- const m = Math.floor(seconds / 60);
- const s = Math.floor(seconds % 60);
- return `${m}:${String(s).padStart(2, "0")}`;
-}
-
-function getInitials(name: string): string {
- return name
- .split(/\s+/)
- .map((w) => w[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
-}
-
-// Deterministic color per author id (cycles through member palette)
-const MEMBER_COLORS = [
- { bg: "rgba(91,156,240,0.18)", border: "rgba(91,156,240,0.6)", text: "#7aabf0" },
- { bg: "rgba(200,90,180,0.18)", border: "rgba(200,90,180,0.6)", text: "#d070c0" },
- { bg: "rgba(61,200,120,0.18)", border: "rgba(61,200,120,0.6)", text: "#4dba85" },
- { bg: "rgba(232,162,42,0.18)", border: "rgba(232,162,42,0.6)", text: "#e8a22a" },
- { bg: "rgba(140,90,220,0.18)", border: "rgba(140,90,220,0.6)", text: "#a878e8" },
-];
-
-function memberColor(authorId: string) {
- let h = 0;
- for (let i = 0; i < authorId.length; i++) h = (h * 31 + authorId.charCodeAt(i)) >>> 0;
- return MEMBER_COLORS[h % MEMBER_COLORS.length];
-}
-
-const TAG_STYLES: Record
= {
- suggestion: { bg: "rgba(91,156,240,0.1)", color: "#7aabf0" },
- issue: { bg: "rgba(220,80,80,0.1)", color: "#e07070" },
- keeper: { bg: "rgba(61,200,120,0.1)", color: "#4dba85" },
-};
-
-// ── Avatar circle ─────────────────────────────────────────────────────────────
-
-function Avatar({
- name,
- avatarUrl,
- authorId,
- size = 24,
-}: {
- name: string;
- avatarUrl: string | null;
- authorId: string;
- size?: number;
-}) {
- const mc = memberColor(authorId);
- if (avatarUrl) {
- return (
-
- );
- }
- return (
-
- {getInitials(name)}
-
- );
-}
-
-// ── Transport icons ───────────────────────────────────────────────────────────
-
-function IconSkipBack() {
- return (
-
- );
-}
-
-function IconSkipFwd() {
- return (
-
- );
-}
-
-function IconPlay() {
- return (
-
- );
-}
-
-function IconPause() {
- return (
-
- );
-}
-
-// ── WaveformPins — rendered in a div above the WaveSurfer canvas ───────────────
-
-function WaveformPins({
- comments,
- duration,
- containerWidth,
- onSeek,
- onScrollToComment,
-}: {
- comments: SongComment[];
- duration: number;
- containerWidth: number;
- onSeek: (t: number) => void;
- onScrollToComment: (id: string) => void;
-}) {
- const [hoveredId, setHoveredId] = useState(null);
-
- const pinned = comments.filter((c) => c.timestamp != null);
-
- return (
-
- {pinned.map((c) => {
- const pct = duration > 0 ? (c.timestamp! / duration) : 0;
- const left = Math.round(pct * containerWidth);
- const mc = memberColor(c.author_id);
- const isHovered = hoveredId === c.id;
-
- return (
-
setHoveredId(c.id)}
- onMouseLeave={() => setHoveredId(null)}
- onClick={() => {
- onSeek(c.timestamp!);
- onScrollToComment(c.id);
- }}
- >
- {/* Tooltip */}
- {isHovered && (
-
-
-
- {getInitials(c.author_name)}
-
-
{c.author_name}
-
- {formatTime(c.timestamp!)}
-
-
-
- {c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body}
-
-
- )}
- {/* Avatar circle */}
-
- {getInitials(c.author_name)}
-
- {/* Stem */}
-
-
- );
- })}
-
- );
-}
-
-// ── SongPage ──────────────────────────────────────────────────────────────────
+import { PlayerPanel } from "../components/PlayerPanel";
+/**
+ * Standalone song view — used when navigating directly to /bands/:bandId/songs/:songId.
+ * Wraps PlayerPanel with back-navigation to the band's library.
+ */
export function SongPage() {
const { bandId, songId } = useParams<{ bandId: string; songId: string }>();
const navigate = useNavigate();
- const qc = useQueryClient();
- const waveformRef = useRef(null);
- const waveformContainerRef = useRef(null);
- const [selectedVersionId, setSelectedVersionId] = useState(null);
- const [commentBody, setCommentBody] = useState("");
- const [selectedTag, setSelectedTag] = useState("");
- const [composeFocused, setComposeFocused] = useState(false);
- const [waveformWidth, setWaveformWidth] = useState(0);
-
- // ── Data fetching ──────────────────────────────────────────────────────
-
- const { data: me } = useQuery({
- queryKey: ["me"],
- queryFn: () => api.get("/auth/me"),
- });
-
- const { data: song } = useQuery({
- queryKey: ["song", songId],
- queryFn: () => api.get(`/songs/${songId}`),
- enabled: !!songId,
- });
-
- const { data: versions } = useQuery({
- queryKey: ["versions", songId],
- queryFn: () => api.get(`/songs/${songId}/versions`),
- enabled: !!songId,
- });
-
- const { data: session } = useQuery({
- queryKey: ["session", song?.session_id],
- queryFn: () => api.get(`/bands/${bandId}/sessions/${song!.session_id}`),
- enabled: !!song?.session_id && !!bandId,
- });
-
- const { data: comments } = useQuery({
- queryKey: ["comments", songId],
- queryFn: () => api.get(`/songs/${songId}/comments`),
- enabled: !!songId,
- });
-
- // ── Version selection ──────────────────────────────────────────────────
-
- const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
-
- // ── Waveform ─────────────────────────────────────────────────────────────
-
- const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
- url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
- peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
- songId: songId,
- bandId: bandId,
- });
-
- // Track waveform container width for pin positioning
- useEffect(() => {
- const el = waveformContainerRef.current;
- if (!el) return;
- const ro = new ResizeObserver((entries) => {
- setWaveformWidth(entries[0].contentRect.width);
- });
- ro.observe(el);
- setWaveformWidth(el.offsetWidth);
- return () => ro.disconnect();
- }, []);
-
- // ── Keyboard shortcut: Space ────────────────────────────────────────────
-
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- const target = e.target as HTMLElement;
- if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
- if (e.code === "Space") {
- e.preventDefault();
- if (isPlaying) {
- pause();
- } else {
- if (isReady) {
- play();
- }
- }
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [isPlaying, isReady, play, pause]);
-
- // ── Comments ─────────────────────────────────────────────────────────────
-
- const addCommentMutation = useMutation({
- mutationFn: ({ body, timestamp, tag }: { body: string; timestamp: number; tag: string }) =>
- api.post(`/songs/${songId}/comments`, { body, timestamp, tag: tag || null }),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: ["comments", songId] });
- setCommentBody("");
- setSelectedTag("");
- },
- });
-
- const deleteCommentMutation = useMutation({
- mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
- });
-
- const scrollToComment = useCallback((commentId: string) => {
- const el = document.getElementById(`comment-${commentId}`);
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
- }
- }, []);
-
-
- // ── Styles ──────────────────────────────────────────────────────────────
-
- const border = "rgba(255,255,255,0.055)";
-
- // ── Render ──────────────────────────────────────────────────────────────
+ if (!bandId || !songId) return null;
return (
-
-
- {/* ── Breadcrumb header ──────────────────────────────────────────── */}
-
-
- navigate(`/bands/${bandId}`)}
- style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(255,255,255,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
- onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(255,255,255,0.6)")}
- onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(255,255,255,0.28)")}
- >
- Library
-
- {session && (
- <>
- ›
- navigate(`/bands/${bandId}/sessions/${session.id}`)}
- style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(255,255,255,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
- onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(255,255,255,0.6)")}
- onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(255,255,255,0.28)")}
- >
- {session.date}
-
- >
- )}
- ›
-
- {song?.title ?? "…"}
-
-
-
- {/* Version selector */}
- {versions && versions.length > 1 && (
-
- {versions.map((v) => (
- setSelectedVersionId(v.id)}
- style={
- {
- background: v.id === activeVersion ? "rgba(232,162,42,0.14)" : "transparent",
- border: `1px solid ${v.id === activeVersion ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.09)"}`,
- borderRadius: 6,
- padding: "4px 10px",
- color: v.id === activeVersion ? "#e8a22a" : "rgba(255,255,255,0.38)",
- cursor: "pointer",
- fontSize: 11,
- fontFamily: "monospace",
- }
- }
- >
- v{v.version_number}{v.label ? ` · ${v.label}` : ""}
-
- ))}
-
- )}
-
-
- Share
-
-
-
- {/* ── Body: waveform | comments ────────────────────────────────── */}
-
-
- {/* ── Waveform section (top) ──────────────────────────────── */}
-
-
- {/* Waveform card */}
-
-
-
- {song?.title ?? "…"}
-
-
- {isReady ? formatTime(duration) : "—"}
-
-
-
- {/* Pin layer */}
-
- {isReady && duration > 0 && comments && (
-
- )}
- {!isReady &&
}
-
- {/* WaveSurfer canvas target */}
-
-
- {/* Error message */}
- {error && (
-
- Audio error: {error}
-
- )}
-
- {/* Loading indicator */}
- {!isReady && !error && (
-
- Loading audio...
-
- )}
-
-
- {/* Time display */}
-
-
- {formatTime(currentTime)}
-
-
- {isReady && duration > 0 ? formatTime(duration / 2) : "—"}
-
-
- {isReady ? formatTime(duration) : "—"}
-
-
-
-
- {/* Transport */}
-
- {/* Skip back */}
- seekTo(Math.max(0, currentTime - 30))} title="−30s">
-
-
-
- {/* Play/Pause */}
- { if (activeVersion) e.currentTarget.style.background = "#f0b740"; }}
- onMouseLeave={(e) => { e.currentTarget.style.background = "#e8a22a"; }}
- >
- {isPlaying ? : }
-
-
- {/* Skip forward */}
- seekTo(currentTime + 30)} title="+30s">
-
-
-
-
-
-
-
-
- {/* ── Comments section (bottom) ──────────────────────────────── */}
-
- {/* Header */}
-
- Comments
- {comments && comments.length > 0 && (
-
- {comments.length}
-
- )}
-
-
- {/* Compose section (moved to top) */}
-
-
- {/* My avatar */}
- {me ? (
-
- ) : (
-
- )}
-
-
- {/* Timestamp pill */}
-
-
- {isPlaying && (
-
- )}
- {formatTime(currentTime)}
-
-
· pins to playhead
-
-
- {/* Textarea */}
-
-
-
-
- {/* Scrollable comment list */}
-
- {comments?.map((c) => {
- const tagStyle = c.tag ? TAG_STYLES[c.tag] : null;
- const isNearPlayhead = isReady && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 5;
-
- return (
-
- );
- })}
-
- {comments?.length === 0 && (
-
No comments yet.
- )}
-
-
-
-
-
- {/* Blink animation for timestamp dot */}
-
+
+
navigate(`/bands/${bandId}`)}
+ />
);
}
-
-// ── Small reusable components ─────────────────────────────────────────────────
-
-function TransportButton({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) {
- const [hovered, setHovered] = useState(false);
- return (
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- style={
- {
- width: 34,
- height: 34,
- borderRadius: "50%",
- background: hovered ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.04)",
- border: "1px solid rgba(255,255,255,0.07)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- cursor: "pointer",
- color: hovered ? "rgba(255,255,255,0.7)" : "rgba(255,255,255,0.35)",
- flexShrink: 0,
- transition: "all 0.12s",
- }
- }
- >
- {children}
-
- );
-}