o2o-site-AEO/solution/site/scripts/mockup/AUTOPLAY.md
Mina Choi 9773bc0496 [fix] solution: 엽서 자리·판 색·푸터 표기, 재발행이 노래를 다시 만들지 않게
대표 지적 여섯을 한 번에 고친다.

- **재발행이 Suno 를 다시 부르던 것**(`song_service.ensure_song`). `build_service` 가
  `publish=True` 마다 이 함수를 불렀는데 가드가 없었다 — 내용이 하나도 안 바뀐 재발행에도
  Gemini 가사 1회 + Suno 작곡 1회가 그대로 나갔고, 다섯 번 누르면 유료 호출 다섯 번에
  `place_songs` 행 다섯 개다. 화면은 최신 READY 한 곡만 쓰므로 나머지는 돈만 쓴다.
  → READY 곡이 있으면 건너뛴다. 일부러 다시 만드는 길은 SONG 잡(`force=True`)으로 남긴다
- **엽서 쓰기 자리**를 계절별 축제 바로 앞으로(시연본과 같게). 목록 맨 뒤라 FAQ 보다도
  아래였다. 축제가 없는 사이트는 종전대로 맨 뒤
- **엽서가 모바일에서 화면을 밀어내던 것** — 그리드 칸의 기본 `min-width:auto` 가
  사진 레일의 최소 너비(실측 390px 화면에서 2,056px)를 그대로 받아 칸이 밖으로 나갔다.
  `min-w-0` 과 캔버스 비율 고정(`aspect-square h-auto`). 실측 넘침 0px · 캔버스 358×358
- **가요 다방 판이 한 색이던 것** — 폴백 팔레트를 시연본(`/s/stay`)이 쓰는 23색 그대로
  옮겼다(대표: "그냥 정해놔 · /s/stay 보고 맞춰라"). 목록을 새로 짜면 시연본과 갈린다
- **푸터에 만든 곳 표기** — `AI O2O의 Web4Ai로 만든 사이트입니다.`(o2osolution.ai 링크).
  발행본(`SiteFooter`)과 시연본(`patch_stay.py`) 둘 다
- **에디터 캔버스의 푸터·모바일 탭바 제거** — 발행본이 iframe 안에서 둘 다 이미 그린다
  (`SiteFooter` · `MobileTabBar`). 바깥에 한 벌 더 그려 두 번 서 있었다
- **시연본 자동재생 끔**(`inject.js`, 대표: "클릭하고 나서 되니까 사람들이 에러로 보잖아").
  세 번째 뒤집기고 이번 이유는 다르다 — 정책을 못 이겨서가 아니라 정책에 걸린 모습이
  고장으로 읽혀서다. 함수는 남기고 호출만 뺐다. 제품 렌더러엔 원래 없다

검증: tsc·eslint 통과(site·frontend), site 79 passed. 실제로 구워서 확인 —
섹션 순서 `…오시는 길 → 엽서쓰기 → 계절별 축제 → …`, 푸터에 o2osolution.ai 링크,
모바일 가로 넘침 0. 킹서버 실측: 재발행 안 한 사이트(joyyy·bbbb)도 중계로 공유 버튼이 뜬다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:22:26 +09:00

7.9 KiB

자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지

2026-09-15 — 자동재생을 껐다. 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 사람들이 에러로 보잖아 그냥 꺼놔." 아래 내용은 왜 자동재생이 어려운가의 기록으로 남긴다 — 되살릴 일이 생기면 inject.jsvoid autoplay;autoplay(); 로 되돌리면 된다. 함수와 첫 손길 처리는 지우지 않았다.

/s/stay 배경음악을 새로고침 직후 바로 나게 하려고 시도한 것 전부와 실측값이다. 같은 얘기가 다시 나오면 이 파일부터 본다.

측정: 2026-09-14 · 킹서버 https://web4ai.o2osolution.ai/s/stay · Chrome(정식 채널)


결론

브라우저에서 첫 방문에 소리가 저절로 나게 하는 방법은 없다. 사람 손 입력(isTrusted: true) 없이는 크롬이 play()NotAllowedError 로 거절한다. 자바스크립트로 그 검사를 통과시키는 길은 없고, 그건 우회 대상이 아니라 그 우회를 막으려고 만들어진 검사다.

지금 동작: 손님이 화면에 처음 닿으면(클릭·탭·키) 켜진다. 그 이상은 어떤 사이트도 못 한다.

해 본 것과 결과

시도 결과
로드 즉시 audio.play() NotAllowedError
무음(muted=true)으로 시작 → 나중에 unmute NotAllowedError (무음도 거절된다)
<audio muted playsinline> 태그로 두고 play() NotAllowedError
가짜 이벤트 강제 발생 — dispatchEventpointerdown·mousedown·pointerup·mouseup·click·keydown 6종 navigator.userActivation false 그대로, 거절
재생 단추를 코드로 누르기 — btn.click(), 0.7초 간격 3회 play=0
--app=URL 앱 모드로 띄우기 play=0
Web Audio APInew AudioContext() state: "suspended"
ctx.resume() 약속이 영원히 안 돌아온다 (제스처를 기다리며 매달린다)
mp3 를 decodeAudioData 로 풀어 BufferSource 재생 currentTime 0.000 → 0.000, 안 돈다
speechSynthesis.speak() error: not-allowed
마우스만 움직임 / 스크롤만 play=0 (act=false)
진짜 클릭·탭·키 입력 한 번 켜진다 (act=true, play=1)

dispatchEventel.click() 이 만든 이벤트는 isTrusted: false 로 찍혀 나간다. 크롬의 사용자 활성화 카운터(navigator.userActivation)는 isTrusted: true 인 입력만 세고, 그게 올라야 소리가 풀린다.

브라우저에서 소리를 낼 수 있는 API 는 셋뿐이고 셋 다 같은 게이트 뒤에 있다 — HTMLMediaElement(<audio>) · Web Audio(AudioContext) · speechSynthesis. 우회로가 없는 이유다.

활성화를 준다: mousedown · pointerdown · pointerup · touchend · keydown(Esc 제외) · click 안 준다: mousemove · pointermove · touchmove · wheel · scroll


안 한 것과 그 이유

크롬 문서(developer.chrome.com/blog/autoplay)에 예외가 넷 있지만 전부 손님 쪽 설정이라 제품에서는 쓸 수 없다. 한 번 만들었다가 되돌렸다.

예외 왜 안 쓰나
PWA 설치본 펜션 사이트 보러 온 손님이 앱을 설치할 리 없다. 매니페스트까지 붙였다가 걷어냈다
MEI (같은 출처에서 7초 이상 여러 번) 손님이 쌓는 것이라 첫 방문에는 없다
--autoplay-policy=no-user-gesture-required 브라우저를 그 플래그로 띄워야 한다
기업정책 AutoplayAllowlist 기계 관리자가 넣는 것이다

강제 클릭 코드도 뺐다. 넣어서 배포까지 해 봤지만 기본 크롬에서 play=0 이고, 동작하지 않는 코드가 남으면 다음 사람이 "이미 처리돼 있네" 로 읽는다.


지금 코드가 하는 일 (inject.js startPlayer)

  1. 로드 즉시 play() 를 건다. (거절되지만, 자동재생이 허락된 환경이면 여기서 켜진다)
  2. 거절되면 첫 사람 손길을 기다린다. 창 전체에 걸고, 플레이어 자기 단추만 빼고 어디를 눌러도 걸린다.
    • STRONG(활성화를 주는 입력): 스로틀 없이 매번 시도
    • WEAK(안 주는 입력): 1초에 한 번만 — 초당 수십 번 오는 mousemove 로 단추가 깜빡이는 것을 막는다
  3. 탭이 다시 앞으로 나올 때(visibilitychange) 한 번 더 건다.

여기서 밟았던 함정 둘

  • 가짜 pointerdown 이 대기를 태웠다. tameRails() 가 레일 자동 넘김을 세우려고 레일마다 pointerdown 을 쏘는데(정상 동작), 그게 첫 손길 대기를 먼저 먹었다. 그 뒤로는 손님이 아무 데나 눌러도 안 켜졌다. → onFirstTouchevent.isTrusted 를 먼저 본다.
  • 스로틀이 클릭을 걸렀다. 입력을 안 가리고 1초 스로틀을 걸었더니 클릭 직전의 mousemove 가 그 1초를 먹어 정작 활성화를 주는 mousedown 이 걸러졌다(실측: act=true 인데 play=0). → STRONG/WEAK 로 갈라 STRONG 에는 스로틀을 안 건다.

"트리플픽은 되던데?" — 실측 결과

트리플픽도 안 된다. 똑같이 거절당한다.

https://triplepick.o2o.kr/match/MLB_BAL_TOR_20260914?song=BAL (공유 링크 = 자동 로드 경로)에 깨끗한 크롬으로 들어가 HTMLMediaElement.prototype.play 를 가로채 찍은 값이다.

[T] play() 거절 NotAllowedError  .kr/api/songs/audio/815/0?v=89a0fcd6

코드도 우리와 같은 한 줄이다 — usePlayer.tsx:133 if (shouldPlay) audio.play().catch(() => {}). 게스처 감지 코드는 0줄이고, 평소 재생 경로는 MatchupHUD.tsx:55「♪ 응원가 듣기」 버튼이다. playlist.ts:19PATH_PLAYLISTS 는 비어 있어서 경로 진입 자동 로드는 실을 곡도 없다.

그럼 왜 내 브라우저에서는 나나 — MEI

크롬은 같은 출처에서 소리를 여러 번 들은 사람에게만 그 출처의 자동재생을 풀어 준다 (Media Engagement Index). 조건: 7초 초과 재생 · 음소거 아님 · 탭 활성 · (영상이면) 200x140px 초과.

트리플픽을 자주 쓰면서 응원가를 여러 번 들었으면 그 브라우저에는 이미 허락이 쌓여 있다. 그 브라우저에서만 자동재생된다. 손님의 새 브라우저에서는 안 된다.

확인: chrome://media-engagement/ 에서 출처별 점수와 Has high score 를 본다.

같은 일이 이 사이트에도 일어난다. 목업을 자주 열고 음악을 들으면 그 브라우저는 web4ai.o2osolution.ai 에 허락을 쌓고, 그 뒤로는 새로고침해도 바로 난다. 대표님 노트북에서 시연하면 그 상태가 된다 — 다만 손님 브라우저는 아니다.


재보고 싶을 때 — 측정 주의

Playwright 로 잴 때 page.evaluate 를 쓰면 안 된다. CDP 가 userGesture: true 로 넣어서 상태를 읽으려고 부른 그 호출이 브라우저를 "조작됨" 으로 만든다. 그 뒤 측정은 전부 거짓 양성이다 — 실제로 이것 때문에 "마우스만 움직여도 켜진다" 는 틀린 결론을 한 번 냈다.

상태는 addInitScript 로 심고 console 로만 받는다.

await page.addInitScript(() => {
  setInterval(() => {
    const el = document.getElementById('w4d-play');
    console.log('[C] play=' + (el ? el.dataset.playing : '-')
      + ' act=' + navigator.userActivation.isActive);
  }, 1000);
});
page.on('console', (m) => { /* 여기서만 읽는다 */ });

지금 무엇을 보고 있는지 가리는 법

/s/max-age=300, must-revalidate 다. "고쳤는데 안 된다" 의 상당수가 캐시된 옛 파일이었다. 콘솔 첫 줄에 빌드 시각이 찍힌다.

[w4d] 주입분 로드 · 빌드 2026-09-14T07:12:00+00:00

이 시각이 방금 구운 것과 다르면 옛 파일을 보고 있는 것이다. ⌘⇧R.