Rework song player view to match design system
- New split layout: waveform/transport/queue left, comment panel right
- Avatar pins above waveform positioned by timestamp with hover tooltips
- Transport bar: speed selector, ±30s skip, 46px amber play/pause, volume
- Comment compose: live timestamp pill, suggestion/issue/keeper tag buttons
- Comment list: per-author colour avatars, amber timestamp seek chips,
playhead-proximity highlight, delete only shown on own comments
- Queue panel showing other songs in the same session
- Waveform colours updated to amber/dim palette (104px height)
- Add GET /songs/{song_id} endpoint for song metadata
- Add tag field to SongComment (model, schema, router, migration 0005)
- Fix migration 0005 down_revision to use short ID "0004"
- Fix ESLint no-unused-expressions in keyboard shortcut handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
api/alembic/versions/0005_comment_tag.py
Normal file
25
api/alembic/versions/0005_comment_tag.py
Normal file
@@ -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")
|
||||||
@@ -207,6 +207,7 @@ class SongComment(Base):
|
|||||||
)
|
)
|
||||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
@router.patch("/songs/{song_id}", response_model=SongRead)
|
||||||
async def update_song(
|
async def update_song(
|
||||||
song_id: uuid.UUID,
|
song_id: uuid.UUID,
|
||||||
@@ -264,7 +282,7 @@ async def create_comment(
|
|||||||
):
|
):
|
||||||
await _assert_song_membership(song_id, current_member.id, session)
|
await _assert_song_membership(song_id, current_member.id, session)
|
||||||
repo = CommentRepository(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)
|
comment = await repo.get_with_author(comment.id)
|
||||||
return SongCommentRead.from_model(comment)
|
return SongCommentRead.from_model(comment)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
class SongCommentCreate(BaseModel):
|
class SongCommentCreate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
timestamp: float | None = None
|
timestamp: float | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SongCommentRead(BaseModel):
|
class SongCommentRead(BaseModel):
|
||||||
@@ -21,6 +22,7 @@ class SongCommentRead(BaseModel):
|
|||||||
author_name: str
|
author_name: str
|
||||||
author_avatar_url: str | None
|
author_avatar_url: str | None
|
||||||
timestamp: float | None
|
timestamp: float | None
|
||||||
|
tag: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -33,5 +35,6 @@ class SongCommentRead(BaseModel):
|
|||||||
author_name=getattr(getattr(c, "author"), "display_name"),
|
author_name=getattr(getattr(c, "author"), "display_name"),
|
||||||
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
||||||
timestamp=getattr(c, "timestamp"),
|
timestamp=getattr(c, "timestamp"),
|
||||||
|
tag=getattr(c, "tag", None),
|
||||||
created_at=getattr(c, "created_at"),
|
created_at=getattr(c, "created_at"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function useWaveform(
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
const markersRef = useRef<CommentMarker[]>([]);
|
||||||
|
|
||||||
@@ -31,12 +32,12 @@ export function useWaveform(
|
|||||||
|
|
||||||
const ws = WaveSurfer.create({
|
const ws = WaveSurfer.create({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
waveColor: "#2A3050",
|
waveColor: "rgba(255,255,255,0.09)",
|
||||||
progressColor: "#F0A840",
|
progressColor: "#c8861a",
|
||||||
cursorColor: "#FFD080",
|
cursorColor: "#e8a22a",
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
height: 80,
|
height: 104,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export function useWaveform(
|
|||||||
|
|
||||||
ws.on("ready", () => {
|
ws.on("ready", () => {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
setDuration(ws.getDuration());
|
||||||
options.onReady?.(ws.getDuration());
|
options.onReady?.(ws.getDuration());
|
||||||
// Reset playing state when switching versions
|
// Reset playing state when switching versions
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -141,7 +143,7 @@ export function useWaveform(
|
|||||||
markersRef.current = [];
|
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 {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import json
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
def extract_peaks(audio: np.ndarray, num_points: int = 1000) -> list[float]:
|
def extract_peaks(audio: np.ndarray, num_points: int = 500) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Downsample audio to `num_points` RMS+peak values for waveform display.
|
Downsample audio to `num_points` RMS+peak values for waveform display.
|
||||||
Returns a flat list of [peak, peak, ...] normalized to 0-1.
|
Returns a flat list of [peak, peak, ...] normalized to 0-1.
|
||||||
|
|||||||
Reference in New Issue
Block a user