Fix backend imports and clip pinning flow

This commit is contained in:
Codex
2026-04-22 07:48:12 -05:00
parent 45c2b46304
commit ec73156966
12 changed files with 736 additions and 156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -146,12 +146,27 @@ export const api = {
end_ms: number;
}) =>
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) =>
request<GameAssignment[]>(
`/games/${encodeURIComponent(gameId)}/assignments${
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
listPins: (playerId: string) =>
request<GameAssignment[]>(`/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<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
createPlaybackSession: (gameId: string, teamId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {

View File

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

View File

@@ -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 (
<section className="container-fluid py-4">
<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>
</section>
);
@@ -106,10 +116,10 @@ export function GamePage() {
<section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<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>
<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)}.
</p>
</div>
@@ -131,7 +141,7 @@ export function GamePage() {
</select>
</label>
<div className="text-body-secondary">
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to pin clips."}
</div>
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
</div>
@@ -141,7 +151,7 @@ export function GamePage() {
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<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 ? (
<>
<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))} />
</label>
<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>
{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>
@@ -176,21 +186,31 @@ export function GamePage() {
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<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">
{assignmentsQuery.data?.map((assignment) => (
<div className="list-group-item d-flex justify-content-between align-items-center" key={assignment.id}>
<div>
<div className="list-group-item d-flex justify-content-between align-items-center gap-3" key={assignment.id}>
<div className="d-grid gap-1">
<strong>{assignment.clip_label}</strong>
<div className="text-body-secondary">
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
</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>
))}
{!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}
</div>
</div>
@@ -202,8 +222,8 @@ export function GamePage() {
<h2 className="h4 mb-0">Prepared payload</h2>
<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">Assignments 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">Pinned clips in package: {prepQuery.data?.assignments.length ?? 0}</div>
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} pinned clips` : "No"}</div>
</div>
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
Cache on this device

View File

@@ -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 WaveSurfer from "wavesurfer.js";
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
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 { queryClient } from "../lib/queryClient";
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
import { formatMemberName } from "../lib/teamsnapHelpers";
import { formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
const MEDIA_ACCEPT =
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
@@ -25,7 +25,16 @@ type WalkupClipModalState =
| { mode: "create" }
| { 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 SourceCreationProgress = {
label: string;
@@ -55,6 +64,24 @@ export function LibraryPage() {
queryFn: () => api.listClips(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(() => {
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() {
const audio = audioRef.current ?? new Audio();
if (!audioRef.current) {
@@ -210,7 +276,7 @@ export function LibraryPage() {
</button>
</div>
<div className="stack">
{clipsQuery.data?.map((clip) => (
{orderedClips.map((clip, index) => (
<WalkupClipCard
key={clip.id}
clip={clip}
@@ -218,9 +284,34 @@ export function LibraryPage() {
onPreview={() => 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 ? (
<div className="muted">No walkup clips created yet. Open the modal to make the first one.</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") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
@@ -854,14 +969,54 @@ function WalkupClipCard({
onPreview,
onEdit,
onStopPreview,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
games,
pinnedAssignmentsByClipAndGame,
onTogglePin,
}: {
clip: AudioClip;
isPreviewing: boolean;
onPreview: () => void;
onEdit: () => 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 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 (
<div className="clip-summary">
@@ -879,7 +1034,29 @@ function WalkupClipCard({
<div className="clip-summary-title-row">
<strong>{clip.label}</strong>
</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
type="button"
className="icon-button-bare icon-button-menu"
@@ -899,6 +1076,29 @@ function WalkupClipCard({
</span>
<span>Edit clip</span>
</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>
) : null}

View File

@@ -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<number | null>(null);
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
const selectedPlayerWasManualRef = useRef(false);
const playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
const hasInitializedExpandedPlayerRef = useRef(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(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() {
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game for gameday"}</h1>
<p>
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&apos;s library clips.
row keeps pinned clips at the top of the player&apos;s library.
</p>
</div>
<div className="panel-grid">
@@ -455,16 +462,63 @@ export function OperatorPage() {
</div>
</div>
<div className="panel stack">
<div className="row">
<div className="operator-panel-header">
<h2 style={{ margin: 0 }}>Players</h2>
<label className="field" style={{ marginLeft: "auto", minWidth: 180 }}>
Filter
<select className="form-select" value={playerFilter} onChange={(event) => setPlayerFilter(event.target.value as typeof playerFilter)}>
<option value="players">Players</option>
<option value="nonPlayers">Non-players</option>
<option value="all">All</option>
</select>
</label>
<div className="operator-filter-menu-wrap" ref={playerFilterMenuRef}>
<button
type="button"
className="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center"
onClick={() => setPlayerFilterMenuOpen((current) => !current)}
aria-label="Filter players"
aria-haspopup="menu"
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 className="list-group operator-player-list">
{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() {
<div className={`operator-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
<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={() => {
selectedPlayerWasManualRef.current = true;
setSelectedPlayerId(memberId);
@@ -512,9 +568,6 @@ export function OperatorPage() {
</div>
<div className="muted">{playerMeta.join(" • ")}</div>
</div>
<span className="operator-player-chevron" aria-hidden="true">
{isExpanded ? "" : ""}
</span>
</button>
{isExpanded ? (
<div
@@ -523,45 +576,10 @@ export function OperatorPage() {
role="region"
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-title">
<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 className="operator-clip-list">
<LibraryClips
@@ -569,6 +587,7 @@ export function OperatorPage() {
playerId={selectedPlayerId}
playingClipKey={playingClipKey}
onPlayClip={playClip}
pinnedAssignmentsByClipId={selectedPinnedAssignmentByClipId}
/>
</div>
</div>
@@ -613,7 +632,6 @@ export function OperatorPage() {
Player:{" "}
{selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
</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}
</div>
</div>
@@ -626,11 +644,13 @@ function LibraryClips({
playerId,
playingClipKey,
onPlayClip,
pinnedAssignmentsByClipId,
}: {
teamId: string;
playerId: string;
playingClipKey: string | null;
onPlayClip: (clip: AudioClip) => Promise<void>;
pinnedAssignmentsByClipId: Map<string, GameAssignment>;
}) {
const fallbackClipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
@@ -646,19 +666,36 @@ function LibraryClips({
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 (
<>
{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 (
<div className="operator-clip-row" key={clip.id}>
<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>
<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)}
aria-pressed={isPlaying}
>

View File

@@ -289,6 +289,20 @@ select {
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 {
width: 2rem;
height: 2rem;
@@ -324,6 +338,71 @@ select {
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 {
width: 1em;
height: 1em;
@@ -802,6 +881,7 @@ select {
.operator-player-summary {
display: grid;
gap: 0.3rem;
flex: 1 1 auto;
text-align: left;
}
@@ -843,27 +923,35 @@ select {
background: #adb5bd;
}
.operator-player-chevron {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
display: grid;
place-items: center;
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;
.operator-player-toggle {
--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");
--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");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
}
.list-group-item.active .operator-player-chevron {
transform: rotate(90deg);
background: rgba(217, 79, 4, 0.18);
color: var(--accent);
.operator-player-toggle::after {
flex-shrink: 0;
width: var(--bs-accordion-btn-icon-width);
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 {
@@ -900,7 +988,7 @@ select {
.operator-clip-row {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
@@ -911,6 +999,12 @@ select {
.operator-clip-copy {
display: grid;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
}
.operator-clip-play-button {
margin-left: auto;
}
.operator-clip-button-indicator {