FeaturesStore (매장)Store Player Home (/store)

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 안에 마운트되고 (openModal state 가 어느 모달이 열렸는지 관리), portal 이라 레이아웃 영향은 0이다.
  • 모달이 재사용하는 client 들은 embedded prop 으로 페이지 셸(min-h-screen·StoreSubHeader· 닫기 Link 헤더)을 생략하고 본문만 DialogBody(스크롤 바디) 안에 렌더한다. 송출/적용 성공 시 onSent/onApplied 콜백으로 모달을 닫는다:
    • 즉시방송 모달 = broadcast/now/broadcast-now-client.tsx(initialTab·initialMode prop 으로 초기 탭/모드 seed) — 즉시방송(tts 탭)·자주 쓰는 방송(templates 탭)·예약 방송(예약 모드)을 한 컴포넌트로 연다(이전 stub broadcast/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용도
useGetStoreMeGET /api/v1/store/me상단 매장명(name)·본사명(hqName). 5xx/네트워크 시 “내 매장” 폴백.
useGetStorePlaybackQueueGET /api/v1/store/queue재생 큐 {active, source, playlistId, playlistName, total, truncated, reason, items[]}.
useListStorePendingAnnouncementsGET /api/v1/store/announcements/pending본인 매장의 미재생(PENDING) 안내방송 송출 { items[] } (created_at ASC). 20초 interval 폴링(큐와 독립).
useAckStoreAnnouncementPOST /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/nextSPEC #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. playing state 를 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)으로만 흐른다.
  • 폴링: useListStorePendingAnnouncementsrefetchInterval: 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·미존재는 404 DISPATCH_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 일치 검사(해제 후 도착하는 늦은 이벤트 차단).
  • 한 dispatch 는 1회만 재생(무한 반복 가드): 종료한 dispatchId 를 finishedAnnouncementsRef영구 마킹하고 ack 성공/실패·refetch 지연과 무관하게 같은 id 를 다시 재선출하지 않는다. ack onSettled 에서 이 집합을 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 가 이 신호를 소비한다(revokedDispatchIdsuseMemoReadonlySet 화).

  • 현재 재생 중 오버레이 즉시 정지: 폴링 갱신마다(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 에 마킹 + blockVersion bump 으로 nextPlayable* 메모 즉시 재계산해 재선출을 차단한다. revoke 된 dispatch 는 CANCELED 라 곧 pending 에서 빠지고, 그때 기존 prune effectfinishedRef 에서 정리한다.
  • 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): 긴급 오버레이 errorfailedAnnouncementsRef 마킹(즉시 재진입 차단) + 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 카운터 songsSinceCommercialRefeffective 빈도 (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 면 음악 원음 유지). fallback false.
    • duckVolumePercent — 방송 중 음악 목표 볼륨(정상 대비 %, 0~100). fallback 20. 음악 트랙의 실제 목표 볼륨 = 사용자 볼륨 × (duckVolumePercent / 100).
    • duckFadeMs — 감쇠/복원 fade 시간(ms, 0~5000). fallback 400.
    • ⚠️ DUCK 제약 상수(0..100·0..5000)는 본사/매장 설정 화면과 함께 @/lib/ducking 한 곳에서 export 해 재사용한다(BE OpenAPI Update{Hq,Store}DuckingRequest 와 정합 · drift 방지).
  • 더킹 멀티플라이어(daiso 곱 램프): duckMultiplierRef(1=원음 · duckVolumePercent/100=감쇠)를 방송 시작 시 감쇠치로, 종료 시 1.0 으로 fade 하고 음악 트랙(메인 · 크로스페이드 중이면 incoming)에 volume * multiplier(* fade계수) 로 곱해 적용한다. 부드러운 램프: daiso use-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 · onLoadedMetadata 1회 적용) 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-broadcast 2개 카드를 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/Seoul UTC+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 = DEFAULTcover 우측에 “기본 재생목록 재생 중” 안내(본사 기본 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, markSupportSeenapps/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-restartrefetch + 재생 위치 리셋(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 에서 추적한다.