Spaces:
Running
Running
| document.addEventListener('DOMContentLoaded', () => { | |
| const gridContainer = document.getElementById('character-grid'); | |
| const audioElement = document.getElementById('main-audio'); | |
| // 新しい右カラムの要素 | |
| const mainPlayBtn = document.getElementById('main-play-btn'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const currentTimeEl = document.getElementById('current-time'); | |
| const durationEl = document.getElementById('duration'); | |
| const previewImage = document.getElementById('preview-image'); | |
| const previewName = document.getElementById('preview-name'); | |
| const previewNameReading = document.getElementById('preview-name-reading'); | |
| const previewMeta = document.getElementById('preview-meta'); | |
| let voiceActors = []; | |
| // ファイル名の指定 | |
| const jsonFile = 'voice_actors_local.json'; | |
| // JSONデータの読み込み | |
| fetch(jsonFile) | |
| .then(response => { | |
| if (!response.ok) { | |
| return fetch('voice_actors.json').then(res => res.json()); | |
| } | |
| return response.json(); | |
| }) | |
| .then(data => { | |
| voiceActors = data; | |
| renderGrid(); | |
| // 初期選択 | |
| if (voiceActors.length > 0) { | |
| selectActor(0); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Error:', error); | |
| gridContainer.innerHTML = '<div class="col-span-full text-center text-red-500 py-10">データの読み込みに失敗しました。</div>'; | |
| }); | |
| // グリッドの描画 | |
| function renderGrid() { | |
| gridContainer.innerHTML = ''; | |
| voiceActors.forEach((actor, index) => { | |
| const card = document.createElement('div'); | |
| card.className = `voice-card bg-white rounded-2xl overflow-hidden border-2 border-transparent transition-all duration-200 cursor-pointer hover:shadow-lg`; | |
| card.dataset.index = index; | |
| const tagsHtml = actor.tags ? actor.tags.slice(0, 3).map(tag => | |
| `<span class="bg-black/20 backdrop-blur-sm text-white text-[10px] font-bold px-2 py-0.5 rounded">${tag}</span>` | |
| ).join('') : ''; | |
| card.innerHTML = ` | |
| <div class="card-image-wrapper bg-gray-200 relative pt-[150%]"> | |
| <img src="${actor.thumbnail_url || actor.image_url}" alt="${actor.name}" loading="lazy" class="absolute top-0 left-0 w-full h-full object-cover transition-transform duration-300 hover:scale-105"> | |
| <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex flex-col justify-end p-3 md:p-4 pointer-events-none"> | |
| <p class="text-[10px] text-gray-300 leading-tight mb-0.5">${actor.nameReading || ''}</p> | |
| <h3 class="text-base md:text-lg font-bold text-white leading-tight mb-1">${actor.name}</h3> | |
| <p class="text-[10px] md:text-xs text-gray-200 mb-2 font-medium">${actor.age}歳</p> | |
| <div class="flex flex-wrap gap-1"> | |
| ${tagsHtml} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-3 border-t border-gray-100 flex justify-between items-center bg-white"> | |
| <span class="text-xs font-bold text-gray-500 flex items-center gap-1"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| 試聴 | |
| </span> | |
| </div> | |
| `; | |
| // クリックイベント | |
| card.addEventListener('click', () => { | |
| selectActor(index); | |
| // クリック時に再生したい場合はここを有効化 | |
| playAudio(actor.audio_url); | |
| }); | |
| gridContainer.appendChild(card); | |
| }); | |
| } | |
| // キャラクター選択処理 | |
| function selectActor(index) { | |
| const actor = voiceActors[index]; | |
| // グリッドの選択状態更新 | |
| document.querySelectorAll('.voice-card').forEach(el => el.classList.remove('border-indigo-500', 'shadow-xl')); | |
| const selectedCard = gridContainer.children[index]; | |
| if(selectedCard) selectedCard.classList.add('border-indigo-500', 'shadow-xl'); | |
| // 右カラムの更新 | |
| previewImage.src = actor.image_url; | |
| previewName.textContent = actor.name; | |
| previewNameReading.textContent = actor.nameReading || ''; | |
| previewMeta.textContent = `${actor.age}歳`; | |
| // 音声ソースの更新 | |
| if (audioElement.src !== new URL(actor.audio_url, document.baseURI).href) { | |
| audioElement.src = actor.audio_url; | |
| resetPlayerUI(); | |
| } | |
| } | |
| // オーディオ制御 | |
| function playAudio(url) { | |
| const absoluteUrl = new URL(url, document.baseURI).href; | |
| if(audioElement.src !== absoluteUrl) { | |
| audioElement.src = url; | |
| } | |
| audioElement.play().catch(e => console.log("再生エラー:", e)); | |
| updateBtnState(true); | |
| } | |
| // メインボタンクリック | |
| mainPlayBtn.addEventListener('click', () => { | |
| if (audioElement.paused) { | |
| audioElement.play(); | |
| } else { | |
| audioElement.pause(); | |
| } | |
| }); | |
| audioElement.addEventListener('play', () => updateBtnState(true)); | |
| audioElement.addEventListener('pause', () => updateBtnState(false)); | |
| audioElement.addEventListener('ended', () => updateBtnState(false)); | |
| audioElement.addEventListener('timeupdate', () => { | |
| if (audioElement.duration) { | |
| const percent = (audioElement.currentTime / audioElement.duration) * 100; | |
| progressBar.style.width = `${percent}%`; | |
| currentTimeEl.textContent = formatTime(audioElement.currentTime); | |
| durationEl.textContent = formatTime(audioElement.duration); | |
| } | |
| }); | |
| audioElement.addEventListener('loadedmetadata', () => { | |
| durationEl.textContent = formatTime(audioElement.duration); | |
| }); | |
| function updateBtnState(isPlaying) { | |
| const textSpan = mainPlayBtn.lastChild; // ボタンのテキストノード | |
| if (isPlaying) { | |
| textSpan.textContent = " 停止する"; | |
| mainPlayBtn.classList.add('bg-indigo-600'); | |
| mainPlayBtn.classList.remove('bg-gray-900'); | |
| } else { | |
| textSpan.textContent = " サンプルボイスを再生"; | |
| mainPlayBtn.classList.remove('bg-indigo-600'); | |
| mainPlayBtn.classList.add('bg-gray-900'); | |
| } | |
| } | |
| function resetPlayerUI() { | |
| progressBar.style.width = '0%'; | |
| currentTimeEl.textContent = '0:00'; | |
| durationEl.textContent = '0:00'; | |
| updateBtnState(false); | |
| } | |
| function formatTime(seconds) { | |
| if (!seconds || isNaN(seconds)) return "0:00"; | |
| const m = Math.floor(seconds / 60); | |
| const s = Math.floor(seconds % 60); | |
| return `${m}:${s.toString().padStart(2, '0')}`; | |
| } | |
| }); |