オンライン講座や動画配信のサービスを作っていると、必ずこの相談が来ます。「1つのアカウントを複数人で使い回されると困る。同時に見られないようにしたい」。いわゆる同時視聴の禁止(1アカウント=同時1再生)です。
一見すると「再生中フラグを立てて、立っていたら再生させない」だけに見えます。ところが素直にそう作ると、ほぼ確実に運用で事故ります。この記事では、なぜ事故るのか、どう作れば安定するのかを、プログラミングにあまり詳しくない方でも追えるように順番に説明します。
そもそも何が課題なのか
課題は2つあります。ひとつはビジネス上の課題。1アカウントを何人でも共有できてしまうと、有料コンテンツの売上が立ちません。eラーニングであれば「本人が受講した」という実績そのものが成立しなくなります。
もうひとつは技術上の課題。「今、誰が再生しているか」という情報は、利用者の端末(スマホやPC)の中に置いても意味がありません。端末は複数あり、お互いの状況を知らないからです。しかも端末側のプログラムは、その気になれば止められます。判断はサーバー側で一元管理するしかない——ここが出発点です。
「再生中フラグ」だけで作ると必ず詰まる
もっとも素朴な実装は「再生を始めたらフラグを立て、やめたら下ろす」です。これは次の3つで破綻します。
1. アプリは必ず落ちる
電池切れ、機内モード、ブラウザの強制終了、電車でのトンネル。「やめたらフラグを下ろす」処理は、実行されないことが日常的にあります。下りなかったフラグは永久に立ちっぱなしになり、その利用者は自分のアカウントなのに二度と再生できなくなります。実際、この手の機能でいちばん多い問い合わせがこれです。
2. 同時に押されると両方通る
端末Aと端末Bがほぼ同時に再生ボタンを押すと、両方が「フラグは空いている」と読み取り、両方が書き込み、両方とも再生できてしまいます。「読んでから書く」までのわずかな隙間に、もう一方が割り込むためです。防ぐには、読み取りと書き込みを一続きの処理として順番待ちさせる仕組み(トランザクション)が要ります。
3. 端末の時計は信用できない
「最後の反応から○分たったら解除」という判定を端末の時計で行うと、時計をずらすだけで回避できます。加えて、そもそも端末の時計は数分ずれていることが普通にあります。時刻はサーバーが刻んだものだけを使います。
考え方は「鍵は1本だけ」
やっていることは、貸し会議室の鍵とまったく同じです。
- 部屋(=コンテンツ)に入るには鍵が要る
- 鍵は1本しかない
- 鍵は受付(=サーバー)が管理し、「今どの端末に貸しているか」を1行だけ記録する
現実の貸し会議室と違うのは1点だけ。借りた人が鍵を返さずに帰ってしまうことが日常的に起きるので、鍵のほうに「返却期限」を持たせておきます。
仕組みは3つの部品でできている
| 部品 | 役割 | 実体 |
|---|---|---|
| 名簿 | 今どの端末が再生中かを1行だけ記録する | サーバー上の1レコード |
| 生存報告(ハートビート) | 再生中の端末が15秒ごとに「まだ見てます」と伝える | 定期的な更新処理 |
| 見張り | 名簿が自分以外に書き換わったら、その場で再生を止める | リアルタイム購読 |
動作の流れは、たった5行で言い切れます。
- サーバーに「いま誰が再生中か」を1行だけ記録する(=再生の鍵)
- 再生を始めるとき、その1行を順番待ちで奪い合う。取れた端末だけが再生できる
- 再生中の端末は15秒ごとに「まだ見てます」と報告する
- 記録が自分以外に書き換わったら、その端末は即座に再生を停止する
- 報告が45秒とだえた行は自動で失効する
いちばん大事なのは「期限」
初心者の方がまず引っかかるのが、ここです。「再生をやめたら鍵を返せばいいのでは?」——もちろん返します。ただし「返してくれること」を前提に設計してはいけません。返却処理は、あくまでおまけです。
本体は期限切れのほうです。鍵に「最終連絡の時刻」を書いておき、そこから45秒たったら誰も返さなくても自動的に無効になる。だから電池が切れても、圏外に入っても、他の端末はしばらく待てば必ず再生できるようになります。45秒という数字は「報告の間隔(15秒)の3倍」から来ています。1〜2回ほど報告が届かなくても誤って解除しない、という余裕です。
実装してみる(Firebase / Firestore の例)
ここからは実際のコードです。データはユーザーごとに1件だけ持ちます。配列にしたり複数件にしたりすると、途端に「奪い合い」の判定が難しくなるので、1ユーザー=1行を守るのがコツです。
playbackLocks/{userId}
├ sessionId : "a3f9..." // 端末ごとのランダムなID
├ deviceLabel : "Chrome / Windows" // 利用者に見せる名前
├ contentId : "lesson-07"
├ lastHeartbeat : (サーバー時刻) // 最終連絡
└ startedAt : (サーバー時刻)
① 鍵を取る(順番待ちが必須)
const LEASE_MS = 45_000; // 45秒 連絡がなければ失効
async function acquireLock(userId, sessionId, contentId, { force = false }) {
const ref = doc(db, "playbackLocks", userId);
// runTransaction = 「読んで書く」を一続きにして割り込みを防ぐ
return runTransaction(db, async (tx) => {
const snap = await tx.get(ref);
if (snap.exists()) {
const cur = snap.data();
const alive = cur.lastHeartbeat &&
Date.now() - cur.lastHeartbeat.toMillis() < LEASE_MS;
// 他の端末がまだ生きている → 断る(force なら奪い取る)
if (alive && cur.sessionId !== sessionId && !force) {
return { ok: false, heldBy: cur.deviceLabel };
}
}
tx.set(ref, {
sessionId,
deviceLabel: getDeviceLabel(),
contentId,
lastHeartbeat: serverTimestamp(),
startedAt: serverTimestamp(),
});
return { ok: true };
});
}
なお上の例は判定に端末の時計(Date.now())を使っています。手軽ですが、前述のとおり時計はずらせます。本気で守るなら、この判定自体をサーバー側(Cloud Functions など)に寄せて、サーバー時刻どうしで比較してください。
② 生存報告と「奪われた」検知
// 15秒ごとに「まだ見てます」と報告する
const hb = setInterval(() => {
updateDoc(ref, { lastHeartbeat: serverTimestamp() });
}, 15_000);
// 名簿が自分以外に書き換わったら、その場で止める
onSnapshot(ref, (snap) => {
if (snap.data()?.sessionId !== mySessionId) {
player.pause();
clearInterval(hb);
showModal("別の端末で再生が始まったため、こちらの再生を停止しました");
}
});
この「見張り」があるおかげで、後から来た端末を断るのも、先に見ていた端末を止めて乗り換えるのも、まったく同じ仕組みで実現できます。
③ 鍵を返す
const release = () => {
clearInterval(hb);
// 自分が持っているときだけ消す(他端末の鍵を消さない)
runTransaction(db, async (tx) => {
const s = await tx.get(ref);
if (s.exists() && s.data().sessionId === mySessionId) tx.delete(ref);
});
};
player.addEventListener("pause", release);
window.addEventListener("pagehide", release); // beforeunload より確実
「断る」か「乗り換えさせる」か
仕組みができたら、次は見せ方です。ここで断るだけの設計にすると、必ずクレームになります。「さっきスマホでちょっと見て、アプリを閉じただけなのに、PCで見られない」——期限切れを待つ45秒が、利用者にとっては理不尽な待ち時間だからです。
おすすめは乗り換え式。こう出して、利用者自身に選ばせます。
別の端末(iPhone / Safari)で再生中です。
こちらで再生すると、そちらの再生は停止されます。
[ こちらで再生する ][ キャンセル ]
「同時に2つは再生させない」というルールは守られたまま、利用者は詰まりません。実装上は、先ほどのコードの force を true にして呼ぶだけです。
画面を止めるだけでは、破られる
ここまでの仕組みは、すべて利用者の端末が正直にルールを守ることに依存しています。ブラウザの開発者ツールでプログラムを止めてしまえば、鍵の確認は素通りです。動画のURLが固定なら、それを直接叩かれて終わります。
本気で守るなら、「再生できる/できない」ではなく「動画データを渡す/渡さない」で制御します。再生用URLを有効期限つき(5〜10分)の使い捨てにして、そのURLを発行するときに「この人は今、鍵を持っているか?」を確認する。HLSのように動画を細切れに配信する方式なら、断片を取りに来るたびに期限が来るので、確認のタイミングが自然に作れます。
あわせて、データベース側でも「他人の鍵は触れない」ことを保証しておきます。
match /playbackLocks/{userId} {
allow read, write: if request.auth.uid == userId;
}
見落としがちな穴
| 起きること | 対策 |
|---|---|
| 同じPCで2つのタブを開く | ブラウザ内で先に排他する(BroadcastChannel)。IDは端末単位ではなくタブ単位で発行する |
| ページを再読み込みした | IDを sessionStorage に持たせ、同じセッションとして扱う。自分自身と衝突しなくなる |
| アプリが裏に回り、タイマーが間引かれる | 画面が戻ったときに即座に報告し、鍵を持っているか確認し直す |
| 一時停止中はどう扱う? | 「視聴中」とみなすかを最初に決める。みなさないなら停止時に鍵を返す、みなすなら報告を続ける |
| 通信が切れた | 復帰したら必ず取り直す。失敗したら再生を止める(「たぶん大丈夫」で続けない) |
まとめ
- 同時視聴の禁止は、サーバー上にある「1本の鍵」の奪い合いとして設計する
- 鍵の取得は必ず順番待ち(トランザクション)で。同時に押されると両方通ってしまう
- 「返してくれること」を前提にしない。生存報告+期限切れを本体にする
- 画面の制御は入口にすぎない。本当に守るなら配信URLの発行を鍵に紐づける
- 断るだけでなく「こちらで再生しますか?」と乗り換えさせると、体験が壊れない
やること自体は「1行のデータを奪い合う」だけで、決して難しくありません。難しいのは、アプリが落ちる・時計がずれる・利用者が2台持っているという現実を、最初から設計に織り込むことです。ここを外すと、公開後に問い合わせが止まらなくなります。
AppTalentHub では、こうしたeラーニング・動画配信まわりの設計や実装のご相談も承っています。「作りたいものはあるが、どこに落とし穴があるか分からない」という段階でもお気軽にどうぞ。


