diff --git a/public/app.js b/public/app.js
index 80f9f72..a9589ca 100644
--- a/public/app.js
+++ b/public/app.js
@@ -42,6 +42,7 @@ const state = {
seeds: [], // [{ tmdb_id, media_type, title }]
editMode: false, // editing an existing profile (vs. first-time onboarding)
mediaType: "movie",
+ platforms: new Set(), // selected streaming platforms (empty = any); creator-only
room: null,
soloMode: false,
deck: [],
@@ -321,6 +322,26 @@ function setupMediaToggle() {
});
}
+// Multi-select streaming platforms; empty set = "Any". Results include titles on ANY
+// picked platform. The "Any" chip (data-platform="") clears the selection.
+function syncPlatformChips() {
+ document.querySelectorAll("#platform-chips .chip").forEach((b) => {
+ const p = b.dataset.platform;
+ b.classList.toggle("on", p === "" ? state.platforms.size === 0 : state.platforms.has(p));
+ });
+}
+function setupPlatformChips() {
+ document.querySelectorAll("#platform-chips .chip").forEach((btn) => {
+ btn.onclick = () => {
+ const p = btn.dataset.platform;
+ if (p === "") state.platforms.clear(); // "Any" resets to no filter
+ else if (state.platforms.has(p)) state.platforms.delete(p);
+ else state.platforms.add(p);
+ syncPlatformChips();
+ };
+ });
+}
+
async function handleCreateRoom() {
const err = $("lobby-error");
err.textContent = "";
@@ -330,7 +351,7 @@ async function handleCreateRoom() {
body: JSON.stringify({
user_id: state.userId,
media_type: state.mediaType,
- platform_filter: $("platform-select").value || null,
+ platform_filter: [...state.platforms].join(",") || null, // CSV; empty = any
solo: $("solo-check").checked,
}),
});
@@ -444,10 +465,17 @@ async function maybeTopUp() {
}
}
+// The creator (user_a) owns the shared movie/tv choice; solo user is their own creator.
+function isRoomCreator() {
+ return !!state.room && state.userId === state.room.user_a_id;
+}
+
function syncMediaToggle() {
document.querySelectorAll("#swipe-media-toggle .toggle-opt").forEach((b) => {
b.classList.toggle("active", b.dataset.media === state.mediaType);
});
+ // Only the creator can switch type (shared pool); hide the control for the joiner.
+ $("swipe-media-toggle").classList.toggle("hidden", !isRoomCreator());
}
async function toggleMedia(media) {
@@ -1022,6 +1050,7 @@ function init() {
renderAvoidChips();
setupSeedSearch();
setupMediaToggle();
+ setupPlatformChips();
setupGestures();
$("onboarding-continue").onclick = handleOnboarding;
diff --git a/public/index.html b/public/index.html
index 276039c..197caea 100644
--- a/public/index.html
+++ b/public/index.html
@@ -65,15 +65,15 @@
Create a room
-
-
+
+
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index ee8f11a..e7c5a09 100644
--- a/public/style.css
+++ b/public/style.css
@@ -174,6 +174,11 @@ select.input {
border-color: var(--nope);
color: #fff;
}
+.chip.on {
+ background: linear-gradient(135deg, var(--accent), var(--accent-dim));
+ border-color: transparent;
+ color: #fff;
+}
.swipe-links {
display: flex;
justify-content: center;
diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts
index 9c2a4be..5bebd9a 100644
--- a/src/routes/rooms.ts
+++ b/src/routes/rooms.ts
@@ -310,6 +310,8 @@ rooms.get("/:id/ws", async (c) => {
});
// PATCH /api/rooms/:id — live movie/tv toggle: change media_type + reset the pool.
+// Creator-only: media_type is a shared, room-level setting (both users draw from ONE
+// pool), so only the room creator (user_a) decides it; the joiner inherits it.
// Body: { user_id, media_type }
rooms.patch("/:id", async (c) => {
const id = c.req.param("id");
@@ -327,8 +329,8 @@ rooms.patch("/:id", async (c) => {
if (!row) return c.json({ error: "room_not_found" }, 404);
const room = mapRoom(row);
- // AUTH GATING: only room members can change the type.
- if (userId !== room.user_a_id && userId !== room.user_b_id) {
+ // AUTH GATING: only the room creator (user_a) may change the shared media_type.
+ if (userId !== room.user_a_id) {
return c.json({ error: "forbidden" }, 403);
}
diff --git a/src/services/tmdb.ts b/src/services/tmdb.ts
index e58b191..7f4f3c1 100644
--- a/src/services/tmdb.ts
+++ b/src/services/tmdb.ts
@@ -101,6 +101,20 @@ const PROVIDER_IDS: Record = {
"apple tv": 350,
};
+/**
+ * Map a platform filter (single key or CSV like "netflix,prime") to TMDb provider ids,
+ * joined with "|" (OR) for with_watch_providers. Unknown/empty → null. Pure → unit-tested.
+ */
+export function platformProviderParam(platform?: string | null): string | null {
+ if (!platform) return null;
+ const ids = new Set();
+ for (const raw of platform.split(",")) {
+ const id = PROVIDER_IDS[raw.trim().toLowerCase()];
+ if (id) ids.add(id);
+ }
+ return ids.size > 0 ? [...ids].join("|") : null;
+}
+
function dateToYear(date?: string): number | null {
if (!date || date.length < 4) return null;
const y = Number(date.slice(0, 4));
@@ -156,9 +170,9 @@ export async function discoverTitles(env: Env, opts: DiscoverOptions): Promise {
it("genJoinCode is 6 chars from the safe alphabet", () => {
@@ -77,3 +78,21 @@ describe("rejectAvoidGenres (adaptive top-up filter)", () => {
expect(rejectAvoidGenres(cards, [])).toHaveLength(2);
});
});
+
+describe("platformProviderParam (multi-select platforms → TMDb providers)", () => {
+ it("maps a single platform to its provider id", () => {
+ expect(platformProviderParam("netflix")).toBe("8");
+ });
+ it("maps a CSV of platforms to OR-joined ids (netflix + prime)", () => {
+ expect(platformProviderParam("netflix,prime")).toBe("8|119");
+ });
+ it("treats empty / null as 'any' (no filter)", () => {
+ expect(platformProviderParam("")).toBeNull();
+ expect(platformProviderParam(null)).toBeNull();
+ expect(platformProviderParam(undefined)).toBeNull();
+ });
+ it("ignores unknown keys and dedupes, trimming/casing", () => {
+ expect(platformProviderParam(" Netflix , bogus ")).toBe("8");
+ expect(platformProviderParam("prime,amazon")).toBe("119"); // both → 119, deduped
+ });
+});