WIP: Add timestamp to comments and fix frontend errors

This commit is contained in:
Mistral Vibe
2026-03-29 22:06:36 +02:00
parent f7a07ba05e
commit a8aba72b3a
6 changed files with 139 additions and 6 deletions

View File

@@ -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")

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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"),
)

View File

@@ -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<HTMLDivElement>,
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<CommentMarker[]>([]);
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")}`;
}

View File

@@ -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<SongComment[]>({
queryKey: ["comments", songId],
queryFn: () => api.get<SongComment[]>(`/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() {
</div>
</div>
{/* Current Play Time Display */}
<div style={{ marginBottom: 16, textAlign: "center" }}>
<span style={{ color: "var(--text-muted)", fontSize: 14, fontFamily: "monospace" }}>
Current Time: {formatTime(currentTime)}
</span>
</div>
{/* Annotations */}
<div style={{ display: "grid", gap: 8, marginBottom: 32 }}>
{annotations?.map((a) => (
@@ -144,7 +180,7 @@ export function SongPage() {
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
{comments?.map((c) => (
<div key={c.id} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div id={`comment-${c.id}`} key={c.id} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: "var(--text)" }}>{c.author_name}</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
@@ -157,6 +193,16 @@ export function SongPage() {
</button>
</div>
</div>
{c.timestamp !== undefined && c.timestamp !== null && (
<div style={{ display: "flex", gap: 8, marginBottom: 6 }}>
<button
onClick={() => seekTo(c.timestamp)}
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 4, color: "var(--accent)", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }}
>
{formatTime(c.timestamp)}
</button>
</div>
)}
<p style={{ margin: 0, fontSize: 13, color: "var(--text)", lineHeight: 1.5 }}>{c.body}</p>
</div>
))}