🎧

推しの歌配信を音楽アプリのように聴けるWebサービスを作ってみた

2022/02/12に公開

成果物

ソースコード

https://github.com/qisarazu/iroha-fansite

動くもの

https://gozaru.fans/singing-streams

出来ること

  • 曲名で検索
  • 動画視聴
    • 連続再生
    • シャッフル
    • リピート
    • シーク
    • 再生/停止
    • 前後スキップ
    • 音量調整

スクショ

Desktop Mobile

経緯

推しが毎週末に歌枠配信をしてくれている
→ 動画数が多くなり、あの曲どの配信で歌ってたっけ?となる
→ 検索できるサービス作ろう!
→ どうせならリピート再生とか出来る音楽アプリみたいにしよう!!

というざっくり経緯説明

歌部分だけを編集で切り抜いて聴くという手もありますが
それだと元動画へ再生数がいかないので推しに対して申し訳ない。。

これは元動画を再生しつついい感じに曲を聴きけたらいいなという自分の願望を叶えたものです💪

つかったもの

Next.js

https://nextjs.org/
react で作りたかったので next にしました。
remix にしなかった理由としては、別に SSR したいわけではなかったからです!
正直全部 CSR でも良かったので next すらいらなかった説はありますが、code splitting とか画像の最適化とか色々自動でしてくれたり開発環境構築が爆速で出来るので選びました。
今まで Web アプリ作る時 webpack + react-router で自分で設定していって~みたいなことしていて next ちゃんと使ってこなかったので、今回いい機会なのでというのもある。

ハマったところ

使い慣れていないからとは思うんですが、クライアント側で処理されるものと、そうでないものとの処理を意識して書かないといけないのは難しいポイント。。
とりあえずクライアントでしか処理してほしくないものについては useEffect に突っ込むという理解をしました。そのほかは window があるかどうかを一々みて判断しないといけない。
意図しないところでサーバー側で処理されてエラーになるあるある。

dynamic import で全部読み込むという方法もあるらしい。

TypeScript

https://www.typescriptlang.org/
型がないとやってられねぇよなぁ!!

supabase

https://supabase.com/
Database に利用しています。
firebase の代替として最近注目を集めているサービスです!
firebase と異なる点として、一番大きいのは DB に postgresql を利用していることです。これにより RDB が使えるので、めちゃ便利になります。
例えば、今回で言うと1つの配信に複数の曲が関連付けられている状態なので、まさにうってつけでした。
あまり SQL 書いたことない自分でもブラウザ上のGUIからテーブル定義や、データの挿入・編集・削除が行えて使いやすいです!
DB 以外にも Auth や Storage、Functions(開発中) もあるので、今後の機能追加で使っていきたいと思います!

swr

https://swr.vercel.app/
クライアントでのデータ管理として swr を利用しました。
API で得られるデータはこれで管理して、クライアントで使い回すようなデータは react context で管理する方針です。
swr はデフォルトの useSWR だと自動で再検証が走り再フェッチをしてくれますが、ほとんどデータ更新がない今回のサービスには不要かつ無駄にAPIリクエストが飛んでしまうので自動再検証を無効にした useSWRImmutable を利用しました。
https://swr.vercel.app/docs/revalidation#disable-automatic-revalidations

supabase と組み合わせてみた

supabase は js ライブラリを用意してくれているので、それを利用して Database のデータをフェッチすることができます。
https://github.com/supabase/supabase-js
なので swr と supabase-js を組み合わせて使えるようにしました。
swr は利用する fetcher を自由に定義することが出来ます。なので、fetcher を supabase-js で定義してあげればいいわけです。
例えば、検索機能でデータを取得したい場合↓のようになります。

const useSearchList = (keyword: string) => {
  const { data, error } = useSWRImmutable(keyword, fetcherForList);
}

const fetcherForList = async (keyword: string) => {
  const query = supabase
    .from('singing_stream')
    .select('id, ...')
    .ilike('song.title', `%${keyword}%`);

  const { data, error } = await query;

  if (error) throw error;
  return data;
}

useSWRImmutable (useSWR も同様) の第一引数は、第二引数の fetcher に渡す引数になります。
また、この第一引数が変わっているかどうかで swr は再フェッチを行うか、キャッシュから返すかを選択します。
そのため、同じキーワードで検索された場合は、再フェッチされずキャッシュから返されるのでAPI リクエストを減らすことが出来ます。

fetcher は渡ってきた引数を利用してデータを fetch します。
今回であれば検索キーワードが渡ってくるのでそれを supabase-js に渡してあげて DB からデータを取得します。

また、swr は第一引数が null だった場合は、fetcher を実行しません。
https://swr.vercel.app/docs/conditional-fetching#conditional
next のよくあるハマりポイントとして、next/router の useRouter() から取得できる query が初回レンダリングは undefined になる現象があります。
視聴ページで、query にある ID を利用してデータの取得を行いたいときに、初回レンダリング時の undefined だった場合は key を null にしてフェッチさせないということが出来ます。

export const useWatch = (id: string | undefined) => {
  const { data, error } = useSWRImmutable(id ?? null, fetcherForWatch);
  return {
    stream: data,
    error,
  };
}

YouTube IFrame API

https://developers.google.com/youtube/iframe_api_reference
今回の要 YouTube IFrame API さんです!
YouTube が公式で提供している API で、iframe で埋め込んだ動画を操作する事ができます。
これを利用して、元動画を利用して曲部分だけを再生したりリピートしたりすることが可能です。
ただ、react と組み合わせるのが若干難しくて恐らくメイビー未だに完璧ではない。。
このAPIを利用するためには公式リファレンスを引用すると以下の処理が必要になります。

  1. script タグで https://www.youtube.com/iframe_api を読み込む
  2. ダウンロードされると自動で実行される onYouTubeIframeAPIReady を global な関数として用意する
  3. onYouTubeIframeAPIReady 内で Player の初期化を行う
  4. Player に紐付いた iframe の動画を操作することが出来る

ハマリポイント1: APIが利用できるタイミング

iframe_api が読み込まれると global に YT というオブジェクトが生えて、その中に Player クラスが定義されます。
そのクラスをインスタンス化して利用するんですが、インスタンス化した段階ではまだ Player を利用できず、onReady イベントが実行されるのを待つ必要があります。

ハマりポイント2: removeEventListener が動かない

Player に定義出来るイベントとして上記で説明した onReady や、再生ステータスが変化した際に実行される onStateChange などがあります。
これらのイベントは player.addEventListener, player.removeEventListener を利用して player に対して追加、削除を行うことが出来ます。
特に onStateChange は再生/停止アイコンの変更やリピート処理などの state の値によって処理を分けることも多いので state が変化したら都度 removeEventListener → addEventListener をして更新したいんですが、
何故か removeEventListener が動いてくれない。。自分の書き方が悪い気がしますが原因不明。。
そのため、state の値が更新されずに意図しない動作をしてしまいます。

回避策として、onStateChange で利用する値は state ではなくローカル変数として定義することにしました。
これにより、初回に addEventListener してあげれば良くなります。
ただし、react はローカル変数の値が変わっても再レンダリングしてくれないので、UI に関わってくる値は state とローカル変数の2重管理状態になります😇

ハマりポイント3: unmount 時の処理

Player.destroy() があるので、unmount 時にはこれを実行するのが良さそうですが、やってもやらなくても変わらない。。
マウント先のDOMがなくなったよという waring が出る。
解決方法がわからないのと warning であればとりあえず動きはするのでヨシ👈!!

再生数カウントの仕様

YouTube IFrame API を利用する場合、埋め込んだ動画に対して正しく再生数をカウントさせるにはプレイヤーの再生ボタンを押す必要があります。

Note: A playback only counts toward a video's official view count if it is initiated via a native play button in the player.
(公式ドキュメントより)

player.playVideo() や player.loadVideoById() などで自動再生させてもカウント対象にはならないとのこと。

再生数は、同一IPからは1動画につきある期間内で1カウントのみというの仕様があるらしいため、本サービスも一定期間ごとにプレイヤーの再生ボタンからしか再生出来ないようにしています。
ただし、YouTube の再生数カウントの詳しい仕組みは公開されていないため、YouTube公式サイトから視聴するのとでは恐らく違ってきてしまうのと、毎回再生ボタンを押さないと再生出来ないのは使い勝手も悪いため期間の設定は妥協...

今後

今後のアップデートでは、大きいものとしてカスタムプレイリスト機能の追加を予定しています!
現在はすべての曲を流すことしか出来ないですが、お気に入りの曲のみに限定して流せるようになります。
(これで supabase の Auth 機能を使いたい)

あとは検索機能の改善だったりもろもろもやっていきます!

読んで頂きありがとうございました🤟

Discussion