2023.03.29

コーディング

React&Tailwind CSSでvideoタグをカスタマイズする

この記事ではvideoタグを使用した再生、停止、音量調整といった動画を再生するときのインターフェイスをカスタマイズして作る方法を紹介したいと思います。
最終的に出来上がるものがこちらです。

react-video-custom - CodeSandbox

react-video-custom by hirokikoharurun using autoprefixer, loader-utils, postcss, react, react-dom, react-scripts, tailwindcss


この記事を通して、videoタグを使ったカスタマイズを行うときの参考になると幸いです。

前置き

まず、初めにお伝えしておくと基本的にUIをカスタマイズするというのはあまりオススメしません。
というのも、 基本的にサイトに訪れる方々というのはvideoタグのデフォルトで実装されているインターフェイスに慣れていると思います。それを変えてしまうと返って操作性が悪くなってしまう場合もあるので特段変更する必要性が無いのであればデフォルトのインターフェイスを使用したほうが良いと思います。

では、なぜ自分はカスタマイズをやろうと思ったかというと、instagramのリール動画をサイト上に表示させるときにデフォルトのインターフェイスが横向きに表示される問題が生じたためです。

なぜこのようになるかというと、リール動画は9:16の比率でできた縦長の動画なんですが、僕の場合は16:9の比率でリール動画として作ったりすることがあるのでそれをサイト上で表示しようとすると90度横向きにしないといけないのでインターフェイスが横の方に位置してしまいます。それを防ぐためにインターフェイスをカスタマイズしようと思ったわけです。

プロジェクトを作成する

今回はJavaScriptのライブラリであるReactを使用して作っていきますのであらかじめ開発するマシン(パソコン)にNode.jsをインストールしておく必要があります。
私のNode.jsバージョンはv14.15.1です。

create-react-appでプロジェクトの雛形を作成する

npx create-react-app custom-video

custom-videoの部分がプロジェクトの名前になりますのでここは好きな名前にしてもらって構いません。インストールが完了したらプロジェクトのディレクトリに移動します。

cd custom-video

今回、見た目の装飾にTailwind CSSをしますので必要なパッケージをインストールしていきます。

npm install -D tailwindcss postcss autoprefixer

Tailwind CSSの設定ファイルを作成します。

npm tailwindcss init -p

生成されたtailwind.config.jsファイルを以下に変更します。変更箇所はcontentの中身です。
Tailwind CSSを適応させたいファイル群を書き込んでます。

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}",],
  theme: {
    extend: {},
  },
  plugins: [],
}

App.jsの中身を以下のように変更して下さい。

import logo from './logo.svg';
import './App.css';
import 'tailwindcss/tailwind.css';

function App() {
  return (
<div className="flex items-center justify-center h-screen w-full">
        <img src={logo} className="App-logo" alt="logo" /> ← isPlay変数の状態で停止アイコンと再生アイコンを出し分ける
</div>
  );
}

export default App;

これでひとまず下準備は完了となります。npm run startを実行しローカルサーバーを起動させます。

npm run start

このように画面が表示されればTailwind CSSが適用されてますのでOKです。

App.jsをカスタマイズ

まずは、今回作成するデザインに見た目にします。App.jsの中身を以下に変更して下さい。

import './App.css';
import 'tailwindcss/tailwind.css';

const movieUrl = `https://video-nrt1-1.cdninstagram.com/o1/v/t16/f1/m82/F14AB0A070DB6AA7E9E783FC1EDA62BB_video_dashinit.mp4?efg=eyJ2ZW5jb2RlX3RhZyI6InZ0c192b2RfdXJsZ2VuLjcyMC5jbGlwcyJ9&_nc_ht=video-nrt1-1.cdninstagram.com&_nc_cat=101&vs=957148465272642_2080163262&_nc_vs=HBksFQIYT2lnX3hwdl9yZWVsc19wZXJtYW5lbnRfcHJvZC9GMTRBQjBBMDcwREI2QUE3RTlFNzgzRkMxRURBNjJCQl92aWRlb19kYXNoaW5pdC5tcDQVAALIAQAVABgkR0doejhCT2ZfRnpLZkZrQ0FIS2YzbHhHbkhGdGJxX0VBQUFGFQICyAEAKAAYABsBiAd1c2Vfb2lsATEVAAAmvNyY%2FpOl8z8VAigCQzMsF0AaqfvnbItEGBJkYXNoX2Jhc2VsaW5lXzFfdjERAHUAAA%3D%3D&ccb=9-4&oh=00_AfDfxAPrniTyi9azL8aA7XEoSemIZ6aJGjNvnEotF9HZ5Q&oe=6423E071&_nc_sid=ea0b6e&_nc_rid=97ecb47f72`;

function App() {
  return (
    <div className="flex items-center justify-center h-screen w-full text-[#fff]">
        <div>
            <div className="relative rounded-tl-[10px] rounded-tr-[10px] overflow-hidden w-[720px] aspect-[16/9]">
                <video
                    className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
                    src={ movieUrl }
                    muted loop controls
                />
                <div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
                    <input
                        className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
                        value=""
                        min="0"
                        max="100"
                        type="range"
                    />
                    <span className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
                </div>
            </div>
            <div className="w-full flex p-[15px] rounded-bl-[10px] rounded-br-[10px] bg-[#000]">
                <button
                    className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                >
                    <StartIcon/>
                </button>
                <div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>00</p>&ensp;/&ensp;<p>00</p></div>
                <div className="flex items-center ml-[15px]">
                    <button
                        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                    >
                        <VolumeOnIcon className="w-[20px]"/>
                    </button>
                    <input
                        type="range"
                        className="videoVolumeBar newmo-inset h-[4px] rounded-[6px] ml-[5px]"
                        value=""
                        min="0"
                        max="10"
                    />
                </div>
            </div>
        </div>
    </div>
  );
}

export default App;


export const StartIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
    );
};


export const StopIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" viewBox="0 0 384 512"><path d="M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"/></svg>
    );
};

export const VolumeOnIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>
    );
};

export const VolumeOffIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>
    );
}

再生ボタンや、音量調節ボタンが表示が表示されるようになったかと思います。 ここからボタンを操作したときに動画が動くように処理を記述していきます。

  • ※movieUrlの中身が表示させたい動画のURLになるのでここは適宜お好きな動画に差し替えて下さい。

デフォルトの操作ボタンを削除する

videoタグのcontrols属性を削除して下さい。これで動画の右側に表示されているデフォルトの操作ボタンが消えます。

 <video
    className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
    src={ movieUrl }
    muted
    loop
    controls ←削除
/>

再生・停止ボタンの処理

videoタグを操作するためにuseRefを使用してDOMを取得します。

const video = useRef(null);
 <video
    ref={ video }
    className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
    src={ movieUrl }
    muted
    loop
/>

videoタグが再生中は停止ボタンを表示、停止中は再生ボタンが表示されるようにしたいので再生中かどうかを判別する変数をuseStateを使って定義します。

const [isPlay, setIsPlay] = useState(false);

再生ボタンをクリックしたときに発火する関数(ここではvideoPlayという名前で作ってます)を作成してvideoタグのplayメソッドとpauseメソッドを使用して動画を再生&停止させます。

const videoPlay = () => {
    if(!video.current) return;
    if(video.current.paused){
        video.current.play(); ← videoタグを再生させる
        setIsPlay(true); 
    }else{
        video.current.pause();  ← videoタグを停止させる
        setIsPlay(true);
    }
}
<button
    className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
    onClick={ play }
>
    { !isPlay ? <StartIcon/> : <StopIcon/> }<StartIcon/> ← isPlay変数の状態で停止アイコンと再生アイコンを出し分ける
</button>

ここまでのApp関数の中身

function App() {

    const video = useRef(null);
    const [isPlay, setIsPlay] = useState(false);

    const videoPlay = () => {
        if(!video.current) return;
        if(video.current.paused){
            video.current.play();
            setIsPlay(true);
        }else{
            video.current.pause();
            setIsPlay(true);
        }
    }
    
    return (
        <div className="flex items-center justify-center h-screen w-full text-[#fff]">
            <div>
                <div className="relative rounded-tl-[10px] rounded-tr-[10px] overflow-hidden w-[720px] aspect-[16/9]">
                    <video
                        ref={ video }
                        className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
                        src={ movieUrl }
                        muted loop
                    />
                    <div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
                        <input
                            className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
                            value=""
                            min="0"
                            max="100"
                            type="range"
                        />
                        <span className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
                    </div>
                </div>
                <div className="w-full flex p-[15px] rounded-bl-[10px] rounded-br-[10px] bg-[#000]">
                    <button
                        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                        onClick={ videoPlay }
                    >
                        { !isPlay ? <StartIcon/> : <StopIcon/> }<StartIcon/>
                    </button>
                    <div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>00</p>&ensp;/&ensp;<p>00</p></div>
                    <div className="flex items-center ml-[15px]">
                        <button
                            className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                        >
                            <VolumeOnIcon className="w-[20px]"/>
                        </button>
                        <input
                            type="range"
                            className="videoVolumeBar newmo-inset h-[4px] rounded-[6px] ml-[5px]"
                            value=""
                            min="0"
                            max="10"
                        />
                    </div>
                </div>
            </div>
        </div>
    );
}

これで再生ボタンを押したときの挙動ができたと思います。

ボリュームの調整

ボリュームの調整はこの部分のinput:rangeを使用して切り替えます。

<div className="flex items-center ml-[15px]">
    <button
        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
    >
        <VolumeOnIcon className="w-[20px]"/>
    </button>
    <input
        type="range"
        className="videoVolumeBar newmo-inset h-[4px] rounded-[6px] ml-[5px]"
        value=""
        min="0"
        max="10"
    />
</div>

ボリュームのメーターを管理する変数volumeMeterを定義します。

const [volumeMeter, setVolumeMeter] = useState(0);

ボリュームを調整するinput:rangeのvalue属性をvolumeMeterにし、onChangeイベントでinputの値が変更されたときの処理を記述します。

<input
    type="range"
    value={ volumeMeter } ← ここを追加
    className="videoVolumeBar newmo-inset h-[4px] rounded-[6px] ml-[5px]"
    min="0"
    max="10"
    onChange={ e => { ← ここを追加
        const value = Number(e.target.value);
        if( value ){
            video.current.muted = false;  ← valueが0じゃなかったらミュートを解除する
        }else{
            video.current.muted = true;   ← valueが0だったらミュートにする
        }
        video.current.volume = value * 0.1;  ← videoタグのボリュームを変更する
        setVolumeMeter(value);
    }}
/>

volumeMeterの値が0だったらミュートのアイコン、0以上だったらボリュームのアイコンに変更されるようにします。
そしてアイコンクリックでミュートとミュート解除を行えるようにonClickイベントを定義します。

<button
    className="w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
    onClick={ () => {   ← ここを追加
        if( !volumeMeter ){
            video.current.muted = false;
            setVolumeMeter(video.current.volume * 10);
        }else{
            video.current.muted = true; 
            setVolumeMeter(0); 
        }
    }}
>
    { !volumeMeter ? <VolumeOffIcon/> : <VolumeOnIcon/> } ← ここを追加
</button>

これでinputタグのメーターを操作したらボリュームが変更されるようになったと思います。

ここまでのApp関数の中身

function App() {

    const video = useRef(null);

    const [isPlay, setIsPlay] = useState(false);
    const [volumeMeter, setVolumeMeter] = useState(0);

    const videoPlay = () => {
        if(!video.current) return;
        if(video.current.paused){
            video.current.play();
            setIsPlay(true);
        }else{
            video.current.pause();
            setIsPlay(false);
        }
    }
    
    return (
        <div className="flex items-center justify-center h-screen w-full text-[#fff]">
            <div>
                <div className="relative rounded-tl-[10px] rounded-tr-[10px] overflow-hidden w-[720px] aspect-[16/9]">
                    <video
                        ref={ video }
                        className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
                        src={ movieUrl }
                        muted loop
                    />
                    <div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
                        <input
                            className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
                            value=""
                            min="0"
                            max="100"
                            type="range"
                        />
                        <span className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
                    </div>
                </div>
                <div className="w-full flex p-[15px] rounded-bl-[10px] rounded-br-[10px] bg-[#000]">
                    <button
                        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                        onClick={ videoPlay }
                    >
                        { !isPlay ? <StartIcon/> : <StopIcon/> }
                    </button>
                    <div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>00</p>&ensp;/&ensp;<p>00</p></div>
                    <div className="flex items-center ml-[15px]">
                        <button
                            className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                            onClick={ () => {
                                if( !volumeMeter ){
                                    video.current.muted = false;
                                    setVolumeMeter(video.current.volume * 10);
                                }else{
                                    video.current.muted = true;
                                    setVolumeMeter(0);
                                }
                            }}
                        >
                            { !volumeMeter ? <VolumeOffIcon/> : <VolumeOnIcon/> }
                        </button>
                        <input
                            type="range"
                            value={ volumeMeter }
                            className="videoVolumeBar h-[4px] rounded-[6px] ml-[5px]"
                            min="0"
                            max="10"
                            onChange={ e => {
                                const value = Number(e.target.value);
                                if( value ){
                                    video.current.muted = false;
                                }else{
                                    video.current.muted = true;
                                }
                                video.current.volume = value * 0.1;
                                setVolumeMeter(value);
                            }}
                        />
                    </div>
                </div>
            </div>
        </div>
    );
}

合計時間の表示

動画の合計時間と現在の時間を表示する変数を定義します。

const time = useRef(`0:00`); ← 現在の時間
const duration = useRef(`0:00`); ← 合計時間

videoタグのonLoadedDataイベントを使用して読み込みが完了したタイミングで合計時間を変更させます。

<video
    ref={ video }
    className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
    src={ movieUrl }
    onLoadedData={ loadedVideo }
    muted loop
/>
const loadedVideo = () => {
    const minutes = Math.floor(video.current.duration / 60);  ← 合計時間の分数を取得
    const seconds = Math.floor(video.current.duration - minutes * 60);  ← 合計時間の秒数を取得
    duration.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;

    video.current.play();
    setIsPlay(true);
};

時間を表示している部分のHTMLにtime変数とduration変数をはめ込みます。
<div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>{ time.current }</p>&ensp;/&ensp;<p>{ duration.current }</p></div>

これで動画の合計時間が表示されるようになったと思います。

ここまでのApp関数の中身

function App() {

    const video = useRef(null);
    const time = useRef(`0:00`);
    const duration = useRef(`0:00`);

    const [isPlay, setIsPlay] = useState(false);
    const [volumeMeter, setVolumeMeter] = useState(0);

    const videoPlay = () => {
        if(!video.current) return;
        if(video.current.paused){
            video.current.play();
            setIsPlay(true);
        }else{
            video.current.pause();
            setIsPlay(false);
        }
    }

    const loadedVideo = () => {
        const minutes = Math.floor(video.current.duration / 60);
        const seconds = Math.floor(video.current.duration - minutes * 60);
        duration.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;

        video.current.play();
        setIsPlay(true);
    };
    
    return (
        <div className="flex items-center justify-center h-screen w-full text-[#fff]">
            <div>
                <div className="relative rounded-tl-[10px] rounded-tr-[10px] overflow-hidden w-[720px] aspect-[16/9]">
                    <video
                        ref={ video }
                        className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
                        src={ movieUrl }
                        onLoadedData={ loadedVideo }
                        muted loop
                    />
                    <div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
                        <input
                            className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
                            value=""
                            min="0"
                            max="100"
                            type="range"
                        />
                        <span className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
                    </div>
                </div>
                <div className="w-full flex p-[15px] rounded-bl-[10px] rounded-br-[10px] bg-[#000]">
                    <button
                        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                        onClick={ videoPlay }
                    >
                        { !isPlay ? <StartIcon/> : <StopIcon/> }
                    </button>
                    <div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>{ time.current }</p>&ensp;/&ensp;<p>{ duration.current }</p></div>
                    <div className="flex items-center ml-[15px]">
                        <button
                            className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                            onClick={ () => {
                                if( !volumeMeter ){
                                    video.current.muted = false;
                                    setVolumeMeter(video.current.volume * 10);
                                }else{
                                    video.current.muted = true;
                                    setVolumeMeter(0);
                                }
                            }}
                        >
                            { !volumeMeter ? <VolumeOffIcon/> : <VolumeOnIcon/> }
                        </button>
                        <input
                            type="range"
                            value={ volumeMeter }
                            className="videoVolumeBar h-[4px] rounded-[6px] ml-[5px]"
                            min="0"
                            max="10"
                            onChange={ e => {
                                const value = Number(e.target.value);
                                if( value ){
                                    video.current.muted = false;
                                }else{
                                    video.current.muted = true;
                                }
                                video.current.volume = value * 0.1;
                                setVolumeMeter(value);
                            }}
                        />
                    </div>
                </div>
            </div>
        </div>
    );
}

現在の再生時間とシークバー作成

シークバーの位置を管理するための変数を定義します。

const [timeProgress, setTimeProgress] = useState(0);

videoタグ下にあるinput:rangeがシークバーになります。そこの部分を以下に変更します。

<div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
    <input
        className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
        value={ timeProgress }
        onChange ={ e => changeTime(e.target.value) }
        onMouseUp={ () => {
            video.current.play();
            setIsPlay(true);
        }}
        onMouseDown={ () => video.current.pause() }
        onTouchStart={ () => video.current.pause()  }
        onTouchEnd={ () => {
            video.current.play();
            setIsPlay(true);
        }}
        min="0"
        max="100"
        type="range"
    />
    <span style={{ width: `${ timeProgress }%` }} className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
</div>

min属性とmax属性は0〜100%でどのくらい進んでいるか表すためにminを0、maxを100にしています。
onMouseUp,onMouseDown,onTouchStart,onTouchEndイベントは、シークバーの操作を開始したときに動画をストップさせ、操作完了したときに動画を再生させるようにしています。

onChangeイベントはシークバーを変更したときに行う処理です。changeTime関数を作成します。

const changeTime = (value) => {
    const currentTime = Number(value);
    const persent = video.current.duration * (currentTime / 100);  ← シークバー変更後の時間を計算する
    
    const minutes = Math.floor(persent / 60);
    const seconds = Math.floor(persent - minutes * 60);
    time.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`; ← 変更後の時間(persent)を使って現在の時間を表示しているHTMLを変更する

    video.current.currentTime = persent; ← 変更後の時間にvideoタグの時間を変更する
    setTimeProgress(currentTime); 
};

あとは、動画を再生中にシークバーと再生時間を変更する処理をvideoタグのonTimeUpdateイベントを使って作ります。

<video
    ref={ video }
    className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
    src={ movieUrl }
    onTimeUpdate={ timeUpdate } ← ここ追加
    onLoadedData={ loadedVideo }
    muted loop
/>
const timeUpdate = () => {

    const currentTime = video.current.currentTime;
    const minutes = Math.floor(currentTime / 60); 
    const seconds = Math.floor(currentTime - minutes * 60);
    
    const percent = ( currentTime / (video.current.duration) ) * 100;
    time.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;
    setTimeProgress(percent);
}

これで完成になります。

最終コード

import { useRef, useState } from 'react';
import './App.css';
import 'tailwindcss/tailwind.css';

const movieUrl = `https://video-nrt1-1.cdninstagram.com/o1/v/t16/f1/m82/F14AB0A070DB6AA7E9E783FC1EDA62BB_video_dashinit.mp4?efg=eyJ2ZW5jb2RlX3RhZyI6InZ0c192b2RfdXJsZ2VuLjcyMC5jbGlwcyJ9&_nc_ht=video-nrt1-1.cdninstagram.com&_nc_cat=101&vs=957148465272642_2080163262&_nc_vs=HBksFQIYT2lnX3hwdl9yZWVsc19wZXJtYW5lbnRfcHJvZC9GMTRBQjBBMDcwREI2QUE3RTlFNzgzRkMxRURBNjJCQl92aWRlb19kYXNoaW5pdC5tcDQVAALIAQAVABgkR0doejhCT2ZfRnpLZkZrQ0FIS2YzbHhHbkhGdGJxX0VBQUFGFQICyAEAKAAYABsBiAd1c2Vfb2lsATEVAAAmvNyY%2FpOl8z8VAigCQzMsF0AaqfvnbItEGBJkYXNoX2Jhc2VsaW5lXzFfdjERAHUAAA%3D%3D&ccb=9-4&oh=00_AfDfxAPrniTyi9azL8aA7XEoSemIZ6aJGjNvnEotF9HZ5Q&oe=6423E071&_nc_sid=ea0b6e&_nc_rid=97ecb47f72`;

function App() {

    const video = useRef(null);
    const time = useRef(`0:00`);
    const duration = useRef(`0:00`);

    const [isPlay, setIsPlay] = useState(false);
    const [volumeMeter, setVolumeMeter] = useState(0);
    const [timeProgress, setTimeProgress] = useState(0);

    const videoPlay = () => {
        if(!video.current) return;
        if(video.current.paused){
            video.current.play();
            setIsPlay(true);
        }else{
            video.current.pause();
            setIsPlay(false);
        }
    }

const timeUpdate = () => {

    const currentTime = video.current.currentTime;
    const minutes = Math.floor(currentTime / 60); 
    const seconds = Math.floor(currentTime - minutes * 60);
    
    const percent = ( currentTime / (video.current.duration) ) * 100;
    time.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;
    setTimeProgress(percent);
}

    const changeTime = (value) => {
        const currentTime = Number(value);
        const persent = video.current.duration * (currentTime / 100);
        
        const minutes = Math.floor(persent / 60);
        const seconds = Math.floor(persent - minutes * 60);
        time.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;

        video.current.currentTime = persent;
        setTimeProgress(currentTime);
    };

    const loadedVideo = () => {
        const minutes = Math.floor(video.current.duration / 60);
        const seconds = Math.floor(video.current.duration - minutes * 60);
        duration.current = `${ minutes }:${ String(seconds).padStart(2, '0') }`;

        video.current.play();
        setIsPlay(true);
    };
    
    return (
        <div className="flex items-center justify-center h-screen w-full text-[#fff]">
            <div>
                <div className="relative rounded-tl-[10px] rounded-tr-[10px] overflow-hidden w-[720px] aspect-[16/9]">
                    <video
                        ref={ video }
                        className="absolute top-[405px] left-0 h-[720px] -rotate-[90deg] origin-top-left"
                        src={ movieUrl }
                        onTimeUpdate={ timeUpdate }
                        onLoadedData={ loadedVideo }
                        muted loop
                    />
                    <div className="h-[6px] w-full absolute bottom-0 left-0 newmo-inset rounded-[3px]">
                        <input
                            className="videoSeekBar w-full absolute left-0 top-[0] h-full rounded-[3px] z-[1]"
                            value={ timeProgress }
                            onChange ={ e => changeTime(e.target.value) }
                            onMouseUp={ () => {
                                video.current.play();
                                setIsPlay(true);
                            }}
                            onMouseDown={ () => video.current.pause() }
                            onTouchStart={ () => video.current.pause()  }
                            onTouchEnd={ () => {
                                video.current.play();
                                setIsPlay(true);
                            }}
                            min="0"
                            max="100"
                            type="range"
                        />
                        <span style={{ width: `${ timeProgress }%` }} className="absolute left-0 top-0 h-full bg-main rounded-[4px]"></span>
                    </div>
                </div>
                <div className="w-full flex p-[15px] rounded-bl-[10px] rounded-br-[10px] bg-[#000]">
                    <button
                        className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                        onClick={ videoPlay }
                    >
                        { !isPlay ? <StartIcon/> : <StopIcon/> }
                    </button>
                    <div className="newmo-inset flex justify-between items-center rounded-[18px] h-[35px] px-[12px] ml-[10px] text-[12px]"><p>{ time.current }</p>&ensp;/&ensp;<p>{ duration.current }</p></div>
                    <div className="flex items-center ml-[15px]">
                        <button
                            className="newmo-hover w-[35px] h-[35px] flex items-center justify-center !rounded-[50%]"
                            onClick={ () => {
                                if( !volumeMeter ){
                                    video.current.muted = false;
                                    setVolumeMeter(video.current.volume * 10);
                                }else{
                                    video.current.muted = true;
                                    setVolumeMeter(0);
                                }
                            }}
                        >
                            { !volumeMeter ? <VolumeOffIcon/> : <VolumeOnIcon/> }
                        </button>
                        <input
                            type="range"
                            value={ volumeMeter }
                            className="videoVolumeBar h-[4px] rounded-[6px] ml-[5px]"
                            min="0"
                            max="10"
                            onChange={ e => {
                                const value = Number(e.target.value);
                                if( value ){
                                    video.current.muted = false;
                                }else{
                                    video.current.muted = true;
                                }
                                video.current.volume = value * 0.1;
                                setVolumeMeter(value);
                            }}
                        />
                    </div>
                </div>
            </div>
        </div>
    );
}

export default App;


export const StartIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
    );
};


export const StopIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" viewBox="0 0 384 512"><path d="M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"/></svg>
    );
};

export const VolumeOnIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>
    );
};

export const VolumeOffIcon = () => {
    return(
        <svg className="fill-[#fff] w-[20px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>
    );
}

この記事を気に入ったら

柄本 広樹

美しい景色、人、食べ物、そんな当たり前の日常に彩るすべてに感謝し、 今も映像を作り続ける。

この人が書いた記事を見る >>
カモメのアイコンTOP