diff --git a/api/alembic/versions/0005_comment_tag.py b/api/alembic/versions/0005_comment_tag.py new file mode 100644 index 0000000..f9314cc --- /dev/null +++ b/api/alembic/versions/0005_comment_tag.py @@ -0,0 +1,25 @@ +"""Add tag column to song_comments + +Revision ID: 0005_comment_tag +Revises: 0004_rehearsal_sessions +Create Date: 2026-04-06 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0005_comment_tag" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "song_comments", + sa.Column("tag", sa.String(length=32), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("song_comments", "tag") diff --git a/api/src/rehearsalhub/db/models.py b/api/src/rehearsalhub/db/models.py index 8a59aba..5b9bd0d 100644 --- a/api/src/rehearsalhub/db/models.py +++ b/api/src/rehearsalhub/db/models.py @@ -207,6 +207,7 @@ class SongComment(Base): ) body: Mapped[str] = mapped_column(Text, nullable=False) timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True) + tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/api/src/rehearsalhub/routers/songs.py b/api/src/rehearsalhub/routers/songs.py index cb0a9ee..3c9ce40 100644 --- a/api/src/rehearsalhub/routers/songs.py +++ b/api/src/rehearsalhub/routers/songs.py @@ -89,6 +89,24 @@ async def search_songs( ] +@router.get("/songs/{song_id}", response_model=SongRead) +async def get_song( + song_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + current_member: Member = Depends(get_current_member), +): + song_repo = SongRepository(session) + song = await song_repo.get_with_versions(song_id) + if song is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found") + band_svc = BandService(session) + try: + await band_svc.assert_membership(song.band_id, current_member.id) + except PermissionError: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member") + return SongRead.model_validate(song).model_copy(update={"version_count": len(song.versions)}) + + @router.patch("/songs/{song_id}", response_model=SongRead) async def update_song( song_id: uuid.UUID, @@ -264,7 +282,7 @@ async def create_comment( ): await _assert_song_membership(song_id, current_member.id, session) repo = CommentRepository(session) - comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp) + comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp, tag=data.tag) comment = await repo.get_with_author(comment.id) return SongCommentRead.from_model(comment) diff --git a/api/src/rehearsalhub/schemas/comment.py b/api/src/rehearsalhub/schemas/comment.py index ad7c90a..e269558 100644 --- a/api/src/rehearsalhub/schemas/comment.py +++ b/api/src/rehearsalhub/schemas/comment.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict class SongCommentCreate(BaseModel): body: str timestamp: float | None = None + tag: str | None = None class SongCommentRead(BaseModel): @@ -21,6 +22,7 @@ class SongCommentRead(BaseModel): author_name: str author_avatar_url: str | None timestamp: float | None + tag: str | None created_at: datetime @classmethod @@ -33,5 +35,6 @@ class SongCommentRead(BaseModel): author_name=getattr(getattr(c, "author"), "display_name"), author_avatar_url=getattr(getattr(c, "author"), "avatar_url"), timestamp=getattr(c, "timestamp"), + tag=getattr(c, "tag", None), created_at=getattr(c, "created_at"), ) diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index be2f204..c2a1894 100644 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -23,6 +23,7 @@ export function useWaveform( const [isPlaying, setIsPlaying] = useState(false); const [isReady, setIsReady] = useState(false); const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); const wasPlayingRef = useRef(false); const markersRef = useRef([]); @@ -31,12 +32,12 @@ export function useWaveform( const ws = WaveSurfer.create({ container: containerRef.current, - waveColor: "#2A3050", - progressColor: "#F0A840", - cursorColor: "#FFD080", + waveColor: "rgba(255,255,255,0.09)", + progressColor: "#c8861a", + cursorColor: "#e8a22a", barWidth: 2, barRadius: 2, - height: 80, + height: 104, normalize: true, }); @@ -45,6 +46,7 @@ export function useWaveform( ws.on("ready", () => { setIsReady(true); + setDuration(ws.getDuration()); options.onReady?.(ws.getDuration()); // Reset playing state when switching versions setIsPlaying(false); @@ -141,7 +143,7 @@ export function useWaveform( markersRef.current = []; }; - return { isPlaying, isReady, currentTime, play, pause, seekTo, addMarker, clearMarkers }; + return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers }; } function formatTime(seconds: number): string { diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx index 665804d..7241b16 100644 --- a/web/src/pages/SongPage.tsx +++ b/web/src/pages/SongPage.tsx @@ -1,11 +1,30 @@ import { useRef, useState, useCallback, useEffect } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; -import { listAnnotations, addReaction } from "../api/annotations"; -import { useVersionWebSocket } from "../hooks/useWebSocket"; +import type { MemberRead } from "../api/auth"; import { useWaveform } from "../hooks/useWaveform"; -import type { Annotation } from "../api/annotations"; + +// ── 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; @@ -14,93 +33,367 @@ interface SongComment { author_id: string; author_name: string; author_avatar_url: string | null; + timestamp: number | null; + tag: string | null; created_at: string; - timestamp: number | null; // Timestamp in seconds } +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 ( + {name} + ); + } + return ( +
+ {getInitials(name)} +
+ ); +} + +// ── Transport icons ─────────────────────────────────────────────────────────── + +function IconSkipBack() { + return ( + + + + ); +} + +function IconSkipFwd() { + return ( + + + + ); +} + +function IconPlay() { + return ( + + + + ); +} + +function IconPause() { + return ( + + + + ); +} + +function IconVolume() { + 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 ────────────────────────────────────────────────────────────────── + 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); - const { data: versions } = useQuery({ - queryKey: ["versions", songId], - queryFn: () => api.get<{ id: string; version_number: number; label: string | null; analysis_status: string }[]>(`/songs/${songId}/versions`), + // ── 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 activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null; - - const { data: annotations } = useQuery({ - queryKey: ["annotations", activeVersion], - queryFn: () => listAnnotations(activeVersion!), - enabled: !!activeVersion, + const { data: versions } = useQuery({ + queryKey: ["versions", songId], + queryFn: () => api.get(`/songs/${songId}/versions`), + enabled: !!songId, }); - const { isPlaying, currentTime, play, pause, seekTo, addMarker, clearMarkers } = useWaveform(waveformRef, { - url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null, - peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null, + const { data: session } = useQuery({ + queryKey: ["session", song?.session_id], + queryFn: () => api.get(`/bands/${bandId}/sessions/${song!.session_id}`), + enabled: !!song?.session_id && !!bandId, }); - // Add space key shortcut for play/pause - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "Space") { - e.preventDefault(); - if (isPlaying) { - pause(); - } else { - play(); - } - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isPlaying, play, pause]); - const { data: comments } = useQuery({ queryKey: ["comments", songId], queryFn: () => api.get(`/songs/${songId}/comments`), enabled: !!songId, }); - // Scroll to comment when a marker is clicked - const scrollToComment = (commentId: string) => { - const commentElement = document.getElementById(`comment-${commentId}`); - if (commentElement) { - commentElement.scrollIntoView({ behavior: "smooth", block: "center" }); - commentElement.style.backgroundColor = "var(--accent-bg)"; - setTimeout(() => { - commentElement.style.backgroundColor = "var(--bg-subtle)"; - }, 2000); - } - }; + // ── Version selection ──────────────────────────────────────────────────── + + const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null; + + // ── Waveform ───────────────────────────────────────────────────────────── + + const { isPlaying, isReady, currentTime, duration, play, pause, seekTo } = useWaveform(waveformRef, { + url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null, + peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null, + }); + + // 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(() => { - if (comments) { - clearMarkers(); - comments.forEach((comment) => { - if (comment.timestamp !== undefined && comment.timestamp !== null) { - addMarker({ - id: comment.id, - time: comment.timestamp, - onClick: () => scrollToComment(comment.id), - icon: comment.author_avatar_url || undefined, - }); - } - }); - } - }, [comments, addMarker, clearMarkers]); + 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 { play(); } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isPlaying, play, pause]); + + // ── Comments ───────────────────────────────────────────────────────────── const addCommentMutation = useMutation({ - mutationFn: ({ body, timestamp }: { body: string; timestamp: number }) => - api.post(`/songs/${songId}/comments`, { body, timestamp }), + 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(""); }, }); @@ -109,182 +402,624 @@ export function SongPage() { onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }), }); - const invalidateAnnotations = useCallback( - () => qc.invalidateQueries({ queryKey: ["annotations", activeVersion] }), - [qc, activeVersion] - ); + const scrollToComment = useCallback((commentId: string) => { + const el = document.getElementById(`comment-${commentId}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + }, []); - useVersionWebSocket(activeVersion, { - "annotation.created": invalidateAnnotations, - "annotation.updated": invalidateAnnotations, - "annotation.deleted": invalidateAnnotations, - "reaction.added": invalidateAnnotations, - }); + // ── Queue: other songs in the session ──────────────────────────────────── + + const queueSongs = session?.songs?.filter((s) => s.id !== songId) ?? []; + + // ── Styles ──────────────────────────────────────────────────────────────── + + const border = "rgba(255,255,255,0.055)"; + + // ── Render ──────────────────────────────────────────────────────────────── return ( -
- - ← Library - +
- {/* Version selector */} -
- {versions?.map((v) => ( + {/* ── Breadcrumb header ──────────────────────────────────────────── */} +
+
+ {session && ( + <> + + + + )} + + - v{v.version_number} {v.label ?? ""} · {v.analysis_status} - - ))} -
- - {/* Waveform */} -
-
-
- - - {formatTime(currentTime)} + {song?.title ?? "…"}
+ + {/* Version selector */} + {versions && versions.length > 1 && ( +
+ {versions.map((v) => ( + + ))} +
+ )} + +
- {/* Current Play Time Display */} -
- - Current Time: {formatTime(currentTime)} - -
+ {/* ── Body: waveform/queue | comments ────────────────────────────── */} +
- {/* Annotations */} -
- {annotations?.map((a) => ( - - ))} -
+ {/* ── Left: waveform + transport + queue ───────────────────────── */} +
- {/* Comments */} -
-

COMMENTS

- -
- {comments?.map((c) => ( -
-
- {c.author_name} -
- {new Date(c.created_at).toLocaleString()} - -
-
- {c.timestamp !== undefined && c.timestamp !== null && ( -
- -
- )} -

{c.body}

+ {/* Waveform card */} +
+
+ + {song?.title ?? "…"} + + + {isReady ? formatTime(duration) : "—"} + +
+ + {/* Pin layer */} +
+ {isReady && duration > 0 && comments && ( + + )} + {!isReady &&
} + + {/* WaveSurfer canvas target */} +
+
+ + {/* Time display */} +
+ + {formatTime(currentTime)} + + + {isReady && duration > 0 ? formatTime(duration / 2) : "—"} + + + {isReady ? formatTime(duration) : "—"} + +
+
+ + {/* Transport */} +
+ {/* Speed selector */} + + + {/* Skip back */} + seekTo(Math.max(0, currentTime - 30))} title="−30s"> + + + + {/* Play/Pause */} + + + {/* Skip forward */} + seekTo(currentTime + 30)} title="+30s"> + + + + {/* Time display */} + + {formatTime(currentTime)} + {isReady && duration > 0 ? ` / ${formatTime(duration)}` : ""} + + + {/* Volume */} +
+ + +
+
+ + {/* Queue */} + {queueSongs.length > 0 && ( +
+
+ Up next in session +
+
+ {queueSongs.map((s, i) => ( + + ))} +
- ))} - {comments?.length === 0 && ( -

No comments yet. Be the first.

)}
-
-