aibow/web/index.html

345 lines
12 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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #131314;
--surface: #1e1f20;
--surface-2: #282a2c;
--surface-3: #333537;
--outline: #444746;
--on-surface: #e3e3e3;
--on-surface-dim: #9aa0a6;
--primary: #a8c7fa;
--on-primary: #062e6f;
--secondary: #7fd8b0;
--elevation-1: 0 1px 2px rgba(0,0,0,.5), 0 1px 3px 1px rgba(0,0,0,.3);
--elevation-2: 0 1px 2px rgba(0,0,0,.5), 0 2px 6px 2px rgba(0,0,0,.35);
--radius-lg: 24px;
--radius-md: 16px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Roboto", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
background: var(--bg);
color: var(--on-surface);
}
.app { display: grid; grid-template-columns: 1fr 380px; height: 100vh; }
.videoPane { display: flex; flex-direction: column; min-width: 0; padding: 12px; gap: 10px; }
.topBar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; margin-right: auto; }
.mdSelect {
appearance: none; -webkit-appearance: none;
background: var(--surface-2); color: var(--on-surface);
border: 1px solid var(--outline); border-radius: 20px;
padding: 8px 32px 8px 14px; font-family: Roboto; font-size: 13px; cursor: pointer;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239aa0a6'><path d='M7 10l5 5 5-5z'/></svg>");
background-repeat: no-repeat; background-position: right 8px center; background-size: 18px;
}
.mdSelect:focus { outline: none; border-color: var(--primary); }
.pillInput {
display: flex; align-items: center; gap: 4px;
background: var(--surface-2); border: 1px solid var(--outline);
border-radius: var(--radius-lg); padding: 4px 4px 4px 18px;
}
.pillInput:focus-within { border-color: var(--primary); }
.pillInput input {
flex: 1; border: none; background: transparent; color: var(--on-surface);
font-family: Roboto; font-size: 14px; outline: none; padding: 10px 0; min-width: 0;
}
.pillInput input::placeholder { color: var(--on-surface-dim); }
.iconBtn {
width: 40px; height: 40px; border-radius: 50%; border: none; flex-shrink: 0;
background: var(--primary); color: var(--on-primary); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
.iconBtn:hover { filter: brightness(1.08); }
.searchResults {
display: none; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px; max-height: 260px; overflow-y: auto; padding: 4px 2px;
}
.searchResults.show { display: grid; }
.resultCard {
background: var(--surface-2); border-radius: var(--radius-md); overflow: hidden;
cursor: pointer; box-shadow: var(--elevation-1); transition: box-shadow .15s, transform .15s;
}
.resultCard:hover { box-shadow: var(--elevation-2); transform: translateY(-2px); }
.resultCard img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; }
.resultCard .meta { padding: 8px 10px; }
.resultCard .title { font-size: 12.5px; line-height: 1.4; max-height: 2.8em; overflow: hidden; }
.resultCard .channel { font-size: 11px; color: var(--on-surface-dim); margin-top: 4px; }
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; }
.playerWrap {
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
background: #000; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-2);
}
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
.chatPane {
display: flex; flex-direction: column; min-width: 0;
background: var(--surface); border-left: 1px solid var(--outline);
}
.chatHeader { padding: 14px 16px; border-bottom: 1px solid var(--outline); display: flex; flex-direction: column; gap: 8px; }
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
.selectorRow { display: flex; gap: 8px; flex-wrap: wrap; }
.unlockRow { display: none; gap: 6px; }
.unlockRow.show { display: flex; }
.unlockRow input {
flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid var(--outline);
background: var(--surface-2); color: var(--on-surface); font-size: 12px; outline: none;
}
.unlockRow button {
padding: 8px 14px; border-radius: 999px; border: none; background: var(--primary);
color: var(--on-primary); font-size: 12px; cursor: pointer; font-weight: 500;
}
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
.bubble { padding: 10px 14px; border-radius: var(--radius-md); max-width: 88%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
.bubble.user { align-self: flex-end; background: var(--primary); color: var(--on-primary); border-bottom-right-radius: 4px; }
.bubble.model { align-self: flex-start; background: var(--surface-3); border-bottom-left-radius: 4px; }
#chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
}
</style>
</head>
<body>
<div class="app">
<div class="videoPane">
<div class="topBar">
<div class="brand">🐾 aibow</div>
</div>
<div class="pillInput">
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
<button id="loadBtn" class="iconBtn" title="読み込み/検索">🔎</button>
</div>
<div id="searchResults" class="searchResults"></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 class="selectorRow">
<select id="personaSelect" class="mdSelect">
<option value="neutral">性格: ふつう</option>
<option value="energetic">性格: 活発</option>
<option value="downer">性格: ダウナー</option>
<option value="goofy">性格: ひょうきん</option>
</select>
<select id="modelSelect" class="mdSelect">
<option value="gemma">モデル: Gemma (無料)</option>
<option value="gemini">モデル: Gemini 🔒</option>
</select>
</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">
<div class="pillInput">
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
<button type="submit" class="iconBtn" title="送信"></button>
</div>
</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 = '';
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);
}
}
function hideResults() {
const box = document.getElementById('searchResults');
box.classList.remove('show');
box.innerHTML = '';
}
function loadVideo(id) {
if (player && player.loadVideoById) {
player.loadVideoById(id);
}
loadVideoMeta(id);
hideResults();
}
async function runSearch(query) {
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
if (!res.ok) throw new Error('search failed');
const items = await res.json();
const box = document.getElementById('searchResults');
box.innerHTML = '';
for (const item of (items || [])) {
const card = document.createElement('div');
card.className = 'resultCard';
card.innerHTML = `
<img src="${item.thumbnail}" alt="">
<div class="meta">
<div class="title">${item.title}</div>
<div class="channel">${item.channelTitle}</div>
</div>`;
card.addEventListener('click', () => {
document.getElementById('urlInput').value = item.videoId;
loadVideo(item.videoId);
});
box.appendChild(card);
}
box.classList.add('show');
} catch (e) {
console.error('search error', e);
}
}
document.getElementById('loadBtn').addEventListener('click', () => {
const raw = document.getElementById('urlInput').value.trim();
if (!raw) return;
const id = extractVideoId(raw);
if (id) {
loadVideo(id);
} else {
runSearch(raw);
}
});
document.getElementById('urlInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('loadBtn').click();
});
const modelSelect = document.getElementById('modelSelect');
modelSelect.addEventListener('change', () => {
if (modelSelect.value === 'gemini' && !unlockCode) {
document.getElementById('unlockRow').classList.add('show');
} else {
document.getElementById('unlockRow').classList.remove('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('unlockRow').classList.remove('show');
} else {
alert('コードが違います');
modelSelect.value = 'gemma';
}
} catch (e) {
alert('解放に失敗しました');
modelSelect.value = 'gemma';
}
});
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);
const wantsGemini = modelSelect.value === 'gemini';
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoTitle, videoDescription,
history: chatHistory,
message,
unlockCode: wantsGemini ? unlockCode : '',
persona: document.getElementById('personaSelect').value,
}),
});
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>