260 lines
9.1 KiB
HTML
260 lines
9.1 KiB
HTML
<!doctype html>
|
|
<html lang="ja">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>aibow</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0b0c10;
|
|
--panel: rgba(255,255,255,0.05);
|
|
--panel-border: rgba(255,255,255,0.08);
|
|
--text: #e9ebf0;
|
|
--text-dim: #9096a3;
|
|
--accent: #6c8bff;
|
|
--accent-2: #8f6cff;
|
|
--bubble-user: linear-gradient(135deg, #6c8bff, #8f6cff);
|
|
--bubble-model: rgba(255,255,255,0.07);
|
|
--radius: 16px;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
|
|
background: radial-gradient(1200px 800px at 20% -10%, #17193a 0%, var(--bg) 60%);
|
|
color: var(--text);
|
|
}
|
|
.app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; }
|
|
.videoPane { display: flex; flex-direction: column; min-width: 0; }
|
|
.urlBar { display: flex; gap: 8px; padding: 14px 16px; }
|
|
.urlBar input {
|
|
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
|
|
background: var(--panel); color: var(--text); outline: none; font-size: 14px;
|
|
}
|
|
.urlBar input:focus { border-color: var(--accent); }
|
|
.urlBar button {
|
|
padding: 10px 20px; border-radius: 999px; border: none; cursor: pointer;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
|
|
}
|
|
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 0 16px 16px; }
|
|
.playerWrap {
|
|
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
|
|
background: #000; border-radius: var(--radius); overflow: hidden;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
}
|
|
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
|
|
.chatPane {
|
|
display: flex; flex-direction: column; min-width: 0;
|
|
background: var(--panel); border-left: 1px solid var(--panel-border);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
.chatHeader { padding: 16px; border-bottom: 1px solid var(--panel-border); }
|
|
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
|
.modelBadge {
|
|
margin-top: 8px; display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px; border-radius: 999px; font-size: 12px; cursor: pointer;
|
|
border: 1px solid var(--panel-border); color: var(--text-dim); user-select: none;
|
|
}
|
|
.modelBadge.unlocked { color: #ffd479; border-color: rgba(255,212,121,0.4); }
|
|
.unlockRow { display: none; gap: 6px; margin-top: 8px; }
|
|
.unlockRow.show { display: flex; }
|
|
.unlockRow input {
|
|
flex: 1; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--panel-border);
|
|
background: rgba(0,0,0,0.3); color: var(--text); font-size: 12px; outline: none;
|
|
}
|
|
.unlockRow button {
|
|
padding: 6px 10px; border-radius: 8px; border: none; background: var(--accent); color: #fff;
|
|
font-size: 12px; cursor: pointer;
|
|
}
|
|
|
|
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
|
|
.bubble { padding: 10px 14px; border-radius: var(--radius); max-width: 88%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
|
|
.bubble.user { align-self: flex-end; background: var(--bubble-user); color: #fff; border-bottom-right-radius: 4px; }
|
|
.bubble.model { align-self: flex-start; background: var(--bubble-model); border-bottom-left-radius: 4px; }
|
|
|
|
#chatForm { display: flex; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--panel-border); }
|
|
#chatForm input {
|
|
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
|
|
background: rgba(0,0,0,0.25); color: var(--text); outline: none; font-size: 14px;
|
|
}
|
|
#chatForm input:focus { border-color: var(--accent); }
|
|
#chatForm button {
|
|
padding: 10px 18px; border-radius: 999px; border: none; cursor: pointer;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 320px; }
|
|
.chatPane { border-left: none; border-top: 1px solid var(--panel-border); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="videoPane">
|
|
<div class="urlBar">
|
|
<input id="urlInput" type="text" placeholder="YouTubeのURLまたは動画IDを入力">
|
|
<button id="loadBtn">読み込む</button>
|
|
</div>
|
|
<div class="playerStage">
|
|
<div class="playerWrap"><div id="player"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chatPane">
|
|
<div class="chatHeader">
|
|
<div class="name">🐾 aibow</div>
|
|
<div id="modelBadge" class="modelBadge">🔓 Gemma</div>
|
|
<div id="unlockRow" class="unlockRow">
|
|
<input id="unlockInput" type="text" placeholder="解放コード">
|
|
<button id="unlockBtn">解放</button>
|
|
</div>
|
|
</div>
|
|
<div id="chatLog"></div>
|
|
<form id="chatForm">
|
|
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
|
<button type="submit">送信</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://www.youtube.com/iframe_api"></script>
|
|
<script>
|
|
let player;
|
|
|
|
function extractVideoId(input) {
|
|
input = input.trim();
|
|
if (/^[\w-]{11}$/.test(input)) return input;
|
|
try {
|
|
const u = new URL(input);
|
|
if (u.hostname.includes('youtu.be')) return u.pathname.slice(1);
|
|
if (u.searchParams.get('v')) return u.searchParams.get('v');
|
|
const liveMatch = u.pathname.match(/\/(live|embed|shorts)\/([\w-]{11})/);
|
|
if (liveMatch) return liveMatch[2];
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
function onYouTubeIframeAPIReady() {
|
|
player = new YT.Player('player', {
|
|
height: '100%',
|
|
width: '100%',
|
|
playerVars: { autoplay: 1 },
|
|
});
|
|
}
|
|
|
|
let videoTitle = '';
|
|
let videoDescription = '';
|
|
let chatHistory = [];
|
|
let unlockCode = '';
|
|
let unlocked = false;
|
|
|
|
async function loadVideoMeta(id) {
|
|
videoTitle = '';
|
|
videoDescription = '';
|
|
try {
|
|
const res = await fetch('/api/video-meta?id=' + encodeURIComponent(id));
|
|
if (!res.ok) throw new Error('meta fetch failed');
|
|
const meta = await res.json();
|
|
videoTitle = meta.title || '';
|
|
videoDescription = meta.description || '';
|
|
} catch (e) {
|
|
console.error('video meta error', e);
|
|
}
|
|
}
|
|
|
|
document.getElementById('loadBtn').addEventListener('click', () => {
|
|
const id = extractVideoId(document.getElementById('urlInput').value);
|
|
if (!id) { alert('動画IDが読み取れませんでした'); return; }
|
|
if (player && player.loadVideoById) {
|
|
player.loadVideoById(id);
|
|
}
|
|
loadVideoMeta(id);
|
|
});
|
|
|
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
|
});
|
|
|
|
function setModelBadge(isUnlocked) {
|
|
unlocked = isUnlocked;
|
|
const badge = document.getElementById('modelBadge');
|
|
if (isUnlocked) {
|
|
badge.textContent = '🔓 Gemini';
|
|
badge.classList.add('unlocked');
|
|
} else {
|
|
badge.textContent = '🔓 Gemma (タップでGemini解放)';
|
|
badge.classList.remove('unlocked');
|
|
}
|
|
}
|
|
setModelBadge(false);
|
|
|
|
document.getElementById('modelBadge').addEventListener('click', () => {
|
|
document.getElementById('unlockRow').classList.toggle('show');
|
|
});
|
|
|
|
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
|
const code = document.getElementById('unlockInput').value.trim();
|
|
if (!code) return;
|
|
try {
|
|
const res = await fetch('/api/unlock', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
unlockCode = code;
|
|
document.getElementById('modelBadge').textContent = '🔓 Gemini';
|
|
document.getElementById('modelBadge').classList.add('unlocked');
|
|
document.getElementById('unlockRow').classList.remove('show');
|
|
} else {
|
|
alert('コードが違います');
|
|
}
|
|
} catch (e) {
|
|
alert('解放に失敗しました');
|
|
}
|
|
});
|
|
|
|
function appendBubble(role, text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'bubble ' + role;
|
|
div.textContent = text;
|
|
const log = document.getElementById('chatLog');
|
|
log.appendChild(div);
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const input = document.getElementById('chatInput');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
input.value = '';
|
|
appendBubble('user', message);
|
|
|
|
try {
|
|
const res = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
videoTitle, videoDescription,
|
|
history: chatHistory,
|
|
message,
|
|
unlockCode,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error('chat failed: ' + res.status);
|
|
const data = await res.json();
|
|
appendBubble('model', data.reply);
|
|
chatHistory.push({ role: 'user', text: message });
|
|
chatHistory.push({ role: 'model', text: data.reply });
|
|
} catch (err) {
|
|
appendBubble('model', '(エラー: ' + err.message + ')');
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|