Fix backend imports and clip pinning flow
This commit is contained in:
@@ -7,6 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs &
|
|||||||
COPY pyproject.toml /app/pyproject.toml
|
COPY pyproject.toml /app/pyproject.toml
|
||||||
COPY requirements.txt requirements-dev.txt /app/
|
COPY requirements.txt requirements-dev.txt /app/
|
||||||
COPY app /app/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"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class AudioClip(Base):
|
|||||||
label: Mapped[str] = mapped_column(String(255))
|
label: Mapped[str] = mapped_column(String(255))
|
||||||
start_ms: Mapped[int] = mapped_column(Integer)
|
start_ms: Mapped[int] = mapped_column(Integer)
|
||||||
end_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")
|
normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
normalized_path: Mapped[str | None] = mapped_column(String(512))
|
normalized_path: Mapped[str | None] = mapped_column(String(512))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
@@ -65,7 +66,7 @@ class AudioClip(Base):
|
|||||||
class GameAssignment(Base):
|
class GameAssignment(Base):
|
||||||
__tablename__ = "game_assignments"
|
__tablename__ = "game_assignments"
|
||||||
__table_args__ = (
|
__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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import require_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])
|
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
|
||||||
def list_assignments(
|
def list_assignments(
|
||||||
external_game_id: str,
|
external_game_id: str,
|
||||||
@@ -47,10 +65,10 @@ def list_assignments(
|
|||||||
_: UserSession = Depends(require_session),
|
_: UserSession = Depends(require_session),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> list[GameAssignmentResponse]:
|
) -> 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:
|
if external_player_id:
|
||||||
query = query.where(GameAssignment.external_player_id == 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]
|
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:
|
if clip.asset.external_team_id != payload.external_team_id:
|
||||||
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
|
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
|
||||||
if clip.asset.owner_external_player_id != payload.external_player_id:
|
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(
|
assignment = db.scalar(
|
||||||
select(GameAssignment).where(
|
select(GameAssignment).where(
|
||||||
GameAssignment.external_game_id == external_game_id,
|
GameAssignment.external_game_id == external_game_id,
|
||||||
GameAssignment.external_player_id == payload.external_player_id,
|
|
||||||
GameAssignment.clip_id == payload.clip_id,
|
GameAssignment.clip_id == payload.clip_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -88,6 +105,7 @@ def create_assignment(
|
|||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
else:
|
else:
|
||||||
assignment.external_team_id = payload.external_team_id
|
assignment.external_team_id = payload.external_team_id
|
||||||
|
assignment.external_player_id = payload.external_player_id
|
||||||
assignment.clip_id = payload.clip_id
|
assignment.clip_id = payload.clip_id
|
||||||
assignment.batting_slot = payload.batting_slot
|
assignment.batting_slot = payload.batting_slot
|
||||||
assignment.status = payload.status
|
assignment.status = payload.status
|
||||||
@@ -96,6 +114,25 @@ def create_assignment(
|
|||||||
return assignment_to_response(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)
|
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
|
||||||
def prepare_game(
|
def prepare_game(
|
||||||
external_game_id: str,
|
external_game_id: str,
|
||||||
@@ -104,8 +141,9 @@ def prepare_game(
|
|||||||
) -> GamePrepResponse:
|
) -> GamePrepResponse:
|
||||||
assignments = db.scalars(
|
assignments = db.scalars(
|
||||||
select(GameAssignment)
|
select(GameAssignment)
|
||||||
|
.join(GameAssignment.clip)
|
||||||
.where(GameAssignment.external_game_id == external_game_id)
|
.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()
|
).all()
|
||||||
external_team_id = assignments[0].external_team_id if assignments else ""
|
external_team_id = assignments[0].external_team_id if assignments else ""
|
||||||
return GamePrepResponse(
|
return GamePrepResponse(
|
||||||
@@ -148,12 +186,12 @@ def trigger_playback(
|
|||||||
raise HTTPException(status_code=404, detail="Playback session not found")
|
raise HTTPException(status_code=404, detail="Playback session not found")
|
||||||
|
|
||||||
if payload.assignment_id is None and payload.clip_id is None:
|
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:
|
if payload.assignment_id is not None:
|
||||||
assignment = db.get(GameAssignment, payload.assignment_id)
|
assignment = db.get(GameAssignment, payload.assignment_id)
|
||||||
if assignment is None or assignment.external_game_id != external_game_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
|
playback.current_assignment_id = assignment.id
|
||||||
else:
|
else:
|
||||||
clip = db.get(AudioClip, payload.clip_id)
|
clip = db.get(AudioClip, payload.clip_id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import require_session
|
from ..auth import require_session
|
||||||
@@ -19,6 +19,7 @@ from ..schemas import (
|
|||||||
AudioClipCreate,
|
AudioClipCreate,
|
||||||
AudioClipResponse,
|
AudioClipResponse,
|
||||||
AudioClipUpdate,
|
AudioClipUpdate,
|
||||||
|
AudioClipReorder,
|
||||||
)
|
)
|
||||||
from ..storage import storage
|
from ..storage import storage
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
|
|||||||
label=clip.label,
|
label=clip.label,
|
||||||
start_ms=clip.start_ms,
|
start_ms=clip.start_ms,
|
||||||
end_ms=clip.end_ms,
|
end_ms=clip.end_ms,
|
||||||
|
sort_order=clip.sort_order,
|
||||||
normalization_status=clip.normalization_status,
|
normalization_status=clip.normalization_status,
|
||||||
normalized_url=normalized_url,
|
normalized_url=normalized_url,
|
||||||
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
|
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
|
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(
|
def create_asset_with_default_clip(
|
||||||
*,
|
*,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -83,6 +97,11 @@ def create_asset_with_default_clip(
|
|||||||
label=asset.title,
|
label=asset.title,
|
||||||
start_ms=0,
|
start_ms=0,
|
||||||
end_ms=DEFAULT_CLIP_LENGTH_MS,
|
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",
|
normalization_status="processing",
|
||||||
)
|
)
|
||||||
db.add(clip)
|
db.add(clip)
|
||||||
@@ -282,6 +301,11 @@ def create_clip(
|
|||||||
label=payload.label,
|
label=payload.label,
|
||||||
start_ms=payload.start_ms,
|
start_ms=payload.start_ms,
|
||||||
end_ms=payload.end_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",
|
normalization_status="processing",
|
||||||
)
|
)
|
||||||
db.add(clip)
|
db.add(clip)
|
||||||
@@ -314,6 +338,8 @@ def update_clip(
|
|||||||
clip.label = payload.label or clip.label
|
clip.label = payload.label or clip.label
|
||||||
clip.start_ms = payload.start_ms
|
clip.start_ms = payload.start_ms
|
||||||
clip.end_ms = payload.end_ms
|
clip.end_ms = payload.end_ms
|
||||||
|
if payload.sort_order is not None:
|
||||||
|
clip.sort_order = payload.sort_order
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(clip)
|
db.refresh(clip)
|
||||||
return clip_to_response(clip)
|
return clip_to_response(clip)
|
||||||
@@ -357,7 +383,7 @@ def list_clips(
|
|||||||
select(AudioClip)
|
select(AudioClip)
|
||||||
.join(AudioClip.asset)
|
.join(AudioClip.asset)
|
||||||
.where(AudioAsset.external_team_id == external_team_id)
|
.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:
|
if owner_external_player_id:
|
||||||
query = query.where(AudioAsset.owner_external_player_id == 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]
|
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}")
|
@router.get("/files/{relative_path:path}")
|
||||||
def media_file(relative_path: str) -> FileResponse:
|
def media_file(relative_path: str) -> FileResponse:
|
||||||
path = storage.absolute_path(relative_path)
|
path = storage.absolute_path(relative_path)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class AudioClipUpdate(BaseModel):
|
|||||||
label: str | None = Field(default=None, min_length=1, max_length=255)
|
label: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
start_ms: int = Field(ge=0)
|
start_ms: int = Field(ge=0)
|
||||||
end_ms: int = Field(gt=0)
|
end_ms: int = Field(gt=0)
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class AudioClipResponse(BaseModel):
|
class AudioClipResponse(BaseModel):
|
||||||
@@ -78,6 +79,7 @@ class AudioClipResponse(BaseModel):
|
|||||||
label: str
|
label: str
|
||||||
start_ms: int
|
start_ms: int
|
||||||
end_ms: int
|
end_ms: int
|
||||||
|
sort_order: int
|
||||||
normalization_status: str
|
normalization_status: str
|
||||||
normalized_url: str | None
|
normalized_url: str | None
|
||||||
waveform_duration_ms: int | None = None
|
waveform_duration_ms: int | None = None
|
||||||
@@ -116,6 +118,12 @@ class GamePrepResponse(BaseModel):
|
|||||||
assignments: list[GameAssignmentResponse]
|
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):
|
class PlaybackSessionCreate(BaseModel):
|
||||||
external_team_id: str
|
external_team_id: str
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def test_walkup_session_selection_is_persisted_in_session() -> None:
|
|||||||
assert response.json()["external_player_id"] == "player-1002"
|
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"})
|
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
|
||||||
assert login.status_code == 200
|
assert login.status_code == 200
|
||||||
|
|
||||||
@@ -159,18 +159,9 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
|
|||||||
normalization_status="ready",
|
normalization_status="ready",
|
||||||
normalized_path="clips/intro.mp3",
|
normalized_path="clips/intro.mp3",
|
||||||
)
|
)
|
||||||
second_clip = AudioClip(
|
db.add(first_clip)
|
||||||
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.commit()
|
db.commit()
|
||||||
db.refresh(first_clip)
|
db.refresh(first_clip)
|
||||||
db.refresh(second_clip)
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
first_response = client.post(
|
first_response = client.post(
|
||||||
@@ -183,28 +174,68 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
|
|||||||
"status": "ready",
|
"status": "ready",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
second_response = client.post(
|
second_game_response = client.post(
|
||||||
"/games/game-1/assignments",
|
"/games/game-2/assignments",
|
||||||
json={
|
json={
|
||||||
"external_team_id": "team-1",
|
"external_team_id": "team-1",
|
||||||
"external_player_id": "player-1",
|
"external_player_id": "player-1",
|
||||||
"clip_id": second_clip.id,
|
"clip_id": first_clip.id,
|
||||||
"batting_slot": 1,
|
"batting_slot": 1,
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert first_response.status_code == 200
|
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()["start_ms"] == 0
|
||||||
assert first_response.json()["end_ms"] == 10000
|
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")
|
game_one_assignments = client.get("/games/game-1/assignments")
|
||||||
assert assignments.status_code == 200
|
game_two_assignments = client.get("/games/game-2/assignments")
|
||||||
assignment_ids = [item["clip_id"] for item in assignments.json()]
|
assert game_one_assignments.status_code == 200
|
||||||
assert assignment_ids == [second_clip.id, first_clip.id]
|
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:
|
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"
|
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:
|
def test_clip_updates_can_use_player_scoped_authorization() -> None:
|
||||||
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
|
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
|
||||||
editor_session = UserSession(session_token="editor-session", provider="teamsnap")
|
editor_session = UserSession(session_token="editor-session", provider="teamsnap")
|
||||||
|
|||||||
@@ -146,12 +146,27 @@ export const api = {
|
|||||||
end_ms: number;
|
end_ms: number;
|
||||||
}) =>
|
}) =>
|
||||||
request<AudioClip>("/media/clips", { method: "POST", body: JSON.stringify(payload) }),
|
request<AudioClip>("/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) =>
|
listAssignments: (gameId: string, playerId?: string) =>
|
||||||
request<GameAssignment[]>(
|
request<GameAssignment[]>(
|
||||||
`/games/${encodeURIComponent(gameId)}/assignments${
|
`/games/${encodeURIComponent(gameId)}/assignments${
|
||||||
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||||
}`,
|
}`,
|
||||||
),
|
),
|
||||||
|
listPins: (playerId: string) =>
|
||||||
|
request<GameAssignment[]>(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
|
||||||
createAssignment: (
|
createAssignment: (
|
||||||
gameId: string,
|
gameId: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -166,6 +181,20 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
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<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
|
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
|
||||||
createPlaybackSession: (gameId: string, teamId: string) =>
|
createPlaybackSession: (gameId: string, teamId: string) =>
|
||||||
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {
|
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface AudioClip {
|
|||||||
label: string;
|
label: string;
|
||||||
start_ms: number;
|
start_ms: number;
|
||||||
end_ms: number;
|
end_ms: number;
|
||||||
|
sort_order: number;
|
||||||
normalization_status: string;
|
normalization_status: string;
|
||||||
normalized_url?: string | null;
|
normalized_url?: string | null;
|
||||||
waveform_duration_ms?: number | null;
|
waveform_duration_ms?: number | null;
|
||||||
@@ -57,6 +58,7 @@ export interface AudioClipUpdate {
|
|||||||
start_ms: number;
|
start_ms: number;
|
||||||
end_ms: number;
|
end_ms: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
sort_order?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameAssignment {
|
export interface GameAssignment {
|
||||||
|
|||||||
@@ -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) {
|
function selectGame(gameId: string) {
|
||||||
setSelectedGameId(gameId);
|
setSelectedGameId(gameId);
|
||||||
setSearchParams({ gameId });
|
setSearchParams({ gameId });
|
||||||
@@ -74,7 +84,7 @@ export function GamePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
savePreparedGame(selectedGameId, prepQuery.data);
|
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;
|
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
|
||||||
@@ -84,7 +94,7 @@ export function GamePage() {
|
|||||||
return (
|
return (
|
||||||
<section className="container-fluid py-4">
|
<section className="container-fluid py-4">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-body">Reconnect with TeamSnap to attach clips to games.</div>
|
<div className="card-body">Reconnect with TeamSnap to pin clips to games.</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -106,10 +116,10 @@ export function GamePage() {
|
|||||||
<section className="container-fluid py-4 d-grid gap-4">
|
<section className="container-fluid py-4 d-grid gap-4">
|
||||||
<div className="card bg-dark text-white border-0 shadow-sm">
|
<div className="card bg-dark text-white border-0 shadow-sm">
|
||||||
<div className="card-body p-4 p-lg-5">
|
<div className="card-body p-4 p-lg-5">
|
||||||
<p className="text-uppercase small text-info-emphasis mb-2">Game clips</p>
|
<p className="text-uppercase small text-info-emphasis mb-2">Pinned clips</p>
|
||||||
<h1 className="h2">{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}</h1>
|
<h1 className="h2">{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}</h1>
|
||||||
<p className="mb-0 text-white-50">
|
<p className="mb-0 text-white-50">
|
||||||
{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)}.
|
{formatTeamLabel(walkup.selectedTeam)}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +141,7 @@ export function GamePage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-body-secondary">
|
<div className="text-body-secondary">
|
||||||
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
|
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to pin clips."}
|
||||||
</div>
|
</div>
|
||||||
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
|
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +151,7 @@ export function GamePage() {
|
|||||||
<div className="col-12 col-xl-6">
|
<div className="col-12 col-xl-6">
|
||||||
<div className="card shadow-sm h-100">
|
<div className="card shadow-sm h-100">
|
||||||
<div className="card-body d-grid gap-3">
|
<div className="card-body d-grid gap-3">
|
||||||
<h2 className="h4 mb-0">Attach a clip</h2>
|
<h2 className="h4 mb-0">Pin a clip</h2>
|
||||||
{selectedGame ? (
|
{selectedGame ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-body-secondary">{formatGameDate(selectedGame)}</div>
|
<div className="text-body-secondary">{formatGameDate(selectedGame)}</div>
|
||||||
@@ -161,12 +171,12 @@ export function GamePage() {
|
|||||||
<input className="form-control" type="number" value={slot} onChange={(event) => setSlot(Number(event.target.value))} />
|
<input className="form-control" type="number" value={slot} onChange={(event) => setSlot(Number(event.target.value))} />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn btn-primary" disabled={!clipId} onClick={() => void saveMutation.mutateAsync()}>
|
<button type="button" className="btn btn-primary" disabled={!clipId} onClick={() => void saveMutation.mutateAsync()}>
|
||||||
{saveMutation.isPending ? "Saving..." : "Attach clip to this game"}
|
{saveMutation.isPending ? "Saving..." : "Pin clip to this game"}
|
||||||
</button>
|
</button>
|
||||||
{saveMutation.error instanceof Error ? <div className="text-body-secondary">{saveMutation.error.message}</div> : null}
|
{saveMutation.error instanceof Error ? <div className="text-body-secondary">{saveMutation.error.message}</div> : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-body-secondary">Pick a game to attach clips.</div>
|
<div className="text-body-secondary">Pick a game to pin clips.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,21 +186,31 @@ export function GamePage() {
|
|||||||
<div className="col-12 col-xl-6">
|
<div className="col-12 col-xl-6">
|
||||||
<div className="card shadow-sm h-100">
|
<div className="card shadow-sm h-100">
|
||||||
<div className="card-body d-grid gap-3">
|
<div className="card-body d-grid gap-3">
|
||||||
<h2 className="h4 mb-0">Your selected clips</h2>
|
<h2 className="h4 mb-0">Pinned clips</h2>
|
||||||
<div className="list-group">
|
<div className="list-group">
|
||||||
{assignmentsQuery.data?.map((assignment) => (
|
{assignmentsQuery.data?.map((assignment) => (
|
||||||
<div className="list-group-item d-flex justify-content-between align-items-center" key={assignment.id}>
|
<div className="list-group-item d-flex justify-content-between align-items-center gap-3" key={assignment.id}>
|
||||||
<div>
|
<div className="d-grid gap-1">
|
||||||
<strong>{assignment.clip_label}</strong>
|
<strong>{assignment.clip_label}</strong>
|
||||||
<div className="text-body-secondary">
|
<div className="text-body-secondary">
|
||||||
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
|
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge rounded-pill text-bg-warning">{assignment.status}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||||
|
onClick={() => void unpinMutation.mutateAsync(assignment.id)}
|
||||||
|
aria-label="Unpin clip"
|
||||||
|
title="Unpin clip"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M9.828.722a1 1 0 0 1 1.415 0l3.535 3.535a1 1 0 0 1 0 1.415l-1.06 1.06a1 1 0 0 1-1.414 0l-2.03-2.03-1.75 1.75a1 1 0 0 1-.22.17l-1.74.97-.97 1.74a1 1 0 0 1-.17.22l-3.03 3.03.71.71 3.03-3.03a1 1 0 0 1 .22-.17l1.74-.97 1.75 1.75a1 1 0 0 1 .17.22l.97 1.74 3.03-3.03 1.06 1.06a1 1 0 0 1 0 1.415l-1.06 1.06a1 1 0 0 1-1.415 0l-3.535-3.535a1 1 0 0 1 0-1.415l1.75-1.75-2.03-2.03a1 1 0 0 1 0-1.415z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
|
{!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
|
||||||
<div className="text-body-secondary">No clips attached to this game yet.</div>
|
<div className="text-body-secondary">No clips pinned to this game yet.</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,8 +222,8 @@ export function GamePage() {
|
|||||||
<h2 className="h4 mb-0">Prepared payload</h2>
|
<h2 className="h4 mb-0">Prepared payload</h2>
|
||||||
<div className="d-grid gap-2">
|
<div className="d-grid gap-2">
|
||||||
<div className="text-body-secondary">Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}</div>
|
<div className="text-body-secondary">Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}</div>
|
||||||
<div className="text-body-secondary">Assignments in package: {prepQuery.data?.assignments.length ?? 0}</div>
|
<div className="text-body-secondary">Pinned clips in package: {prepQuery.data?.assignments.length ?? 0}</div>
|
||||||
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} assignments` : "No"}</div>
|
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} pinned clips` : "No"}</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
|
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
|
||||||
Cache on this device
|
Cache on this device
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
|
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
|
||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AudioAsset, AudioClip } from "../api/types";
|
import type { AudioAsset, AudioClip, TeamSnapEvent } from "../api/types";
|
||||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
|
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
|
||||||
import { formatMemberName } from "../lib/teamsnapHelpers";
|
import { formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
|
||||||
|
|
||||||
const MEDIA_ACCEPT =
|
const MEDIA_ACCEPT =
|
||||||
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
|
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
|
||||||
@@ -25,7 +25,16 @@ type WalkupClipModalState =
|
|||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
| { mode: "edit"; clip: AudioClip };
|
| { mode: "edit"; clip: AudioClip };
|
||||||
|
|
||||||
type BootstrapIconName = "play" | "stop" | "three-dots-vertical" | "pencil-square" | "plus-lg" | "x-lg";
|
type BootstrapIconName =
|
||||||
|
| "play"
|
||||||
|
| "stop"
|
||||||
|
| "three-dots-vertical"
|
||||||
|
| "pencil-square"
|
||||||
|
| "plus-lg"
|
||||||
|
| "x-lg"
|
||||||
|
| "chevron-up"
|
||||||
|
| "chevron-down"
|
||||||
|
| "pin-fill";
|
||||||
type TrimFocusEdge = "start" | "end";
|
type TrimFocusEdge = "start" | "end";
|
||||||
type SourceCreationProgress = {
|
type SourceCreationProgress = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -55,6 +64,24 @@ export function LibraryPage() {
|
|||||||
queryFn: () => api.listClips(teamId, playerId),
|
queryFn: () => api.listClips(teamId, playerId),
|
||||||
enabled: Boolean(teamId && playerId),
|
enabled: Boolean(teamId && playerId),
|
||||||
});
|
});
|
||||||
|
const pinsQuery = useQuery({
|
||||||
|
queryKey: ["pins", teamId, playerId],
|
||||||
|
queryFn: () => api.listPins(playerId),
|
||||||
|
enabled: Boolean(playerId),
|
||||||
|
});
|
||||||
|
const orderedClips = useMemo(
|
||||||
|
() =>
|
||||||
|
[...(clipsQuery.data ?? [])].sort((left, right) => {
|
||||||
|
if (left.sort_order !== right.sort_order) {
|
||||||
|
return left.sort_order - right.sort_order;
|
||||||
|
}
|
||||||
|
return right.created_at.localeCompare(left.created_at);
|
||||||
|
}),
|
||||||
|
[clipsQuery.data],
|
||||||
|
);
|
||||||
|
const pinnedAssignmentsByClipAndGame = useMemo(() => {
|
||||||
|
return new Map((pinsQuery.data ?? []).map((assignment) => [`${assignment.external_game_id}:${assignment.clip_id}`, assignment]));
|
||||||
|
}, [pinsQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -70,6 +97,45 @@ export function LibraryPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reorderClipsMutation = useMutation({
|
||||||
|
mutationFn: (clipIds: number[]) =>
|
||||||
|
api.reorderClips({
|
||||||
|
external_team_id: teamId,
|
||||||
|
owner_external_player_id: playerId,
|
||||||
|
clip_ids: clipIds,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePinMutation = useMutation({
|
||||||
|
mutationFn: async ({ clipId, gameId }: { clipId: number; gameId: string }) => {
|
||||||
|
const assignment = (pinsQuery.data ?? []).find(
|
||||||
|
(entry) => entry.clip_id === clipId && entry.external_game_id === gameId,
|
||||||
|
);
|
||||||
|
if (assignment) {
|
||||||
|
await api.deleteAssignment(gameId, assignment.id, playerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createAssignment(gameId, {
|
||||||
|
external_team_id: teamId,
|
||||||
|
external_player_id: playerId,
|
||||||
|
clip_id: clipId,
|
||||||
|
batting_slot: null,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["pins", teamId, playerId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assignments"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prep"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function getAudio() {
|
function getAudio() {
|
||||||
const audio = audioRef.current ?? new Audio();
|
const audio = audioRef.current ?? new Audio();
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
@@ -210,7 +276,7 @@ export function LibraryPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{clipsQuery.data?.map((clip) => (
|
{orderedClips.map((clip, index) => (
|
||||||
<WalkupClipCard
|
<WalkupClipCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
clip={clip}
|
clip={clip}
|
||||||
@@ -218,9 +284,34 @@ export function LibraryPage() {
|
|||||||
onPreview={() => void playPreview(clip)}
|
onPreview={() => void playPreview(clip)}
|
||||||
onEdit={() => openEditWalkupClip(clip)}
|
onEdit={() => openEditWalkupClip(clip)}
|
||||||
onStopPreview={stopPreview}
|
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 ? (
|
||||||
<div className="muted">No walkup clips created yet. Open the modal to make the first one.</div>
|
<div className="muted">No walkup clips created yet. Open the modal to make the first one.</div>
|
||||||
) : null}
|
) : null}
|
||||||
{deleteClipMutation.error instanceof Error ? <div className="muted">{deleteClipMutation.error.message}</div> : null}
|
{deleteClipMutation.error instanceof Error ? <div className="muted">{deleteClipMutation.error.message}</div> : null}
|
||||||
@@ -300,6 +391,30 @@ function BootstrapIcon({ name }: { name: BootstrapIconName }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === "chevron-up") {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M7.646 5.854a.5.5 0 0 1 .708 0l4.5 4.5a.5.5 0 0 1-.708.708L8 7.207 3.854 11.062a.5.5 0 1 1-.708-.708z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "chevron-down") {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M1.646 5.854a.5.5 0 0 1 .708 0L8 11.5l5.646-5.646a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "pin-fill") {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4.146 1.5a.5.5 0 0 1 .708 0l3.75 3.75 1.646-1.646a1 1 0 0 1 1.414 0l.732.732a1 1 0 0 1 0 1.414L10.75 7.896l3.75 3.75-1.5 1.5-3.75-3.75-2.354 2.354a1 1 0 0 1-1.414 0l-.732-.732a1 1 0 0 1 0-1.414L6.354 7.25l-3.75-3.75a.5.5 0 0 1 0-.708z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === "x-lg") {
|
if (name === "x-lg") {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
@@ -854,14 +969,54 @@ function WalkupClipCard({
|
|||||||
onPreview,
|
onPreview,
|
||||||
onEdit,
|
onEdit,
|
||||||
onStopPreview,
|
onStopPreview,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
games,
|
||||||
|
pinnedAssignmentsByClipAndGame,
|
||||||
|
onTogglePin,
|
||||||
}: {
|
}: {
|
||||||
clip: AudioClip;
|
clip: AudioClip;
|
||||||
isPreviewing: boolean;
|
isPreviewing: boolean;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onStopPreview: () => void;
|
onStopPreview: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
|
games: TeamSnapEvent[];
|
||||||
|
pinnedAssignmentsByClipAndGame: Map<string, { id: number }>;
|
||||||
|
onTogglePin: (gameId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!menuRef.current?.contains(event.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="clip-summary">
|
<div className="clip-summary">
|
||||||
@@ -879,7 +1034,29 @@ function WalkupClipCard({
|
|||||||
<div className="clip-summary-title-row">
|
<div className="clip-summary-title-row">
|
||||||
<strong>{clip.label}</strong>
|
<strong>{clip.label}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="clip-summary-menu-wrap">
|
<div className="clip-summary-order-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={!canMoveUp}
|
||||||
|
aria-label="Move clip up"
|
||||||
|
title="Move clip up"
|
||||||
|
>
|
||||||
|
<BootstrapIcon name="chevron-up" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
aria-label="Move clip down"
|
||||||
|
title="Move clip down"
|
||||||
|
>
|
||||||
|
<BootstrapIcon name="chevron-down" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button-bare icon-button-menu"
|
className="icon-button-bare icon-button-menu"
|
||||||
@@ -899,6 +1076,29 @@ function WalkupClipCard({
|
|||||||
</span>
|
</span>
|
||||||
<span>Edit clip</span>
|
<span>Edit clip</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="clip-summary-menu-label">Pin to game</div>
|
||||||
|
{games.map((game) => {
|
||||||
|
const gameId = String(game.id);
|
||||||
|
const isPinned = pinnedAssignmentsByClipAndGame.has(`${gameId}:${clip.id}`);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={gameId}
|
||||||
|
className={`clip-summary-menu-item${isPinned ? " is-active" : ""}`}
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={isPinned}
|
||||||
|
onClick={() => {
|
||||||
|
onTogglePin(gameId);
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="clip-summary-menu-icon">
|
||||||
|
<BootstrapIcon name="pin-fill" />
|
||||||
|
</span>
|
||||||
|
<span>{isPinned ? "Pinned: " : "Pin: "}{formatGameTitle(game)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ export function OperatorPage() {
|
|||||||
const [selectedPlayerId, setSelectedPlayerId] = useState("");
|
const [selectedPlayerId, setSelectedPlayerId] = useState("");
|
||||||
const [expandedPlayerId, setExpandedPlayerId] = useState("");
|
const [expandedPlayerId, setExpandedPlayerId] = useState("");
|
||||||
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
|
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
|
||||||
|
const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false);
|
||||||
const [playbackSessionId, setPlaybackSessionId] = useState<number | null>(null);
|
const [playbackSessionId, setPlaybackSessionId] = useState<number | null>(null);
|
||||||
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
|
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
|
||||||
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
|
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
|
||||||
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
|
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
|
||||||
const selectedPlayerWasManualRef = useRef(false);
|
const selectedPlayerWasManualRef = useRef(false);
|
||||||
|
const playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasInitializedExpandedPlayerRef = useRef(false);
|
const hasInitializedExpandedPlayerRef = useRef(false);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
@@ -99,6 +101,31 @@ export function OperatorPage() {
|
|||||||
stopPlayback();
|
stopPlayback();
|
||||||
}, [selectedPlayerId]);
|
}, [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({
|
const assignmentsQuery = useQuery({
|
||||||
queryKey: ["assignments", selectedGameId],
|
queryKey: ["assignments", selectedGameId],
|
||||||
queryFn: () => api.listAssignments(selectedGameId),
|
queryFn: () => api.listAssignments(selectedGameId),
|
||||||
@@ -180,25 +207,20 @@ export function OperatorPage() {
|
|||||||
(selectedPlayerId ? { id: selectedPlayerId } : null);
|
(selectedPlayerId ? { id: selectedPlayerId } : null);
|
||||||
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
|
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
|
||||||
|
|
||||||
const selectedAssignments = useMemo(
|
const selectedPinnedAssignments = useMemo(
|
||||||
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
|
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
|
||||||
[assignmentList, selectedPlayerId],
|
[assignmentList, selectedPlayerId],
|
||||||
);
|
);
|
||||||
|
const selectedPinnedAssignmentByClipId = useMemo(
|
||||||
|
() => new Map(selectedPinnedAssignments.map((assignment) => [String(assignment.clip_id), assignment])),
|
||||||
|
[selectedPinnedAssignments],
|
||||||
|
);
|
||||||
|
|
||||||
const createSession = useMutation({
|
const createSession = useMutation({
|
||||||
mutationFn: () => api.createPlaybackSession(selectedGameId, teamId),
|
mutationFn: () => api.createPlaybackSession(selectedGameId, teamId),
|
||||||
onSuccess: (session) => setPlaybackSessionId(session.id),
|
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({
|
const triggerClipMutation = useMutation({
|
||||||
mutationFn: (clip: AudioClip) => {
|
mutationFn: (clip: AudioClip) => {
|
||||||
if (!playbackSessionId) {
|
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) {
|
async function playClip(clip: AudioClip) {
|
||||||
await playAudio(
|
await playAudio(
|
||||||
clip.normalized_url,
|
clip.normalized_url,
|
||||||
@@ -431,7 +438,7 @@ export function OperatorPage() {
|
|||||||
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game for gameday"}</h1>
|
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game for gameday"}</h1>
|
||||||
<p>
|
<p>
|
||||||
Any player can run gameday. The player list now follows the event lineup first, then RSVP order, and each expanded
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-grid">
|
<div className="panel-grid">
|
||||||
@@ -455,16 +462,63 @@ export function OperatorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel stack">
|
<div className="panel stack">
|
||||||
<div className="row">
|
<div className="operator-panel-header">
|
||||||
<h2 style={{ margin: 0 }}>Players</h2>
|
<h2 style={{ margin: 0 }}>Players</h2>
|
||||||
<label className="field" style={{ marginLeft: "auto", minWidth: 180 }}>
|
<div className="operator-filter-menu-wrap" ref={playerFilterMenuRef}>
|
||||||
Filter
|
<button
|
||||||
<select className="form-select" value={playerFilter} onChange={(event) => setPlayerFilter(event.target.value as typeof playerFilter)}>
|
type="button"
|
||||||
<option value="players">Players</option>
|
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center"
|
||||||
<option value="nonPlayers">Non-players</option>
|
onClick={() => setPlayerFilterMenuOpen((current) => !current)}
|
||||||
<option value="all">All</option>
|
aria-label="Filter players"
|
||||||
</select>
|
aria-haspopup="menu"
|
||||||
</label>
|
aria-expanded={playerFilterMenuOpen}
|
||||||
|
title="Filter players"
|
||||||
|
>
|
||||||
|
<svg className="icon-button-menu-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M1.5 2.75A.75.75 0 0 1 2.25 2h11.5a.75.75 0 0 1 .56 1.25L10 7.74V12a.75.75 0 0 1-1.14.64l-2-1.2A.75.75 0 0 1 6.5 10.8V7.74L1.69 3.25a.75.75 0 0 1-.19-.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{playerFilterMenuOpen ? (
|
||||||
|
<div className="operator-filter-menu" role="menu" aria-label="Player filter options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`operator-filter-menu-item${playerFilter === "players" ? " is-active" : ""}`}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={playerFilter === "players"}
|
||||||
|
onClick={() => {
|
||||||
|
setPlayerFilter("players");
|
||||||
|
setPlayerFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Players
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`operator-filter-menu-item${playerFilter === "nonPlayers" ? " is-active" : ""}`}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={playerFilter === "nonPlayers"}
|
||||||
|
onClick={() => {
|
||||||
|
setPlayerFilter("nonPlayers");
|
||||||
|
setPlayerFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Non-players
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`operator-filter-menu-item${playerFilter === "all" ? " is-active" : ""}`}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={playerFilter === "all"}
|
||||||
|
onClick={() => {
|
||||||
|
setPlayerFilter("all");
|
||||||
|
setPlayerFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-group operator-player-list">
|
<div className="list-group operator-player-list">
|
||||||
{visibleMembers.map((member) => {
|
{visibleMembers.map((member) => {
|
||||||
@@ -474,12 +528,12 @@ export function OperatorPage() {
|
|||||||
const availability = (availabilityQuery.data ?? []).find(
|
const availability = (availabilityQuery.data ?? []).find(
|
||||||
(entry) => String(entry.memberId) === memberId,
|
(entry) => String(entry.memberId) === memberId,
|
||||||
) ?? null;
|
) ?? 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 isExpanded = memberId === expandedPlayerId;
|
||||||
const expansionId = `player-clips-${memberId}`;
|
const expansionId = `player-clips-${memberId}`;
|
||||||
const availabilityStatusCode = availability?.statusCode ?? null;
|
const availabilityStatusCode = availability?.statusCode ?? null;
|
||||||
const playerMeta = [
|
const playerMeta = [
|
||||||
assignmentCount ? `${assignmentCount} game clip${assignmentCount === 1 ? "" : "s"}` : null,
|
pinCount ? `${pinCount} pinned clip${pinCount === 1 ? "" : "s"}` : null,
|
||||||
lineupEntry?.label ?? null,
|
lineupEntry?.label ?? null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
@@ -487,7 +541,9 @@ export function OperatorPage() {
|
|||||||
<div className={`operator-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
|
<div className={`operator-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${isExpanded ? " active" : ""}`}
|
className={`operator-player-toggle list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${
|
||||||
|
isExpanded ? " active" : ""
|
||||||
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
selectedPlayerWasManualRef.current = true;
|
selectedPlayerWasManualRef.current = true;
|
||||||
setSelectedPlayerId(memberId);
|
setSelectedPlayerId(memberId);
|
||||||
@@ -512,9 +568,6 @@ export function OperatorPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="muted">{playerMeta.join(" • ")}</div>
|
<div className="muted">{playerMeta.join(" • ")}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="operator-player-chevron" aria-hidden="true">
|
|
||||||
{isExpanded ? "−" : "›"}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div
|
<div
|
||||||
@@ -523,45 +576,10 @@ export function OperatorPage() {
|
|||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={`player-${memberId}-toggle`}
|
aria-labelledby={`player-${memberId}-toggle`}
|
||||||
>
|
>
|
||||||
<div className="operator-section">
|
|
||||||
<div className="operator-section-title">
|
|
||||||
<h3 style={{ margin: 0 }}>Game clips</h3>
|
|
||||||
<span className="muted">Attached to this game</span>
|
|
||||||
</div>
|
|
||||||
<div className="operator-clip-list">
|
|
||||||
{selectedAssignments.length ? (
|
|
||||||
selectedAssignments.map((assignment) => {
|
|
||||||
const key = clipKey("assignment", assignment.id);
|
|
||||||
const isPlaying = playingClipKey === key;
|
|
||||||
return (
|
|
||||||
<div className="operator-clip-row" key={assignment.id}>
|
|
||||||
<div className="operator-clip-copy">
|
|
||||||
<strong>{assignment.clip_label}</strong>
|
|
||||||
<div className="muted">
|
|
||||||
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
|
|
||||||
onClick={() => void playAssignment(assignment)}
|
|
||||||
aria-pressed={isPlaying}
|
|
||||||
>
|
|
||||||
<span className={`operator-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
|
|
||||||
{isPlaying ? "Stop" : "Play"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="muted">No clips attached to this game for this player yet.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="operator-section">
|
<div className="operator-section">
|
||||||
<div className="operator-section-title">
|
<div className="operator-section-title">
|
||||||
<h3 style={{ margin: 0 }}>Clip library</h3>
|
<h3 style={{ margin: 0 }}>Clip library</h3>
|
||||||
<span className="muted">Available clips for this player</span>
|
<span className="muted">Pinned clips stay at the top</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="operator-clip-list">
|
<div className="operator-clip-list">
|
||||||
<LibraryClips
|
<LibraryClips
|
||||||
@@ -569,6 +587,7 @@ export function OperatorPage() {
|
|||||||
playerId={selectedPlayerId}
|
playerId={selectedPlayerId}
|
||||||
playingClipKey={playingClipKey}
|
playingClipKey={playingClipKey}
|
||||||
onPlayClip={playClip}
|
onPlayClip={playClip}
|
||||||
|
pinnedAssignmentsByClipId={selectedPinnedAssignmentByClipId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +632,6 @@ export function OperatorPage() {
|
|||||||
Player:{" "}
|
Player:{" "}
|
||||||
{selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
|
{selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
|
||||||
</div>
|
</div>
|
||||||
{triggerAssignmentMutation.error instanceof Error ? <div className="muted">{triggerAssignmentMutation.error.message}</div> : null}
|
|
||||||
{triggerClipMutation.error instanceof Error ? <div className="muted">{triggerClipMutation.error.message}</div> : null}
|
{triggerClipMutation.error instanceof Error ? <div className="muted">{triggerClipMutation.error.message}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -626,11 +644,13 @@ function LibraryClips({
|
|||||||
playerId,
|
playerId,
|
||||||
playingClipKey,
|
playingClipKey,
|
||||||
onPlayClip,
|
onPlayClip,
|
||||||
|
pinnedAssignmentsByClipId,
|
||||||
}: {
|
}: {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playingClipKey: string | null;
|
playingClipKey: string | null;
|
||||||
onPlayClip: (clip: AudioClip) => Promise<void>;
|
onPlayClip: (clip: AudioClip) => Promise<void>;
|
||||||
|
pinnedAssignmentsByClipId: Map<string, GameAssignment>;
|
||||||
}) {
|
}) {
|
||||||
const fallbackClipsQuery = useQuery({
|
const fallbackClipsQuery = useQuery({
|
||||||
queryKey: ["clips", teamId, playerId],
|
queryKey: ["clips", teamId, playerId],
|
||||||
@@ -646,19 +666,36 @@ function LibraryClips({
|
|||||||
return <div className="muted">No library clips available for this player.</div>;
|
return <div className="muted">No library clips available for this player.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{fallbackClipsQuery.data.map((clip) => {
|
{clips.map((clip) => {
|
||||||
const key = clipKey("library", clip.id);
|
const key = clipKey("library", clip.id);
|
||||||
const isPlaying = playingClipKey === key;
|
const isPlaying = playingClipKey === key;
|
||||||
|
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
|
||||||
return (
|
return (
|
||||||
<div className="operator-clip-row" key={clip.id}>
|
<div className="operator-clip-row" key={clip.id}>
|
||||||
<div className="operator-clip-copy">
|
<div className="operator-clip-copy">
|
||||||
<strong>{clip.label}</strong>
|
<strong className="operator-clip-title">
|
||||||
|
{clip.label}
|
||||||
|
{isPinned ? <span className="pill">Pinned</span> : null}
|
||||||
|
</strong>
|
||||||
|
{isPinned ? <div className="muted">Pinned to this game</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
|
className={`operator-clip-play-button btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
onClick={() => void onPlayClip(clip)}
|
onClick={() => void onPlayClip(clip)}
|
||||||
aria-pressed={isPlaying}
|
aria-pressed={isPlaying}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -289,6 +289,20 @@ select {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-summary-order-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-clip-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button-circle {
|
.icon-button-circle {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
@@ -324,6 +338,71 @@ select {
|
|||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operator-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-filter-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-menu-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: block;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-filter-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 8.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 16px 28px rgba(19, 34, 56, 0.14);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-filter-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-filter-menu-item.is-active {
|
||||||
|
background: rgba(217, 79, 4, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.operator-filter-menu-item:hover {
|
||||||
|
background: rgba(19, 34, 56, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-filter-menu-item:focus-visible,
|
||||||
|
.operator-filter-button:focus-visible {
|
||||||
|
outline: 2px solid rgba(217, 79, 4, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button svg {
|
.icon-button svg {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
@@ -802,6 +881,7 @@ select {
|
|||||||
.operator-player-summary {
|
.operator-player-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,27 +923,35 @@ select {
|
|||||||
background: #adb5bd;
|
background: #adb5bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operator-player-chevron {
|
.operator-player-toggle {
|
||||||
flex: 0 0 auto;
|
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23132238' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||||
width: 1.75rem;
|
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23d94f04' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||||
height: 1.75rem;
|
--bs-accordion-btn-icon-width: 1.25rem;
|
||||||
display: grid;
|
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||||
place-items: center;
|
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgba(19, 34, 56, 0.08);
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
line-height: 1;
|
|
||||||
transition:
|
|
||||||
transform 0.18s ease,
|
|
||||||
background-color 0.18s ease,
|
|
||||||
color 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.active .operator-player-chevron {
|
.operator-player-toggle::after {
|
||||||
transform: rotate(90deg);
|
flex-shrink: 0;
|
||||||
background: rgba(217, 79, 4, 0.18);
|
width: var(--bs-accordion-btn-icon-width);
|
||||||
color: var(--accent);
|
height: var(--bs-accordion-btn-icon-width);
|
||||||
|
margin-left: auto;
|
||||||
|
content: "";
|
||||||
|
background-image: var(--bs-accordion-btn-icon);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: var(--bs-accordion-btn-icon-width);
|
||||||
|
transition: var(--bs-accordion-btn-icon-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-player-toggle.active::after {
|
||||||
|
background-image: var(--bs-accordion-btn-active-icon);
|
||||||
|
transform: var(--bs-accordion-btn-icon-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.operator-player-toggle::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.operator-expansion {
|
.operator-expansion {
|
||||||
@@ -900,7 +988,7 @@ select {
|
|||||||
.operator-clip-row {
|
.operator-clip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -911,6 +999,12 @@ select {
|
|||||||
.operator-clip-copy {
|
.operator-clip-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-clip-play-button {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operator-clip-button-indicator {
|
.operator-clip-button-indicator {
|
||||||
|
|||||||
Reference in New Issue
Block a user