diff --git a/api/alembic/versions/0005_add_timestamp_to_song_comments.py b/api/alembic/versions/0005_add_timestamp_to_song_comments.py new file mode 100644 index 0000000..05c67d6 --- /dev/null +++ b/api/alembic/versions/0005_add_timestamp_to_song_comments.py @@ -0,0 +1,22 @@ +"""Add timestamp to song_comments. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-03-29 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("song_comments", sa.Column("timestamp", sa.Float(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("song_comments", "timestamp") diff --git a/api/src/rehearsalhub/db/models.py b/api/src/rehearsalhub/db/models.py index af9460e..8a59aba 100644 --- a/api/src/rehearsalhub/db/models.py +++ b/api/src/rehearsalhub/db/models.py @@ -206,6 +206,7 @@ class SongComment(Base): UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False ) body: Mapped[str] = mapped_column(Text, nullable=False) + timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), 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 ea4f40b..219ff00 100644 --- a/api/src/rehearsalhub/routers/songs.py +++ b/api/src/rehearsalhub/routers/songs.py @@ -264,7 +264,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) + comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp) 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 b569602..4872e12 100644 --- a/api/src/rehearsalhub/schemas/comment.py +++ b/api/src/rehearsalhub/schemas/comment.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict class SongCommentCreate(BaseModel): body: str + timestamp: float | None = None class SongCommentRead(BaseModel): @@ -18,6 +19,7 @@ class SongCommentRead(BaseModel): body: str author_id: uuid.UUID author_name: str + timestamp: float | None created_at: datetime @classmethod @@ -28,5 +30,6 @@ class SongCommentRead(BaseModel): body=getattr(c, "body"), author_id=getattr(c, "author_id"), author_name=getattr(getattr(c, "author"), "display_name"), + timestamp=getattr(c, "timestamp"), created_at=getattr(c, "created_at"), ) diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index 45b713d..de20cc8 100644 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -8,6 +8,13 @@ export interface UseWaveformOptions { onTimeUpdate?: (currentTime: number) => void; } +export interface CommentMarker { + id: string; + time: number; // Time in seconds + onClick: () => void; + icon?: string; // URL for the account icon +} + export function useWaveform( containerRef: React.RefObject, options: UseWaveformOptions @@ -17,6 +24,7 @@ export function useWaveform( const [isReady, setIsReady] = useState(false); const [currentTime, setCurrentTime] = useState(0); const wasPlayingRef = useRef(false); + const markersRef = useRef([]); useEffect(() => { if (!containerRef.current || !options.url) return; @@ -79,10 +87,63 @@ export function useWaveform( wasPlayingRef.current = false; }; const seekTo = (time: number) => { - if (wsRef.current && isReady) { + if (wsRef.current && isReady && isFinite(time)) { wsRef.current.setTime(time); } }; - return { isPlaying, isReady, currentTime, play, pause, seekTo }; + const addMarker = (marker: CommentMarker) => { + if (wsRef.current && isReady) { + const wavesurfer = wsRef.current; + const markerElement = document.createElement("div"); + markerElement.style.position = "absolute"; + markerElement.style.width = "20px"; + markerElement.style.height = "20px"; + markerElement.style.borderRadius = "50% "; + markerElement.style.backgroundColor = "var(--accent)"; + markerElement.style.cursor = "pointer"; + markerElement.style.zIndex = "9999"; + markerElement.style.left = `${(marker.time / wavesurfer.getDuration()) * 100}%`; + markerElement.style.transform = "translateX(-50%) translateY(-50%)"; + markerElement.style.top = "50% "; + markerElement.title = `Comment at ${formatTime(marker.time)}`; + markerElement.onclick = marker.onClick; + + if (marker.icon) { + const iconElement = document.createElement("img"); + iconElement.src = marker.icon; + iconElement.style.width = "100% "; + iconElement.style.height = "100% "; + iconElement.style.borderRadius = "50% "; + markerElement.appendChild(iconElement); + } + + const waveformContainer = containerRef.current; + if (waveformContainer) { + waveformContainer.style.position = "relative"; + waveformContainer.appendChild(markerElement); + } + + markersRef.current.push(marker); + } + }; + + const clearMarkers = () => { + const waveformContainer = containerRef.current; + if (waveformContainer) { + const markerElements = waveformContainer.querySelectorAll("div[title^='Comment at']"); + markerElements.forEach((element) => { + waveformContainer.removeChild(element); + }); + } + markersRef.current = []; + }; + + return { isPlaying, isReady, currentTime, play, pause, seekTo, addMarker, clearMarkers }; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, "0")}`; } diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx index b96071f..8141799 100644 --- a/web/src/pages/SongPage.tsx +++ b/web/src/pages/SongPage.tsx @@ -14,6 +14,7 @@ interface SongComment { author_id: string; author_name: string; created_at: string; + timestamp: number; // Timestamp in seconds } export function SongPage() { @@ -37,7 +38,7 @@ export function SongPage() { enabled: !!activeVersion, }); - const { isPlaying, currentTime, play, pause, seekTo } = useWaveform(waveformRef, { + 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, }); @@ -59,12 +60,40 @@ export function SongPage() { return () => window.removeEventListener("keydown", handleKeyDown); }, [isPlaying, play, pause]); - const { data: comments } = useQuery({ + 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); + } + }; + + 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: "https://via.placeholder.com/20", // Replace with actual user icon URL + }); + } + }); + } + }, [comments, addMarker, clearMarkers]); + const addCommentMutation = useMutation({ mutationFn: (body: string) => api.post(`/songs/${songId}/comments`, { body }), onSuccess: () => { @@ -131,6 +160,13 @@ export function SongPage() { + {/* Current Play Time Display */} +
+ + Current Time: {formatTime(currentTime)} + +
+ {/* Annotations */}
{annotations?.map((a) => ( @@ -144,7 +180,7 @@ export function SongPage() {
{comments?.map((c) => ( -
+
{c.author_name}
@@ -157,6 +193,16 @@ export function SongPage() {
+ {c.timestamp !== undefined && c.timestamp !== null && ( +
+ +
+ )}

{c.body}

))}