드래그 위치 이동으로 바꿔줘봐 그리고, 이 크롬확장자 프로그램의 기능을 정리해서 나에게 알려줘봐




좋지! 버튼을 드래그로 이동 가능하게 바꾸고, **토스트(완료/실패 팝업)**도 버튼 바로 옆에 뜨도록 업데이트했어요. 기본 위치는 이전처럼 화면 오른쪽 상단에서 아래로 약 20% 지점이지만, 한 번 옮기면 위치가 저장되어 다음에도 그대로 뜹니다.


적용 패치

1) floating_button.css (위치값은 JS에서 동적으로 넣으니, 고정 top/right 제거)

css
#ycx-fab { position: fixed; /* top/right는 JS에서 주입 */ width: 56px; height: 56px; border-radius: 50%; border: none; background: #111; color: #fff; font-weight: 700; font-family: system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans KR", sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,.2); cursor: grab; z-index: 2147483646; user-select: none; } #ycx-fab:active { cursor: grabbing; } #ycx-fab:hover { transform: translateY(-1px); } .ycx-toast { position: fixed; /* 위치는 JS에서 버튼 옆으로 계산 */ padding: 8px 12px; border-radius: 6px; background: #333; color: #fff; font-size: 13px; z-index: 2147483647; opacity: .95; white-space: nowrap; user-select: text; } .ycx-toast.ok { background: #0a7d32; } .ycx-toast.err { background: #b00020; }

2) content_script.js (드래그 + 토스트 근접 표시 + 위치 저장)

js
const EXT_VERSION = chrome.runtime.getManifest().version; const DEFAULT_ENDPOINT = "http://localhost:8000/captions/process"; let currentVideoId = null; let buttonInjected = false; let lastPostAt = 0; // ▼ 위치 저장/불러오기 async function loadSavedPos() { const data = await chrome.storage.sync.get({ ycxPos: null }); return data.ycxPos; // { topPct, leftPct } } async function savePos(pos) { await chrome.storage.sync.set({ ycxPos: pos }); } function isWatchPage() { return location.pathname === "/watch" && (new URL(location.href)).searchParams.get("v"); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ▼ 버튼 주입 + 기본 위치 계산(우측 24px 여백, 상단 20%) async function injectButton() { if (buttonInjected) return; buttonInjected = true; const btn = document.createElement("button"); btn.id = "ycx-fab"; btn.title = "자막 추출 & 전송"; btn.innerText = "CAP"; document.documentElement.appendChild(btn); // 저장된 위치 불러오기 const saved = await loadSavedPos(); if (saved?.topPct != null && saved?.leftPct != null) { btn.style.top = `${saved.topPct}%`; btn.style.left = `${saved.leftPct}%`; } else { // 기본: 오른쪽 24px, 위에서 20% (leftPct 계산) const W = window.innerWidth; const btnW = 56; const rightPx = 24; const leftPx = Math.max(0, W - (btnW + rightPx)); const leftPct = (leftPx / Math.max(1, W)) * 100; const topPct = 20; btn.style.left = `${leftPct}%`; btn.style.top = `${topPct}%`; } // 클릭 → 추출/전송 btn.addEventListener("click", onClickExtractAndSend); // 드래그 이동 makeDraggable(btn); } function removeButton() { const btn = document.getElementById("ycx-fab"); if (btn) btn.remove(); buttonInjected = false; } // ▼ 토스트를 버튼 옆에 띄우기 function showToast(msg, ok = true) { const btn = document.getElementById("ycx-fab"); const t = document.createElement("div"); t.className = `ycx-toast ${ok ? "ok" : "err"}`; t.innerText = msg; document.documentElement.appendChild(t); // 위치 계산: 기본은 버튼 오른쪽, 공간 부족하면 왼쪽 const rect = btn?.getBoundingClientRect(); const margin = 12; const vw = window.innerWidth; const vh = window.innerHeight; // 우선 임시로 화면 밖에 뒀다가 사이즈 측정 t.style.left = "-9999px"; t.style.top = "-9999px"; requestAnimationFrame(() => { const tw = t.offsetWidth; const th = t.offsetHeight; let left = rect ? rect.right + margin : margin; let top = rect ? rect.top : (vh * 0.2); // 화면을 벗어나면 왼쪽으로 붙임 if (left + tw > vw - 8 && rect) left = rect.left - tw - margin; // 위아래 클램프 top = clamp(top, 8, vh - th - 8); t.style.left = `${left}px`; t.style.top = `${top}px`; }); setTimeout(() => t.remove(), 3000); } // ▼ 드래그 가능하게 function makeDraggable(el) { let dragging = false; let startX = 0, startY = 0; let elStartLeft = 0, elStartTop = 0; const onPointerDown = (e) => { // 왼클릭만 드래그, 터치 포함 if (e.button !== undefined && e.button !== 0) return; dragging = true; el.setPointerCapture?.(e.pointerId); startX = e.clientX; startY = e.clientY; const r = el.getBoundingClientRect(); elStartLeft = r.left; elStartTop = r.top; e.preventDefault(); }; const onPointerMove = async (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; let newLeft = elStartLeft + dx; let newTop = elStartTop + dy; // 화면 범위 내로 제한 const vw = window.innerWidth; const vh = window.innerHeight; const bw = el.offsetWidth; const bh = el.offsetHeight; newLeft = clamp(newLeft, 0, vw - bw); newTop = clamp(newTop, 0, vh - bh); // %로 저장/표현 (반응형) const leftPct = (newLeft / Math.max(1, vw)) * 100; const topPct = (newTop / Math.max(1, vh)) * 100; el.style.left = `${leftPct}%`; el.style.top = `${topPct}%`; // 이동 중 토스트가 떠 있었다면 위치 갱신되도록(다음 show에서 자동 반영) // 이동 중 저장은 과도하므로, mouseup에서 1회 저장 }; const onPointerUp = async (e) => { if (!dragging) return; dragging = false; try { // 현재 위치를 저장 const r = el.getBoundingClientRect(); const leftPct = (r.left / Math.max(1, window.innerWidth)) * 100; const topPct = (r.top / Math.max(1, window.innerHeight)) * 100; await savePos({ leftPct, topPct }); } catch {} }; el.addEventListener("pointerdown", onPointerDown); window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); } // ▼ 추출 + 전송(기존 로직, 디바운스 포함) async function onClickExtractAndSend() { const now = Date.now(); if (now - lastPostAt < 3000) { showToast("잠시만요… 처리 중입니다", false); return; } try { const pr = await window.__YCX__.getPlayerResponse(); const base = await window.__YCX__.extractAll(); const url = window.__YCX__.getWatchUrl(); const video_id = window.__YCX__.getVideoIdFromUrl(url) || pr?.videoDetails?.videoId || ""; const payload = { video_id, url, title: base.title || "", description: base.description || "", channel_name: base.channel_name || "", upload_date: base.upload_date || "", duration_seconds: base.duration_seconds || 0, view_count: base.view_count || 0, captions: base.captions || [], language: base.language || "", is_auto_generated: !!base.is_auto_generated, source: "external", extracted_at: new Date().toISOString(), extension_version: EXT_VERSION }; if (!payload.video_id) return showToast("video_id 식별 실패", false); if (!payload.captions?.length) return showToast("이 영상은 자막이 없습니다", false); const { apiEndpoint } = await chrome.storage.sync.get({ apiEndpoint: DEFAULT_ENDPOINT }); lastPostAt = now; const res = await chrome.runtime.sendMessage({ type: "POST_CAPTIONS", endpoint: apiEndpoint, payload }); if (res?.ok) showToast(`전송 성공: ${payload.video_id}`, true); else { const bodyPreview = (res?.body || "").slice(0, 120); showToast(`전송 실패(${res?.status}): ${bodyPreview}`, false); } } catch (e) { showToast(`오류: ${e.message.slice(0,120)}`, false); console.error(e); } } // ▼ YouTube SPA 감지(기존과 동일) const observer = new MutationObserver(() => { if (isWatchPage()) { const vid = (new URL(location.href)).searchParams.get("v"); if (vid !== currentVideoId) { currentVideoId = vid; injectButton(); } } else { currentVideoId = null; removeButton(); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); if (isWatchPage()) { currentVideoId = (new URL(location.href)).searchParams.get("v"); injectButton(); }

참고: captions_extractor.js는 기존 버전 그대로 두셔도 되고, 이전에 드린 json3→srv3/srv1 폴백/자동생성 판별 보강 패치까지 적용하시면 더 견고합니다.


크롬 확장프로그램 기능 정리 (최신 사양)

  1. 한 번 클릭으로 자막 전송

  • 유튜브 watch 페이지에서 CAP 버튼 클릭 → 자막/메타데이터 추출 → 지정한 REST API로 전송

  1. 드래그해서 버튼 위치 자유롭게 이동

  • 버튼을 마우스로 끌어 원하는 위치에 고정

  • 위치(상단/좌측 퍼센트)가 자동 저장되어 다음에도 유지

  1. 토스트 안내를 버튼 옆에 표시

  • 성공/실패/경고 메시지를 버튼 바로 옆에 3초간 표시

  • 화면 여백이 부족하면 자동으로 반대편에 띄움

  1. 견고한 자막 추출

  • ytInitialPlayerResponse 기반 메타데이터 수집

  • 캡션 트랙에서 우선 언어(ko, en 등) 선택

  • json3 우선, 필요 시 srv3/srv1 폴백(옵션)

  • 자동 생성 자막(asr) 여부 추정

  1. 중복 전송 방지 & 에러 가시화

  • 버튼 연타시 3초 디바운스

  • 실패 시 상태코드/본문 일부 미리보기로 즉시 원인 파악

  1. 보안/권한

  • CORS 회피를 위해 background에서 네트워크 요청

  • host_permissions 최소화 구성 권장 (예: http://localhost:8000/* + 운영 도메인)

  1. 연동 스키마(요약)

  • 필수: video_id, url, title, captions[], language

  • 권장: description, channel_name, upload_date, duration_seconds, view_count

  • 시스템용: source="external", extracted_at, extension_version


필요하면 Shorts/임베드 대응, 토큰 헤더(Authorization) 설정, 언어 우선순위 설정 UI도 바로 붙여드릴게요. 원하시는 옵션 말씀만 주세요!

댓글 남기기