[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:
hbyang 2026-09-11 11:36:16 +09:00
parent 38bb4be9e5
commit 46e4d091a4
2 changed files with 571 additions and 122 deletions

View File

@ -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>
</>
); );
} }

View 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;
}