/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다. ★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 — 먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다. 값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다. 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다. ★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는 것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서 노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다. 가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가 없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다. ★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다. ★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면 "요청은 성공했는데 결과가 영영 안 옴" 이 된다. - services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기 - services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르 - services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다 - build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료) - place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 — 수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus) - snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다 - prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다** (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다) - site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다. 패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다) - azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 — 업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다 - compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL 검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1) → 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인, 지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed (실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
168 lines
7.1 KiB
TypeScript
168 lines
7.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { ListMusic, Pause, Play } from "lucide-react";
|
||
import { useSite } from "@site/lib/site-context";
|
||
|
||
/**
|
||
* 이 숙소의 노래 — 헤더의 작은 플레이어.
|
||
*
|
||
* ★ 무엇인가 · 무엇이 아닌가
|
||
* 발행할 때 이 숙소의 사실로 쓴 가사에 곡을 붙여 만든 노래다(백엔드 `song_service`).
|
||
* 배경음악처럼 **저절로 틀지 않는다** — 손님이 누를 때만 난다. 자동 재생은 브라우저가
|
||
* 막기도 하거니와, 막지 않는 경우가 더 나쁘다(소리가 갑자기 나는 페이지는 닫힌다).
|
||
*
|
||
* ★ 곡이 없으면 아무것도 그리지 않는다.
|
||
* 발행은 노래를 기다리므로 보통은 있다. 그래도 없을 수 있다 — 키가 없거나(SUNO_API_KEY),
|
||
* 작곡이 실패하거나, 옛 버전으로 구운 사이트다. 그때 빈 플레이어를 그리면 손님에게는
|
||
* **고장난 버튼**이다. 없으면 없는 대로 둔다.
|
||
*
|
||
* ★ 가사를 함께 싣는다.
|
||
* 무슨 노래인지 읽히지 않으면 아무도 누르지 않는다. 그리고 이 가사는 화면에 있는 편이
|
||
* 검색·AI 쪽에도 맞다 — 오디오 안의 말은 크롤러가 못 듣는다.
|
||
*
|
||
* ★ 패널은 조건부 렌더가 아니라 `hidden` 으로 여닫는다.
|
||
* `{open && ...}` 로 두면 **닫힌 동안 곡 제목과 가사가 DOM 에 아예 없다.** 서버 렌더 결과가
|
||
* 곧 크롤러가 읽는 전부인데(정적 발행), 그러면 이 노래는 검색·AI 쪽에 존재하지 않는 것과
|
||
* 같아진다 — 오디오 안의 말은 크롤러가 못 듣기 때문에 가사가 유일한 단서다.
|
||
* 그래서 markup 은 늘 있고, 보이고 안 보이고만 바뀐다.
|
||
*/
|
||
export function SongPlayer() {
|
||
const payload = useSite();
|
||
const songs = payload.songs ?? [];
|
||
|
||
const [open, setOpen] = useState(false);
|
||
const [playing, setPlaying] = useState<string | null>(null);
|
||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
// 페이지를 떠나거나 컴포넌트가 사라질 때 소리를 끊는다 — 리액트가 노드를 지워도
|
||
// 재생 중인 오디오는 계속 난다(브라우저가 소리를 DOM 과 함께 정리하지 않는다).
|
||
useEffect(() => () => audioRef.current?.pause(), []);
|
||
|
||
if (songs.length === 0) return null;
|
||
|
||
const toggle = (songId: string, url: string) => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
if (playing === songId) {
|
||
audio.pause();
|
||
setPlaying(null);
|
||
return;
|
||
}
|
||
// 다른 곡을 누르면 하던 것을 끊고 갈아탄다.
|
||
audio.src = url;
|
||
// 재생은 거절될 수 있다(자동재생 정책·네트워크). 그때 '재생 중' 으로 표시해 두면
|
||
// 소리는 안 나는데 멈춤 버튼만 보인다 — 성공했을 때만 상태를 바꾼다.
|
||
void audio.play().then(
|
||
() => setPlaying(songId),
|
||
() => setPlaying(null),
|
||
);
|
||
};
|
||
|
||
// 가사는 **고른 곡**의 것을 보여준다. 아직 아무것도 안 눌렀으면(서버 렌더 포함) 첫 곡이다 —
|
||
// 그래야 크롤러가 읽는 HTML 에도 가사가 들어간다.
|
||
const current = songs.find((song) => song.songId === playing) ?? songs[0];
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((was) => !was)}
|
||
aria-expanded={open}
|
||
aria-label="노래 목록"
|
||
title="노래 목록"
|
||
className="border-line hover:bg-card flex size-9 items-center justify-center rounded-md border transition-colors"
|
||
>
|
||
{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>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(false)}
|
||
aria-label="닫기"
|
||
className="text-muted px-1 text-lg leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<ul>
|
||
{songs.map((song, index) => (
|
||
<li
|
||
key={song.songId}
|
||
className="border-line border-b last:border-b-0"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggle(song.songId, song.audioUrl)}
|
||
className="hover:bg-card flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
|
||
style={
|
||
playing === song.songId
|
||
? { color: "var(--color-brand)" }
|
||
: undefined
|
||
}
|
||
>
|
||
<span className="text-muted w-4 shrink-0 text-[length:var(--fs-xs)] tabular-nums">
|
||
{index + 1}
|
||
</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 className="text-muted shrink-0 truncate text-[length:var(--fs-xs)]">
|
||
{payload.place.name}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
{current?.lyrics && (
|
||
<div className="border-line max-h-56 overflow-y-auto border-t px-4 py-3">
|
||
{/* 가사는 줄바꿈이 곧 구조다. Suno 가 읽는 구조 태그([Verse])는 손님에게 뜻이 없어 뺀다. */}
|
||
<p className="text-muted whitespace-pre-line text-[length:var(--fs-xs)] leading-relaxed">
|
||
{current.lyrics
|
||
.replace(/^\[.*\]$/gm, "")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim()}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|