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 에 박아 둔다 — 누를 때 비로소 넣으면 서버 렌더 결과에 오디오 주소가 + 한 글자도 없어, 크롤러에게 이 페이지는 소리 없는 페이지다. */}
+ ); } diff --git a/solution/site/src/sections/song-player.css b/solution/site/src/sections/song-player.css new file mode 100644 index 0000000..fa891f6 --- /dev/null +++ b/solution/site/src/sections/song-player.css @@ -0,0 +1,325 @@ +/* + * 헤더 미니 플레이어 — `/s/stay` 목업의 주입 CSS(`scripts/mockup/inject.css`)를 그대로 옮긴 것. + * + * ★ 값을 바꾸지 않고 옮겼다. 목업이 사장님 확인을 여러 번 거친 화면이라(카세트 · 햄버거 대신 + * 음표 목록 · 모바일 폭 · 셔플 제거) 여기서 "조금 더 낫게" 고치면 그 합의가 조용히 풀린다. + * 고칠 일이 생기면 목업이 아니라 **이 파일이 단일 출처**다 — 목업은 손으로 만든 한 장이고 + * 앞으로 발행되는 모든 사이트는 이 파일을 쓴다. + * + * ★ 색·테두리·서체를 전부 템플릿 변수(--tpl-*)에서 가져온다. 값을 박으면 템플릿을 바꿀 때 + * 플레이어만 옛 색으로 남는다. + * ★ 플레이어는 헤더 **안**이다. 하단 바는 모바일 탭바(fixed bottom:0 · 61px)를 덮었고, + * 상단 바는 헤더와 두 줄이 됐다. 헤더 안이면 화면에서 차지하는 줄이 늘지 않는다. + */ + +/* ── 헤더에 꽂는 아이콘 세 개 ─────────────────────────────────────── */ +#w4d-mini { + display: inline-flex; + align-items: center; + gap: 2px; + margin-right: 6px; + padding-right: 8px; + border-right: 1px solid var(--tpl-border, #bcb49e); +} +#w4d-mini button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + color: var(--tpl-text, #1b1a15); + background: transparent; + border: 0; + border-radius: var(--tpl-radius, 0); + cursor: pointer; + opacity: 0.75; +} +#w4d-mini button:hover { + opacity: 1; + background: rgb(27 26 21 / 0.06); +} +#w4d-mini svg { + width: 16px; + height: 16px; +} + +/* 지금 나오는 곡명. 좁은 화면에서는 헤더에 자리가 없어 접는다 — + 실측(390px): 상호 115px + 오른쪽 묶음 94px 이라 남는 폭이 55px 뿐이다. + 그 화면에서는 목록을 열면 재생 중인 줄에 곡명과 시간이 있다. */ +#w4d-now { + max-width: 150px; + overflow: hidden; + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--tpl-accent, #bf2f1b); + padding-right: 2px; +} +#w4d-mini[data-playing='0'] #w4d-now { + opacity: 0.55; + font-weight: 400; +} +@media (max-width: 860px) { + #w4d-now { + display: none; + } +} + +/* 카세트 — 단추가 아니라 표지다. 이 자리가 무엇인지 한눈에 말해 준다. */ +#w4d-tape { + display: inline-flex; + align-items: center; + padding: 0 4px 0 2px; + color: var(--tpl-text, #1b1a15); + opacity: 0.8; +} +#w4d-tape svg { + width: 20px; + height: 20px; +} +#w4d-mini[data-playing='1'] #w4d-tape { + color: var(--tpl-accent, #bf2f1b); + opacity: 1; +} +/* 재생 중에는 릴이 돈다 — 소리가 나고 있다는 것을 글자 없이 말한다. */ +#w4d-mini[data-playing='1'] .w4d-reel { + transform-box: fill-box; + transform-origin: center; + animation: w4d-reel 2.6s linear infinite; +} +@keyframes w4d-reel { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .w4d-reel { + animation: none !important; + } +} +/* 재생 중일 때만 강조색 — 헤더에 곡명을 안 두므로 색이 유일한 '지금 나온다' 표시다. */ +#w4d-mini button[data-playing='1'] { + color: var(--tpl-accent, #bf2f1b); + opacity: 1; +} +@media (max-width: 400px) { + #w4d-mini { + margin-right: 2px; + padding-right: 4px; + } + #w4d-mini button { + width: 27px; + height: 27px; + } +} + +/* ── 목록 패널 (헤더 아래에 떠 있다) ────────────────────────────── + ★ 좁은 화면에서는 좌우 8px 만 남기고 펼친다. 320px 고정이었을 때 390px 화면에서 + 제목이 두 줄로 접히고 시간이 잘렸다(2026-09-11 사장님: "모바일 대응이 안 되어 있음"). */ +#w4d-panel { + position: fixed; + z-index: 45; /* 사이트 헤더가 z-40 이라 그 위 */ + width: 344px; + max-width: calc(100vw - 16px); + max-height: min(64vh, 520px); + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--tpl-font-body, serif); + color: var(--tpl-text, #1b1a15); + background: var(--tpl-card, #efe7d3); + border: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15); + box-shadow: var(--tpl-shadow, 4px 4px 0 rgb(27 26 21 / 0.16)); +} +#w4d-panel[hidden] { + display: none; +} +@media (max-width: 640px) { + #w4d-panel { + width: auto; + } +} + +#w4d-panel .w4d-head { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + padding: 11px 8px 11px 14px; + border-bottom: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15); +} +.w4d-head-t { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; +} +.w4d-head-n { + flex: 1 1 auto; + font-size: 11px; + opacity: 0.5; +} +#w4d-panel .w4d-head button { + display: inline-flex; + width: 28px; + height: 28px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: inherit; + background: transparent; + border: 0; + cursor: pointer; + opacity: 0.6; +} +#w4d-panel .w4d-head button:hover { + opacity: 1; +} +#w4d-panel .w4d-head svg { + width: 14px; + height: 14px; +} + +#w4d-panel ol { + flex: 1 1 auto; + margin: 0; + padding: 4px 0; + overflow-y: auto; + list-style: none; + -webkit-overflow-scrolling: touch; +} + +/* 한 줄 = 판 + 제목/가수 + 시간. 48px 아래로 내리지 않는다 — 손가락으로 누르는 자리다. */ +.w4d-item { + display: flex; + width: 100%; + min-height: 52px; + align-items: center; + gap: 11px; + padding: 8px 14px; + color: inherit; + background: transparent; + border: 0; + font: inherit; + text-align: left; + cursor: pointer; +} +.w4d-item + .w4d-item { + border-top: 1px dashed var(--tpl-border, #bcb49e); +} +.w4d-item:hover { + background: rgb(27 26 21 / 0.05); +} +.w4d-item[aria-current='true'] { + background: rgb(27 26 21 / 0.06); +} +.w4d-item[aria-current='true'] .w4d-t { + color: var(--tpl-accent, #bf2f1b); +} + +/* 판 자리. 재생 중인 줄에서는 판 대신 막대가 뛴다. */ +.w4d-mark { + position: relative; + flex: 0 0 26px; + height: 26px; +} +.w4d-disc-sm { + position: absolute; + inset: 0; + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, var(--w4d-lbl, #bf2f1b) 0 34%, transparent 34%), + repeating-radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.12) 0 1px, transparent 1px 3px), + #14110f; +} +.w4d-disc-sm::after { + content: ''; + position: absolute; + inset: 45%; + border-radius: 50%; + background: var(--tpl-card, #efe7d3); +} +.w4d-eq { + position: absolute; + inset: 0; + display: none; + align-items: flex-end; + justify-content: center; + gap: 3px; + padding-bottom: 4px; +} +.w4d-eq i { + width: 3px; + height: 6px; + background: var(--tpl-accent, #bf2f1b); + animation: w4d-eq 0.9s ease-in-out infinite; +} +.w4d-eq i:nth-child(2) { + animation-delay: 0.18s; +} +.w4d-eq i:nth-child(3) { + animation-delay: 0.36s; +} +@keyframes w4d-eq { + 0%, + 100% { + height: 5px; + } + 50% { + height: 17px; + } +} +.w4d-item[data-playing='1'] .w4d-disc-sm { + display: none; +} +.w4d-item[data-playing='1'] .w4d-eq { + display: flex; +} +@media (prefers-reduced-motion: reduce) { + .w4d-eq i { + animation: none; + height: 11px; + } +} + +.w4d-info { + min-width: 0; + flex: 1 1 auto; +} +.w4d-t { + display: block; + overflow: hidden; + font-size: 13.5px; + font-weight: 700; + white-space: nowrap; + text-overflow: ellipsis; +} +.w4d-sub { + display: block; + margin-top: 2px; + overflow: hidden; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; + opacity: 0.55; +} +.w4d-time { + flex: 0 0 auto; + font-size: 11px; + opacity: 0.6; + font-variant-numeric: tabular-nums; +} + +/* 가사 — 우리 곡만. 종이 위 인쇄물처럼 둔다. */ +#w4d-lyrics-box { + padding: 10px 12px 14px; + border-top: 1px dashed var(--tpl-border, #bcb49e); + white-space: pre-line; + font-size: 12px; + line-height: 1.9; + overflow-y: auto; +} +#w4d-lyrics-box[hidden] { + display: none; +}