福原弘基が作成しました
YouTube Data API v3 を利用した代替フロントエンド

再生待機中

チャンネル情報がここに表示されます
--

検索結果

コメント

動画を再生するとコメントがここに表示されます。
`; // 埋め込み (srcdoc) playerShell.srcdoc = playerSrcdoc; // Player の内部 iframe videoFrame にアクセスして src をセットするためのヘルパー function setPlayerVideoById(videoId, paramSource = null){ // playerShell が読み込まれた後に iframe 内の DOM を操作 try { const shellWindow = playerShell.contentWindow; const shellDoc = playerShell.contentDocument || shellWindow.document; // 内部の #videoFrame が読み込まれていれば直接セット const innerFrame = shellDoc.getElementById('videoFrame'); if(innerFrame){ // もし内部の右側パラメータ選択 UI を活かしたいなら paramSource を送ることも可能だが // 要求通り、Player内部のコードは一切変えず、ここでは iframe.src を設定して再生させる innerFrame.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; return true; } else { // 内部がまだ完全に読み込まれていない場合は少し待ってから再試行 setTimeout(()=>setPlayerVideoById(videoId,paramSource),200); return false; } } catch(e){ // cross-origin の問題が起きる可能性は低い(srcdoc は同一オリジン)だが念のため捕捉 console.error("setPlayerVideoById error:", e); return false; } } // API 呼び出しの共通関数 async function ytFetch(path, params = {}){ if(!API_KEY) { alert("先に左上の数字ボタンから API キーを選択してください(必須)。"); throw new Error("API_KEY missing"); } params.key = API_KEY; const qs = new URLSearchParams(params); const url = `https://www.googleapis.com/youtube/v3/${path}?${qs.toString()}`; const res = await fetch(url); if(!res.ok){ const text = await res.text(); throw new Error(`YouTube API Error: ${res.status} ${text}`); } return await res.json(); } // 日付入力 -> ISO8601 変換(YYYY-MM-DD => YYYY-MM-DDT00:00:00Z) function isoFromDateInput(val, isEnd=false){ if(!val) return null; // treat end date as end of day by adding T23:59:59Z return isEnd ? (val + "T23:59:59Z") : (val + "T00:00:00Z"); } /* ========== 検索実装 ========== */ async function doSearch(reset=true){ if(!API_KEY){ alert("APIキーを選んでください。"); return; } const q = queryInput.value.trim(); if(!q){ alert("検索語を入力してください。"); return; } if(reset){ resultsEl.innerHTML = ""; nextPageToken = null; lastQuery = q; lastSearchType = typeSelect.value; } const publishedAfter = isoFromDateInput(dateFromEl.value,false); const publishedBefore = isoFromDateInput(dateToEl.value,true); const params = { part: 'snippet', q: q, type: typeSelect.value, maxResults: 10, order: orderSelect.value }; if(nextPageToken) params.pageToken = nextPageToken; if(publishedAfter) params.publishedAfter = publishedAfter; if(publishedBefore) params.publishedBefore = publishedBefore; try { const data = await ytFetch('search', params); nextPageToken = data.nextPageToken || null; renderSearchResults(data.items); loadMoreBtn.style.display = nextPageToken ? 'block' : 'none'; } catch(e){ console.error(e); alert("検索に失敗しました: " + e.message); } } function renderSearchResults(items){ for(const it of items){ const id = (it.id && (it.id.videoId || it.id.channelId || it.id.playlistId)) || (it.snippet && it.snippet.resourceId && it.snippet.resourceId.videoId) || ""; const kind = it.id? it.id.kind || (it.id.videoId? 'youtube#video': '') : ''; const thumb = it.snippet.thumbnails && (it.snippet.thumbnails.medium || it.snippet.thumbnails.default) ? (it.snippet.thumbnails.medium?.url || it.snippet.thumbnails.default.url) : ''; const title = it.snippet.title; const channelTitle = it.snippet.channelTitle; const publishedAt = it.snippet.publishedAt; const card = document.createElement('div'); card.className = 'card'; card.innerHTML = `

${escapeHtml(title)}

${escapeHtml(channelTitle)} ・ ${new Date(publishedAt).toLocaleString()}

`; // 再生ボタン card.querySelector('.playBtn').addEventListener('click', async (e)=>{ const vid = e.currentTarget.dataset.id; if(vid){ playVideoById(vid); } }); // チャンネルボタン card.querySelector('.channelBtn').addEventListener('click', (e)=>{ const chId = e.currentTarget.dataset.channel; if(chId) loadChannelById(chId); }); resultsEl.appendChild(card); } } /* 再生: 動画IDを受けて player 内の videoFrame.src を設定し、メタ情報を読み出す */ async function playVideoById(videoId){ currentVideoId = videoId; // Player 内の videoFrame に動画をセット(player 内部コードは一文字も変えず採用) const ok = setPlayerVideoById(videoId); currentTitle.textContent = "読み込み中..."; currentChannel.textContent = ""; videoStats.textContent = ""; // 取得: videos API で詳細を取る try { const details = await ytFetch('videos', { part: 'snippet,statistics,contentDetails', id: videoId }); if(details.items && details.items.length){ const v = details.items[0]; currentTitle.textContent = v.snippet.title; currentChannel.innerHTML = `${escapeHtml(v.snippet.channelTitle)}`; const stats = `${Number(v.statistics.viewCount || 0).toLocaleString()} 回視聴 ・ ${new Date(v.snippet.publishedAt).toLocaleDateString()}`; videoStats.textContent = stats; // チャンネルリンクのイベント const chlink = currentChannel.querySelector('.channelLink'); if(chlink){ chlink.addEventListener('click',(ev)=>{ ev.preventDefault(); loadChannelById(ev.currentTarget.dataset.channelid); }); } // 関連動画を読み込む loadRelatedVideos(videoId); // コメント読み込み loadComments(videoId, true); } else { currentTitle.textContent = "情報が取得できませんでした"; } } catch(e){ console.error(e); currentTitle.textContent = "動画情報の取得に失敗しました"; } } /* 関連動画 */ async function loadRelatedVideos(videoId){ relatedList.innerHTML = '

関連動画

'; try { const data = await ytFetch('search', { part:'snippet', relatedToVideoId: videoId, type:'video', maxResults: 10 }); for(const it of data.items){ const thumb = it.snippet.thumbnails && (it.snippet.thumbnails.medium?.url || it.snippet.thumbnails.default?.url) || ''; const t = document.createElement('div'); t.className = 'rel-card'; t.innerHTML = `
${escapeHtml(it.snippet.title)}
${escapeHtml(it.snippet.channelTitle)}
`; t.addEventListener('click', ()=> playVideoById(it.id.videoId)); relatedList.appendChild(t); } } catch(e){ console.error(e); } } /* コメント読み込み */ async function loadComments(videoId, reset=true){ commentsEl.innerHTML = '読み込み中...'; commentNextPageToken = null; if(reset) commentNextPageToken = null; try { const params = { part:'snippet', videoId: videoId, maxResults: 10, order: 'relevance' }; if(commentNextPageToken) params.pageToken = commentNextPageToken; const data = await ytFetch('commentThreads', params); commentNextPageToken = data.nextPageToken || null; renderComments(data.items, reset); loadMoreCommentsBtn.style.display = commentNextPageToken ? 'block' : 'none'; } catch(e){ console.error(e); commentsEl.innerHTML = 'コメントの取得に失敗しました(非公開やコメント無効の可能性あり)'; } } function renderComments(items, reset){ if(reset) commentsEl.innerHTML = ''; if(!items || !items.length){ if(reset) commentsEl.innerHTML = 'コメントは見つかりません。'; return; } for(const it of items){ const top = it.snippet.topLevelComment.snippet; const div = document.createElement('div'); div.className = 'comment'; div.innerHTML = `
${escapeHtml(top.authorDisplayName)} ${timeAgoISO(top.publishedAt)}
${escapeHtml(top.textDisplay)}
`; commentsEl.appendChild(div); } } /* チャンネル表示 */ async function loadChannelById(channelId){ channelPanel.innerHTML = ''; try { const ch = await ytFetch('channels', { part:'snippet,statistics,contentDetails', id: channelId }); if(!ch.items || !ch.items.length) { channelPanel.innerHTML = 'チャンネルが見つかりません'; return; } const c = ch.items[0]; const uploadsPlaylistId = c.contentDetails.relatedPlaylists.uploads; const panel = document.createElement('div'); panel.innerHTML = `
${escapeHtml(c.snippet.title)}
${Number(c.statistics.subscriberCount || 0).toLocaleString()} 人の登録者
${escapeHtml(c.snippet.description || '').slice(0,120)}...

チャンネルの動画

`; channelPanel.appendChild(panel); // 初回取得 let playlistNext = null; async function loadPlaylistPage(){ try { const params = { part:'snippet', playlistId: uploadsPlaylistId, maxResults: 10 }; if(playlistNext) params.pageToken = playlistNext; const res = await ytFetch('playlistItems', params); playlistNext = res.nextPageToken || null; renderChannelVideos(res.items); document.getElementById('moreChannelVideos').style.display = playlistNext ? 'block' : 'none'; } catch(e){ console.error(e); } } function renderChannelVideos(items){ const container = document.getElementById('channelVideos'); for(const it of items){ const vid = it.snippet.resourceId.videoId; const card = document.createElement('div'); card.className = 'card'; card.style.flexDirection = 'column'; card.style.cursor = 'pointer'; card.innerHTML = `
${escapeHtml(it.snippet.title)}
`; card.addEventListener('click', ()=> playVideoById(vid)); container.appendChild(card); } } // イベント document.getElementById('moreChannelVideos').addEventListener('click', loadPlaylistPage); // 初回ロード loadPlaylistPage(); } catch(e){ console.error(e); channelPanel.innerHTML = 'チャンネル情報の取得に失敗しました'; } } /* ========== イベントバインド ========== */ keyChoices.querySelectorAll('button').forEach(btn=>{ btn.addEventListener('click', ()=>{ // active style keyChoices.querySelectorAll('button').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); selectedKeyIndex = btn.dataset.index; API_KEY = KEYS[selectedKeyIndex]; // 小さな通知 btn.textContent = btn.dataset.index + ' ✓'; setTimeout(()=>{ if(btn.textContent.endsWith(' ✓')) btn.textContent = btn.dataset.index; }, 1200); }); }); searchBtn.addEventListener('click', ()=> doSearch(true)); queryInput.addEventListener('keydown', (e)=>{ if(e.key === 'Enter') doSearch(true); }); loadMoreBtn.addEventListener('click', ()=> doSearch(false)); loadMoreCommentsBtn.addEventListener('click', ()=> { // コメントの次ページを取る (YT API pagination handled in loadComments) if(currentVideoId) loadComments(currentVideoId, false); }); themeSelect.addEventListener('change', (e)=> { const val = e.target.value; if(val === 'auto'){ document.documentElement.classList.remove('dark'); localStorage.removeItem('forcedTheme'); } else if(val === 'dark'){ document.documentElement.classList.add('dark'); localStorage.setItem('forcedTheme','dark'); } else { document.documentElement.classList.remove('dark'); localStorage.setItem('forcedTheme','light'); } }); // 保存されているテーマを適用 (function initTheme(){ const forced = localStorage.getItem('forcedTheme'); if(forced === 'dark') { document.documentElement.classList.add('dark'); themeSelect.value='dark'; } else if(forced === 'light'){ document.documentElement.classList.remove('dark'); themeSelect.value='light';} else { themeSelect.value='auto'; } })(); /* ========== ユーティリティ関数 ========== */ function escapeHtml(s){ if(!s) return ''; return s.replace(/&/g,'&').replace(//g,'>'); } function timeAgoISO(iso){ if(!iso) return ''; const d = new Date(iso); const diff = Date.now() - d.getTime(); const mins = Math.floor(diff/60000); if(mins < 60) return mins + ' 分前'; const hrs = Math.floor(mins/60); if(hrs < 24) return hrs + ' 時間前'; const days = Math.floor(hrs/24); if(days < 30) return days + ' 日前'; return d.toLocaleDateString(); } /* ========== 初期のダミー表示 / ヘルプ的な説明は出さない(要望) ========== */ resultsEl.innerHTML = '
検索ワードを入力して「検索」を押してください。左上の数字ボタンで API キーを選択してから実行してください。
'; /* ========== 補助: 検索結果から動画IDで動画ページへ遷移するための helper ========= */ window.playVideoById = playVideoById; /* ========== 追加: 検索結果の無限スクロール(ユーザーの要請により「もっと読み込むボタン」での追加読み込みを採用済み) ========== */ /* ========== エラーハンドリングの改善ポイント(注意) ========= */ /* - ブラウザで直接 API キーを使うため、quota・キー漏洩に注意してください。 - 実運用ではサーバー側で API キーを代理で保持し、クライアントはサーバー経由で呼ぶのが安全です。 */ // End of script