star back image
people4
電飾 電飾
moon
men

【WEBアプリ】ビジュアライーザーのある動画プレイヤーを作る

BLOG AIWEBアプリWEBログWordPress
読了約:121分

AIコーディング界隈の一角、Cursor – The AI Code Editor を使う機会がありました。
勤め先で有料で登録してくれて使いたい放題だと言うのです。

感謝しかありません。

  • 「ブラウザで動作する動画プレイヤーを作りたいです」
  • 「音の周波数で連動するビジュアライーザーを一緒に表示させたいです」

と希望を言ったら、速攻でHTMLソースやjavascriptを提供してくれます。

今回「ビジュアライーザーのある動画プレイヤー」を作ろうと思い至りました。

【経緯】なぜビジュアライーザーのある動画プレイヤーなのか

バブル景気への憧れです。
ディスコやDJ、お立ち台。それは氷河期世代の夢。

派手に人生を謳歌して、彼女と一緒に踊りたい。
自慢の車でオリジナルカセットテープをかけて、アッシー君したかった。

彼女がいたことないですよね。

でもそんな人生は送れないという現実を、ここぞとばかり味わってきました。

仕事があっただけ御の字です。

しかし今はAIがあります。
自分だけのDJブースをつくれるかもしかない。

【共有1】サンプルページ

ブラウザーで自分だけのローカルファイル動画を再生できるプレイヤー。
ビジュアライーザー付きです(*゚∀゚)ノアヒャヒャ!

プレイヤーの見た目はこんな感じです。

ファイル名: 読み込み中…
動画情報: 読み込み中…
0:00
🔊

動画プレイヤー(サンプルページ)
https://astrowave.jp/amnesia_record/visualizer_movie.php

◉ボタンはビジュアライザーを非表示にするボタンです。

音楽や動画を「聴く」だけでなく、「見る」楽しみも加わります!どうですか。ダメ?

え!?ブラウザーに動画ファイルを放り込めば、普通に見れるでしょ? それに。。

ビジュアライーザーって必要なくないですか。

あった方がカッコいいです(#^ω^)

プログラムは初心者のようなもの。何度も修正して、ようやく今の形になりました。
まだ変なところあるかも。。

動画情報: 読み込み中…

となっていると思います。
URLで指定した動画は、ブラウザのセキュリティ制限により直接取得不可になります。悪しからず。手動のガチ書き込みして下さい。

ローカルファイル選択時は、File APIでサイズ取得可能(file.size)で取得され以下のように切り替わります。

動画情報: video/mp4 (99.69 MB)

【共有2】ソースコード

HTMLのUIで基礎的な動作。
好きな場所にコピペして貼り付けて使えます。

HTML

<link rel="stylesheet" href="css/visualizer_movie.css">

<!-- content -->
<section class="main">
  <div class="video-visualizer">
    <div class="video-container">
      <video id="videoPlayer">
        <source src="" type="video/mp4">
        お使いのブラウザは動画の再生に対応していません。
      </video>
      <canvas id="visualizerCanvas"></canvas>
    </div>
    
    <div class="audio-info">
        <div id="fileName">ファイル名: 未選択</div>
        <div id="videoInfo">動画情報: 未選択</div>
    </div>
    
    <div class="video-controls">
        <input type="file" id="videoFile" accept="video/*" style="display: none;">
        <button id="fileSelectBtn" class="control-btn">
            <span>動画を選択</span>
        </button>
        
        <button id="playBtn" class="control-btn" disabled>
            <span class="play-icon">▶</span>
        </button>
        <button id="pauseBtn" class="control-btn" disabled>
            <span class="pause-icon">⏸</span>
        </button>
        <button id="stopBtn" class="control-btn" disabled>
            <span class="stop-icon">⏹</span>
        </button>
        
        <button id="fullscreenBtn" class="control-btn">
            <span class="fullscreen-icon">⛶</span>
        </button>
        
        <button id="visualizerToggleBtn" class="control-btn">
            <span class="visualizer-icon">◉</span>
        </button>
        
        <div class="time-display">
            <span id="timeDisplay">0:00</span>
        </div>
        
        <input type="range" id="seekBar" class="seek-bar" min="0" max="100" value="0">
        
        <div class="volume-control">
            <span class="volume-icon">🔊</span>
            <input type="range" id="volumeSlider" class="volume-slider" min="0" max="100" value="100">
        </div>
    </div>
  </div>
</section>

<script>
// ====================================
// シンプルなビデオビジュアライザー
// ====================================

let videoVisualizer = null;

// ページ読み込み後に初期化
document.addEventListener('DOMContentLoaded', function() {
    const video = document.getElementById('videoPlayer');
    const canvas = document.getElementById('visualizerCanvas');
    const playBtn = document.getElementById('playBtn');
    const pauseBtn = document.getElementById('pauseBtn');
    const stopBtn = document.getElementById('stopBtn');
    const seekBar = document.getElementById('seekBar');
    const volumeSlider = document.getElementById('volumeSlider');
    const timeDisplay = document.getElementById('timeDisplay');
    const fileSelectBtn = document.getElementById('fileSelectBtn');
    const videoFile = document.getElementById('videoFile');
    const fileNameDisplay = document.getElementById('fileName');
    const videoInfoDisplay = document.getElementById('videoInfo');
    const fullscreenBtn = document.getElementById('fullscreenBtn');
    const visualizerToggleBtn = document.getElementById('visualizerToggleBtn');
    const videoContainer = document.querySelector('.video-container');
    const videoVisualizer = document.querySelector('.video-visualizer');

    if (!video || !canvas) {
        console.error('必要な要素が見つかりません');
        return;
    }

    const ctx = canvas.getContext('2d');
    
    // canvasのサイズを動画のサイズに合わせる
    function resizeCanvas() {
        canvas.width = video.offsetWidth || video.videoWidth || canvas.offsetWidth;
        canvas.height = video.offsetHeight || video.videoHeight || 400;
    }
    resizeCanvas();

    let audioContext = null;
    let analyser = null;
    let source = null;
    let isPlaying = false;
    let isVisualizerVisible = true;  // ビジュアライザーの表示状態

    // ファイル選択ボタン
    fileSelectBtn.addEventListener('click', function() {
        videoFile.click();
    });

    // 動画ファイル選択時
    videoFile.addEventListener('change', function(e) {
        const file = e.target.files[0];
        if (file) {
            // 前の動画を停止
            video.pause();
            isPlaying = false;
            
            // 新しい動画を読み込み
            video.src = URL.createObjectURL(file);
            video.load();
            
            // シークバーと動画位置をリセット
            video.currentTime = 0;
            seekBar.value = 0;
            
            // ボタンの状態を初期状態にリセット
            playBtn.disabled = false;  // 再生ボタンを有効化
            pauseBtn.disabled = true;  // 一時停止ボタンを無効化
            stopBtn.disabled = false;  // 停止ボタンを有効化
            
            // 動画情報を更新
            if (fileNameDisplay) {
                fileNameDisplay.textContent = 'ファイル名: ' + file.name;
            }
            if (videoInfoDisplay) {
                const fileSize = (file.size / (1024 * 1024)).toFixed(2); // MB単位
                videoInfoDisplay.textContent = '動画情報: ' + file.type + ' (' + fileSize + ' MB)';
            }
        }
    });

    // メタデータ読み込み完了時
    video.addEventListener('loadedmetadata', function() {
        seekBar.max = 100;
        seekBar.value = 0;  // シークバーを0にリセット
        video.currentTime = 0;  // 動画位置を0にリセット
        timeDisplay.textContent = '0:00 / ' + formatTime(video.duration);
        
        // canvasのサイズを動画に合わせる
        resizeCanvas();
        
        // AudioContextの初期化(初回のみ)
        if (!audioContext) {
            try {
                audioContext = new (window.AudioContext || window.webkitAudioContext)();
                analyser = audioContext.createAnalyser();
                analyser.fftSize = 2048;
                
                source = audioContext.createMediaElementSource(video);
                source.connect(analyser);
                analyser.connect(audioContext.destination);
                
                // ビジュアライザー描画開始
                draw();
                console.log('AudioContext初期化完了');
            } catch (error) {
                console.error('AudioContext初期化エラー:', error);
            }
        }
    });

    // 再生ボタン
    playBtn.addEventListener('click', async function() {
        try {
            await video.play();
            isPlaying = true;
            playBtn.disabled = true;  // 再生中は再生ボタンを無効化(薄く表示)
            pauseBtn.disabled = false;
            
            // AudioContextがsuspend状態の場合はresume
            if (audioContext && audioContext.state === 'suspended') {
                await audioContext.resume();
            }
        } catch (error) {
            console.error('再生エラー:', error);
        }
    });

    // 一時停止ボタン
    pauseBtn.addEventListener('click', function() {
        video.pause();
        isPlaying = false;
        playBtn.disabled = false;  // 一時停止後は再生ボタンを有効化
        pauseBtn.disabled = true;
    });

    // 停止ボタン
    stopBtn.addEventListener('click', function() {
        video.pause();
        video.currentTime = 0;  // 0:00に戻る
        seekBar.value = 0;
        isPlaying = false;
        playBtn.disabled = false;  // 停止後は再生ボタンを有効化
        pauseBtn.disabled = true;
    });

    // シークバー
    seekBar.addEventListener('input', function() {
        const seekTime = (video.duration * seekBar.value) / 100;
        video.currentTime = seekTime;
    });

    // 音量スライダー
    volumeSlider.addEventListener('input', function() {
        video.volume = volumeSlider.value / 100;
    });

    // 時間更新
    video.addEventListener('timeupdate', function() {
        const currentTime = Math.floor(video.currentTime);
        const duration = Math.floor(video.duration);
        timeDisplay.textContent = formatTime(currentTime) + ' / ' + formatTime(duration);
        seekBar.value = (currentTime / duration) * 100;
    });

    // 再生終了時
    video.addEventListener('ended', function() {
        isPlaying = false;
        playBtn.disabled = false;  // 再生終了後は再生ボタンを有効化
        pauseBtn.disabled = true;
        video.currentTime = 0;
        seekBar.value = 0;
    });

    // 時間フォーマット
    function formatTime(seconds) {
        const minutes = Math.floor(seconds / 60);
        const remainingSeconds = Math.floor(seconds % 60);
        return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
    }

    // ビジュアライザー描画
    function draw() {
        requestAnimationFrame(draw);

        if (!analyser) return;

        const bufferLength = analyser.frequencyBinCount;
        const dataArray = new Uint8Array(bufferLength);
        analyser.getByteFrequencyData(dataArray);

        // キャンバスクリア(透明に)
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // バー描画
        const barWidth = (canvas.width / bufferLength) * 2.5;
        let x = 0;

        for (let i = 0; i < bufferLength; i++) {
            const barHeight = (dataArray[i] / 255) * canvas.height;
            const hue = (i / bufferLength) * 360;
            ctx.fillStyle = 'hsl(' + hue + ', 100%, 50%)';
            ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
            x += barWidth + 1;
        }
    }

    // フルスクリーンボタン
    if (fullscreenBtn) {
        fullscreenBtn.addEventListener('click', function() {
            if (!document.fullscreenElement) {
                // フルスクリーンモードに入る
                if (videoContainer.requestFullscreen) {
                    videoContainer.requestFullscreen();
                } else if (videoContainer.webkitRequestFullscreen) { // Safari対応
                    videoContainer.webkitRequestFullscreen();
                } else if (videoContainer.mozRequestFullScreen) { // Firefox対応
                    videoContainer.mozRequestFullScreen();
                } else if (videoContainer.msRequestFullscreen) { // IE/Edge対応
                    videoContainer.msRequestFullscreen();
                }
            } else {
                // フルスクリーンモードを解除
                if (document.exitFullscreen) {
                    document.exitFullscreen();
                } else if (document.webkitExitFullscreen) {
                    document.webkitExitFullscreen();
                } else if (document.mozCancelFullScreen) {
                    document.mozCancelFullScreen();
                } else if (document.msExitFullscreen) {
                    document.msExitFullscreen();
                }
            }
        });
    }

    // フルスクリーン状態の変化を監視
    document.addEventListener('fullscreenchange', function() {
        if (document.fullscreenElement) {
            fullscreenBtn.querySelector('.fullscreen-icon').textContent = '⛶'; // フルスクリーン中
        } else {
            fullscreenBtn.querySelector('.fullscreen-icon').textContent = '⛶'; // 通常表示
        }
    });

    // ビジュアライザー表示切り替えボタン
    if (visualizerToggleBtn) {
        visualizerToggleBtn.addEventListener('click', function() {
            isVisualizerVisible = !isVisualizerVisible;
            
            if (isVisualizerVisible) {
                // 表示状態(緑)
                canvas.style.opacity = '1';
                visualizerToggleBtn.querySelector('.visualizer-icon').textContent = '◉';
                visualizerToggleBtn.classList.remove('hidden');
            } else {
                // 非表示状態(赤)
                canvas.style.opacity = '0';
                visualizerToggleBtn.querySelector('.visualizer-icon').textContent = '○';
                visualizerToggleBtn.classList.add('hidden');
            }
        });
    }

    // ウィンドウリサイズ対応
    window.addEventListener('resize', function() {
        resizeCanvas();
    });
});
</script>

css/visualizer_movie.css

.audio-visualizer {
    position: relative;
    width: calc(100% - 40px);
    margin: 20px 0;
    background: rgba(0, 0, 0, 0.8);
    padding: 20px;
    border-radius: 10px;
    box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}

.audio-info {
    margin: 10px 0;
    padding: 10px;
    background: rgba(0, 0, 0, 0.1);
    border-radius: 5px;
}

#fileName, #videoInfo {
    margin: 5px 0;
    font-size: 14px;
    color: #c8c8c8;
}

.audio-controls {
    display: flex;
    justify-content: center;
    gap: 20px;
    margin-top: 20px;
}

#audioFile {
    display: none;
}
.audio-controls button.control-btn {
    /* 既存のスタイル */
    margin-right: 5px; /* ボタン間のスペース */
}
.file-upload-btn {
    padding: 10px 20px;
    background-color: #333;
    color: #fff;
    border: 2px solid #00ffff;
    border-radius: 5px;
    cursor: pointer;
    transition: all 0.3s ease;
}

.file-upload-btn:hover {
    background-color: #444;
    transform: scale(1.05);
}

.time-display {
    color: #c8c8c8;
}

.control-btn {
    background-color: #4CAF50;
    color: white;
    border: none;
    padding: 10px 20px;
    text-align: center;
    text-decoration: none;
    display: inline-block;
    font-size: 16px;
    margin: 4px 2px;
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.3s;
}

.control-btn:hover {
    background-color: #45a049;
}

.control-btn:disabled {
    background-color: #cccccc;
    cursor: not-allowed;
    opacity: 0.6;
}

.control-btn:disabled:hover {
    background-color: #cccccc;
    cursor: not-allowed;
    opacity: 0.6;
    padding: 10px 20px;
    margin: 5px;
    border-radius: 5px;
}

.control-btn.playing {
    background-color: #cccccc;
    cursor: not-allowed;
    opacity: 0.6;
}

.video-visualizer {
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
    background: rgba(0, 0, 0, 0.8);
    border-radius: 10px;
}

.video-container {
    position: relative;
    width: 100%;
    margin-bottom: 20px;
}

.video-container video {
    width: 100%;
    height: 100%;
    min-height: 400px;
    border-radius: 5px;
    display: block;
    background: #000;
}

.control-btn {
    background: #4CAF50;  /* 緑色 */
    color: white;
    border: none;
    padding: 10px 20px;
    margin: 5px;
    border-radius: 5px;
    cursor: pointer;
    transition: background 0.3s;
}

.control-btn:hover {
    background: #45a049;  /* 少し濃い緑 */
}

.control-btn:disabled {
    background: #cccccc;  /* グレー */
    cursor: not-allowed;
    opacity: 0.6;
}

.control-btn .play-icon {
    width: 20px;
    height: 20px;
}

/* ビジュアライザートグルボタン専用スタイル */
#visualizerToggleBtn {
    background: #4CAF50;  /* 緑色(表示中) */
}

#visualizerToggleBtn:hover {
    background: #45a049;
}

#visualizerToggleBtn.hidden {
    background: #f44336;  /* 赤色(非表示中) */
}

#visualizerToggleBtn.hidden:hover {
    background: #da190b;  /* 少し濃い赤 */
}

.time-display {
    color: white;
    margin: 10px 0;
    font-family: monospace;
}

.seek-bar {
    width: 100%;
    margin: 10px 0;
}

.volume-control {
    display: flex;
    align-items: center;
    margin: 10px 0;
}

.volume-control .volume-icon {
    color: white;
    margin-right: 10px;
}

.volume-slider {
    flex-grow: 1;
}

#visualizerCanvas {
    width: 100%;
    height: 200px;
    background: none;
    margin-top: 20px;
    border-radius: 5px;
    padding: 0 0 0 0;
}
#visualizerCanvas {
    position: absolute;
    top: unset;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 30%;
    pointer-events: none;
    border-radius: 5px;
}

/* フルスクリーン時のコンテナ */
.video-container:fullscreen,
.video-container:-webkit-full-screen,
.video-container:-moz-full-screen,
.video-container:-ms-fullscreen {
    position: fixed !important;
    top: 0 !important;
    left: 0 !important;
    background: #000 !important;
    width: 100vw !important;
    height: 100vh !important;
    margin: 0 !important;
    padding: 0 !important;
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
}

/* フルスクリーン時の動画(YouTubeスタイル) */
.video-container:fullscreen video,
.video-container:-webkit-full-screen video,
.video-container:-moz-full-screen video,
.video-container:-ms-fullscreen video {
    position: relative !important;
    top: auto !important;
    left: auto !important;
    transform: none !important;
    width: 100% !important;         /* 幅いっぱい */
    height: 100vh !important;       /* 高さいっぱい */
    object-fit: contain !important; /* アスペクト比維持 */
    min-height: unset !important;
    border-radius: 0 !important;
}

/* フルスクリーン時のビジュアライザー */
.video-container:fullscreen #visualizerCanvas,
.video-container:-webkit-full-screen #visualizerCanvas,
.video-container:-moz-full-screen #visualizerCanvas,
.video-container:-ms-fullscreen #visualizerCanvas {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 30%;
    max-height: 30vh;
    pointer-events: none;
    z-index: 10;
}

/* レスポンシブ対応 */
@media (max-width: 768px) {
    .video-visualizer {
        padding: 10px;
    }
    
    .control-btn {
        padding: 8px 16px;
        font-size: 14px;
    }
}

@media (max-width: 480px) {
    .video-visualizer {
        padding: 5px;
    }
    
    .control-btn {
        padding: 6px 12px;
        font-size: 12px;
    }
}

/* タブレットサイズのスタイル */
@media screen and (max-width: 1024px) {
    .audio-controls {
        display: block;
        justify-content: center;
        gap: 20px;
        margin-top: 20px;
    }
}

/* スマホサイズのスタイル */
@media screen and (max-width: 768px) {
    .audio-controls {
        display: block;
        justify-content: center;
        gap: 20px;
        margin-top: 20px;
    }
    .video-container video {
        min-height: 165px;
    }
}

解説ならGeminiやchatGPTに放り込んでみてどうぞ。。

ビジュアライザーの描画能力

今回は2048を採用。1024個の周波数帯域を細かく分析し、滑らかで精密なビジュアライザーを実現しました。

FFTサイズ周波数帯域数精度処理負荷
256128軽量
512256
1024512
20481024最高
40962048超高超重
カラースキーム

const hue = (i / bufferLength) * 360;
ctx.fillStyle = 'hsl(' + hue + ', 100%, 50%)';

低音域(左側):赤 → オレンジ
中音域(中央):黄 → 緑 → シアン
高音域(右側):青 → 紫 → 赤
虹色のグラデーションで、音域ごとに色が変わります。

カスタマイズ方法

単色に変更
// 緑色のビジュアライザー
ctx.fillStyle = 'rgb(0, 255, 0)';

グラデーション範囲を変更
// 緑から青のみ
const hue = 120 + (i / bufferLength) * 120; // 120-240度

ビジュアライザーの高さを変更
#visualizerCanvas {
height: 50%; /* 30% → 50%に変更 */
}

バーの太さを変更
const barWidth = (canvas.width / bufferLength) * 5.0; // 2.5 → 5.0に変更

Web Audio API × Canvasで作成ですが。
かっこ悪いと思う人は、改造してください。(;^_^A ・・・

非表示のボタンを追加したのは、センスに自信がないからですね。

【共有3】WordpressのカスタムHTML用コード

wordpressの記事にプレイヤーを差し込みたいです。

メディアにUPした動画を読み込ませて、再生するだけにしたい。

wordpressの記事ブロック要素「カスタムHTML」にソースを放り入れて、気軽に使えないのでしょうか。AIに頼みました。

カスタムHTML用プレイヤー(サンプルページ)
https://astrowave.jp/amnesia_record/visualizer_movie_embed.html

お借りした動画(総務省)
https://www.soumu.go.jp/menu_seisaku/ictseisaku/housou_suishin/4k8k_suishin_download.html

「カスタムHTML」用コード

<!-- ビデオビジュアライザー WordPress埋め込み用 -->
<link rel="stylesheet" href="https://astrowave.jp/amnesia_record/css/visualizer_movie.css">

<section class="main">
  <div class="video-visualizer">
    <div class="video-container">
      <video id="videoPlayer" crossorigin="anonymous" preload="metadata">
        <source src="https://neo.astrowave.jp/wp-content/uploads/2025/10/000487279.mp4" type="video/mp4">
        お使いのブラウザは動画の再生に対応していません。
      </video>
      <canvas id="visualizerCanvas"></canvas>
    </div>
    
    <div class="audio-info">
        <div id="fileName">ファイル名: 読み込み中...</div>
        <div id="videoInfo">動画情報: 読み込み中...</div>
    </div>
    
    <div class="video-controls">
        <input type="file" id="videoFile" accept="video/*" style="display: none;">
        <button id="fileSelectBtn" class="control-btn">
            <span>動画を選択</span>
        </button>
        
        <button id="playBtn" class="control-btn" disabled>
            <span class="play-icon">▶</span>
        </button>
        <button id="pauseBtn" class="control-btn" disabled>
            <span class="pause-icon">⏸</span>
        </button>
        <button id="stopBtn" class="control-btn" disabled>
            <span class="stop-icon">⏹</span>
        </button>
        
        <button id="fullscreenBtn" class="control-btn">
            <span class="fullscreen-icon">⛶</span>
        </button>
        
        <button id="visualizerToggleBtn" class="control-btn">
            <span class="visualizer-icon">◉</span>
        </button>
        
        <div class="time-display">
            <span id="timeDisplay">0:00</span>
        </div>
        
        <input type="range" id="seekBar" class="seek-bar" min="0" max="100" value="0">
        
        <div class="volume-control">
            <span class="volume-icon">🔊</span>
            <input type="range" id="volumeSlider" class="volume-slider" min="0" max="100" value="100">
        </div>
    </div>
  </div>
</section>

<script>
(function() {
    'use strict';
    
    // DOMContentLoadedイベントを待つ
    function initVisualizer() {
        const video = document.getElementById('videoPlayer');
        const canvas = document.getElementById('visualizerCanvas');
        const playBtn = document.getElementById('playBtn');
        const pauseBtn = document.getElementById('pauseBtn');
        const stopBtn = document.getElementById('stopBtn');
        const seekBar = document.getElementById('seekBar');
        const volumeSlider = document.getElementById('volumeSlider');
        const timeDisplay = document.getElementById('timeDisplay');
        const fileSelectBtn = document.getElementById('fileSelectBtn');
        const videoFile = document.getElementById('videoFile');
        const fileNameDisplay = document.getElementById('fileName');
        const videoInfoDisplay = document.getElementById('videoInfo');
        const fullscreenBtn = document.getElementById('fullscreenBtn');
        const visualizerToggleBtn = document.getElementById('visualizerToggleBtn');
        const videoContainer = document.querySelector('.video-container');
        const videoVisualizer = document.querySelector('.video-visualizer');

        if (!video || !canvas) {
            console.error('ビデオビジュアライザー: 必要な要素が見つかりません');
            return;
        }

        const ctx = canvas.getContext('2d');
        
        function resizeCanvas() {
            canvas.width = video.offsetWidth || video.videoWidth || canvas.offsetWidth;
            canvas.height = video.offsetHeight || video.videoHeight || 400;
        }
        resizeCanvas();

        let audioContext = null;
        let analyser = null;
        let source = null;
        let isPlaying = false;
        let isVisualizerVisible = true;

        // URLからファイル名を取得する関数
        function getFileNameFromUrl(url) {
            if (!url) return '未選択';
            try {
                // URLの最後の部分を取得(ファイル名)
                const urlParts = url.split('/');
                const fileName = urlParts[urlParts.length - 1];
                // クエリパラメータがある場合は除去
                return decodeURIComponent(fileName.split('?')[0]);
            } catch (e) {
                return '未選択';
            }
        }

        // 初期読み込み:動画を確実にロード
        console.log('ビデオビジュアライザー: 初期化開始');
        const videoSource = video.src || video.querySelector('source')?.src;
        console.log('ビデオビジュアライザー: 動画src:', videoSource);
        
        // 初期ファイル名を表示
        if (videoSource && fileNameDisplay) {
            const initialFileName = getFileNameFromUrl(videoSource);
            fileNameDisplay.textContent = 'ファイル名: ' + initialFileName;
            console.log('ビデオビジュアライザー: 初期ファイル名:', initialFileName);
        }
        
        // 動画の読み込み状態をチェック
        if (video.readyState >= 1) {
            // すでにメタデータが読み込まれている場合
            console.log('ビデオビジュアライザー: 動画はすでに読み込まれています');
            playBtn.disabled = false;
            stopBtn.disabled = false;
        } else {
            // まだ読み込まれていない場合は強制的にload
            console.log('ビデオビジュアライザー: 動画を読み込みます');
            video.load();
        }

        // ファイル選択ボタン
        if (fileSelectBtn) {
            fileSelectBtn.addEventListener('click', function() {
                videoFile.click();
            });
        }

        // 動画ファイル選択時
        if (videoFile) {
            videoFile.addEventListener('change', function(e) {
                const file = e.target.files[0];
                if (file) {
                    video.pause();
                    isPlaying = false;
                    video.src = URL.createObjectURL(file);
                    video.load();
                    video.currentTime = 0;
                    seekBar.value = 0;
                    playBtn.disabled = false;
                    pauseBtn.disabled = true;
                    stopBtn.disabled = false;
                    
                    if (fileNameDisplay) {
                        fileNameDisplay.textContent = 'ファイル名: ' + file.name;
                    }
                    if (videoInfoDisplay) {
                        const fileSize = (file.size / (1024 * 1024)).toFixed(2);
                        videoInfoDisplay.textContent = '動画情報: ' + file.type + ' (' + fileSize + ' MB)';
                    }
                }
            });
        }

        // メタデータ読み込み完了時
        video.addEventListener('loadedmetadata', function() {
            console.log('ビデオビジュアライザー: loadedmetadata イベント発火');
            console.log('ビデオビジュアライザー: 動画の長さ:', video.duration);
            
            seekBar.max = 100;
            seekBar.value = 0;
            video.currentTime = 0;
            timeDisplay.textContent = '0:00 / ' + formatTime(video.duration);
            playBtn.disabled = false;
            stopBtn.disabled = false;
            
            if (videoInfoDisplay && video.src) {
                const duration = formatTime(video.duration);
                const width = video.videoWidth;
                const height = video.videoHeight;
                const resolution = width && height ? width + 'x' + height : '不明';
                videoInfoDisplay.textContent = '動画情報: video/mp4 | ' + resolution + ' | 長さ: ' + duration;
            }
            
            resizeCanvas();
            
            // AudioContextの初期化(初回のみ)
            if (!audioContext) {
                try {
                    audioContext = new (window.AudioContext || window.webkitAudioContext)();
                    analyser = audioContext.createAnalyser();
                    analyser.fftSize = 2048;
                    
                    source = audioContext.createMediaElementSource(video);
                    source.connect(analyser);
                    analyser.connect(audioContext.destination);
                    
                    draw();
                    console.log('ビデオビジュアライザー: AudioContext初期化完了 state:', audioContext.state);
                } catch (error) {
                    console.error('ビデオビジュアライザー: AudioContext初期化エラー:', error);
                }
            }
        });

        // 再生ボタン
        playBtn.addEventListener('click', async function() {
            try {
                // AudioContextをresume(重要!)
                if (audioContext && audioContext.state === 'suspended') {
                    await audioContext.resume();
                    console.log('ビデオビジュアライザー: AudioContext resumed:', audioContext.state);
                }
                
                await video.play();
                isPlaying = true;
                playBtn.disabled = true;
                pauseBtn.disabled = false;
                
                if (audioContext) {
                    console.log('ビデオビジュアライザー: AudioContext state after play:', audioContext.state);
                }
            } catch (error) {
                console.error('ビデオビジュアライザー: 再生エラー:', error);
            }
        });

        // 一時停止ボタン
        pauseBtn.addEventListener('click', function() {
            video.pause();
            isPlaying = false;
            playBtn.disabled = false;
            pauseBtn.disabled = true;
        });

        // 停止ボタン
        stopBtn.addEventListener('click', function() {
            video.pause();
            video.currentTime = 0;
            seekBar.value = 0;
            isPlaying = false;
            playBtn.disabled = false;
            pauseBtn.disabled = true;
        });

        // シークバー
        seekBar.addEventListener('input', function() {
            const seekTime = (video.duration * seekBar.value) / 100;
            video.currentTime = seekTime;
        });

        // 音量スライダー
        volumeSlider.addEventListener('input', function() {
            video.volume = volumeSlider.value / 100;
        });

        // 時間更新
        video.addEventListener('timeupdate', function() {
            const currentTime = Math.floor(video.currentTime);
            const duration = Math.floor(video.duration);
            timeDisplay.textContent = formatTime(currentTime) + ' / ' + formatTime(duration);
            seekBar.value = (currentTime / duration) * 100;
        });

        // 再生終了時
        video.addEventListener('ended', function() {
            isPlaying = false;
            playBtn.disabled = false;
            pauseBtn.disabled = true;
            video.currentTime = 0;
            seekBar.value = 0;
        });

        // エラーハンドリング
        video.addEventListener('error', function(e) {
            console.error('ビデオビジュアライザー: 動画読み込みエラー:', e);
            if (video.error) {
                console.error('ビデオビジュアライザー: エラーコード:', video.error.code);
                console.error('ビデオビジュアライザー: エラーメッセージ:', video.error.message);
            }
        });

        // 動画が読み込み開始した時
        video.addEventListener('loadstart', function() {
            console.log('ビデオビジュアライザー: 動画読み込み開始');
        });

        // 動画データの最初のフレームが読み込まれた時
        video.addEventListener('loadeddata', function() {
            console.log('ビデオビジュアライザー: 動画データ読み込み完了');
        });

        // 時間フォーマット
        function formatTime(seconds) {
            const minutes = Math.floor(seconds / 60);
            const remainingSeconds = Math.floor(seconds % 60);
            return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
        }

        // ビジュアライザー描画
        function draw() {
            requestAnimationFrame(draw);
            if (!analyser) return;

            const bufferLength = analyser.frequencyBinCount;
            const dataArray = new Uint8Array(bufferLength);
            analyser.getByteFrequencyData(dataArray);

            ctx.clearRect(0, 0, canvas.width, canvas.height);

            const barWidth = (canvas.width / bufferLength) * 2.5;
            let x = 0;

            for (let i = 0; i < bufferLength; i++) {
                const barHeight = (dataArray[i] / 255) * canvas.height;
                const hue = (i / bufferLength) * 360;
                ctx.fillStyle = 'hsl(' + hue + ', 100%, 50%)';
                ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
                x += barWidth + 1;
            }
        }

        // フルスクリーンボタン
        if (fullscreenBtn) {
            fullscreenBtn.addEventListener('click', function() {
                if (!document.fullscreenElement) {
                    if (videoContainer.requestFullscreen) {
                        videoContainer.requestFullscreen();
                    } else if (videoContainer.webkitRequestFullscreen) {
                        videoContainer.webkitRequestFullscreen();
                    } else if (videoContainer.mozRequestFullScreen) {
                        videoContainer.mozRequestFullScreen();
                    } else if (videoContainer.msRequestFullscreen) {
                        videoContainer.msRequestFullscreen();
                    }
                } else {
                    if (document.exitFullscreen) {
                        document.exitFullscreen();
                    } else if (document.webkitExitFullscreen) {
                        document.webkitExitFullscreen();
                    } else if (document.mozCancelFullScreen) {
                        document.mozCancelFullScreen();
                    } else if (document.msExitFullscreen) {
                        document.msExitFullscreen();
                    }
                }
            });
        }

        // ビジュアライザー表示切り替えボタン
        if (visualizerToggleBtn) {
            visualizerToggleBtn.addEventListener('click', function() {
                isVisualizerVisible = !isVisualizerVisible;
                if (isVisualizerVisible) {
                    canvas.style.opacity = '1';
                    visualizerToggleBtn.querySelector('.visualizer-icon').textContent = '◉';
                    visualizerToggleBtn.classList.remove('hidden');
                } else {
                    canvas.style.opacity = '0';
                    visualizerToggleBtn.querySelector('.visualizer-icon').textContent = '○';
                    visualizerToggleBtn.classList.add('hidden');
                }
            });
        }

        // ウィンドウリサイズ対応
        window.addEventListener('resize', function() {
            resizeCanvas();
        });
    }

    // DOMの準備が完了したら初期化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initVisualizer);
    } else {
        initVisualizer();
    }
})();
</script>

cssはすごく長いから別ファイルとして読み込ませます。

ファイルの準備

動画ファイルのドメインURLとブログのドメインURLは同じにしないと読み込みに失敗します。

ボタンなどをID指定で作成したので、記事1件につきプレイヤーは1つしか設置できないと思います。悪しからず。

【AI】イラストを描いてもらった

今回の記事のキャッチ画像で使わせてもらいます「Google ImageFX」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

この記事にピッタリなイラストのための考えたリクエストは、「ディスコでライトアップされた頭が昭和レトロなブラウン管テレビのDJが作り出すサウンドで、ノリノリの会場。カラフルなレーザーが乱れている状況。DJにバードアイビューで。」です。

Memeplex.appが最近は人気なのかすごく待たされます。。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

生命も輝いていた、そんな雰囲気がします。

しなやか~