Store Player Home — /store (점장 Classic player)
SPEC #064 도입. 시안 design_handoff_linkmusic/design/screens/home-classic.jsx (Classic 보수안). 묶음5 design_14 핸드오프(Phase 8 §8G-①)로 시안 정합 — 상태별 brand 그라데이션 커버·큐 source 3종 시각·dead-end CTA·CS 새 답변 dot 정식 시각 교체. SPEC #141 개편으로 음악/방송 표기를 분리(우측 컨트롤 컬럼·커버=음악 전용, 방송 표기는 커버 아래 방송 카드)했다 — 커버 상태는 music/none 2상태.
전제: 재생 큐 BE getStorePlaybackQueue(#056)·활성 PL(#055)·기본 PL fallback·source(#058) 라이브. BE 변경 0.
Overview
점장(STORE_MANAGER)이 apps/space /store 에서 본인 매장 활성 플레이리스트의 재생 큐를
받아 브라우저로 직접 재생하는 화면. 직전까지 /store 는 placeholder(“점장 콘솔 준비 중”)
였고, 본 슬라이스에서 Classic player 홈으로 교체했다. 베타 게이트(“본사 세팅→점장 재생
end-to-end”)의 핵심 = 점장이 매장에서 음악을 트는 첫 화면이다.
범위: player 홈 + 재생 + 방송 동시재생(더킹 오버레이 · SPEC #141) + 음악 크로스페이드(곡↔곡 · 큐 내부 · SPEC #139 Part B). 즉시방송 작성·점장 PL 선택·온보딩·장애복구·송출 고도화(스케줄·본사 원격 중단)는 후속(시안 있음). 보조 액션은 모달(Radix Dialog)로 연다 — player 가 언마운트되지 않아 오디오 재생이 끊기지 않는다(이전 라우트 stub 링크에서 모달로 전환, 아래 보조 액션 참조).
방송 동시재생(SPEC #141): 본사가
/admin/announcements에서 [송출](전체 매장)하면 매장당 1 row(PENDING)가 fan-out 된다. 점장 player 가 이를 폴링해 due 시 즉시(곡 끝 대기 없음) 음악을 더킹(낮춤)한 뒤 별도 오버레이<audio>로 음악 위에 동시 재생하고 ack 한다. 음악은 절대 정지하지 않는다. 본사가 만든 TTS 가 점장 화면에서 실제로 들리는 첫 슬라이스다(기존 dead-end 해소).
시안 출처: workspace parent dir
design_handoff_linkmusic/design/screens/home-classic.jsx(HERO: cover + track meta + progress + transport + volume + [즉시방송] + 보조 3버튼, QUEUE: 풀폭 목록). 시안의 “다음 방송(예약/대기)” 큐는 즉시방송/예약 후속 슬라이스라, 본 슬라이스에서는 재생 큐(items) 목록으로 매핑한다. cover 는QueueItem에 이미지 필드가 없어 brand placeholder/그라데이션으로 둔다(SPEC #064 §D4 — 커버 enrich 후속). 시안 더미 보조정보(CM송 빈도·예약 ETA)는 데이터 부재로 미노출.
구성 (server 셸 + client)
app/store/page.tsx— server 셸(force-dynamic). 본문(StorePlayerClient)만 렌더. role 가드는app/store/layout.tsx(STORE_MANAGER, SPEC #050 §F1)가 담당.app/store/store-player-client.tsx— client. 데이터 소비 +<audio>제어 + 큐 목록 + source/빈 상태.- 보조 액션은 모달(Radix Dialog)로 연다 — 라우트 이동(
<Link>)이 아니라 player 위에 뜨는 portal 오버레이다. 페이지 이동 시 player 가 언마운트돼<audio>재생이 끊기던 문제를 해결한다(player 와 모든<audio>가 마운트된 채 유지되어 오디오가 계속 흐른다). 모달은 player JSX 안에 마운트되고 (openModalstate 가 어느 모달이 열렸는지 관리), portal 이라 레이아웃 영향은 0이다. - 모달이 재사용하는 client 들은
embeddedprop 으로 페이지 셸(min-h-screen·StoreSubHeader· 닫기 Link 헤더)을 생략하고 본문만DialogBody(스크롤 바디) 안에 렌더한다. 송출/적용 성공 시onSent/onApplied콜백으로 모달을 닫는다:- 즉시방송 모달 =
broadcast/now/broadcast-now-client.tsx(initialTab·initialModeprop 으로 초기 탭/모드 seed) — 즉시방송(tts 탭)·자주 쓰는 방송(templates 탭)·예약 방송(예약 모드)을 한 컴포넌트로 연다(이전 stubbroadcast/templates·broadcast/schedule의 실 기능 연결). - 플레이리스트 모달 =
playlist/store-playlist-client.tsx(점장 활성 PL 선택, SPEC #129). 적용 성공 시 큐 invalidate → player 가 새 활성 PL 반영. - 다음 방송 모달 =
broadcast/scheduled/store-scheduled-list-client.tsx(defaultScope="today"— 기본 탭 “오늘 남은 방송”, SPEC #142 에서 2탭 토글로 일반화) — 커버 아래 단일 방송 카드 (store-player-broadcast-card) 클릭으로 연다. 각 행 [즉시 방송]으로 예약을 지금 송출(원래 예약 유지).
- 즉시방송 모달 =
- 같은 client 들은 페이지로도 유지된다(딥링크 —
broadcast/now·playlist·broadcast/scheduled). 단 페이지 진입은 player 언마운트라 끊김 → 주 동선은 모달. 레거시 stub 라우트broadcast/templates·broadcast/schedule는 실 기능이 모달로 연결돼/store로 redirect 한다 (_stub/store-stub-page.tsx는 더 이상 player nav 에서 참조하지 않음).
데이터
| 출처 | endpoint | 용도 |
|---|---|---|
useGetStoreMe | GET /api/v1/store/me | 상단 매장명(name)·본사명(hqName). 5xx/네트워크 시 “내 매장” 폴백. |
useGetStorePlaybackQueue | GET /api/v1/store/queue | 재생 큐 {active, source, playlistId, playlistName, total, truncated, reason, items[]}. |
useListStorePendingAnnouncements | GET /api/v1/store/announcements/pending | 본인 매장의 미재생(PENDING) 안내방송 송출 { items[] } (created_at ASC). 20초 interval 폴링(큐와 독립). |
useAckStoreAnnouncement | POST /api/v1/store/announcements/{dispatchId}/ack | 안내방송 재생 완료 ack. 첫 ack(PENDING)만 204, 중복·이미 PLAYED·미존재는 404 DISPATCH_NOT_FOUND(상태·존재 은닉). 오버레이 종료/에러 시 호출 → pending invalidate, 404 는 “이미 소비됨”으로 흡수. |
getStoreNextCommercial (raw fetcher) | GET /api/v1/store/commercial-song/next | SPEC #094 도입 · #104 라운드로빈 #094 F3 마감 — 본사 CM송 1건(매장 단위 라운드로빈, store.last_commercial_song_id 기반 순환) 또는 204(없음). 음악 곡 N회 ended 시점에 imperative 호출(react-query 캐시 미사용 — 매 호출이 새 상태). 200 StoreCommercialNextResponse{id,audioUrl,durationSeconds}. 응답은 generated getStoreNextCommercialResponseSuccess union(status===200 으로 narrow). |
PendingAnnouncementItem = { dispatchId, announcementId, title, audioUrl(Azure public blob), durationSeconds? }.
QueueItem = { musicId, title, audioUrl(Azure public blob), durationSeconds? }. items 는 서버가
이미 셔플한 순서. 보호 endpoint 이므로 generated react-query 훅을 client 에서 호출하고, 토큰은
BFF /api/backend/... catch-all 경유로만 흐른다(서버 전용). mutator 는 성공(200)만 반환하므로
query.data.status === 200 으로 narrow 해 본문을 꺼낸다.
재생 (HTML <audio>)
<audio src={items[index].audioUrl}>로 직접 재생.- 재생/일시정지 — toggle.
playingstate 를el.play()/el.pause()와 동기화. autoplay 정책 등으로play()거부 시 일시정지 상태로 복귀. - 이전/다음 — 인덱스 이동. 이전은 첫 곡에서 마지막으로 wrap. 다음은 마지막 곡이면 소진 처리.
- 진행바 —
onTimeUpdate/onLoadedMetadata로 currentTime·duration 추적. 비인터랙티브 (role=progressbar· seek 미지원), 메인(음악) 트랙 기준이며 방송 중에도 상시 노출한다(음악은 연속 재생되므로 진행 그대로). duration 미확정 시durationSeconds폴백. - 볼륨 — 0~1 range,
el.volume동기화. - 곡 끝(
onEnded) → 다음 곡으로 advance + CM 사이클 카운팅(SPEC #141 — 방송 삽입 없음. 음악은 연속 재생하고 방송은 오버레이로 동시재생).handleMusicEnded는 음악 진행만 담당한다. - 큐 소진(마지막 곡 다음) →
refetch. 서버가 다시 셔플한 새 큐를 받고, items 참조 변경으로 인덱스가 0 으로 리셋되어 이어 재생한다(SPEC #064 §D3).
방송 동시재생 — 더킹 오버레이 (SPEC #141 · 본사 송출 수신)
음악은 항상 연속 재생(메인 <audio> + 크로스페이드 A/B). 모든 방송(긴급/일반 안내방송·CM)은
음악을 정지하지 않고 더킹(낮춤)한 뒤 별도 오버레이 <audio data-testid="store-player-overlay-audio">
로 그 위에 풀볼륨으로 동시 재생한다(daiso use-tts-playback 패턴). 끝나면 음악을 100% 복원한다.
- 트랙 분리: 메인 audio(
store-player-audio)의 src 는 음악 전용(musicSrc= 현재 곡). 방송은 더 이상 메인 src 를 교체하지 않고 오버레이 트랙(overlaySrc= 안내방송 ?? CM audioUrl)으로만 흐른다. - 폴링:
useListStorePendingAnnouncements를refetchInterval: 20_000(20초)으로 폴링한다. 재생 큐와 독립. 본사 [송출] 직후 매장당 PENDING row 가 fan-out 되면 다음 폴링에 잡힌다. - due 시 즉시(곡 끝 대기 없음): pending 이 있으면 due effect 가 곧바로 음악을 더킹하고 오버레이를 재생한다. 안내방송 후보는 가장 오래된(목록 순서, created_at ASC) row. 음악 큐가 비어 있어도(곡 없음) 방송은 그대로 오버레이로 재생된다(더킹은 깔 곡이 없으면 비활성).
- 우선순위: 긴급 안내방송 > 일반 안내방송 > CM. 오버레이 재생 중 긴급 도착 시 현재 오버레이 즉시
교체(preempt). preempt 된 비-긴급 안내방송은 ack 하지 않아 pending 에 남고(긴급 후 재선출),
CM 은 폐기된다(
activeCommercial=null). 이미 다른 긴급이 활성이면 그 종료를 기다려 순차 진입한다(D4 다중 긴급·D6 인터럽트 중 추가 도착 — pendingItems 폴링 결과 자체가 큐 역할). - 종료/ack: 오버레이
onEnded(또는onError) → 안내방송이면useAckStoreAnnouncement(dispatchId)→ pending invalidate → 활성 방송 해제 → 음악 더킹 복원(다음 대기 방송이 있으면 due effect 가 즉시 이어받음). CM 이면 ack 없이 카운터 리셋 + 해제. ack 은 BE 원자 조건부 UPDATE(WHERE status='PENDING')라 첫 ack(PENDING)만 204, 중복·이미 PLAYED·미존재는 404DISPATCH_NOT_FOUND(상태·존재 은닉) — 효과는 멱등이나 응답은 404다. player 는 404 도 “이미 소비됨”으로 흡수하고, 오버레이 에러·404 어느 쪽이든 ack 후 해제해 무한 멈춤을 막는다. 레이스 가드 3중: (a)ackingRef동일-tick 가드(onEnded·onError동시 발화 시 두 번째 호출 차단) → (b) ack settle(onSettled)에서ackingRef리셋- pending invalidate(
finishedAnnouncementsRef는 삭제하지 않음 — 아래 “한 dispatch 1회 재생”) → (c) 활성 announcement/dispatchId 일치 검사(해제 후 도착하는 늦은 이벤트 차단).
- pending invalidate(
- 한 dispatch 는 1회만 재생(무한 반복 가드): 종료한 dispatchId 를
finishedAnnouncementsRef에 영구 마킹하고 ack 성공/실패·refetch 지연과 무관하게 같은 id 를 다시 재선출하지 않는다. ackonSettled에서 이 집합을 delete 하지 않는 것이 핵심이다 — invalidate refetch 는 비동기라, delete 직후~refetch 완료 전 stale pending 에 같은 항목이 남아 있으면nextPlayable*가 재선출 → due effect 재생 → onEnded → 또 finish → 또 재선출 의 무한 루프가 돌았다(ack 가 서버에서 실패/지연해도 동일). 마킹 시blockVersion(state) 을 bump 해nextPlayable*메모를 즉시 재계산(차단된 id 를 후보에서 뺀다 — ref 만으론 메모가 stale). 집합 무한 증가는 prune effect 가 막는다: pending 이 갱신될 때마다 현재 pending 에 더 이상 없는 id 를finished/failed집합에서 정리한다(pending 에서 빠지면 어차피 재선출 불가 → 안전). 새 예약은 새 dispatchId 라 키가 달라 정상 재생된다. - 음악 절대 정지 안 함: 위치보존 resume·긴급 full-stop·activeSrc 방송 교체·곡경계 안내방송 삽입은
전부 제거됐다(SPEC #141 — 의도된 모델 변경).
handleMusicEnded는 음악 진행(advance)+CM 카운트만 한다.
본사 원격 즉시중단(revoke) 수신 (SPEC #077 확장)
본사가 /admin/announcements 송출 이력에서 원격 중단하면 BE 가 그 dispatch 를 PENDING→CANCELED
전이시키고, 점장 GET /api/v1/store/announcements/pending 응답에 revokedDispatchIds(오늘 KST 윈도우
본인 매장에서 본사가 revoke 한 dispatchId 목록)를 함께 내려준다. SSE 없이 기존 20초 pending 폴링으로
전달되며 player 가 이 신호를 소비한다(revokedDispatchIds 를 useMemo 로 ReadonlySet 화).
- 현재 재생 중 오버레이 즉시 정지: 폴링 갱신마다(
revokedDispatchIds·activeAnnouncement변화) 현재 재생 중 오버레이의 dispatchId 가 revoke 목록에 있으면 즉시 정지한다(stopRevokedAnnouncement).activeAnnouncement를 null 로 만들면overlaySrc가 사라져 오버레이<audio>가 pause·리셋되고,isDucking이 false 가 되어 음악 더킹이 복원된다. 음악은 동시재생 모델이라 정지하지 않으므로 더킹 복원만으로 원음으로 계속 흐른다(곡 src·currentTime 유지). - best-effort — ack 하지 않음: revoke 는 BE 가 이미 CANCELED 로 전이했으므로 ack 는 404 멱등이라 무의미
하다. player 는 로컬 정지만 수행하고 ack 호출을 하지 않는다(
finishAnnouncement의 ack 경로와 다른 점). 무한반복 가드는 동일하게 적용 — 정지한 dispatchId 를finishedAnnouncementsRef에 마킹 +blockVersionbump 으로nextPlayable*메모 즉시 재계산해 재선출을 차단한다. revoke 된 dispatch 는 CANCELED 라 곧 pending 에서 빠지고, 그때 기존 prune effect 가finishedRef에서 정리한다. - pending 후보 선출 제외:
nextPlayableAnnouncement·nextPlayableEmergency메모가revokedDispatchIds에 든 후보를failed/finished와 함께 선출 제외한다(긴급/일반 공통). 같은 폴링 응답에 revoke 안 된 다른 후보가 있으면 그것은 정상 재생된다(선택적 제외). - CM 은 대상 아님: CM(
activeCommercial)은 dispatchId 가 없어 revoke 대상이 아니다. - 기존 무한반복 가드(
finishedRef·prune·blockVersion)·동시재생·시작 게이트·backlog drain·크로스페이드와 정합한다(중복 정지는activeAnnouncement일치 검사로 가드 — 정지 후 null 이라 재진입해도 no-op).
긴급 안내방송 (danger 톤 · SPEC #090, #082 F1)
PendingAnnouncementItem.isEmergency=true row 도 동일하게 음악 위에 동시 재생한다(긴급도 음악을 멈추지
않는다 — 더킹 + 오버레이). 곡 끝 대기 없이 due 즉시 재생하며 우선순위 최상위(preempt).
- UI 표시 (D3): 본문 상단 Banner danger ”🚨 긴급 안내 재생 중 — 음악 위에서 함께 재생됩니다”(role=alert)
- 커버 아래 방송 카드의 배지를 danger 톤·🚨 prefix 로 노출(카드 테두리·배경도 danger 톤). 다중 긴급 대기 시 Banner 우측 “대기 N건” pill. 우측 컨트롤 컬럼(h1·배지)은 음악 전용이라 긴급 중에도 음악 곡 제목을 유지한다. 일반 컨트롤·볼륨도 그대로 유지(완전 비활성화 X).
- 다중 긴급 순차 처리 (D4):
createdAt ASC순. 첫 row 종료 → ack settle → invalidate → 폴링 응답이 다음 긴급으로 좁아지면 같은 effect 가 즉시 이어받아 두 번째 오버레이로 진입. 다 소진되면 음악 복원. - 오버레이 에러 (D5): 긴급 오버레이
error→failedAnnouncementsRef마킹(즉시 재진입 차단) + ack (중복 송출 회피) + Banner danger “긴급 안내 재생 실패” 노출 + 음악 복원. - 종료 마킹 (
finishedAnnouncementsRef): ack mutate 진입 시점에 dispatchId 추가하고 삭제하지 않는다(한 dispatch 1회 재생 · 위 “무한 반복 가드” 참조). 긴급도 같은 차단 집합을 쓰므로 동일하게 ack 실패/refetch 지연에도 같은 긴급이 재선출되지 않는다. 정리는 prune effect 가 pending 에서 사라진 뒤 수행한다.
후속(범위 밖): F1 본사 audit 누적(HqAuditAction.STORE_EMERGENCY_BROADCAST_DISPATCHED) · F2 사용 빈도
rate limit · 본사 원격 중단.
CM송 사이클 동시재생 (SPEC #094 · SPEC #095 · SPEC #103 · SPEC #104 · #141)
본사가 등록한 CM송(#093)을 음악 큐 사이에 주기적으로 자동 재생한다. 음악 곡 ended 카운터
songsSinceCommercialRef 가 effective 빈도 (useGetStoreMe() 응답의 commercialCycleSongs,
SPEC #095 본사 default + #103 매장 override 의 BE 측 계산 결과) 에 도달하면 1회 본사 CM 1건을
getStoreNextCommercial(GET /api/v1/store/commercial-song/next, generated raw fetcher imperative
호출 — react-query 캐시 미사용으로 매 호출이 BE 의 새 라운드로빈 상태를 반영, SPEC #104) 으로 가져와
오버레이 트랙(store-player-overlay-audio)으로 음악 위에 동시재생한다(SPEC #141 — 음악 정지 안 함).
me 응답이 도착하기 전 또는 값이 0(레거시 본사 — V32 default 5 이전) 이면 모듈 fallback
상수 SONGS_BETWEEN_COMMERCIALS_FALLBACK = 5 를 쓴다. #103 도착으로 점장 client 는 effective 값
단일 필드만 소비(storeCommercialCycleSongs ?? hqCommercialCycleSongs 분기 없음 — BE 가 계산).
본사/매장 override 변경 시 me query 갱신 시점부터 새 빈도가 적용되며, 변경 시점의 누적 카운터는
유지된다(다음 임계치 도달 판정에서 새 값 비교).
200(CM 있음) → CM 을 오버레이 트랙으로 동시재생(음악은 그대로 다음 곡 진행), CM ended → 카운터 0
리셋 + CM 해제(음악 더킹 복원). 204(CM 없음) → 카운터 0 리셋(본사가 CM 미등록한 경우 호환). fetch
실패 → silent + 카운터 그대로(다음 곡 끝에 재시도). 안내방송(긴급·일반) 재생은 카운팅에서 제외 — 본
사이클은 음악 곡만 카운팅한다(handleMusicEnded 진입 시 +1, 크로스페이드 swap 에서 1회 — 곡당 1회).
CM 진입은 안내방송이 활성/대기면 하지 않는다(우선순위: 안내방송 > CM).
인터럽트 우선순위 (D8): 긴급 안내방송(#090) > 일반 안내방송(#061) > CM송. CM 오버레이 재생 중 폴링
응답에 긴급/일반 안내방송이 도착하면 CM 폐기(activeCommercial=null + 카운터 0 리셋) → 안내방송 due
effect 가 즉시 이어받는다. CM 은 ack 계약이 없으므로(서버 비-stateful) 단순 폐기.
더킹 — 방송 중 음악 트랙 감쇠 (SPEC #141 · 부드러운 램프)
방송(긴급/일반 안내방송·CM)이 오버레이로 재생되는 동안, 연속 재생 중인 음악 트랙의 볼륨만 부드럽게 낮춘다(음악은 정지하지 않음). 방송이 끝나면 음악을 100% 복원한다. 더킹은 모든 방송에 적용된다(긴급 포함 — 음악은 작게 깔린 채로 긴급이 그 위에서 재생).
- effective 값(
useGetStoreMe()): BE 가 per-field 로매장 override ?? 본사 default를 계산해StoreMeResponse로 전달한다(점장 화면은 분기 로직 없이 한 필드씩만 읽음). me 도착 전엔 모듈 fallback(더킹 OFF) 로 안전하게 종전 동작을 유지한다.duckEnabled— 더킹 사용 여부(true 면 음악 감쇠, false 면 음악 원음 유지). fallbackfalse.duckVolumePercent— 방송 중 음악 목표 볼륨(정상 대비 %, 0~100). fallback20. 음악 트랙의 실제 목표 볼륨 =사용자 볼륨 × (duckVolumePercent / 100).duckFadeMs— 감쇠/복원 fade 시간(ms, 0~5000). fallback400.- ⚠️ DUCK 제약 상수(
0..100·0..5000)는 본사/매장 설정 화면과 함께@/lib/ducking한 곳에서 export 해 재사용한다(BE OpenAPIUpdate{Hq,Store}DuckingRequest와 정합 · drift 방지).
- 더킹 멀티플라이어(daiso 곱 램프):
duckMultiplierRef(1=원음 ·duckVolumePercent/100=감쇠)를 방송 시작 시 감쇠치로, 종료 시 1.0 으로 fade 하고 음악 트랙(메인 · 크로스페이드 중이면 incoming)에volume * multiplier(* fade계수)로 곱해 적용한다. 부드러운 램프: daisouse-crossfade-engine(DUCK_FADE_STEP_MS=30)식 미세 30ms step + ease-in-out(cubic) 보간으로 시작/끝 급변을 없앤다 (종전 40ms 선형 램프가 “부자연스럽다”는 피드백 반영).duckFadeMs=0이면 즉시 적용. - 더킹 활성 조건 =
duckEnabled && 방송 재생 중 && 깔 음악 곡 존재(current). 음악 큐가 비어 깔 곡이 없으면 비활성(방송만 오버레이 재생). 오버레이 트랙은 풀볼륨(1)이라 더킹 멀티플라이어를 곱하지 않는다. - 상태 표현(G3): 더킹 중 커버 아래 방송 카드의 배지를 “본사 안내방송 재생 중 · 음악 위에 함께
재생” / “CM송 재생 중 · 음악 위에 함께 재생” 으로 노출하고, 카드에 ”· 배경음악 작게 깔림” 보조 문구를
덧붙인다(음악이 멈춘 게 아니라 작아져 함께 흐름을 점장이 인지). 종전 더킹 정보 패널·EQ 미터·[멘트 중단]
버튼(
store-player-ment-controls·store-player-stop-ment)과 별도 더킹 bed 트랙 (store-player-duck-audio)은 제거됐다(SPEC #141 — 방송 전용 UI 최소화).
음악 크로스페이드 — 곡↔곡 매끄러운 전환 (SPEC #139 Part B · 큐 내부만)
곡 끝 무렵 다음 곡과 볼륨을 교차(crossfade)해 끊김을 줄인다. daiso use-crossfade-engine 컨셉을
우리 구조로 변형 — 음악 재생에만 적용하고 방송 오버레이 경로는 전혀 건드리지 않는다(회귀 0).
- 트리거: 음악 재생 중(방송 없음) + 큐 내부 다음 곡 존재(
index+1 < items.length) + 곡 끝CROSSFADE_SECONDS(3s·FE 상수) 전.onTimeUpdate가 1회 진입 표시(setIsCrossfading(true)). - incoming 트랙: 진입 시 두 번째 음악
<audio>(store-player-crossfade-audio)가 렌더되어 다음 곡을 0:00 부터 페이드인하고, 메인<audio>(outgoing)는 페이드아웃한다(50ms 틱 fade interval). 완료 시 메인을 다음 곡으로 swap 하고 incoming 이 도달한 위치를 시드해(pendingSeekAtRef·onLoadedMetadata1회 적용) seamless 로 이어받는다(0:00 재시작 아님). swap 후 incoming 은 언마운트. - 큐 경계 gapless 유지: 현재가 마지막 곡이면 다음 곡을 미리 알 수 없으므로(서버 refetch·재셔플)
크로스페이드하지 않고 종전 gapless
advanceToNext로 진행한다. - 방송 abort: 방송(안내방송·CM)이 활성화되면 진행 중 크로스페이드를 즉시 abort(fade interval clear + incoming 정지)하고 메인 단일 트랙에 더킹을 적용한다. 방송은 음악 src 를 교체하지 않으므로 메인은 그대로 현재 곡을 계속 재생한다(SPEC #141 — 음악 연속 재생).
- 더킹과 직교(B-D2): 음악 트랙 실효 볼륨 =
volume * duckMultiplier * fade계수. 더킹 감쇠를 daiso 식 멀티플라이어(duckMultiplierRef) 곱 램프로 통일해 안전하게 직교 보장한다. - config: 현재 FE 상수(3s) 고정. 본사 default/매장 override 화는 B-D3 후속 결정.
방송 중 UI — 음악/방송 표기 분리 (SPEC #141 개편)
방송 표기는 음악 컨트롤과 분리한다. 우측 컨트롤 컬럼은 음악 전용, 방송 표기는 커버 아래 방송 카드로 내린다.
- 우측 컨트롤 컬럼(음악 전용): h1(
store-player-title)은 항상 현재 음악 곡(current?.title)을 표기한다(방송 중에도). 배지/컨텍스트 줄은 음악 재생 상태(재생중/일시정지) + 플레이리스트명/곡수만. 방송 배지(info/danger/CM pill)·“본사 안내방송 재생 중” 류는 우측에서 제거됐다. 진행바·트랜스포트· 볼륨은 음악 기준 그대로. 커버(CoverPlaceholder)도 상태/제목을 음악 기준(music/none)으로만 분기. - 단일 방송 카드(
store-player-broadcast-card— 항상 1개·클릭 시 “다음 방송” 목록 모달store-player-next-broadcasts-modal을 연다): 커버 컬럼 커버 아래에 항상 1개를 노출하며, 방송 재생 여부로 내용을 분기한다(종전store-player-broadcast-card+store-player-next-broadcast2개 카드를 1개로 병합). 구조상 바깥 컨테이너는<div>이고, 모달을 여는 카드 본문 버튼 (store-player-broadcast-card)과 아래 TTS 음소거 토글 버튼이 형제로 배치된다(중첩 인터랙티브 회피).- 방송 재생 중(
activeAnnouncement || activeCommercial): 컨텍스트 배지 (store-player-announcement-badge/store-player-commercial-badge— “본사 안내방송 재생 중” / ”🚨 본사 긴급 안내방송 재생 중”(danger) / “CM송 재생 중”) + 방송 제목(store-player-broadcast-title=activeAnnouncement?.title ?? "본사 CM송") + 재생중 dot. 더킹 중이면 ”· 배경음악 작게 깔림” 보조 문구까지만(패널·EQ·버튼은 추가하지 않음). 긴급=danger 톤. - 방송 아님: 라벨 “다음 방송” +
pendingItems[0]미리보기(제목·긴급 pill) 또는 “예정된 방송 없음”(store-player-broadcast-title). - 모달 내용(
StoreScheduledListClient embedded defaultScope="today"): 카드 본문 클릭 시 열리는 다음 방송 목록은 2탭(오늘 남은 / 전체, SPEC #142)을 노출하며 기본 탭 “오늘 남은 방송” 은now <= scheduledAt <= 오늘 영업종료만 보여준다. 영업종료 기준은 매장 도메인에 영업시간 필드가 없어 오늘 KST 자정(23:59:59.999) 으로 정의한다(Asia/SeoulUTC+9 고정 비교). “전체 방송 목록” 탭은 BE SCHEDULED 전체(필터 없음·페이지네이션)를 보여준다. 별도 페이지 라우트 (/store/broadcast/scheduled)도 동일 2탭(기본 “오늘 남은 방송”). “오늘 남은 방송” 탭은 오늘 남은 게 없으면 “이번 영업시간 내 남은 예약이 없습니다.” 빈 상태를 노출하고 페이지네이션을 숨긴다. 각 행 [즉시 방송] 버튼으로 그 예약 안내방송을 지금 1회 추가 송출할 수 있다(원래 예약은 예정대로 유지, SPEC #142 — 자세히는 Broadcast).
- 방송 재생 중(
- 진행바(
store-player-progress)는 음악(메인) 트랙 기준으로 상시 노출한다(방송 중에도 — 음악은 연속 재생되므로 진행 그대로). 비인터랙티브(role=progressbar·seek 미지원). 트랜스포트(이전/재생/ 다음)·볼륨은 음악 제어로 유지한다(긴급 중에도 비활성화 X). - TTS(방송) 음소거 토글(
store-player-tts-mute): 방송 카드 우측 상단에 절대배치한 아이콘 버튼(종전 볼륨 컨트롤 옆에서 이동). 모달을 여는 카드 본문 버튼과 형제(별도 클릭 영역)라 뮤트 클릭이 [다음 방송] 모달을 열지 않는다.ttsMuted(기본 false)를 토글하면 방송 오버레이<audio>볼륨을 0(뮤트)/1로 적용한다(소리만 0 — 재생·ack·소비는 그대로 진행되어 방송이 정상 종료되고 pending 이 쌓이지 않음). 더킹(음악 볼륨)과는 독립. aria-label “안내방송 음소거”/“안내방송 음소거 해제” +aria-pressed. - 종전 더킹 정보 패널·EQ 미터·[멘트 중단] 버튼(
store-player-ment-controls·store-player-stop-ment)· 더킹 bed 트랙(store-player-duck-audio)은 제거됐다.
플랜(plan) 위반 음원 큐 제외 (SPEC #122 · #060 F1)
큐를 빌드해 점장에게 전달할 때 plan 위반 음원을 서버가 제외한다(“애초에 전달할 때 빼고 전달”).
점장은 위반 음원을 애초에 받지 않으므로 FE 코드 변경은 없다(계약 shape 불변 — getStorePlaybackQueue
응답이 필터된 큐를 반환). 동작은 전부 BE(StorePlaybackQueueService)에서 일어난다.
- effective plan =
store.plan ?? hq.plan: 매장 plan 우선, 없으면 본사 plan. - 필터 단위 = 라이브러리:
LibraryService.addMusic가 음원 type=라이브러리 type 을 강제하므로 한 라이브러리의 전 음원은 라이브러리 타입과 동일 → 큐 빌드의 라이브러리 병합 루프에서library.libraryType != effectivePlan인 라이브러리를 통째로 건너뛴다(추가 쿼리 0). - plan null = 무제약: effective plan 이 null(INDEPENDENT 본사 등)이면 필터 미적용(전곡 통과). plan 미설정 매장에 무음을 만들지 않는다(무음=C·안전).
- 전부 위반 시 =
EMPTY_PLAYLIST: 필터 후 큐가 비면 기존 빈 큐 동작으로 수렴(reason=EMPTY_PLAYLIST→ 아래 dead-end CTA 무음+연락). 새 reason code 를 만들지 않는다(PLAN_FILTERED차등 안내는 후속). 운영사가 plan 호환 PL 을 걸어야 한다(운영 절차). - 활성/기본 PL 동일 필터: 활성 PL·기본 PL fallback 이 같은 병합 루프를 공유하므로 양 경로에 같은 필터가 자동 적용된다(일관성). 단, 활성 PL 이 필터로 비어도 기본 PL 로 자동 폴백하지는 않는다(v1 — 흐름 재구성 필요한 후속). plan↔type 게이팅 모델은 domain/status-lifecycle· domain/enums 참조.
source / 빈 상태 (SPEC #064 §D5)
| 조건 | 표시 |
|---|---|
source = DEFAULT | cover 우측에 “기본 재생목록 재생 중” 안내(본사 기본 PL fallback). |
| 곡 있음 | hero 에 현재곡 제목·플레이리스트명·총 곡 수, 풀폭 재생 큐 목록(현재곡 하이라이트·행 클릭 점프). |
active=false + reason=NO_ACTIVE_PLAYLIST(또는 그 외) | 빈 상태 “본사가 플레이리스트를 준비 중입니다” + dead-end CTA(아래). |
reason=EMPTY_PLAYLIST | 빈 상태 “재생할 곡이 없습니다” + dead-end CTA. |
| 로딩 | ”재생 큐를 불러오는 중…” (transient — CTA 없음). |
| 에러(query.isError) | “재생 큐를 불러올 수 없습니다” + “[새로고침]으로 재시도, 계속되면 고객지원 문의” + dead-end CTA. |
playlistName 은 곡이 있을 때 hero 상단에 노출한다.
dead-end CTA (SPEC #118 §D2)
콘텐츠 미셋업 신규 매장은 무음 + “본사가 준비 중” 메시지에서 멈추는 dead-end 였다. 빈/에러 상태 카드 본문(로딩 제외)에 다음 행동 CTA 2개를 붙여 동선을 연다(무음 자체 제거는 아님 — UX dead-end 완화. 무음 콘텐츠 시드는 roadmap §범위 밖 후속):
| CTA | 동작 | testid |
|---|---|---|
| [새로고침] | queueQuery.refetch() — 본사가 PL 지정·곡 추가했으면 즉시 반영(handleRestart 재사용). | store-player-empty-refresh |
| [고객지원 문의] | /store/support/new?category=PLAYBACK — 재생 분류 사전선택 진입. | store-player-empty-support |
?category= query param 은 작성 폼(StoreTicketNewClient)이 useSearchParams() 로 읽어 첫 렌더
초기값에 적용한다(generated TicketCategory enum 에 없는 값은 무시 — 미선택 유지). 헤더의 [고객지원]
상시 진입점은 그대로 유지(빈 상태 CTA 는 추가).
CS 새 답변 dot (SPEC #119 F5 · D2)
헤더 [고객지원] Link(store-player-support-link)에 새 운영자 답변 dot 을 붙인다.
getStoreSupportUnreadSignal(StoreSupportUnreadSignalResponse.latestOperatorReplyAt)을 60초 폴링
(refetchInterval:60s·refetchIntervalInBackground:false — 점장 안내방송 20초 폴링과 별개 query)
하고, localStorage lastSeen(lm.support.lastSeen.store.<storeId> — useGetStoreMe().id)보다 최신
운영자 REPLY 가 있으면 버튼 우상단에 primary 도트(store-player-support-dot)를 표시한다(boolean dot —
카운트 아님, D2). /store/support 목록 진입(mount) 시 lastSeen=now 로 갱신해 dot 을 해소한다(D5,
markSupportSeen — apps/space/src/lib/support-last-seen.ts). localStorage 실패(시크릿모드 등)
시 보수적으로 dot 을 유지(깨지지 않음, D1 리스크 1). 점장 안내방송 배지는 만들지 않는다(D3 —
player 가 PENDING 안내방송을 이미 자동 재생·소비, 중복). 헤더 [고객지원] 버튼·CS 새 답변 dot 의
시각은 design_14 §8G-① 정합 완료(시각만 교체·dot 동작·폴링·lastSeen 로직 보존).
보조 액션 (SPEC #064 §D6)
보조 액션은 모달(Radix Dialog)로 연다 — player 위 portal 오버레이라 player·<audio> 가 유지되어
오디오가 끊기지 않는다. 각 버튼은 <button aria-haspopup="dialog"> 이며 openModal state 로 어느
Dialog 가 열렸는지 관리한다.
| 액션 | testid | 동작 |
|---|---|---|
| 상단 [음악이 이상하면 재시작] | store-player-restart | 큐 refetch + 재생 위치 리셋(in-app 복구 — window.reload 대신 세션 유지). |
| 헤더 우측 [로그아웃] | — | StoreLogoutButton(store-logout-button) — 운영사 AccountMenu 미러: POST /api/auth/logout(best-effort) → router.replace("/login") + refresh. 점장 세션 쿠키 lm_space_session 을 BFF 가 session.destroy() 로 비운다. 시안 부재라 프로필 옆 pill atom-grounded. |
| [즉시 방송 보내기] | store-player-broadcast-now | 즉시방송 모달(store-player-broadcast-modal)을 tts 탭으로 연다. |
| 자주 쓰는 방송 | store-player-action-broadcast-templates | 즉시방송 모달을 templates 탭으로 연다(이전 stub 연결). |
| 예약 방송 | store-player-action-broadcast-schedule | 즉시방송 모달을 **예약 모드(SCHEDULED)**로 연다(이전 stub 연결). |
| 플레이리스트 | store-player-action-playlist | 플레이리스트 선택 모달(store-player-playlist-modal)을 연다. 적용 성공 시 큐 invalidate → player 가 새 활성 PL 반영. |
| 단일 방송 카드(현재/다음) | store-player-broadcast-card | ”다음 방송” 모달(store-player-next-broadcasts-modal)을 연다(목록=scope="today" — 오늘 영업종료(KST 자정)까지 남은 예약만). 방송 중=현재 방송, 비방송=다음 방송/예정 없음. |
| 방송 음소거 토글 | store-player-tts-mute | 방송 카드 우상단(카드 본문 버튼의 형제 — stopPropagation 불필요한 별도 클릭 영역). 방송 오버레이 볼륨 0/1 토글(ttsMuted). 소리만 차단 — 재생·ack·소비는 진행. |
player 오디오 유지 불변식: 모달은 라우트 이동이 아니라 portal Dialog 이므로 열려 있어도
store-player-audio(및 더킹·크로스페이드 트랙)가 마운트된 채 유지된다. player 테스트가 각 버튼 클릭 → 해당 모달 열림(초기 탭/모드) + audio 마운트 유지를 검증한다.
인가·세션 (SPEC #064 §D7)
/store/* 는 app/store/layout.tsx(server)가 STORE_MANAGER role 가드를 담당한다(세션 없음→
/login, HQ_MANAGER→/admin, 그 외→fail-closed /login). 데이터는 refresh-aware mutator
(BFF)가 처리한다. 모달이 재사용·딥링크로 유지되는 페이지 라우트(broadcast/now·playlist·
broadcast/scheduled)도 같은 layout 가드 아래에 있고, 레거시 stub broadcast/templates·
broadcast/schedule 는 /store 로 redirect 한다.
미구현 / 후속
- 즉시방송 작성(
broadcast-now.jsx)·점장 PL 선택(F-store-select endpoint 선행)·안내방송 송출 고도화(스케줄·미리듣기·점장 즉시방송·STORES 개별선택·송출 감사·본사 원격 중단)·온보딩·장애복구· 커버 이미지(음원 enrich). 방송 동시재생(더킹 오버레이 · SPEC #141)·음악 크로스페이드는 라이브. - 점장 모드 전 화면의 stub ↔ 기획(PRD Page 1~9) 대응·막힌 선행조건은 Store Surface Matrix 에서 추적한다.