[fix] solution/site: 헤더 플레이어를 /s/stay 목업과 같은 것으로 — 주입 CSS·마크업을 그대로 옮긴다
발행본에 붙인 플레이어가 목업(/s/stay)과 다른 물건이었다. 목업은 카세트 · 현재 곡명 ·
재생 · 목록(음표) 네 자리에 크림색 패널·LP판·EQ 막대인데, 새로 만든 쪽은 lucide 아이콘
하나에 흰 드롭다운이었다. 같은 제품에서 두 화면이 서로 다른 플레이어를 갖고 있었다.
목업 화면은 사장님 확인을 여러 번 거친 것이다 — 카세트를 앞에 세운 것("전혀 뮤직플레이어
같지가 않아"), 목록 아이콘을 햄버거에서 음표로 바꾼 것("메뉴 버튼이랑 헷갈림"), 셔플 제거,
자동재생 제거("그럼 자동 빼"), 모바일 폭(좌우 8px). 새로 그리면 그 합의가 통째로 풀린다.
→ `scripts/mockup/inject.css` 를 **값 그대로** 옮기고, `inject.js` 의 DOM 을 같은 구조로 짰다.
- site/sections/song-player.css: 목업 주입 CSS 이식(w4d-* 클래스·--tpl-* 변수 그대로).
앞으로는 목업이 아니라 이 파일이 단일 출처다 — 목업은 손으로 만든 한 장이고
발행되는 모든 사이트는 이 컴포넌트를 쓴다
- SongPlayer: 카세트(재생 중 릴 회전) · 현재 곡명 · 재생/멈춤 · 목록 네 자리.
패널은 fixed 로 헤더 아래에 붙이고(sticky 헤더라 스크롤해도 자리가 같다) 좁은 화면에서는
좌우 8px 만 남기고 펼친다. 줄은 LP판 + 제목/가수 + 시간, 재생 중인 줄은 판 대신 EQ 막대
- 시작 곡만 무작위, 그다음은 순차(목업과 같은 규칙). ★ 무작위는 마운트 후에 고른다 —
서버 렌더에서 뽑으면 HTML 과 하이드레이션 결과가 달라져 화면을 통째로 다시 그린다
- 패널은 여전히 hidden 여닫이다. 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어
크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다)
검증: tsc --noEmit · eslint · vitest 55 passed. 재굽기 후 /s/스테이머뭄-99a887f8 에서
w4d-mini/tape/now/play/toggle · panel(head·head-n·item·mark·disc-sm·eq·info·t·sub·time)
· lyrics-box 전부 확인, 번들 CSS 에 w4d 규칙 포함 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38bb4be9e5
commit
46e4d091a4
@ -1,167 +1,291 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import { ListMusic, Pause, Play } from "lucide-react";
|
import type {SongTrack} from '@o2o/shared';
|
||||||
import { useSite } from "@site/lib/site-context";
|
import {useSite} from '@site/lib/site-context';
|
||||||
|
import './song-player.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이 숙소의 노래 — 헤더의 작은 플레이어.
|
* 헤더 미니 플레이어 — 이 숙소가 만든 노래.
|
||||||
*
|
*
|
||||||
* ★ 무엇인가 · 무엇이 아닌가
|
* ★ 생김새와 동작은 `/s/stay` 목업(`scripts/mockup/inject.js`·`inject.css`)을 **그대로 옮긴 것**이다.
|
||||||
* 발행할 때 이 숙소의 사실로 쓴 가사에 곡을 붙여 만든 노래다(백엔드 `song_service`).
|
* 그 화면은 사장님 확인을 여러 번 거쳤다 — 카세트를 앞에 세운 것("전혀 뮤직플레이어 같지가
|
||||||
* 배경음악처럼 **저절로 틀지 않는다** — 손님이 누를 때만 난다. 자동 재생은 브라우저가
|
* 않아"), 목록 아이콘을 햄버거에서 음표로 바꾼 것("메뉴 버튼이랑 헷갈림"), 셔플을 뺀 것,
|
||||||
* 막기도 하거니와, 막지 않는 경우가 더 나쁘다(소리가 갑자기 나는 페이지는 닫힌다).
|
* 자동재생을 뺀 것("그럼 자동 빼")까지. 여기서 "조금 더 낫게" 손대면 그 합의가 조용히 풀린다.
|
||||||
|
* ★ 목업은 손으로 만든 한 장이고 앞으로 발행되는 모든 사이트는 이 컴포넌트를 쓴다 —
|
||||||
|
* 고칠 일이 생기면 여기와 `song-player.css` 가 단일 출처다.
|
||||||
|
*
|
||||||
|
* ★ 무엇이 아닌가
|
||||||
|
* 배경음악이 아니다. **저절로 틀지 않는다** — 손님이 누를 때만 난다.
|
||||||
|
* (목업 주석의 실측: 자동재생을 우회하려고 "첫 터치에 켠다" 를 넣었더니 그 리스너가 재생
|
||||||
|
* 단추의 클릭을 먼저 먹어 두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없다.)
|
||||||
*
|
*
|
||||||
* ★ 곡이 없으면 아무것도 그리지 않는다.
|
* ★ 곡이 없으면 아무것도 그리지 않는다.
|
||||||
* 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다 — 키가 없거나(SUNO_API_KEY),
|
* 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다(키 미설정·작곡 실패·옛 버전으로
|
||||||
* 작곡이 실패하거나, 옛 버전으로 구운 사이트다. 그때 빈 플레이어를 그리면 손님에게는
|
* 구운 사이트). 그때 빈 플레이어를 그리면 손님에게는 고장난 버튼이다.
|
||||||
* **고장난 버튼**이다. 없으면 없는 대로 둔다.
|
|
||||||
*
|
*
|
||||||
* ★ 가사를 함께 싣는다.
|
* ★ 패널은 조건부 렌더가 아니라 `hidden` 이다.
|
||||||
* 무슨 노래인지 읽히지 않으면 아무도 누르지 않는다. 그리고 이 가사는 화면에 있는 편이
|
* `{open && ...}` 로 두면 닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다. 정적 발행이라
|
||||||
* 검색·AI 쪽에도 맞다 — 오디오 안의 말은 크롤러가 못 듣는다.
|
* 서버 렌더 결과가 곧 크롤러가 읽는 전부인데, 오디오 안의 말은 크롤러가 못 듣는다 —
|
||||||
*
|
* 가사가 유일한 단서다.
|
||||||
* ★ 패널은 조건부 렌더가 아니라 `hidden` 으로 여닫는다.
|
|
||||||
* `{open && ...}` 로 두면 **닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다.** 서버 렌더 결과가
|
|
||||||
* 곧 크롤러가 읽는 전부인데(정적 발행), 그러면 이 노래는 검색·AI 쪽에 존재하지 않는 것과
|
|
||||||
* 같아진다 — 오디오 안의 말은 크롤러가 못 듣기 때문에 가사가 유일한 단서다.
|
|
||||||
* 그래서 markup 은 늘 있고, 보이고 안 보이고만 바뀐다.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 목업과 같은 아이콘. `innerHTML` 대신 JSX 로 옮겼고 경로 값은 그대로다. */
|
||||||
|
function CassetteIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||||
|
<g className="w4d-reel">
|
||||||
|
<circle cx="8.5" cy="12" r="2.6" />
|
||||||
|
<path d="M8.5 9.4v5.2M5.9 12h5.2" />
|
||||||
|
</g>
|
||||||
|
<g className="w4d-reel">
|
||||||
|
<circle cx="15.5" cy="12" r="2.6" />
|
||||||
|
<path d="M15.5 9.4v5.2M12.9 12h5.2" />
|
||||||
|
</g>
|
||||||
|
<path d="M6 19l1.5-2.4h9L18 19" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 5h3.4v14H7zm6.6 0H17v14h-3.4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 햄버거(줄 세 개)를 쓰지 않는다 — 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있어
|
||||||
|
* 헷갈린다는 지적이 있었다. 줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다.
|
||||||
|
*/
|
||||||
|
function ListIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M4 7h11M4 12h11M4 17h7" />
|
||||||
|
<path d="M19 17.5V9l3 1.2" />
|
||||||
|
<circle cx="17.4" cy="17.6" r="1.7" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function SongPlayer() {
|
||||||
const payload = useSite();
|
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 [open, setOpen] = useState(false);
|
||||||
const [playing, setPlaying] = useState<string | null>(null);
|
const [time, setTime] = useState({now: 0, total: 0});
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const [panelStyle, setPanelStyle] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 페이지를 떠나거나 컴포넌트가 사라질 때 소리를 끊는다 — 리액트가 노드를 지워도
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
// 재생 중인 오디오는 계속 난다(브라우저가 소리를 DOM 과 함께 정리하지 않는다).
|
const miniRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(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<string, string> = {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(), []);
|
useEffect(() => () => audioRef.current?.pause(), []);
|
||||||
|
|
||||||
if (songs.length === 0) return null;
|
if (songs.length === 0) return null;
|
||||||
|
|
||||||
const toggle = (songId: string, url: string) => {
|
const song = songs[cur] ?? songs[0];
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
if (playing === songId) {
|
const play = (index = cur) => {
|
||||||
audio.pause();
|
const audio = audioRef.current;
|
||||||
setPlaying(null);
|
const target = songs[index];
|
||||||
return;
|
if (!audio || !target) return;
|
||||||
}
|
if (audio.getAttribute('src') !== target.audioUrl) audio.src = target.audioUrl;
|
||||||
// 다른 곡을 누르면 하던 것을 끊고 갈아탄다.
|
// 자동재생 차단·파일 오류는 예외가 아니라 거절된 약속으로 온다.
|
||||||
audio.src = url;
|
|
||||||
// 재생은 거절될 수 있다(자동재생 정책·네트워크). 그때 '재생 중' 으로 표시해 두면
|
|
||||||
// 소리는 안 나는데 멈춤 버튼만 보인다 — 성공했을 때만 상태를 바꾼다.
|
|
||||||
void audio.play().then(
|
void audio.play().then(
|
||||||
() => setPlaying(songId),
|
() => setPlaying(true),
|
||||||
() => setPlaying(null),
|
() => setPlaying(false),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 가사는 **고른 곡**의 것을 보여준다. 아직 아무것도 안 눌렀으면(서버 렌더 포함) 첫 곡이다 —
|
const stop = () => {
|
||||||
// 그래야 크롤러가 읽는 HTML 에도 가사가 들어간다.
|
// 자리는 남긴다 — 다시 누르면 이어 듣는다.
|
||||||
const current = songs.find((song) => song.songId === playing) ?? songs[0];
|
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 (
|
return (
|
||||||
<div className="relative">
|
<>
|
||||||
<button
|
<span id="w4d-mini" ref={miniRef} data-playing={playing ? '1' : '0'}>
|
||||||
type="button"
|
{/* 카세트는 단추가 아니라 표지다 — 이 자리가 무엇인지 한눈에 말해 준다. */}
|
||||||
onClick={() => setOpen((was) => !was)}
|
<span id="w4d-tape" aria-hidden title={`${payload.place.name}이(가) 만든 노래`}>
|
||||||
aria-expanded={open}
|
<CassetteIcon />
|
||||||
aria-label="노래 목록"
|
</span>
|
||||||
title="노래 목록"
|
{/* 지금 나오는 곡명. 소리가 나는데 무슨 곡인지 화면에 없으면 목록을 열어 봐야 안다. */}
|
||||||
className="border-line hover:bg-card flex size-9 items-center justify-center rounded-md border transition-colors"
|
<span id="w4d-now" aria-live="polite">
|
||||||
>
|
{song.title}
|
||||||
{playing ? (
|
|
||||||
<Pause className="size-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<ListMusic className="size-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
★ 오디오 태그는 패널 밖에 둔다. 패널 안에 두면 목록을 닫는 순간 노드가 사라져
|
|
||||||
듣던 노래가 끊긴다 — 닫고 계속 듣는 것이 이 플레이어의 기본 사용법이다.
|
|
||||||
★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
|
|
||||||
*/}
|
|
||||||
{/*
|
|
||||||
★ 첫 곡의 주소를 markup 에 박아 둔다. 누를 때 비로소 src 를 넣으면 **서버 렌더 결과에
|
|
||||||
오디오 주소가 한 글자도 없다** — 크롤러에게 이 페이지는 소리 없는 페이지다.
|
|
||||||
preload="none" 이라 주소만 있고 받아 오지는 않는다.
|
|
||||||
*/}
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={songs[0]?.audioUrl}
|
|
||||||
preload="none"
|
|
||||||
onEnded={() => setPlaying(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
hidden={!open}
|
|
||||||
className="border-line absolute right-0 top-11 z-50 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-lg border shadow-lg"
|
|
||||||
style={{ backgroundColor: "var(--tpl-surface, #fff)" }}
|
|
||||||
>
|
|
||||||
<div className="border-line flex items-center justify-between border-b px-4 py-2.5">
|
|
||||||
<span className="serif text-[length:var(--fs-sm)]">
|
|
||||||
{payload.place.name}이(가) 만든 노래
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
id="w4d-play"
|
||||||
aria-label="닫기"
|
data-playing={playing ? '1' : '0'}
|
||||||
className="text-muted px-1 text-lg leading-none"
|
title={`${playing ? '멈춤' : '재생'} · ${song.title}`}
|
||||||
|
onClick={() => (playing ? stop() : play())}
|
||||||
>
|
>
|
||||||
×
|
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="w4d-toggle"
|
||||||
|
title="노래 목록"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((was) => !was)}
|
||||||
|
>
|
||||||
|
<ListIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* ★ preload="none": 아무도 안 누를 수도 있는 파일을 모든 방문자에게 내려받게 하지 않는다.
|
||||||
|
★ src 를 markup 에 박아 둔다 — 누를 때 비로소 넣으면 서버 렌더 결과에 오디오 주소가
|
||||||
|
한 글자도 없어, 크롤러에게 이 페이지는 소리 없는 페이지다. */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={songs[0].audioUrl}
|
||||||
|
preload="none"
|
||||||
|
onTimeUpdate={(event) =>
|
||||||
|
setTime({now: event.currentTarget.currentTime, total: event.currentTarget.duration})
|
||||||
|
}
|
||||||
|
onDurationChange={(event) => setTime((was) => ({...was, total: event.currentTarget.duration}))}
|
||||||
|
onEnded={() => load(cur + 1)}
|
||||||
|
onError={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="w4d-panel" ref={panelRef} hidden={!open} style={panelStyle}>
|
||||||
|
<div className="w4d-head">
|
||||||
|
<span className="w4d-head-t">{payload.place.name}이(가) 만든 노래</span>
|
||||||
|
<span className="w4d-head-n">{songs.length}곡</span>
|
||||||
|
<button type="button" id="w4d-close" title="닫기" aria-label="닫기" onClick={() => setOpen(false)}>
|
||||||
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul>
|
<ol>
|
||||||
{songs.map((song, index) => (
|
{songs.map((item, index) => (
|
||||||
<li
|
<li key={item.songId}>
|
||||||
key={song.songId}
|
|
||||||
className="border-line border-b last:border-b-0"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(song.songId, song.audioUrl)}
|
className="w4d-item"
|
||||||
className="hover:bg-card flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
|
aria-current={index === cur}
|
||||||
style={
|
data-playing={index === cur && playing ? '1' : '0'}
|
||||||
playing === song.songId
|
onClick={() => load(index)}
|
||||||
? { color: "var(--color-brand)" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span className="text-muted w-4 shrink-0 text-[length:var(--fs-xs)] tabular-nums">
|
{/* 판 자리. 재생 중인 줄에서는 판 대신 막대가 뛴다. */}
|
||||||
{index + 1}
|
<span className="w4d-mark">
|
||||||
|
<span className="w4d-disc-sm" />
|
||||||
|
<span className="w4d-eq" aria-hidden>
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
</span>
|
</span>
|
||||||
{playing === song.songId ? (
|
|
||||||
<Pause className="size-3.5 shrink-0" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Play className="size-3.5 shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[length:var(--fs-sm)]">
|
|
||||||
{song.title}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted shrink-0 truncate text-[length:var(--fs-xs)]">
|
<span className="w4d-info">
|
||||||
{payload.place.name}
|
<span className="w4d-t">{item.title}</span>
|
||||||
|
<span className="w4d-sub">{payload.place.name}</span>
|
||||||
|
</span>
|
||||||
|
{/* 시간은 재생 중인 줄에만 적는다 — 나머지 줄에 같은 글자를 반복하지 않는다. */}
|
||||||
|
<span className="w4d-time">
|
||||||
|
{index === cur && (playing || time.now > 0)
|
||||||
|
? `${clock(time.now)}${time.total ? ` / ${clock(time.total)}` : ''}`
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ol>
|
||||||
|
|
||||||
{current?.lyrics && (
|
{/* 가사 — 종이 위 인쇄물처럼 둔다. 구조 태그([Verse])는 손님에게 뜻이 없어 뺀다. */}
|
||||||
<div className="border-line max-h-56 overflow-y-auto border-t px-4 py-3">
|
<div id="w4d-lyrics-box" hidden={!song.lyrics}>
|
||||||
{/* 가사는 줄바꿈이 곧 구조다. Suno 가 읽는 구조 태그([Verse])는 손님에게 뜻이 없어 뺀다. */}
|
{(song.lyrics ?? '')
|
||||||
<p className="text-muted whitespace-pre-line text-[length:var(--fs-xs)] leading-relaxed">
|
.replace(/^\[.*\]$/gm, '')
|
||||||
{current.lyrics
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.replace(/^\[.*\]$/gm, "")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim()}
|
.trim()}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
325
solution/site/src/sections/song-player.css
Normal file
325
solution/site/src/sections/song-player.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user