本文へスキップ
← 作ったもの一覧

Felody

個人開発 / 公開中

自作のリラックスBGMを配信するサブスク型Webサービス。企画から課金・運用まで一人で回しています。

何を解決するものか

作業中や就寝前に「とりあえず流しておける音」がほしいとき、YouTube を開くと広告が入り、関連動画に気を取られます。Felody はカテゴリと時間を選ぶだけで、自作のBGMを指定時間だけ流し切るサービスです。

relax(lofi / nature / ambient / sleepy)と upbeat(jazz / funk / pop rock / edm)の2ムード・8カテゴリを用意し、Basic は1日30分、Premium は無制限で利用できます。

担当範囲

企画・UI設計・実装・DB設計・決済導線・インフラ・運用のすべて。

技術的に難しかった点と、どう解いたか

一番時間を取られたのは、機能そのものよりもサブスクの課金連携とモバイルでの再生の安定でした。どちらも「動く状態」を作るのは早いのに、「壊れない状態」にするまでが長い領域です。

1. 音声ファイルを「直リンクさせずに」配信する

課金コンテンツなので、Storage の公開URLをそのまま渡すわけにはいきません。Supabase Edge Function 経由で都度 Presigned URL を発行し、クライアントには期限付きURLだけを渡す形にしました。

発行が失敗すると無音になって体験が壊れるため、1s → 2s → 3s の間隔で3回リトライしています。

2. ブラウザによって音が鳴らない

Howler.js は既定で Web Audio API を使いますが、環境によっては初期化に失敗します。失敗を検知したら HTML5 Audio へフォールバックする二段構えにして、鳴らない端末をなくしました。

加えて、UI上の状態(再生中/停止中)と実際の再生状態がずれることがあったため、5秒間隔で両者を突き合わせて補正するチェックを入れています。「押したのに反応しない」は離脱に直結するので、ここは厚めに作りました。

3. モバイルの再生が、とにかく止まる・ずれる

PC では問題なく動いていたものが、スマホに持っていくと一気に崩れました。ここが開発全体で一番粘った箇所です。

  • 最初のタップでしか音を鳴らせない:iOS / Android のブラウザは、ユーザー操作を伴わない再生をブロックします。そこで「再生ボタンの最初のタップで AudioContext を起こしておく」形にし、以降のトラック切り替えは起こし済みのコンテキストを使い回すようにしました。
  • 画面ロックやタブの離脱で音が落ちる:バックグラウンドに回るとタイマーが間引かれ、再生時間の計測も次トラックへの遷移もずれます。visibilitychange で復帰を検知し、戻ってきた時点で実際の再生位置を基準に状態を作り直すようにしました。経過時間を積み上げるのではなく、都度実測値に寄せるのがポイントでした。
  • 着信・他アプリの音・イヤホン抜き差しで無音のまま復帰しない:割り込みのあと「再生中の見た目なのに音が出ていない」状態に落ちるパターンがあり、2 の突き合わせチェックはこれを拾うために入れています。
  • 回線が細いときに再生が始まらない:モバイル回線だと Presigned URL の取得と音声の読み込みがそのまま待ち時間になるため、次に流すトラックを先読みしておき、切り替え時の無音を減らしました。

実機がないと再現しない不具合ばかりで、iOS Safari / Android Chrome を手元で往復しながら潰していきました。

4. 「1日30分」をどう正確に数えるか

再生のたびにサーバーへ書くと負荷もコストも無駄になります。60秒間隔でまとめて記録し、30秒のスロットルで重複送信を防ぐ方式にしました。日付が変わったかどうかは last_reset_date を見て、ログイン時に判定してリセットします。

モバイルではこの計測がバックグラウンド化の影響を受けるため、3 の復帰処理と合わせて「離れていた間の時間を過剰に加算しない」ことを優先しています。

5. 決済とアプリ側の状態を食い違わせない

サブスクは、Stripe 側の状態とアプリ側の状態がずれた瞬間に事故になります。払ったのに使えない/解約したのに課金されるのどちらも起こしたくないので、ここは実装よりも「起こりうるケースの洗い出し」に時間を使いました。

  • Stripe の checkout.session.completed と customer.subscription.deleted を Webhook で受け、プラン状態を DB に同期しています。
  • Webhook は同じイベントが複数回届く前提で組み、処理済みイベントを記録して二重反映を防いでいます。
  • 決済完了直後にリダイレクトしても Webhook が先に届くとは限らないため、戻り先の画面ではプラン状態が反映されるまで待てる作りにし、「決済したのに Basic のまま」に見える瞬間をなくしました。
  • 解約は即時停止ではなく期間末までは Premium のまま使える扱いにし、その状態を DB 側でも表現しています。
  • DB 書き込みはすべてサーバー側(Service Role Key)に閉じ、クライアントには Anon Key しか渡していません。プラン判定をクライアントの状態に依存させないことを徹底しています。

テストは Stripe のテストモードで、決済成功・カード拒否・解約・期間末の切り替えをひと通り通してから本番に出しました。

学んだこと / 次にやること

課金がある以上、「失敗したときにどう振る舞うか」がそのまま品質になります。リトライ・フォールバック・状態の突き合わせは、機能そのものより時間をかける価値がありました。

そしてモバイルは「PC で動いたから大丈夫」がまったく通用しない領域でした。実機で触って初めて分かることが多く、次に音を扱うものを作るなら最初からスマホ基準で組み立てます。

次は Player コンポーネント(約1,300行)の分割と、テストの導入を進めています。

お仕事のご相談

「まだ要件が固まっていない」「実現できるか相談したい」の段階で構いません。技術的に可能かどうかだけでもお答えします。

お問い合わせフォームへ

メールでも受け付けています → dkae1107@gmail.com