diff --git a/backend/Dockerfile b/backend/Dockerfile index dbd9f83..297f05c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,6 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs & COPY pyproject.toml /app/pyproject.toml COPY requirements.txt requirements-dev.txt /app/ COPY app /app/app -RUN pip install --no-cache-dir -r requirements-dev.txt +RUN pip install --no-cache-dir -e .[dev] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/models.py b/backend/app/models.py index b477a9d..468c5b8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -55,6 +55,7 @@ class AudioClip(Base): label: Mapped[str] = mapped_column(String(255)) start_ms: Mapped[int] = mapped_column(Integer) end_ms: Mapped[int] = mapped_column(Integer) + sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True) normalization_status: Mapped[str] = mapped_column(String(32), default="pending") normalized_path: Mapped[str | None] = mapped_column(String(512)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -65,7 +66,7 @@ class AudioClip(Base): class GameAssignment(Base): __tablename__ = "game_assignments" __table_args__ = ( - UniqueConstraint("external_game_id", "external_player_id", "clip_id", name="uq_game_assignment_player_clip"), + UniqueConstraint("external_game_id", "clip_id", name="uq_game_assignment_game_clip"), ) id: Mapped[int] = mapped_column(primary_key=True) diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 5495c95..24b781c 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.orm import Session from ..auth import require_session @@ -40,6 +40,24 @@ def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse ) +@router.get("/pins", response_model=list[GameAssignmentResponse]) +def list_pins( + external_player_id: str | None = Query(default=None), + session: UserSession = Depends(require_session), + db: Session = Depends(get_db), +) -> list[GameAssignmentResponse]: + player_id = external_player_id or session.external_player_id + if not player_id or not session.external_team_id: + raise HTTPException(status_code=422, detail="Provide a player to list pins") + + query = select(GameAssignment).join(GameAssignment.clip).where( + GameAssignment.external_team_id == session.external_team_id, + GameAssignment.external_player_id == player_id, + ) + pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all() + return [assignment_to_response(assignment) for assignment in pins] + + @router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse]) def list_assignments( external_game_id: str, @@ -47,10 +65,10 @@ def list_assignments( _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[GameAssignmentResponse]: - query = select(GameAssignment).where(GameAssignment.external_game_id == external_game_id) + query = select(GameAssignment).join(GameAssignment.clip).where(GameAssignment.external_game_id == external_game_id) if external_player_id: query = query.where(GameAssignment.external_player_id == external_player_id) - assignments = db.scalars(query.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())).all() + assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all() return [assignment_to_response(assignment) for assignment in assignments] @@ -67,12 +85,11 @@ def create_assignment( if clip.asset.external_team_id != payload.external_team_id: raise HTTPException(status_code=422, detail="Clip does not belong to this team") if clip.asset.owner_external_player_id != payload.external_player_id: - raise HTTPException(status_code=403, detail="You can only attach clips owned by that player") + raise HTTPException(status_code=403, detail="You can only pin clips owned by that player") assignment = db.scalar( select(GameAssignment).where( GameAssignment.external_game_id == external_game_id, - GameAssignment.external_player_id == payload.external_player_id, GameAssignment.clip_id == payload.clip_id, ) ) @@ -88,6 +105,7 @@ def create_assignment( db.add(assignment) else: assignment.external_team_id = payload.external_team_id + assignment.external_player_id = payload.external_player_id assignment.clip_id = payload.clip_id assignment.batting_slot = payload.batting_slot assignment.status = payload.status @@ -96,6 +114,25 @@ def create_assignment( return assignment_to_response(assignment) +@router.delete("/{external_game_id}/assignments/{assignment_id}", status_code=204) +def delete_assignment( + external_game_id: str, + assignment_id: int, + external_player_id: str | None = Query(default=None), + _: UserSession = Depends(require_session), + db: Session = Depends(get_db), +) -> None: + assignment = db.get(GameAssignment, assignment_id) + if assignment is None or assignment.external_game_id != external_game_id: + raise HTTPException(status_code=404, detail="Pin not found") + if external_player_id is not None and assignment.external_player_id != external_player_id: + raise HTTPException(status_code=403, detail="Pin does not belong to that player") + + db.execute(update(PlaybackSession).where(PlaybackSession.current_assignment_id == assignment.id).values(current_assignment_id=None)) + db.delete(assignment) + db.commit() + + @router.get("/{external_game_id}/prep", response_model=GamePrepResponse) def prepare_game( external_game_id: str, @@ -104,8 +141,9 @@ def prepare_game( ) -> GamePrepResponse: assignments = db.scalars( select(GameAssignment) + .join(GameAssignment.clip) .where(GameAssignment.external_game_id == external_game_id) - .order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc()) + .order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc()) ).all() external_team_id = assignments[0].external_team_id if assignments else "" return GamePrepResponse( @@ -148,12 +186,12 @@ def trigger_playback( raise HTTPException(status_code=404, detail="Playback session not found") if payload.assignment_id is None and payload.clip_id is None: - raise HTTPException(status_code=422, detail="Provide an assignment or clip to trigger") + raise HTTPException(status_code=422, detail="Provide a pin or clip to trigger") if payload.assignment_id is not None: assignment = db.get(GameAssignment, payload.assignment_id) if assignment is None or assignment.external_game_id != external_game_id: - raise HTTPException(status_code=404, detail="Assignment not found") + raise HTTPException(status_code=404, detail="Pin not found") playback.current_assignment_id = assignment.id else: clip = db.get(AudioClip, payload.clip_id) diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py index 58d378e..fd47f0c 100644 --- a/backend/app/routes/media.py +++ b/backend/app/routes/media.py @@ -6,7 +6,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse -from sqlalchemy import delete, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session from ..auth import require_session @@ -19,6 +19,7 @@ from ..schemas import ( AudioClipCreate, AudioClipResponse, AudioClipUpdate, + AudioClipReorder, ) from ..storage import storage @@ -39,6 +40,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse: label=clip.label, start_ms=clip.start_ms, end_ms=clip.end_ms, + sort_order=clip.sort_order, normalization_status=clip.normalization_status, normalized_url=normalized_url, waveform_duration_ms=waveform["duration_ms"] if waveform else None, @@ -53,6 +55,18 @@ def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_pla return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id +def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int: + highest_sort_order = db.scalar( + select(func.max(AudioClip.sort_order)) + .join(AudioClip.asset) + .where( + AudioAsset.external_team_id == external_team_id, + AudioAsset.owner_external_player_id == owner_external_player_id, + ) + ) + return highest_sort_order + 1 if highest_sort_order is not None else 0 + + def create_asset_with_default_clip( *, db: Session, @@ -83,6 +97,11 @@ def create_asset_with_default_clip( label=asset.title, start_ms=0, end_ms=DEFAULT_CLIP_LENGTH_MS, + sort_order=next_clip_sort_order( + db, + external_team_id=external_team_id, + owner_external_player_id=owner_external_player_id, + ), normalization_status="processing", ) db.add(clip) @@ -282,6 +301,11 @@ def create_clip( label=payload.label, start_ms=payload.start_ms, end_ms=payload.end_ms, + sort_order=next_clip_sort_order( + db, + external_team_id=payload.external_team_id, + owner_external_player_id=payload.owner_external_player_id, + ), normalization_status="processing", ) db.add(clip) @@ -314,6 +338,8 @@ def update_clip( clip.label = payload.label or clip.label clip.start_ms = payload.start_ms clip.end_ms = payload.end_ms + if payload.sort_order is not None: + clip.sort_order = payload.sort_order db.commit() db.refresh(clip) return clip_to_response(clip) @@ -357,7 +383,7 @@ def list_clips( select(AudioClip) .join(AudioClip.asset) .where(AudioAsset.external_team_id == external_team_id) - .order_by(AudioClip.created_at.desc()) + .order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc()) ) if owner_external_player_id: query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) @@ -365,6 +391,33 @@ def list_clips( return [clip_to_response(clip) for clip in clips] +@router.post("/clips/reorder", status_code=204) +def reorder_clips( + payload: AudioClipReorder, + session: UserSession = Depends(require_session), + db: Session = Depends(get_db), +) -> None: + if not session.is_admin and session.external_team_id != payload.external_team_id: + raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team") + + clips = db.scalars( + select(AudioClip) + .join(AudioClip.asset) + .where( + AudioAsset.external_team_id == payload.external_team_id, + AudioAsset.owner_external_player_id == payload.owner_external_player_id, + ) + ).all() + clips_by_id = {clip.id: clip for clip in clips} + if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids): + raise HTTPException(status_code=422, detail="Clip order must include every clip for that player") + + for sort_order, clip_id in enumerate(payload.clip_ids): + clips_by_id[clip_id].sort_order = sort_order + + db.commit() + + @router.get("/files/{relative_path:path}") def media_file(relative_path: str) -> FileResponse: path = storage.absolute_path(relative_path) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8888bbe..96d02cb 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -67,6 +67,7 @@ class AudioClipUpdate(BaseModel): label: str | None = Field(default=None, min_length=1, max_length=255) start_ms: int = Field(ge=0) end_ms: int = Field(gt=0) + sort_order: int | None = None class AudioClipResponse(BaseModel): @@ -78,6 +79,7 @@ class AudioClipResponse(BaseModel): label: str start_ms: int end_ms: int + sort_order: int normalization_status: str normalized_url: str | None waveform_duration_ms: int | None = None @@ -116,6 +118,12 @@ class GamePrepResponse(BaseModel): assignments: list[GameAssignmentResponse] +class AudioClipReorder(BaseModel): + external_team_id: str + owner_external_player_id: str + clip_ids: list[int] = Field(min_length=1) + + class PlaybackSessionCreate(BaseModel): external_team_id: str diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 966457e..19ee69e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -135,7 +135,7 @@ def test_walkup_session_selection_is_persisted_in_session() -> None: assert response.json()["external_player_id"] == "player-1002" -def test_player_can_attach_multiple_clips_to_same_game() -> None: +def test_player_can_pin_a_clip_to_multiple_games_independently() -> None: login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"}) assert login.status_code == 200 @@ -159,18 +159,9 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None: normalization_status="ready", normalized_path="clips/intro.mp3", ) - second_clip = AudioClip( - asset_id=asset.id, - label="Chorus", - start_ms=12000, - end_ms=22000, - normalization_status="ready", - normalized_path="clips/chorus.mp3", - ) - db.add_all([first_clip, second_clip]) + db.add(first_clip) db.commit() db.refresh(first_clip) - db.refresh(second_clip) db.close() first_response = client.post( @@ -183,28 +174,68 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None: "status": "ready", }, ) - second_response = client.post( - "/games/game-1/assignments", + second_game_response = client.post( + "/games/game-2/assignments", json={ "external_team_id": "team-1", "external_player_id": "player-1", - "clip_id": second_clip.id, + "clip_id": first_clip.id, "batting_slot": 1, "status": "ready", }, ) assert first_response.status_code == 200 - assert second_response.status_code == 200 + assert second_game_response.status_code == 200 assert first_response.json()["start_ms"] == 0 assert first_response.json()["end_ms"] == 10000 - assert second_response.json()["start_ms"] == 12000 - assert second_response.json()["end_ms"] == 22000 - assignments = client.get("/games/game-1/assignments") - assert assignments.status_code == 200 - assignment_ids = [item["clip_id"] for item in assignments.json()] - assert assignment_ids == [second_clip.id, first_clip.id] + game_one_assignments = client.get("/games/game-1/assignments") + game_two_assignments = client.get("/games/game-2/assignments") + assert game_one_assignments.status_code == 200 + assert game_two_assignments.status_code == 200 + assert [item["clip_id"] for item in game_one_assignments.json()] == [first_clip.id] + assert [item["clip_id"] for item in game_two_assignments.json()] == [first_clip.id] + + db = SessionLocal() + session = db.query(UserSession).filter_by(session_token="admin-session").one_or_none() + if session is None: + session = UserSession( + session_token="admin-session", + provider="teamsnap", + external_team_id="team-1", + external_player_id="player-1", + ) + db.add(session) + db.commit() + else: + session.external_team_id = "team-1" + session.external_player_id = "player-1" + db.commit() + db.close() + + client.cookies.set(settings.session_cookie_name, "admin-session") + pins_before_delete = client.get("/games/pins", params={"external_player_id": "player-1"}) + assert pins_before_delete.status_code == 200 + assert [item["clip_id"] for item in pins_before_delete.json()] == [first_clip.id, first_clip.id] + + delete_response = client.delete( + f"/games/game-1/assignments/{first_response.json()['id']}", + params={"external_player_id": "player-1"}, + ) + assert delete_response.status_code == 204 + + game_one_after_delete = client.get("/games/game-1/assignments") + game_two_after_delete = client.get("/games/game-2/assignments") + assert game_one_after_delete.status_code == 200 + assert game_two_after_delete.status_code == 200 + assert game_one_after_delete.json() == [] + assert [item["clip_id"] for item in game_two_after_delete.json()] == [first_clip.id] + + client.cookies.set(settings.session_cookie_name, "admin-session") + pins = client.get("/games/pins", params={"external_player_id": "player-1"}) + assert pins.status_code == 200 + assert [item["clip_id"] for item in pins.json()] == [first_clip.id] def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None: @@ -248,6 +279,73 @@ def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None: assert updated_clip["label"] == "Fresh track" +def test_player_can_reorder_clips_in_their_library() -> None: + db = SessionLocal() + session = UserSession( + session_token="player-session", + provider="teamsnap", + external_team_id="team-9", + external_player_id="player-9", + ) + db.add(session) + db.commit() + db.close() + + client.cookies.set(settings.session_cookie_name, "player-session") + + asset = AudioAsset( + external_team_id="team-9", + owner_external_player_id="player-9", + title="Song", + original_filename="song.mp3", + mime_type="audio/mpeg", + size_bytes=123, + storage_path="uploads/song.mp3", + ) + db = SessionLocal() + db.add(asset) + db.flush() + first_clip = AudioClip( + asset_id=asset.id, + label="Intro", + start_ms=0, + end_ms=10000, + sort_order=0, + normalization_status="ready", + normalized_path="clips/intro.mp3", + ) + second_clip = AudioClip( + asset_id=asset.id, + label="Chorus", + start_ms=12000, + end_ms=22000, + sort_order=1, + normalization_status="ready", + normalized_path="clips/chorus.mp3", + ) + db.add_all([first_clip, second_clip]) + db.commit() + db.refresh(first_clip) + db.refresh(second_clip) + db.close() + + reorder = client.post( + "/media/clips/reorder", + json={ + "external_team_id": "team-9", + "owner_external_player_id": "player-9", + "clip_ids": [second_clip.id, first_clip.id], + }, + ) + + assert reorder.status_code == 204 + + clips = client.get("/media/clips", params={"external_team_id": "team-9", "owner_external_player_id": "player-9"}) + assert clips.status_code == 200 + assert [item["id"] for item in clips.json()] == [second_clip.id, first_clip.id] + assert [item["sort_order"] for item in clips.json()] == [0, 1] + + def test_clip_updates_can_use_player_scoped_authorization() -> None: uploader_session = UserSession(session_token="uploader-session", provider="teamsnap") editor_session = UserSession(session_token="editor-session", provider="teamsnap") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8b3e833..bc13e1e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -146,12 +146,27 @@ export const api = { end_ms: number; }) => request("/media/clips", { method: "POST", body: JSON.stringify(payload) }), + reorderClips: async (payload: { external_team_id: string; owner_external_player_id: string; clip_ids: number[] }) => { + const response = await fetch(`${API_BASE}/media/clips/reorder`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + }, listAssignments: (gameId: string, playerId?: string) => request( `/games/${encodeURIComponent(gameId)}/assignments${ playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : "" }`, ), + listPins: (playerId: string) => + request(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`), createAssignment: ( gameId: string, payload: { @@ -166,6 +181,20 @@ export const api = { method: "POST", body: JSON.stringify(payload), }), + deleteAssignment: (gameId: string, assignmentId: number, playerId?: string) => + fetch( + `${API_BASE}/games/${encodeURIComponent(gameId)}/assignments/${assignmentId}${ + playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : "" + }`, + { + method: "DELETE", + credentials: "include", + }, + ).then(async (response) => { + if (!response.ok) { + throw new Error(await response.text()); + } + }), prepareGame: (gameId: string) => request(`/games/${encodeURIComponent(gameId)}/prep`), createPlaybackSession: (gameId: string, teamId: string) => request(`/games/${encodeURIComponent(gameId)}/operator/session`, { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c0e150d..4142075 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -46,6 +46,7 @@ export interface AudioClip { label: string; start_ms: number; end_ms: number; + sort_order: number; normalization_status: string; normalized_url?: string | null; waveform_duration_ms?: number | null; @@ -57,6 +58,7 @@ export interface AudioClipUpdate { start_ms: number; end_ms: number; label?: string; + sort_order?: number | null; } export interface GameAssignment { diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index bb03cbc..5be990b 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -62,6 +62,16 @@ export function GamePage() { }, }); + const unpinMutation = useMutation({ + mutationFn: (assignmentId: number) => api.deleteAssignment(selectedGameId, assignmentId, playerId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["assignments", selectedGameId, playerId] }), + queryClient.invalidateQueries({ queryKey: ["prep", selectedGameId] }), + ]); + }, + }); + function selectGame(gameId: string) { setSelectedGameId(gameId); setSearchParams({ gameId }); @@ -74,7 +84,7 @@ export function GamePage() { return; } savePreparedGame(selectedGameId, prepQuery.data); - setOfflineMessage(`Cached ${prepQuery.data.assignments.length} assignments for offline operator use.`); + setOfflineMessage(`Cached ${prepQuery.data.assignments.length} pinned clips for offline operator use.`); } const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null; @@ -84,7 +94,7 @@ export function GamePage() { return (
-
Reconnect with TeamSnap to attach clips to games.
+
Reconnect with TeamSnap to pin clips to games.
); @@ -106,10 +116,10 @@ export function GamePage() {
-

Game clips

+

Pinned clips

{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}

- {formatMemberName(walkup.currentPlayer)} can attach clips from song files in their own library to any game on{" "} + {formatMemberName(walkup.currentPlayer)} can pin clips from song files in their own library to any game on{" "} {formatTeamLabel(walkup.selectedTeam)}.

@@ -131,7 +141,7 @@ export function GamePage() {
- {selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."} + {selectedGame ? formatGameDate(selectedGame) : "Choose a game to pin clips."}
{walkup.nextGame ?
Next game: {formatGameTitle(walkup.nextGame)}
: null}
@@ -141,7 +151,7 @@ export function GamePage() {
-

Attach a clip

+

Pin a clip

{selectedGame ? ( <>
{formatGameDate(selectedGame)}
@@ -161,12 +171,12 @@ export function GamePage() { setSlot(Number(event.target.value))} /> {saveMutation.error instanceof Error ?
{saveMutation.error.message}
: null} ) : ( -
Pick a game to attach clips.
+
Pick a game to pin clips.
)}
@@ -176,21 +186,31 @@ export function GamePage() {
-

Your selected clips

+

Pinned clips

{assignmentsQuery.data?.map((assignment) => ( -
-
+
+
{assignment.clip_label}
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
- {assignment.status} +
))} {!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? ( -
No clips attached to this game yet.
+
No clips pinned to this game yet.
) : null}
@@ -202,8 +222,8 @@ export function GamePage() {

Prepared payload

Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}
-
Assignments in package: {prepQuery.data?.assignments.length ?? 0}
-
Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} assignments` : "No"}
+
Pinned clips in package: {prepQuery.data?.assignments.length ?? 0}
+
Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} pinned clips` : "No"}
- {clipsQuery.data?.map((clip) => ( + {orderedClips.map((clip, index) => ( void playPreview(clip)} onEdit={() => openEditWalkupClip(clip)} onStopPreview={stopPreview} + onMoveUp={() => { + const nextOrder = [...orderedClips]; + if (index <= 0) { + return; + } + const [movedClip] = nextOrder.splice(index, 1); + nextOrder.splice(index - 1, 0, movedClip); + void reorderClipsMutation.mutateAsync(nextOrder.map((item) => item.id)); + }} + onMoveDown={() => { + const nextOrder = [...orderedClips]; + if (index >= nextOrder.length - 1) { + return; + } + const [movedClip] = nextOrder.splice(index, 1); + nextOrder.splice(index + 1, 0, movedClip); + void reorderClipsMutation.mutateAsync(nextOrder.map((item) => item.id)); + }} + canMoveUp={index > 0} + canMoveDown={index < orderedClips.length - 1} + games={walkup.games} + pinnedAssignmentsByClipAndGame={pinnedAssignmentsByClipAndGame} + onTogglePin={(gameId) => { + void togglePinMutation.mutateAsync({ clipId: clip.id, gameId }); + }} /> ))} - {!clipsQuery.isLoading && !clipsQuery.data?.length ? ( + {!clipsQuery.isLoading && !orderedClips.length ? (
No walkup clips created yet. Open the modal to make the first one.
) : null} {deleteClipMutation.error instanceof Error ?
{deleteClipMutation.error.message}
: null} @@ -300,6 +391,30 @@ function BootstrapIcon({ name }: { name: BootstrapIconName }) { ); } + if (name === "chevron-up") { + return ( + + ); + } + + if (name === "chevron-down") { + return ( + + ); + } + + if (name === "pin-fill") { + return ( + + ); + } + if (name === "x-lg") { return (
@@ -879,7 +1034,29 @@ function WalkupClipCard({
{clip.label}
-
+
+ + +
+
+
Pin to game
+ {games.map((game) => { + const gameId = String(game.id); + const isPinned = pinnedAssignmentsByClipAndGame.has(`${gameId}:${clip.id}`); + return ( + + ); + })}
Source: {clip.asset_title}
) : null} diff --git a/frontend/src/pages/OperatorPage.tsx b/frontend/src/pages/OperatorPage.tsx index 240c7eb..3a32ec7 100644 --- a/frontend/src/pages/OperatorPage.tsx +++ b/frontend/src/pages/OperatorPage.tsx @@ -63,11 +63,13 @@ export function OperatorPage() { const [selectedPlayerId, setSelectedPlayerId] = useState(""); const [expandedPlayerId, setExpandedPlayerId] = useState(""); const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players"); + const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false); const [playbackSessionId, setPlaybackSessionId] = useState(null); const [playingClipKey, setPlayingClipKey] = useState(null); const [nowPlaying, setNowPlaying] = useState(null); const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false); const selectedPlayerWasManualRef = useRef(false); + const playerFilterMenuRef = useRef(null); const hasInitializedExpandedPlayerRef = useRef(false); const audioRef = useRef(null); const audioContextRef = useRef(null); @@ -99,6 +101,31 @@ export function OperatorPage() { stopPlayback(); }, [selectedPlayerId]); + useEffect(() => { + if (!playerFilterMenuOpen) { + return; + } + + function handlePointerDown(event: PointerEvent) { + if (!playerFilterMenuRef.current?.contains(event.target as Node)) { + setPlayerFilterMenuOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setPlayerFilterMenuOpen(false); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [playerFilterMenuOpen]); + const assignmentsQuery = useQuery({ queryKey: ["assignments", selectedGameId], queryFn: () => api.listAssignments(selectedGameId), @@ -180,25 +207,20 @@ export function OperatorPage() { (selectedPlayerId ? { id: selectedPlayerId } : null); const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : ""; - const selectedAssignments = useMemo( + const selectedPinnedAssignments = useMemo( () => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId), [assignmentList, selectedPlayerId], ); + const selectedPinnedAssignmentByClipId = useMemo( + () => new Map(selectedPinnedAssignments.map((assignment) => [String(assignment.clip_id), assignment])), + [selectedPinnedAssignments], + ); const createSession = useMutation({ mutationFn: () => api.createPlaybackSession(selectedGameId, teamId), onSuccess: (session) => setPlaybackSessionId(session.id), }); - const triggerAssignmentMutation = useMutation({ - mutationFn: (assignmentId: number) => { - if (!playbackSessionId) { - throw new Error("Start a gameday session first"); - } - return api.triggerPlaybackAssignment(selectedGameId, playbackSessionId, assignmentId); - }, - }); - const triggerClipMutation = useMutation({ mutationFn: (clip: AudioClip) => { if (!playbackSessionId) { @@ -369,21 +391,6 @@ export function OperatorPage() { } } - async function playAssignment(assignment: GameAssignment) { - await playAudio( - assignment.normalized_url, - clipKey("assignment", assignment.id), - { - key: clipKey("assignment", assignment.id), - title: assignment.clip_label, - subtitle: formatMemberName(selectedPlayer), - }, - assignment.start_ms, - assignment.end_ms, - () => triggerAssignmentMutation.mutateAsync(assignment.id), - ); - } - async function playClip(clip: AudioClip) { await playAudio( clip.normalized_url, @@ -431,7 +438,7 @@ export function OperatorPage() {

{selectedGame ? formatGameTitle(selectedGame) : "Select a game for gameday"}

Any player can run gameday. The player list now follows the event lineup first, then RSVP order, and each expanded - row shows the current game clips before the player's library clips. + row keeps pinned clips at the top of the player's library.

@@ -455,16 +462,63 @@ export function OperatorPage() {
-
+

Players

- +
+ + {playerFilterMenuOpen ? ( +
+ + + +
+ ) : null} +
{visibleMembers.map((member) => { @@ -474,12 +528,12 @@ export function OperatorPage() { const availability = (availabilityQuery.data ?? []).find( (entry) => String(entry.memberId) === memberId, ) ?? null; - const assignmentCount = assignmentList.filter((assignment) => assignment.external_player_id === memberId).length; + const pinCount = assignmentList.filter((assignment) => assignment.external_player_id === memberId).length; const isExpanded = memberId === expandedPlayerId; const expansionId = `player-clips-${memberId}`; const availabilityStatusCode = availability?.statusCode ?? null; const playerMeta = [ - assignmentCount ? `${assignmentCount} game clip${assignmentCount === 1 ? "" : "s"}` : null, + pinCount ? `${pinCount} pinned clip${pinCount === 1 ? "" : "s"}` : null, lineupEntry?.label ?? null, ].filter(Boolean); @@ -487,7 +541,9 @@ export function OperatorPage() {
{playerMeta.join(" • ")}
- {isExpanded ? (
-
-
-

Game clips

- Attached to this game -
-
- {selectedAssignments.length ? ( - selectedAssignments.map((assignment) => { - const key = clipKey("assignment", assignment.id); - const isPlaying = playingClipKey === key; - return ( -
-
- {assignment.clip_label} -
- {assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""} -
-
- -
- ); - }) - ) : ( -
No clips attached to this game for this player yet.
- )} -
-

Clip library

- Available clips for this player + Pinned clips stay at the top
@@ -613,7 +632,6 @@ export function OperatorPage() { Player:{" "} {selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
- {triggerAssignmentMutation.error instanceof Error ?
{triggerAssignmentMutation.error.message}
: null} {triggerClipMutation.error instanceof Error ?
{triggerClipMutation.error.message}
: null}
@@ -626,11 +644,13 @@ function LibraryClips({ playerId, playingClipKey, onPlayClip, + pinnedAssignmentsByClipId, }: { teamId: string; playerId: string; playingClipKey: string | null; onPlayClip: (clip: AudioClip) => Promise; + pinnedAssignmentsByClipId: Map; }) { const fallbackClipsQuery = useQuery({ queryKey: ["clips", teamId, playerId], @@ -646,19 +666,36 @@ function LibraryClips({ return
No library clips available for this player.
; } + const clips = [...fallbackClipsQuery.data].sort((a, b) => { + const aPinned = pinnedAssignmentsByClipId.has(String(a.id)); + const bPinned = pinnedAssignmentsByClipId.has(String(b.id)); + if (aPinned !== bPinned) { + return aPinned ? -1 : 1; + } + if (a.sort_order !== b.sort_order) { + return a.sort_order - b.sort_order; + } + return a.label.localeCompare(b.label); + }); + return ( <> - {fallbackClipsQuery.data.map((clip) => { + {clips.map((clip) => { const key = clipKey("library", clip.id); const isPlaying = playingClipKey === key; + const isPinned = pinnedAssignmentsByClipId.has(String(clip.id)); return (
- {clip.label} + + {clip.label} + {isPinned ? Pinned : null} + + {isPinned ?
Pinned to this game
: null}