golongshortへ戻る

Claudeで構築

Claudeで1日でYouTube Music同期Chrome拡張機能を作った話

私は開発者ではありません。アイデアを出してQAをしただけで、実際に作ったのはClaudeです。何を頼んで、Claudeが何をしたのか、そしてこの記事をまるごと自分のClaudeに貼り付けて同じものを再現する方法まで、すべてお見せします。


Hummo — 同じ曲を、同じ秒に。どこにいても、一緒に聴ける。

作ったもの — Hummo 友達と YouTube Music を同じ秒数で一緒に聴ける Chrome 拡張機能です。誰か1人がホストになると、ほかの全員のプレイヤーがそれに追従します — 同じトラック、同じ再生位置で、再生も一時停止もリアルタイムに揃います。共有キュー、ルーム内チャット、自分の言語で見られる公開ルーム、片方向フォロー、そして3人を超えるとサブスクリプションが必要になるゲートもあります。すでに Chrome Web Store で公開中です。

Hummo をインストール — Chrome Web Store →

Midnight City を再生中の Hummo ルーム。共有キューとライブチャット付き。
ルームの様子:今流れている曲、共有キュー、ライブチャット — すべてがルーム内の全員に同期されています。
ホスト側の画面とリモート側の画面が同期を保っている様子。
核となる仕掛け:同期されるのは再生の状態だけ — トラック、再生位置、再生/一時停止 — 音声そのものは絶対に流しません。ホストが主導し、全員がそれに追従します。
言語でフィルタリングされた Hummo の公開ルーム一覧を閲覧している様子。
閲覧して参加できる公開ルーム — 自分の言語で絞り込まれ、ハートの数で並べ替えられます。

さて — 実際にこれがどう作られたのかをお話しします。

最初に正直に言っておきます。この拡張機能のコードを、私はほとんど1行も書いていません。実際にやったことといえば — 気に入った拡張機能を見つけて、Claude に*「こういう機能が欲しいんだけど、どう作ればいい?」*と伝え、返ってきたものを QA しただけです。それから数日後には Chrome Web Store で公開されていました。

build-in-public 系の投稿はたいてい「こうやって作りました」ときれいに整えられています。でも私の場合はそんなに整然とはしていませんでした。なので、この投稿では実際の役割分担を、**私が頼んだこと(🗣️) → Claude がやったこと(🤖)**という形で、ありのままにお伝えします。

作ったものは Hummo — 友達と YouTube Music を同じ秒数で一緒に聴けるものです。

📋 この投稿はビルドキットです。 まるごとあなたの Claude(Claude Code)に貼り付けて*「これを作って」と言えば、同じ拡張機能を再現できます。大部分はただの構造を平易な言葉で説明したもので、Claude はそこから実装してくれます。Claude が推測できない*部分 — YouTube Music の非公開プレイヤー/DOM、イベントプロトコル — だけは実際のコードとして貼り付けてあります。


Part 0 — 私が見たもの、そして作りたかったもの

ある日 ListenTogether に出会いました — 友達と YouTube Music を一緒に聴ける拡張機能です。「これいいな。でも自分なりに、自分の機能を入れて作り直したい」。出発点はそれだけでした。

私が持っていたのはコードではなく、やりたいことリストでした:

これらを実際にどう作るか — リアルタイム同期、著作権、service worker — はまったく見当もつきませんでした。そこが Claude の出番です。その成果物が Hummo です。

目次

  1. 核となるアイデア + リアルタイムサーバー
  2. 拡張機能のコア — プレイヤー制御、セッション、service worker
  3. UI と多言語対応
  4. インフラ — Claude が lavela を推してきた
  5. 名前、ロゴ、サイト、そして Chrome Web Store でのローンチ

1. 核となるアイデア + リアルタイムサーバー

すべてはここから始まりました。私が作りたかったのは「友達と同じ曲を同じ秒数で聴く」拡張機能で — 最初に存在しなければならなかったのは、画面に見える UI ではなく、その下に座っている共有プロトコルリアルタイムサーバーでした。拡張機能(クライアント)は結局この2つにぶら下がる形になるので、Claude はその順番で作っていきました:骨組み → 共有型 → サーバー。私はコードを書かないので、投げたのは何を作るかだけです。どう作るかは完全に Claude の判断でした。


音声ではなく状態を同期する(これは Claude のアイデアでした)

🗣️ 私が頼んだこと

「誰かが許可したら、他の人のルームを見つけて飛び込めると面白いと思う。(…) これ全体をちゃんと設計して。」

私は Claude に「音声じゃなく状態を同期して」とは一度も言っていません。上のように「listen-together アプリをちゃんと設計して」とただ投げただけで — どう同期するかこそが Claude の下した核心的な判断でした。

🤖 Claude がやったこと 正直、これは私が頼んだものではなく — Claude がゴールから導き出したものです。私は「みんなで一緒に聴けるようにして」と言っただけなのに、Claude はこう押し返してきました:「実際の音声をやり取りして回すと、著作権と帯域が地獄になります」。そして方向を反転させたのです。代わりに、再生状態だけを同期する — トラック ID、再生位置(秒)、再生/一時停止、そしてタイムスタンプ。サーバーが運ぶのはこの4つの値だけです。各クライアントは自分の YouTube Music を自分のブラウザでローカルに再生し、自分の再生位置をサーバーの権威的な状態に合わせます:「このトラックを、この位置で、現在再生中」。音声は1バイトもネットワークを渡りません。この1つの判断によって著作権問題を完全に回避でき、帯域は1曲あたり数十バイトにまで落ち、100人ルームでさえほぼタダで回せるようになります。週末の突貫工事が成立した理由がこれです。そしてこの — 何を同期するか — が次のステップの契約(プロトコル)になりました。


共有プロトコル(拡張機能とサーバーがどちらも読む契約)

🗣️ 私が頼んだこと

「youtube music だから — 丁寧に見ていって。それで、最終的にどういう形になるのかも描いてみせて。」

私は「共有プロトコル」を仕様として書き出したわけではありません。ただ「丁寧にレビューして、うまく設計して」みたいに言っただけで、「拡張機能とサーバーがずれたら同期が壊れる」という気づき — それを防ぐ単一の契約ファイル — は、そのレビューの中で Claude が必然的に敷くことになった土台でした。

🤖 Claude がやったこと Claude は、拡張機能とサーバーがどちらもインポートする — 唯一の真実の源となる1つのファイルを作りました。鍵となる判断はこうです:ホストは自分のプレイヤーから3つの値 { trackId, positionSec, playing }HostState)だけをアップロードし、サーバーはそれに自分の時計で serverTs(送信時刻、ミリ秒)を刻印して、ルーム全体に SyncPacket としてブロードキャストします。サーバーの時計だけが真実なので、ゲストは再生位置について文句を言えません。その上で、すべてのイベント(ルーム / キュー / チャット / フレンド / フォロー / ディスカバリー / 決済)について、ペイロードと ack のシグネチャを型として固定しました — イベント名の1文字やフィールド1つを間違えても両側はコンパイルが通ってしまい、実行時に同期が静かに壊れます。型で強制すれば、この種のバグはまるごとコンパイル時に死にます。無料上限 FREE_ROOM_CAPACITY = 3 や「リモートモード」(remote = 同期は受け取るが自分は黙っている)のようなドメイン定数もここに住んでいます。

ts
/** * Realtime protocol shared between extension and server (single source of truth). * Both server and extension import this from @lt/shared. */ export interface Participant { id: string; name: string; isHost: boolean; remote: boolean; // remote mode (stays in sync but plays no audio) } export interface QueueItem { trackId: string; title: string; artist: string; by: string; // name of the participant who added it thumb?: string; // album thumbnail URL (if available) } export interface ChatMessage { name: string; text: string; ts: number; // server time (ms) } /** Queue-control permission mode (per room). Default open = keeps the current frictionless behavior */ export type QueuePerm = "host-only" | "propose" | "open"; /** Track suggestion submitted by a guest in propose mode (host accepts/rejects) */ export interface Proposal { id: string; item: QueueItem; byId: string; byName: string; ts: number; } /** Authoritative playback state sent by the host (position based on host clock) */ export interface HostState { trackId: string; positionSec: number; playing: boolean; } /** Sync packet the server broadcasts to everyone in the room. serverTs = send time based on the server clock (ms). */ export interface SyncPacket extends HostState { serverTs: number; } export interface RoomSnapshot { code: string; title: string; hostId: string; participants: Participant[]; queue: QueueItem[]; chat: ChatMessage[]; queuePerm: QueuePerm; proposals: Proposal[]; isPublic: boolean; tags: string[]; hostPro: boolean; // whether the host is subscribed (if false, capacity is 2) sync: SyncPacket | null; } /** client → server event payloads */ export interface ClientToServer { "room:create": (p: { title: string; name: string; queuePerm?: QueuePerm; isPublic?: boolean; tags?: string[]; lang?: string }, ack: (r: { code: string; selfId: string; snapshot: RoomSnapshot }) => void) => void; "room:join": (p: { code: string; name: string }, ack: (r: { ok: boolean; selfId?: string; isHost?: boolean; snapshot?: RoomSnapshot; error?: string }) => void) => void; "room:leave": (p: Record<string, never>, ack: (r: { ok: boolean }) => void) => void; // leave room (SW socket doesn't disconnect, so an explicit event is required) "room:close": (p: Record<string, never>, ack: (r: { ok: boolean; error?: string }) => void) => void; // host disbands the room "host:state": (p: HostState) => void; "queue:add": (p: QueueItem) => void; "queue:addMany": (p: { items: QueueItem[] }) => void; "queue:remove": (p: { trackId: string }) => void; "queue:reorder": (p: { order: string[] }) => void; "queue:clear": () => void; "chat:send": (p: { text: string }) => void; "presence:remote": (p: { remote: boolean }) => void; "presence:rename": (p: { name: string }, ack: (r: { ok: boolean; name?: string; error?: string }) => void) => void; "perm:set": (p: { queuePerm: QueuePerm }) => void; "queue:propose": (p: { item: QueueItem }) => void; "queue:approve": (p: { id: string }) => void; "queue:reject": (p: { id: string }) => void; // P2 discovery "room:setPublic": (p: { isPublic: boolean; tags?: string[] }, ack: (r: { ok: boolean; isPublic: boolean; error?: string }) => void) => void; "discover:list": (p: DirectoryQuery, ack: (r: { rooms: PublicRoomCard[]; seeded: boolean }) => void) => void; "discover:report": (p: { code: string; reason?: "spam" | "nsfw" | "other" }, ack: (r: { ok: boolean }) => void) => void; "room:heart": (p: { code: string }, ack: (r: { ok: boolean; hearted?: boolean; hearts?: number; error?: string }) => void) => void; // One-way follow (favorites) — separate from friends. When a followed user opens a room, you get notified. "follow:add": (p: { code: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "follow:remove": (p: { userId: string }, ack: (r: { ok: boolean }) => void) => void; "follow:list": (p: Record<string, never>, ack: (r: { following: FollowInfo[] }) => void) => void; // P3 accounts (full email/password accounts) "auth:signup": (p: { email: string; password: string; name: string }, ack: (r: AuthResult) => void) => void; "auth:login": (p: { email: string; password: string }, ack: (r: AuthResult) => void) => void; "auth:token": (p: { token: string }, ack: (r: AuthResult) => void) => void; "auth:logout": (p: { token: string }, ack: (r: { ok: boolean }) => void) => void; // Subscription (rooms with 3+ people). billing:checkout = lavela checkout URL (when configured); the demo upgrades to pro immediately "billing:checkout": (p: Record<string, never>, ack: (r: { ok: boolean; url?: string; plan?: "free" | "pro" }) => void) => void; // Optimistic upgrade after a real Stripe payment (ADR 003: no payment webhook → user self-confirms) "billing:confirm": (p: Record<string, never>, ack: (r: { ok: boolean; plan?: "free" | "pro" }) => void) => void; "billing:cancel": (p: Record<string, never>, ack: (r: { ok: boolean }) => void) => void; "friends:list": (p: Record<string, never>, ack: (r: { friends: FriendInfo[]; incoming: FriendRequestInfo[] }) => void) => void; "friends:request": (p: { code: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "friends:respond": (p: { requestId: string; accept: boolean }, ack: (r: { ok: boolean }) => void) => void; "friends:remove": (p: { userId: string }, ack: (r: { ok: boolean }) => void) => void; "room:invite": (p: { friendId: string }, ack: (r: { ok: boolean; error?: string }) => void) => void; "time:ping": (p: { t0: number }, ack: (r: { t0: number; tServer: number }) => void) => void; } /** server → client event payloads */ export interface ServerToClient { sync: (p: SyncPacket) => void; "queue:update": (p: { queue: QueueItem[] }) => void; presence: (p: { participants: Participant[]; hostId: string }) => void; "chat:msg": (p: ChatMessage) => void; "host:changed": (p: { hostId: string }) => void; "perm:update": (p: { queuePerm: QueuePerm }) => void; "proposal:update": (p: { proposals: Proposal[] }) => void; "queue:denied": (p: { action: string; reason: "perm" }) => void; "room:meta": (p: { isPublic: boolean; tags: string[] }) => void; "friends:changed": (p: Record<string, never>) => void; // friend/request/presence change → client re-fetches friends:list "follow:changed": (p: Record<string, never>) => void; // following list/presence change → re-fetch follow:list "follow:roomOpened": (p: { userId: string; name: string; code: string; title: string }) => void; // a followed user opened a room → badge notification "billing:updated": (p: { plan: "free" | "pro" }) => void; "room:invited": (p: { code: string; fromName: string; title: string }) => void; "room:closed": (p: { reason: string }) => void; } /** Free-plan room capacity (free up to 3 people; payment gate kicks in at the 4th) */ export const FREE_ROOM_CAPACITY = 3; // ── P2: public room discovery ── export interface PublicRoomCard { code: string; title: string; listeners: number; nowPlaying: { title: string; artist: string } | null; coverIds: string[]; // album-art trackIds for the cover (up to 4, 2x2 collage) tags: string[]; hostPro: boolean; // whether the host is subscribed (if false, limited to 2 people) hearts: number; // ❤️ number of likes (tied to the live room) heartedByMe: boolean; // whether the requesting user has hearted it createdAt: number; } export interface DirectoryQuery { q?: string; sort?: "active" | "new" | "hearts"; lang?: string; // requesting user's UI language (en/ko/ja) — only show rooms created in the same language } // ── P3: accounts/friends ── export interface FriendInfo { userId: string; name: string; online: boolean; roomCode: string | null; // room currently joined (if any, enables one-tap join) roomTitle: string | null; } export interface FriendRequestInfo { id: string; fromName: string; } /** One-way follow target info (same shape as FriendInfo, includes presence) */ export interface FollowInfo { userId: string; name: string; online: boolean; roomCode: string | null; roomTitle: string | null; } export interface AuthResult { ok: boolean; error?: string; userId?: string; friendCode?: string; name?: string; plan?: "free" | "pro"; token?: string; // session token (saved to localStorage → reconnect / login on another device) }

ドリフト補正(小さなズレはわざと無視する)

🗣️ 私が頼んだこと

「youtube music だから — 丁寧に見ていって。それで、最終的にどういう形になるのかも描いてみせて。」

ドリフト補正も私が頼んだものではありません。私は「丁寧にレビューして、みんなで同じ曲を聴くデモを設計して」と言っただけで、「みんなの時計もネットワーク遅延もバラバラだから、放っておくと遅れていく」という問題 — そして補正ロジックは Claude のもので、ゴールを実装するにはそれが必要だったからです。

🤖 Claude がやったこと 状態だけを同期しても1つ問題が残ります:各クライアントの時計はバラバラで、ネットワーク遅延も変動するので、「サーバーが送ってきた位置」をそのまま使うと、受け取るのが遅いほどどんどん遅れていきます。Claude はこれを NTP ライトなアプローチでパッチしました。time:ping/ack の往復でサーバー時計のオフセット(offset = サーバー時計 − クライアント時計)を推定するのですが、決定的なのはこの部分です:複数のサンプルを集めたあと、それらを平均しない — RTT が最も小さいサンプルのオフセットを採用する(つまり最も信頼できるもの)。これが min-RTT サンプルです。これによって全体がジッターに強くなります。再生位置はその上で expectedPosition = パケットの位置 + 送信されてからの経過時間(server-now − serverTs)として、playing のときだけ計算され、負にならないようクランプされます。そして最後に、聴き心地そのものを守るための最も重要な判断:ドリフトが 1.5秒のしきい値(DRIFT_THRESHOLD_SEC)を超えたときだけシークして補正します。0.2秒ずれるたびにシークしていたら曲がぶつ切りになって台無しになるので、小さなドリフトはあえて見逃すのです。このロジックは DOM もソケットも依存しない純粋関数なので、ユニットテストで検証されています。


リアルタイムサーバー(ルーム、権威、そして決済ゲート)

🗣️ 私が頼んだこと

「で、3人より多くの人と共有したいならサブスクしなきゃいけなくて、サブスクしてない人が作ったルームには人数上限を表示すべき。」 「無料枠は2人じゃなくて3人にして。」 「ていうか、よく考えたらルームを閉じる手段がないんだけど。」 / 「で、ルームを閉じるのは — ホストだけができるべき。」

ここでの不変条件 — 「権威はホストからのみ来る」「ユーザー1人につきルームは1つ」 — は私が頼んだものではありません。あの決済ゲートとルーム閉鎖を安全に実装するために Claude が敷いたものです。

🤖 Claude がやったこと Claude はリアルタイムバックエンドを Socket.IO で書き、いくつかの不変条件を固めました。まず、権威はホストからのみ来るsetHostState はホストが送った状態だけを受け付け(それ以外の誰かが送っても無視)、サーバー時計 serverTs を刻印して、それを権威的なパケットにします。だからゲストは再生位置について文句を言えません。次に、ユーザー1人につきルームは1つ — 新しいルームに参加する前に自動的に古いルームから退出させられるので、1つのソケットが同時に2つのルームに入ることはできません。3つ目、私が頼んだ決済ゲート — ホストが PRO ならルームは無制限、そうでなければ無料上限の3人でキャップされ、4人目以降は決済へと誘導されます(その上限に当たる瞬間こそが PRO 転換のタイミングです)。4つ目、「ルームを閉じる」はホスト専用にされ、閉じると room:closed がルーム内全員にブロードキャストされ、そのあと即座に削除されます。

ここで Claude が捕まえた厄介な罠が1つありました:拡張機能の service-worker ソケットはタブを閉じても切断されないのです。なので disconnect イベントだけに頼ると、退出したはずの人がゴースト参加者としてルームに居座ってしまいます。Claude は別途イベントを追加し、クライアントが明示的に room:leave を発火させ、サーバーがその場で取り除くようにしました。そして最後に、パスワードは平文でも単純なハッシュでも保存されず — scrypt(ユーザーごとのランダムソルト) + timingSafeEqual(タイミング攻撃を封じるため)で検証されます。

🧪 私が捕まえたこと(QA)

「サインアップを押しても、うまくいったのかどうかすら分からなかった — それに、メール/ニックネームの重複チェックもないよね?」

これはローンチ後の実利用の中で見つけました。サインアップのバリデーション(メール/ニックネームの重複チェック)が抜けていて — Claude が原因を突き止め、すぐにパッチしてくれました。


2. 拡張機能のコア — プレイヤー制御、セッション、Service Worker

このステージは、画面上で目に見えるボタンの話ではありませんでした。拡張機能をそもそも成立させるための、目に見えない配管の話です。YouTube Music の非公開プレイヤーを動かすブリッジ、ソケットと時計を結びつけるリアルタイムセッション、そしてそのセッションを一箇所で保持する Service Worker。私はこういう言葉すら知りませんでした。ただ「こんなふうに動いてほしい」と言っただけで、構造はいつも Claude が決めてくれました。


ツールバーアイコンからも操作できるように(=アーキテクチャのリファクタリング)

🗣️ 私がお願いしたこと

「ページの中だけじゃなくて、上の拡張機能アイコンからも操作できるようにしたい」

(「Service Worker の中にセッションを単一で持つ」という構造は私が頼んだものではなく、「アイコンから、どこからでも操作」を実際に成立させる必要が出てきたときに Claude が導き出した ADR 004 の設計です。)

🤖 Claude がやったこと 正直に言うと、この一行が途中で構造の全面的な作り直しを強制しました。最初はソケットとルームの状態がページ内のパネルの中にありました。ところがポップアップ(ツールバーアイコン)が同じセッションにアタッチする必要が出てきた瞬間、これは成り立たなくなります。ポップアップは閉じた瞬間に破棄されるので、曲の同期が死んでしまうのです。Claude はこれを ADR 004 として再設計しました。肝になった判断は、セッション(ソケット+同期ループ+全状態)を一つだけ、Service Worker の中にホストすること、そしてポップアップとページ内パネルの両方を、そのセッションにポート越しでアタッチする薄いビューにすることでした。ポップアップ側はメソッド呼び出しを Service Worker に転送するリレー(SessionProxy)を使い、ページ側は再生エージェント(RemoteAdapter)になって、Worker が送ってくる再生コマンドをそのまま本物のプレイヤーへ流し込みます。どちらも同じ SessionLike インターフェースを実装しているので、セッションのコードは一行も変えずに二箇所へ差し込めます。おまけに、Service Worker には localStorage がないので、既存のトークン/ルーム読み取りコードを生かすために、Claude は chrome.storage.local(キー "lt")からメモリキャッシュを事前に埋め、その上に同期的な localStorage のポリフィルを被せました。

もしこれを作り直すなら、その回り道を体験する必要はありません。最初の日から「セッションは Service Worker に単一でホスト+ビューはポート越しでアタッチする薄いクライアント」から始めればいいだけです。 不変条件はこうです。セッションのインスタンスはちょうど一つ。ポップアップ/パネルは状態を直接持たず、ポート越しに観測するだけ。そして Service Worker のコールドスタートを生き延びるために、ポートのハンドラは何かをする前に session-ready の promise を await する。


YouTube Music プレイヤーの制御(★ これは推測しようがない)

🗣️ 私がお願いしたこと

これは一度も頼んでいません。「再生/一時停止/シークの操作をくれ」といった仕様を渡したことはなく、「友達と同じ曲を同じ秒で聴く」という上位の目標を実際に成立させるために、Claude が必然的にプレイヤーを動かさざるを得なかった部分です。

🤖 Claude がやったこと 再生制御は、document.getElementById("movie_player") が返すオブジェクトの非公開メソッド(playVideopauseVideoseekToloadVideoByIdgetVideoData など)を通してしか動きません。問題は、content script はデフォルトで「隔離された世界(isolated world)」で動くので、そのページ上のオブジェクトに触れないことです。そこで Claude は、manifest で world: "MAIN" を宣言した2つ目のスクリプトをインジェクトしました。これがページ自身のコンテキストで動く唯一のコードです。isolated world と MAIN world は厳密に window.postMessage だけでやり取りします。MAIN world 側は2方向に動きます。(1) {__lt:"cmd"} を受け取ってプレイヤーを動かし、(2) 1秒に1回、現在のトラック/再生位置/長さ/再生状態を読み取って {__lt:"state"} として isolated world へ送り返します。getPlayerState() のマッピング(1 = 再生中、2 = 一時停止、0 = 終了)が、曲が終わったと判断する根拠です。これらの非公開 API 名やメッセージの形は、まさに Claude が推測したらハルシネーションを起こす類のものなので、本物のコードとして残します。

ts
/** * MAIN world script (runs in the page context). * The content script (isolated world) cannot call YT Music's internal `movie_player` API. * Here we control/read the state of movie_player and communicate with the isolated world via postMessage. * * isolated → MAIN : { __lt:"cmd", cmd:"loadTrack|play|pause|seek", arg } * MAIN → isolated : { __lt:"state", state:{ trackId,title,artist,positionSec,playing } } */ interface MoviePlayer { loadVideoById(id: string): void; playVideo(): void; pauseVideo(): void; seekTo(seconds: number, allowSeekAhead?: boolean): void; getCurrentTime(): number; getDuration(): number; getPlayerState(): number; // 1=playing, 2=paused, 3=buffering, 5=cued, 0=ended getVideoData(): { video_id: string; title: string; author: string }; } function player(): MoviePlayer | null { return document.getElementById("movie_player") as unknown as MoviePlayer | null; } window.addEventListener("message", (e: MessageEvent) => { const d = e.data; if (e.source !== window || !d || d.__lt !== "cmd") return; const p = player(); if (!p) return; try { if (d.cmd === "loadTrack") p.loadVideoById(String(d.arg)); else if (d.cmd === "play") p.playVideo(); else if (d.cmd === "pause") p.pauseVideo(); else if (d.cmd === "seek") p.seekTo(Number(d.arg), true); } catch { /* player not ready yet, etc. — ignore */ } }); setInterval(() => { const p = player(); if (!p || typeof p.getVideoData !== "function") return; let data: { video_id: string; title: string; author: string }; try { data = p.getVideoData(); } catch { return; } if (!data?.video_id) return; window.postMessage( { __lt: "state", state: { trackId: data.video_id, title: data.title ?? "", artist: data.author ?? "", positionSec: p.getCurrentTime?.() ?? 0, durationSec: p.getDuration?.() ?? 0, playing: p.getPlayerState?.() === 1, ended: p.getPlayerState?.() === 0, }, }, "*", ); }, 1000);

そのうえで、この YT Music への依存を一つのアダプタの後ろに閉じ込めました。MAIN world が1秒ごとに送ってくる状態をキャッシュするので、getState() は同期的かつ即座に返せますし、再生/一時停止/シーク/loadTrack は postMessage 経由でコマンドを押し出します。★ 一つ決定的な罠があります。このモジュールは(セッションエンジン経由で)Service Worker のバンドルにも取り込まれるので、window へのアクセスをモジュールのトップレベルに置いては絶対にいけません。すべて createYtMusicAdapter() ファクトリの中に入れてください。 さもないと、ロードされた瞬間に Service Worker が死にます。

ts
/** *Adapter that isolates the YT Music dependency. * Actual playback control/state goes through the movie_player API in the MAIN world (main-world/index.ts). * This file, living in the isolated world, only communicates with the MAIN world via postMessage. * * - Reading state: cache the "state" message MAIN sends every second → getState() returns the cache (synchronous) * - Control: loadTrack/play/pause/seek → "cmd" message to MAIN */ export interface PlaybackState { trackId: string; title: string; artist: string; positionSec: number; durationSec: number; playing: boolean; ended: boolean; } export interface YtMusicAdapter { getState(): PlaybackState | null; play(): void; pause(): void; seek(sec: number): void; loadTrack(trackId: string): Promise<void>; onStateChange(cb: () => void): () => void; } // ⚠️ All window access (listeners/postMessage) must stay inside createYtMusicAdapter(). // Since this module is also included in the service worker bundle (via LtSession), touching window at the module top level breaks the SW load. (ADR 004) export function createYtMusicAdapter(): YtMusicAdapter { let latest: PlaybackState | null = null; const subscribers = new Set<() => void>(); window.addEventListener("message", (e: MessageEvent) => { const d = e.data; if (e.source !== window || !d || d.__lt !== "state") return; latest = d.state as PlaybackState; for (const fn of subscribers) fn(); }); const sendCmd = (cmd: string, arg?: unknown): void => { window.postMessage({ __lt: "cmd", cmd, arg }, "*"); }; return { getState: () => latest, play: () => sendCmd("play"), pause: () => sendCmd("pause"), seek: (sec: number) => sendCmd("seek", sec), loadTrack: async (trackId: string) => sendCmd("loadTrack", trackId), onStateChange: (cb: () => void) => { subscribers.add(cb); return () => subscribers.delete(cb); }, }; }

「このページの曲を追加」を復活させる

🗣️ 私がお願いしたこと

「YouTube Music にあるみたいなボタンが必要。上の拡張機能アイコンをクリックしたときは『このページの曲を追加』があっちゃダメ。でも YouTube Music の中のボタンをクリックしたときは、同じウィンドウに『このページの曲を追加』があってほしい」

🤖 Claude がやったこと これは2つのピースに分かれました。まず、ページに現在描画されているトラックの行を拾うスクレイパー。★ 罠は、YT Music が仮想スクロールを使っていることです。画面に映っている行だけが実際に DOM に存在するので、まず一番上までスクロールし、それから1画面分ずつ自動スクロールしながら Map に積み上げ、件数が増えなくなって一番下に到達したら(4回連続で件数が安定したら)止まります。これは意図的にベストエフォートな収集です。2つ目は、曲の「⋮」コンテキストメニューに「Hummo のキューに追加」項目を差し込むインジェクタ。MutationObserver が Polymer のメニュー(tp-yt-paper-listbox#items)が現れるたびに項目を滑り込ませ、videoId はメニュー内のリンクから取ります。タイトルとアーティストはメニュー自体には入っていないので、キャプチャフェーズで pointerdown を横取りし、あなたが ⋮ をクリックした元の行を覚えておいて、そこから読み取ります。ルームがなければ項目はグレーアウト(disabled)で表示され、「まずルームを作るか参加してください」と促します。これらのセレクタは生きた YT Music の DOM に密結合していて推測不可能なので、本物のコードとして残します。

ts
/** * Scrapes the track rows rendered on the current YT Music page (playlist/album, etc.). * ⚠️ YT Music uses virtual scrolling → only the rows loaded on screen are captured. Long lists must be scrolled to load more. * The full list (including unrendered rows) requires the InnerTube API — follow-up (M03). */ import type { QueueItem } from "@lt/shared"; export function scrapePageTracks(by: string): QueueItem[] { const rows = document.querySelectorAll("ytmusic-responsive-list-item-renderer"); const items: QueueItem[] = []; const seen = new Set<string>(); rows.forEach((row) => { const link = row.querySelector<HTMLAnchorElement>('a[href*="watch?v="]'); const id = link?.getAttribute("href")?.match(/[?&]v=([^&]+)/)?.[1]; if (!id || seen.has(id)) return; seen.add(id); const title = row.querySelector(".title")?.textContent?.trim() ?? ""; const artist = row.querySelector(".secondary-flex-columns yt-formatted-string")?.textContent?.trim() ?? ""; const thumb = row.querySelector("img")?.getAttribute("src") ?? ""; items.push({ trackId: id, title, artist, by, thumb }); }); return items; } const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); /** Best-guess for the YT Music main scroll container (the element the virtual list scrolls within) */ function findScroller(): HTMLElement { const cands = [ document.querySelector<HTMLElement>("ytmusic-app-layout #contentContainer"), document.querySelector<HTMLElement>("#contentContainer"), document.scrollingElement as HTMLElement | null, document.documentElement, ].filter(Boolean) as HTMLElement[]; return cands.find((el) => el.scrollHeight > el.clientHeight + 50) ?? document.documentElement; } /** * Auto-scrolls the page and collects all tracks (accumulating rows before the virtual scroll unmounts them). * It's not an exact "full" list (InnerTube is the proper way), but it captures most of even long lists. */ export async function scrapeAllPageTracks(by: string, onProgress?: (n: number) => void): Promise<QueueItem[]> { const map = new Map<string, QueueItem>(); const collect = () => { for (const it of scrapePageTracks(by)) if (!map.has(it.trackId)) map.set(it.trackId, it); }; const el = findScroller(); // Start from the top in case of repeated runs (if a previous scrape left us at the bottom, the virtual list renders only some rows → too few / 0 captured) el.scrollTop = 0; window.scrollTo(0, 0); await sleep(250); let stable = 0; for (let i = 0; i < 300 && stable < 4; i++) { const before = map.size; collect(); onProgress?.(map.size); const top = el.scrollTop; const step = Math.max(700, el.clientHeight * 0.85); el.scrollTop = top + step; window.scrollBy(0, step); await sleep(300); collect(); const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8 || el.scrollTop === top; stable = map.size === before && atBottom ? stable + 1 : 0; } return [...map.values()]; }
ts
/** * Injects an "Add to Hummo queue" item into the YT Music "⋮" context menu. * * Based on the actual DOM (verified on live YT Music, 2026-06-16): * menu = <tp-yt-paper-listbox id="items" class="...ytmusic-menu-popup-renderer"> * items = <tp-yt-paper-item> / <ytmusic-menu-service-item-renderer>, etc. * videoId = extracted from the menu's 'a[href*="watch?v="]' (e.g. "Start radio") * * ⚠️ title/artist aren't directly in the menu, so we read them best-effort from the * original list row that the ⋮ was clicked on. */ export interface MenuSong { trackId: string; title: string; artist: string; thumb: string; } const LISTBOX = "tp-yt-paper-listbox#items.ytmusic-menu-popup-renderer"; const ITEM_FLAG = "data-lt-item"; interface InjectorOpts { hasRoom: () => boolean; onAdd: (song: MenuSong) => void; } export function installMenuInjector(opts: InjectorOpts): () => void { // Remember the original row the ⋮ was clicked on (source of title/artist). Captured in the capture phase. let lastRow: Element | null = null; const onPointerDown = (e: Event) => { const t = e.target as Element | null; const row = t?.closest?.( "ytmusic-responsive-list-item-renderer,ytmusic-list-item-renderer,ytmusic-player-queue-item,ytmusic-two-row-item-renderer", ); if (row) lastRow = row; }; document.addEventListener("pointerdown", onPointerDown, true); const observer = new MutationObserver(() => { document.querySelectorAll<HTMLElement>(LISTBOX).forEach((box) => injectInto(box, opts, () => lastRow)); }); observer.observe(document.documentElement, { childList: true, subtree: true }); return () => { document.removeEventListener("pointerdown", onPointerDown, true); observer.disconnect(); }; } function injectInto(listbox: HTMLElement, opts: InjectorOpts, getRow: () => Element | null): void { if (listbox.querySelector(`[${ITEM_FLAG}]`)) return; // already injected const song = extractSong(listbox, getRow()); const enabled = opts.hasRoom() && !!song; const item = document.createElement("tp-yt-paper-item"); item.setAttribute(ITEM_FLAG, "1"); item.setAttribute("role", "menuitem"); item.setAttribute("tabindex", "-1"); item.className = "style-scope ytmusic-menu-popup-renderer"; item.style.cssText = "display:flex;align-items:center;gap:16px;padding:0 16px;min-height:40px;box-sizing:border-box;" + `cursor:${enabled ? "pointer" : "default"};opacity:${enabled ? "1" : "0.4"}`; item.innerHTML = `<div style="width:18px;height:18px;flex-shrink:0;display:flex;align-items:center;justify-content:center">🎵</div>` + `<span style="font-size:14px;white-space:normal;overflow-wrap:break-word;min-width:0">${ enabled ? "Add to Hummo queue" : "Create or join a room first" }</span>`; if (enabled && song) { item.addEventListener("click", (e) => { e.stopPropagation(); opts.onAdd(song); // Close the menu (esc) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); } listbox.insertBefore(item, listbox.firstChild); } function extractSong(listbox: HTMLElement, row: Element | null): MenuSong | null { const link = listbox.querySelector<HTMLAnchorElement>('a[href*="watch?v="]'); const href = link?.getAttribute("href") ?? ""; const trackId = href.match(/[?&]v=([^&]+)/)?.[1] ?? ""; if (!trackId) return null; // title/artist are best-effort from the original row (TODO: refine selectors against live) const title = row?.querySelector(".title")?.textContent?.trim() ?? row?.querySelector("yt-formatted-string.title")?.textContent?.trim() ?? ""; const artist = row?.querySelector(".secondary-flex-columns yt-formatted-string")?.textContent?.trim() ?? row?.querySelector(".subtitle")?.textContent?.trim() ?? ""; const thumb = row?.querySelector("img")?.getAttribute("src") ?? ""; return { trackId, title, artist, thumb }; }

🧪 私が見つけたこと(QA)

「『このページの曲を追加』が、0曲収集のまま止まってる」

原因:スクレイプ要求が Service Worker を経由した往復(agent → SW → agent)になっていて、タイムアウトに引っかかり、結果が返ってこなかったこと。修正:ページ内パネルはどのみち DOM にアクセスできるので、Service Worker を経由せず、scrapeAllPageTracks をページ内でローカルに直接実行するように変えました。

「Uncaught Error: Extension context invalidated が出る」

原因:拡張機能をリロードすると、古い content script が陳腐化した状態のまま残り、死んだ chrome.runtime へ再接続を試み続けること。修正:初回接続と再接続の両方の経路に if (!chrome.runtime?.id) return のガードを追加し、コンテキストが無効化されたら再接続をやめてエラーの連発を止めました(タブを更新すれば新しいスクリプトがアタッチされます)。


3. UI と言語

ここはユーザーが実際に目にするものを全部作った区間です。画面は2箇所に出さないといけません。ポップアップ(ツールバーアイコン)と、YouTube Music 内のページ内パネル。しかもこの2つは見た目が完全に同じである必要がありました。なので画面のコードは一つきりで、その上に韓国語/日本語/英語の辞書を被せています。ここで私が決めたことはほとんど何を見せるかの話でした。ハートと並び替え、一方向のフォロー通知、自分の言語のルームだけが出る browse タブ、3人まで無料、そういうプロダクト判断です。そして最後の最後に、サインアップフォームがあまりに無反応だったので、即時バリデーションとローディング状態を入れてもらいました。


一つの画面、二つの住まい:ポップアップ+ページ内

🗣️ 私がお願いしたこと

これは頼んだものではありません。私が欲しかったのは「友達やチームメイトと YouTube Music を同期して聴ける拡張機能」だけで、同じ画面が2箇所(ポップアップとページ内)に描画されて、なおかつ見た目が同じでなきゃいけない、というのはその目標を実装する過程で Claude が下した設計判断です。

🤖 Claude がやったこと 画面を2回作る代わりに、Claude はそれを一つのレンダー関数に統一しました。一つの mountApp(root, session, opts) がポップアップとページ内パネルの両方を描き、分岐するのは opts.mode"popup""page" かだけです。肝になった判断が2つ。(1) この関数はサーバー接続を自分では持ちません — セッションは外から注入されます。なのでポップアップは Service Worker のセッションをメッセージ越しに中継するプロキシを渡し、ページ内版は自前のセッションを渡し、同じ画面が両側で描画されます。(2) モードの差はちょうど2箇所にしかありません — 「このページの曲を追加」ボタン(一括スクレイパー)はページ内(mode === "page")でしか出ませんし、再レンダーが必要なときは、ポップアップは単にウィンドウをリロードする一方、ページ内版は YouTube Music の DOM には一切触れずにパネルだけを再マウントします。スタイリングは、外部 CSS を使わず一つの大きなインライン <style> ブロックに詰め込んだダークテーマ — 背景 #0b0b0e、テキスト #f1f1f3、アクセントは Hummo オレンジ #ff5722、パネル幅 312px。ポップアップはその同じ画面を取って、ただ上書きするだけです — フローティングのバブルを隠し、パネルをウィンドウいっぱいに広げる。ファイルは1,000行を超えていますが、私は意図的に分割しませんでした。どの画面も一つのクロージャの中で同じ状態変数(screen、各タブ、認証モードなど)と同じテンプレートを共有しなければならないからです。


ハート&並び替え/一方向フォロー+バッジ通知/言語で隔離された browse

🗣️ 私がお願いしたこと

「ルームごとのおすすめは? ハートを押せるみたいなのが欲しい。それで人気順、人数順、みたいに並び替えられる。チャットがあるんだから、アラートもあった方がいい? お気に入りにしたユーザーがルームを作ったら通知が来る、みたいな」 「browse も言語設定で挙動が変わる方がいいと思う。自分の言語に設定したユーザーが作ったルームだけが browse に出るようにしてほしい」

(一方向フォローを相互フレンドから分けることは、私が明示したわけではありません。「フォローしているユーザーがルームを作ったら通知する」を実装する過程で Claude が行き着いたモデルです。)

🤖 Claude がやったこと Claude はメイン画面を3つのタブ(Friends / Following / Browse)に分けました。


仕上げのパス

🗣️ 私がお願いしたこと

「ボタンとかクリックできるものが、なんかしっくりこない — アニメーションが全然ない。ちゃんと一通り見てくれる?」 「今はシャッフルが押すと適用されるだけ — もう一回押したら元に戻るようにできない?」 「ここにニックネーム設定とアカウントのログアウトがあっても意味ない。それにニックネーム保存を押しても『保存しました!』とかが何もないから、効いたのかどうかすら分からない。無料は3人までにして」

🤖 Claude がやったこと 修正は3つ。(1) 無反応に見えたインタラクションに、インライン <style> で hover/active のトランジションを足して、クリックがちゃんと手応えのあるものになるようにしました — CSS だけで、新しいコンポーネントはなし。(2) シャッフルをトグルにしました — 最初に押すと元のキューの順序を脇に退避させ、もう一度押すとその退避から復元します。狙いは「元に戻す」であって、毎回シャッフルし直すことではなかったので、元の順序を保持しておく必要がありました。(3) ルーム設定画面を整理しました:ニックネーム/ログアウトをそこから外し(メイン設定へ移動)、無料の収容人数を FREE_ROOM_CAPACITY = 3 に固定しました — 4人目からは参加/作成が決済ゲートに当たり、「無料ルームは3人まで」といったメッセージが出ます。この 3 はちょうど一箇所だけで定義されています:サーバーと UI が同じ定数を import します。


サインアップの即時バリデーション+ローディング(v0.1.1)

🤖 Claude がやったこと これは、サインアップ/ログインフォームがサーバーとの往復を待つ間、フィードバックがゼロだったことへのパッチです。2つを入れました。

🧪 私が見つけたこと(QA)

「サインアップを押しても通ったのかどうか分からないし、メール/ニックネームのチェックもなさそうなんだけど?」(ローンチ後、実際に使っていて発見)

原因は、フォームがバリデーションもローディングもなく、無言で何もかもサーバーに投げていたこと — ユーザーは自分がクリックしたことすら分からなかったわけです。上記の即時バリデーション+ローディング状態でパッチしました(v0.1.1)。


共有 i18n — 一つの辞書、二つのコンテキスト

🤖 Claude がやったこと en/ko/ja の辞書と t("key", { n }) の補間だけしかない軽量な i18n レイヤーです。外部ライブラリはなし — キー→文字列の辞書と {param} の置換だけで、UI は文字列を必ずこの t() を通してしか取りません。サーバーはエラーをコードとしてだけ投げ(err_room_fullerr_email_taken など)、UI がそのコードをローカライズします — だからサーバーのエラー文言も全部この辞書の中に入っているのです。このレイヤーが存在する本当の理由は、ロケールがどこに保存されるかにあります。 普通の localStorage はオリジンごとに分割されているので、ポップアップ(拡張機能のオリジン)とページ内パネル(youtube.com のオリジン)は別々のストアを読みます — まさにこれが言語が割れた原因でした。ロケールを chrome.storage.local(キー lt:locale に置けば、拡張機能内のあらゆるコンテキストが一つの値を共有します。マウントの前にその共有値を await して両側が同じ言語で立ち上がるようにし、片側で言語を変えると chrome.storage の change イベントがもう片側を即座に更新します(ポップアップはリロード、ページ内は再マウント)。デフォルトはブラウザの言語から検出し、辞書になければ英語へフォールバック、そして古い localStorage の値はちょうど一度だけ移行されます。

🧪 私が見つけたこと(QA)

「YouTube Music の言語と、ツールバーのポップアップの言語が、違うみたいなんだけど?」

原因はロケールを localStorage に保存していたこと — オリジンが違うので、ポップアップとページ内パネルが別々のストアを読んでいました。ストアを chrome.storage.local に移して両コンテキストが一つの値を共有するようにして直しました。

「ルームを作ると、いきなりメイン画面に飛ぶんだけど — なんで?」

原因は、現在の画面(screen)をセッション状態から導出せずに別途保持していたこと — セッションのビューが更新されるたびに、画面が初期状態に戻されていました。screen をセッション状態から導出されるように変えて、ルームを作ってもそのルームの画面に留まるようにしました。


4. インフラ — Claude が lavela を勧めてくれた

このパートで扱ったのはたった一つ、リアルタイムサーバーをどこに置くか、そして決済をどう組み込むかです。ビルド全体で一番怖かった部分でした。WebSocket サーバーをどこで動かすのか、TLS をどう扱うのか、そもそもデプロイのパイプラインとは何なのか — 何も分かっていませんでした。それなのに、ここで自分が実際にやったことはたった二つだけです。コンソールにサインアップし、MCP サーバーを登録するために 1 行貼り付ける。残りは Claude がすべて自前で立ち上げてくれました。コンピュートは月数ドルに収まり、しかも一度も DevOps に触れていません。

インフラを立ち上げる — lavela 接続からコンピュート、ホスティング、決済まで

🗣️ 私が頼んだこと

「よし、決済をもう一回つなぎ直そう。」 / 「リアルタイムサーバーも立ち上げよう。」

頼みごとはこれだけ — 「決済を追加して、リアルタイムサーバーを立ち上げて、しかも簡単に」。正確なインフラ仕様なんて一切ありません。サーバーをどこでどう動かすのか皆目見当もつかず、ただ簡単であってほしかっただけです。そこで Claude が lavela を勧めてくれました。 だから以下のすべて — コンピュートのティア選定、scale-to-zero、optimistic confirm — どれも私が指示したものではありません。「とにかく簡単に立ち上げる」を実際に成立させるために、Claude が組み立てていった道筋です。

🤖 Claude がやったこと

Claude が勧めてくれたのは lavela でした。中核となる発想は*「DevOps をまるごと置き換える」*こと — 自分でサーバーを動かす代わりに、lavela を MCP サーバーとして Claude Code につなぐと、そこから先は Claude がツールコールを通じてコンピュート・ホスティング・決済を直接さばいてくれます。

なので私がやったのは二つだけです。lavela コンソール(https://console.lavela.dev)でサインアップし、ターミナルで 1 行実行する。

bash
npx @lavela/cli connect

実行するとブラウザが開いて lavela に飛び → ログイン(済みならスキップ)→ **「Authorize & connect」**をクリック。すると CLI がトークンを Claude Code の設定へ直接書き込みます — トークンはブラウザの URL にも履歴にもチャットにも一切現れません(セキュリティ上、ここが肝です)。Claude Code を新しく開き直すと lavela が自動でロードされ(すでに開いているなら /mcp → Reconnect)、あとは一言*「lavela で俺のアプリをデプロイして」*で済みます。

そもそもなぜ CLI なのか? Claude Code 組み込みの /mcp Authenticate フローは macOS の localhost/IPv6 コールバックのバグを踏んでしまうため、CLI が自前の 127.0.0.1 ループバックでそれを回避しているからです。

その接続が生きた瞬間、mcp__lavela__* ツールが Claude の中からそのまま呼べるようになり、そこから先は Claude が全部自分でセットアップしてくれました。

Claude がたどった道筋と、実際に呼んだコールはこうです。

Dockerfile そのものは Claude が書いたスリムなコンテナでした。ビルドコンテキストをリポジトリのルートに設定し、@lt/server とそのワークスペース依存(@lt/shared)だけをインストール、ビルドステップを省いて tsx で直接走らせるという構成です。(落とし穴: コンテキストを server/ に絞るとワークスペース依存が壊れます。)いずれにせよ、lavela がそれをイメージにビルドして出荷してくれました — 私はコマンドを 1 つ実行して承認しただけです。

ドメインはあえて買いませんでした。lavela はドメインを売っておらず、すでに自分が持っているものをつなぐだけ(provision_domain)で、デモには .lavela.dev のサブドメインで十分だったので、そのまま使いました。年間のドメインコスト: ゼロ。

🧪 私が捕まえたこと(QA)

「再デプロイしたのに、サイトがまだ古いバージョンを出してる。」

原因はブランドドメインのエイリアスが固着していたことでした。再デプロイ後も .lavela.dev古いデプロイを指したままで、新しいビルドを返すのは raw URL だけ。Claude は URL に ?cb=$(date +%s) を付けてキャッシュバストを試みても古いコンテンツが返ってきた → そこで CDN キャッシュを除外し、原因をエイリアスに絞り込み、list_deploymentscurrent(新しいハッシュ)と登録済みデプロイの url(古いハッシュ)が一致していないことを突き止めて裏付けました。これはクライアント側の再リクエストでは動かない類のもので — プラットフォーム側が最新デプロイを昇格させない限り解消しません — なので報告したら固着が解けました(以降は再デプロイのたびに ~30 秒で正常に反映されるように)。まだ若いプラットフォームなので、たまにこういう鋭利なエッジに当たります — とはいえ「自分でサーバーを運用する」に比べれば、足元にも及ばない手間です。


5. 名前、ロゴ、サイト、そして Chrome Web Store ローンチ

ここまでで何もかも動いてはいました — でも名前が事実上、競合の名前そのものでした。仮タイトルは「ListenTogether」で、これはずっとベンチマークにしてきたプロダクト(listentogether.now)と一字一句同じ。要するに全体がクローンにしか見えなかったんです。だからこの最後の区間は、新しい名前を決め、ロゴを作り、ランディング+法務ページとストア用アセットを生成し、Chrome Web Store へ提出して「審査中」まで持っていく作業でした。この数日はコードにはほとんど触れていません — すべてブランディング、アセット、コンプライアンスです。


リブランディング(名前とロゴ)

🗣️ 私が頼んだこと

「名前をパクりたくない。新しいのが欲しい — ロゴも新しく。」 「いっそ別の名前にしちゃう?」

私がやったのは「ベンチマークの名前をパクるのはやめて新しいのを考えよう、ロゴも」と投げただけ。候補 110 個、whois 照会、商標検索、複数ラウンドのプロセス — それは私が頼んだものではありません。 Claude が「他人がすでに使っている名前を実際に潰す」というゴールから導き出したものです。

🤖 Claude がやったこと Claude は名前を一発で決めようとはせず、ネーミングを並列エージェントの複数ラウンドとして走らせました。3 ラウンドで並列エージェントが ~110 個の候補を生成し、それぞれを三重フィルターにかけます。(1) ドメインの空き(whois/RDAP 照会)、(2) 商標検索、(3) グローバルな発音しやすさ(英語圏非英語圏の両方でどう読めるか)。ここが肝心な判断でした — 狙いは「綺麗な名前」を選ぶことではなく、すでに誰かが使っている名前を排除することです。実際に弾かれたもの — Tempa(有名なダブステップのレーベル)、Synca(60 カ国展開のマッサージチェアのブランド)、Curo(オーストラリアの看板会社+米国の金融会社)、Choira(すでに存在するリアルタイム音楽コラボアプリ)、Soneo(ポッドキャストアプリ)、Unira(すでにアプリストアにある医療アプリ)。ここでの教訓: 短くて綺麗な名前は .com/.app でほぼ全部取られていて、商標でもぶつかります。 生き残ったのが Hummo(hum = 最も普遍的で言語に依存しない「音楽を奏でる」やり方)。ロゴはオレンジのグラデーションタイル+白い 5 本バーのイコライザーで、イコライザーのバーが U の字にカーブして笑顔とサウンドウェーブの両方に見える — 「一緒に口ずさむ音が笑顔に変わる」というアイデアです。結局カスタムドメインは一切買わず、lavela のサブドメインをそのまま使いました。


サイトとアセット

🗣️ 私が頼んだこと

「綺麗なサイトを 1 つ、それと拡張機能のリスティング用の画像 — アイコン、スクリーンショット、プロモタイルも。」 「待って — 利用規約とか法務ページって多言語になってなくない? サイト全体を丁寧に見て、足りないものがないかチェックしてくれる?」

頼みごとはそのくらいゆるくて — 「綺麗なサイトとリスティング用の画像を作って」(あと、前からいいなと思っていたサイトを 1 つリファレンスとして Claude に渡しました)、それから一度見たあとに「規約が多言語になってなさそう、丁寧に見てくれる?」。言語ピッカー、非提携の注記、7 軸の監査、並列でのアセット生成 — こうした具体は、あの曖昧な「丁寧に見て」を Claude が解きほぐしたものであって、私が仕様を書いたわけではありません。

🤖 Claude がやったこと ランディングはダーク/アクセントのマーケティングページとして作られ(Claude はリファレンスとして見せたマーケティングサイトから構造を借りました)、3 つの法務ページ(privacy / terms / support)が付きました。一つ肝心な判断: 法務ページを多言語化したこと — 言語ピッカー付きの en/ko/ja です。理由は明快で — Chrome Web Store のレビュアーはおそらく英語話者で、法務ページが 1 言語に固定されていたら、それは即座に壁になります。さらにフッターに非提携の注記(「YouTube/Google とは提携していません」)を入れて、著作権や提携の誤解を先回りで防ぎました。ストアアセットはエージェントが並列で、同じデザインシステムの下で一気に生成 — 128×128 のアイコン、1280×800 のスクリーンショット 5 枚、440×280 の小サイズのプロモタイル、1400×560 のマーキー、1200×630 の OG 画像。SVG で設計し、PNG にレンダリング。提出直前には7 軸の監査(これも並列エージェント経由)を走らせ、法務、多言語カバレッジ、CTA、アクセシビリティ(WCAG)、SEO、非提携の注記といった軸を敵対的にチェックして、問題を自動的にあぶり出しました。

🧪 私が捕まえたこと(QA) 正直、この段階のバグは監査エージェントが私の代わりに捕まえてくれました。 監査は(ブロッカーとして)「ランディングの連絡先メールに MX レコードが無いのでメールがバウンスする」ことを指摘し、同時に「すべてのインストール CTA が死んだ #install アンカーを指している」こと、そして「3 つの法務ページが韓国語に固定されていて、英語話者のレビュアーには壁になる」ことも捕まえました → 全部修正。ストア用 PNG ももう一つ: sharp が RGBA(アルファチャンネル付き)で書き出していたのですが、Web Store は alpha なしの 24-bit を求めるので、リジェクトされる寸前で flatten して alpha を剥がしました — ただしアイコンだけは透明であるべきなので、それは flatten から除外しました。


Web Store への提出

🗣️ 私が頼んだこと

「とりあえずまず提出しよう。やり方を教えて。」

私が言ったのは「まず提出しよう、やり方を教えて」だけ。パーミッションを storage + alarms に絞り込むこと、ホストパーミッションを music.youtube.com の一つだけに狭めること、single purpose を「再生状態の同期だけ」に固定すること — それは私が頼んだものではありません。 「音声は一切送らず、状態だけを同期する」というこのアプリの最上位の設計から必然的に落ちてきたもので、Claude がそれをやり遂げただけです。

🤖 Claude がやったこと 提出の眼目は、リジェクト理由の上位 2 つ「パーミッションの過剰要求」と「著作権の疑い」を先回りで潰すことでした。なのでマニフェストのパーミッションを最小限に切り詰め — storagealarms だけ、そしてホストパーミッションは たった一つ music.youtube.com(<all_urls>tabsscripting のような広いパーミッションはすべて除外)。privacy タブでは single purpose を*「再生状態を同期するだけ。音声のストリーミング・録音・再配布は一切しない」*と明記して著作権の懸念を先に解除し、remote code は「No」(静的バンドルのみ、eval なし、外部スクリプトなし)。privacy URL は提出前に生きた 200 が返ることを確認しました。ビルドは pnpm --filter @lt/extension build から出して、zip にするときは manifest.json が zip のルートに来るようにしました(dist フォルダごと zip すると 1 階層深くネストしてリジェクトされます)。フォームのフィールドが多かったので、一言 — 「Claude、提出チェックリストを作って」 — で具体的な値を表で受け取り、そのまま埋めていきました。


ローンチ後

ローンチ自体は問題なく通り(ストア公開: chromewebstore.google.com/detail/hummo/naepcidlgdlddijiglpelaigimblcida)、ひやっとした瞬間がちょうど一回ありました。公開後、自分のブラウザの拡張機能が「追加のパーミッションが必要」というダイアログで止まったんです。血の気が引きましたが、これはアップデート時にホストパーミッションの再承認を求める一度きりのプロンプトで、新規インストールでは一切出ないものでした。アップデートを出すには manifest.version を上げて新しい zip をアップロードするだけで、新しいパーミッションを一切追加しない限り、既存ユーザーには再承認プロンプトなしで静かにアップデートされます。


おわりに — 私はエンジニアではなかった

このプロジェクトでの私の役割は明確でした。何を作るか決め、壊れているものを捕まえ(QA)、センスで舵を取る。 エンジニアリングは Claude がやりました。

だから「AI が俺のアプリを作った」は半分しか本当じゃありません。AI は実装を引き受けました。でも何を作るか、何が間違っているか、いつ止めるかは依然として私の側にありました。壁はもう実装じゃない — 判断とセンスです。そっちのほうが、はるかにマシな壁です。

今すぐ誰かと一緒に何かを聴きたくなったら、Hummo をインストールしてください。同じ曲を、同じ秒に。

そして — この記事まるごとを自分の Claude に貼り付けてみてください。気づいたら、自分だけの拡張機能が手元にできているかもしれません。

この記事にいいね

コメント

アカウント不要。コメントは公開されます。