diff --git a/solution/site/src/sections/SongPlayer.tsx b/solution/site/src/sections/SongPlayer.tsx
index 0ac2361..5ac5df6 100644
--- a/solution/site/src/sections/SongPlayer.tsx
+++ b/solution/site/src/sections/SongPlayer.tsx
@@ -1,167 +1,291 @@
-import { useEffect, useRef, useState } from "react";
-import { ListMusic, Pause, Play } from "lucide-react";
-import { useSite } from "@site/lib/site-context";
+import {useCallback, useEffect, useRef, useState} from 'react';
+import type {SongTrack} from '@o2o/shared';
+import {useSite} from '@site/lib/site-context';
+import './song-player.css';
/**
- * 이 숙소의 노래 — 헤더의 작은 플레이어.
+ * 헤더 미니 플레이어 — 이 숙소가 만든 노래.
*
- * ★ 무엇인가 · 무엇이 아닌가
- * 발행할 때 이 숙소의 사실로 쓴 가사에 곡을 붙여 만든 노래다(백엔드 `song_service`).
- * 배경음악처럼 **저절로 틀지 않는다** — 손님이 누를 때만 난다. 자동 재생은 브라우저가
- * 막기도 하거니와, 막지 않는 경우가 더 나쁘다(소리가 갑자기 나는 페이지는 닫힌다).
+ * ★ 생김새와 동작은 `/s/stay` 목업(`scripts/mockup/inject.js`·`inject.css`)을 **그대로 옮긴 것**이다.
+ * 그 화면은 사장님 확인을 여러 번 거쳤다 — 카세트를 앞에 세운 것("전혀 뮤직플레이어 같지가
+ * 않아"), 목록 아이콘을 햄버거에서 음표로 바꾼 것("메뉴 버튼이랑 헷갈림"), 셔플을 뺀 것,
+ * 자동재생을 뺀 것("그럼 자동 빼")까지. 여기서 "조금 더 낫게" 손대면 그 합의가 조용히 풀린다.
+ * ★ 목업은 손으로 만든 한 장이고 앞으로 발행되는 모든 사이트는 이 컴포넌트를 쓴다 —
+ * 고칠 일이 생기면 여기와 `song-player.css` 가 단일 출처다.
+ *
+ * ★ 무엇이 아닌가
+ * 배경음악이 아니다. **저절로 틀지 않는다** — 손님이 누를 때만 난다.
+ * (목업 주석의 실측: 자동재생을 우회하려고 "첫 터치에 켠다" 를 넣었더니 그 리스너가 재생
+ * 단추의 클릭을 먼저 먹어 두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없다.)
*
* ★ 곡이 없으면 아무것도 그리지 않는다.
- * 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다 — 키가 없거나(SUNO_API_KEY),
- * 작곡이 실패하거나, 옛 버전으로 구운 사이트다. 그때 빈 플레이어를 그리면 손님에게는
- * **고장난 버튼**이다. 없으면 없는 대로 둔다.
+ * 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다(키 미설정·작곡 실패·옛 버전으로
+ * 구운 사이트). 그때 빈 플레이어를 그리면 손님에게는 고장난 버튼이다.
*
- * ★ 가사를 함께 싣는다.
- * 무슨 노래인지 읽히지 않으면 아무도 누르지 않는다. 그리고 이 가사는 화면에 있는 편이
- * 검색·AI 쪽에도 맞다 — 오디오 안의 말은 크롤러가 못 듣는다.
- *
- * ★ 패널은 조건부 렌더가 아니라 `hidden` 으로 여닫는다.
- * `{open && ...}` 로 두면 **닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다.** 서버 렌더 결과가
- * 곧 크롤러가 읽는 전부인데(정적 발행), 그러면 이 노래는 검색·AI 쪽에 존재하지 않는 것과
- * 같아진다 — 오디오 안의 말은 크롤러가 못 듣기 때문에 가사가 유일한 단서다.
- * 그래서 markup 은 늘 있고, 보이고 안 보이고만 바뀐다.
+ * ★ 패널은 조건부 렌더가 아니라 `hidden` 이다.
+ * `{open && ...}` 로 두면 닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다. 정적 발행이라
+ * 서버 렌더 결과가 곧 크롤러가 읽는 전부인데, 오디오 안의 말은 크롤러가 못 듣는다 —
+ * 가사가 유일한 단서다.
*/
+
+/** 목업과 같은 아이콘. `innerHTML` 대신 JSX 로 옮겼고 경로 값은 그대로다. */
+function CassetteIcon() {
+ return (
+
+ );
+}
+
+function PlayIcon() {
+ return (
+
+ );
+}
+
+function PauseIcon() {
+ return (
+
+ );
+}
+
+/**
+ * ★ 햄버거(줄 세 개)를 쓰지 않는다 — 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있어
+ * 헷갈린다는 지적이 있었다. 줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다.
+ */
+function ListIcon() {
+ return (
+
+ );
+}
+
+function CloseIcon() {
+ return (
+
+ );
+}
+
+function clock(sec: number | undefined): string {
+ if (typeof sec !== 'number' || !Number.isFinite(sec)) return '';
+ const m = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
+}
+
export function SongPlayer() {
const payload = useSite();
- const songs = payload.songs ?? [];
+ const songs: SongTrack[] = (payload.songs ?? []).filter((song) => song.audioUrl);
+ const [cur, setCur] = useState(0);
+ const [playing, setPlaying] = useState(false);
const [open, setOpen] = useState(false);
- const [playing, setPlaying] = useState(null);
- const audioRef = useRef(null);
+ const [time, setTime] = useState({now: 0, total: 0});
+ const [panelStyle, setPanelStyle] = useState>({});
- // 페이지를 떠나거나 컴포넌트가 사라질 때 소리를 끊는다 — 리액트가 노드를 지워도
- // 재생 중인 오디오는 계속 난다(브라우저가 소리를 DOM 과 함께 정리하지 않는다).
+ const audioRef = useRef(null);
+ const miniRef = useRef(null);
+ const panelRef = useRef(null);
+
+ /**
+ * ★ 시작 곡만 무작위, 그다음은 순차다(목업과 같은 규칙).
+ * 들어올 때마다 같은 곡이면 배경음악이 하나인 것처럼 들리고, 매번 무작위로 뽑으면
+ * "아까 그 곡" 을 다시 찾기 어렵다.
+ * ★ 무작위는 **마운트 후**에 고른다. 서버 렌더에서 뽑으면 HTML 과 하이드레이션 결과가
+ * 달라져 리액트가 화면을 통째로 다시 그린다.
+ */
+ const count = songs.length;
+ useEffect(() => {
+ if (count > 1) setCur(Math.floor(Math.random() * count));
+ }, [count]);
+
+ /** 패널은 fixed 다 — 헤더가 sticky 라 스크롤해도 자리가 같아야 한다. */
+ const placePanel = useCallback(() => {
+ const header = document.querySelector('header');
+ const box = header?.getBoundingClientRect();
+ const next: Record = {top: `${box ? box.bottom + 6 : 66}px`};
+ if (window.innerWidth <= 640) {
+ // 좁은 화면에서는 좌우 8px 만 남기고 펼친다 — 고정 폭이면 390px 화면에서 제목이 접힌다.
+ next.left = '8px';
+ next.right = '8px';
+ } else {
+ next.left = 'auto';
+ const anchor = miniRef.current?.getBoundingClientRect();
+ next.right = `${Math.max(12, window.innerWidth - (anchor?.right ?? 0))}px`;
+ }
+ setPanelStyle(next);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ placePanel();
+
+ const onMove = () => placePanel();
+ // 바깥을 누르면 닫는다. 패널과 헤더 묶음 안은 제외한다.
+ const onClick = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (panelRef.current?.contains(target) || miniRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+
+ window.addEventListener('resize', onMove);
+ window.addEventListener('scroll', onMove, {passive: true});
+ document.addEventListener('click', onClick);
+ return () => {
+ window.removeEventListener('resize', onMove);
+ window.removeEventListener('scroll', onMove);
+ document.removeEventListener('click', onClick);
+ };
+ }, [open, placePanel]);
+
+ // 페이지를 떠날 때 소리를 끊는다 — 리액트가 노드를 지워도 재생 중인 오디오는 계속 난다.
useEffect(() => () => audioRef.current?.pause(), []);
if (songs.length === 0) return null;
- const toggle = (songId: string, url: string) => {
- const audio = audioRef.current;
- if (!audio) return;
+ const song = songs[cur] ?? songs[0];
- if (playing === songId) {
- audio.pause();
- setPlaying(null);
- return;
- }
- // 다른 곡을 누르면 하던 것을 끊고 갈아탄다.
- audio.src = url;
- // 재생은 거절될 수 있다(자동재생 정책·네트워크). 그때 '재생 중' 으로 표시해 두면
- // 소리는 안 나는데 멈춤 버튼만 보인다 — 성공했을 때만 상태를 바꾼다.
+ const play = (index = cur) => {
+ const audio = audioRef.current;
+ const target = songs[index];
+ if (!audio || !target) return;
+ if (audio.getAttribute('src') !== target.audioUrl) audio.src = target.audioUrl;
+ // 자동재생 차단·파일 오류는 예외가 아니라 거절된 약속으로 온다.
void audio.play().then(
- () => setPlaying(songId),
- () => setPlaying(null),
+ () => setPlaying(true),
+ () => setPlaying(false),
);
};
- // 가사는 **고른 곡**의 것을 보여준다. 아직 아무것도 안 눌렀으면(서버 렌더 포함) 첫 곡이다 —
- // 그래야 크롤러가 읽는 HTML 에도 가사가 들어간다.
- const current = songs.find((song) => song.songId === playing) ?? songs[0];
+ const stop = () => {
+ // 자리는 남긴다 — 다시 누르면 이어 듣는다.
+ audioRef.current?.pause();
+ setPlaying(false);
+ };
+
+ /** 목록에서 고른다. 고르면 그 자리에서 튼다. 끝나면 다음 곡으로 넘어가는 길도 여기다. */
+ const load = (index: number) => {
+ const next = ((index % songs.length) + songs.length) % songs.length;
+ setCur(next);
+ setTime({now: 0, total: 0});
+ play(next);
+ };
return (
-
-
+ <>
+
+ {/* 카세트는 단추가 아니라 표지다 — 이 자리가 무엇인지 한눈에 말해 준다. */}
+
+
+
+ {/* 지금 나오는 곡명. 소리가 나는데 무슨 곡인지 화면에 없으면 목록을 열어 봐야 안다. */}
+
+ {song.title}
+
+
+
+
- {/*
- ★ 오디오 태그는 패널 밖에 둔다. 패널 안에 두면 목록을 닫는 순간 노드가 사라져
- 듣던 노래가 끊긴다 — 닫고 계속 듣는 것이 이 플레이어의 기본 사용법이다.
- ★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
- */}
- {/*
- ★ 첫 곡의 주소를 markup 에 박아 둔다. 누를 때 비로소 src 를 넣으면 **서버 렌더 결과에
- 오디오 주소가 한 글자도 없다** — 크롤러에게 이 페이지는 소리 없는 페이지다.
- preload="none" 이라 주소만 있고 받아 오지는 않는다.
- */}
+ {/* ★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
+ ★ src 를 markup 에 박아 둔다 — 누를 때 비로소 넣으면 서버 렌더 결과에 오디오 주소가
+ 한 글자도 없어, 크롤러에게 이 페이지는 소리 없는 페이지다. */}