diff --git a/.env.example b/.env.example index dd822bf8..26980f14 100644 --- a/.env.example +++ b/.env.example @@ -255,6 +255,23 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_OAUTH_REDIRECT_URI=https://your-host/api/youtube/auth/callback +# nixamp (nixamp.com) — OAuth 2.1, with nixamp as the authorization server and +# bittorrented.com as the client. Connecting one lets a watch party here become +# a room on nixamp, joinable from every nixamp surface. +# +# 1. nixamp ships knowing `bittorrented` and its redirect URIs, so in production +# nothing below needs setting: the defaults are https://nixamp.com and +# /api/v1/nixamp/oauth/callback. +# 2. For a staging host, register it on the nixamp side with NIXAMP_OAUTH_CLIENTS +# and set NIXAMP_OAUTH_REDIRECT_URI here to match it exactly — OAuth 2.1 +# matches redirect URIs byte for byte. +# 3. NIXAMP_CLIENT_SECRET is only for a confidential registration. Leave it empty: +# PKCE (which is mandatory either way) is what protects the exchange. +NIXAMP_SITE=https://nixamp.com +NIXAMP_CLIENT_ID=bittorrented +NIXAMP_CLIENT_SECRET= +NIXAMP_OAUTH_REDIRECT_URI=https://your-host/api/v1/nixamp/oauth/callback + # DHT Crawler (Bitmagnet + DHT Search API) # Uses existing SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_DB_PASSWORD # Set to false to skip DHT services installation during setup diff --git a/src/app/api/v1/nixamp/connection/route.ts b/src/app/api/v1/nixamp/connection/route.ts new file mode 100644 index 00000000..7e33f7cd --- /dev/null +++ b/src/app/api/v1/nixamp/connection/route.ts @@ -0,0 +1,41 @@ +/** + * GET /api/v1/nixamp/connection — is a nixamp account connected, and whose + * DELETE /api/v1/nixamp/connection — disconnect it, here and on nixamp + * + * What the settings page reads and what its Disconnect button calls. The + * answer never carries a token: the page needs to know that a connection + * exists and what to call the person, and nothing else. + */ + +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { disconnectNixampAccount, getNixampAccount, isNixampConfigured } from '@/lib/nixamp'; + +export async function GET(): Promise { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + + const account = await getNixampAccount(user.id); + return NextResponse.json({ + configured: isNixampConfigured(), + connected: Boolean(account), + ...(account + ? { + connection: { + // The public name on nixamp. Falling back to the id rather than + // the address, which is a credential over there. + handle: account.handle || account.nixampSub, + site: account.nixampSite, + scopes: account.scopes, + }, + } + : {}), + }); +} + +export async function DELETE(): Promise { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + const gone = await disconnectNixampAccount(user.id); + return NextResponse.json({ success: true, disconnected: gone }); +} diff --git a/src/app/api/v1/nixamp/oauth/callback/route.ts b/src/app/api/v1/nixamp/oauth/callback/route.ts new file mode 100644 index 00000000..76b0881a --- /dev/null +++ b/src/app/api/v1/nixamp/oauth/callback/route.ts @@ -0,0 +1,96 @@ +/** + * GET /api/v1/nixamp/oauth/callback + * + * Where nixamp sends the browser back. This is the exact path registered with + * nixamp, which matches it byte for byte, so it is not something to rename + * lightly: the OAuth 2.1 rule is exact matching, with only a loopback port + * allowed to vary. + * + * The state and the PKCE verifier come out of the httpOnly cookie set on the + * way out, and the cookie is cleared however this ends: a verifier is worth + * exactly one exchange. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + discover, + exchangeCodeForTokens, + fetchUserInfo, + getNixampOAuthConfig, + NIXAMP_OAUTH_STATE_COOKIE, + upsertNixampAccount, +} from '@/lib/nixamp'; + +const SETTINGS = '/settings?tab=connections'; + +function back(origin: string, params: Record): NextResponse { + const url = new URL(SETTINGS, origin); + for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value); + const res = NextResponse.redirect(url); + res.cookies.delete(NIXAMP_OAUTH_STATE_COOKIE); + return res; +} + +export async function GET(request: NextRequest): Promise { + // The public origin, from the configured redirect URI rather than + // request.url, which behind a proxy can be the internal bind address. + let origin: string; + let config; + try { + config = getNixampOAuthConfig(new URL(request.url).origin); + origin = new URL(config.redirectUri).origin; + } catch { + return back(new URL(request.url).origin, { nixamp_error: 'server_misconfigured' }); + } + + const user = await getCurrentUser(); + if (!user) return back(origin, { nixamp_error: 'not_authenticated' }); + + const { searchParams } = new URL(request.url); + const refused = searchParams.get('error'); + if (refused) return back(origin, { nixamp_error: refused }); + + const code = searchParams.get('code'); + const state = searchParams.get('state'); + if (!code || !state) return back(origin, { nixamp_error: 'missing_code_or_state' }); + + const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value; + let kept: { state?: string; verifier?: string } = {}; + try { + kept = cookie ? (JSON.parse(cookie) as typeof kept) : {}; + } catch { + kept = {}; + } + // Both halves, and the state compared rather than merely present: a + // callback whose state we did not issue is somebody replaying a URL. + if (!kept.state || !kept.verifier || kept.state !== state) { + return back(origin, { nixamp_error: 'state_mismatch' }); + } + + try { + const metadata = await discover(config); + const tokens = await exchangeCodeForTokens(config, metadata, code, kept.verifier); + const who = await fetchUserInfo(metadata, tokens.access_token); + + await upsertNixampAccount({ + userId: user.id, + nixampSub: who.sub, + nixampSite: config.site, + // The handle, never the address: on nixamp the account email is the + // linking key and is treated as a credential, and the handle is the + // name that is safe to show a room full of strangers. + ...(who.handle ? { handle: who.handle } : {}), + ...(who.email ? { email: who.email } : {}), + accessToken: tokens.access_token, + ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}), + expiresIn: tokens.expires_in, + scopes: tokens.scope ? tokens.scope.split(' ').filter(Boolean) : [], + }); + + return back(origin, { nixamp: 'connected' }); + } catch (err) { + console.error('[nixamp OAuth] callback failed:', err); + return back(origin, { nixamp_error: 'exchange_failed' }); + } +} diff --git a/src/app/api/v1/nixamp/oauth/start/route.ts b/src/app/api/v1/nixamp/oauth/start/route.ts new file mode 100644 index 00000000..3b34f3c3 --- /dev/null +++ b/src/app/api/v1/nixamp/oauth/start/route.ts @@ -0,0 +1,59 @@ +/** + * GET /api/v1/nixamp/oauth/start + * + * Begins the OAuth 2.1 flow that connects a nixamp account to this one. + * nixamp is the authorization server; we are the client. + * + * Two secrets go out in one httpOnly cookie and neither ever reaches the + * browser's JavaScript: the CSRF state, which proves the callback belongs to + * a flow we started, and the PKCE verifier, which proves the code exchange is + * being done by whoever started it. OAuth 2.1 requires the second of every + * client, and it is what makes a client secret unnecessary here. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + buildAuthUrl, + codeChallenge, + discover, + generateCodeVerifier, + generateState, + getNixampOAuthConfig, + NIXAMP_OAUTH_STATE_COOKIE, + NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS, +} from '@/lib/nixamp'; + +export async function GET(request: NextRequest): Promise { + const origin = new URL(request.url).origin; + const user = await getCurrentUser(); + if (!user) { + // Connecting is an act of an account: there has to be one to connect TO. + const back = new URL('/login', origin); + back.searchParams.set('redirect', '/settings?tab=connections'); + return NextResponse.redirect(back); + } + + let config; + try { + config = getNixampOAuthConfig(origin); + } catch (err) { + console.error('[nixamp OAuth] Missing config:', err); + return NextResponse.json({ error: 'The nixamp connection is not configured on this server.' }, { status: 500 }); + } + + const state = generateState(); + const verifier = generateCodeVerifier(); + const metadata = await discover(config); + const authUrl = buildAuthUrl(config, metadata, state, await codeChallenge(verifier)); + + const response = NextResponse.redirect(authUrl); + response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier }), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS, + }); + return response; +} diff --git a/src/app/api/watch-party/nixamp/route.test.ts b/src/app/api/watch-party/nixamp/route.test.ts new file mode 100644 index 00000000..4e97afa1 --- /dev/null +++ b/src/app/api/watch-party/nixamp/route.test.ts @@ -0,0 +1,152 @@ +/** + * The bridge endpoint: who may put a party on nixamp, and who may only read + * where the room is. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn() })); +const store = vi.hoisted(() => ({ getParty: vi.fn() })); +const nixamp = vi.hoisted(() => ({ + bridgeParty: vi.fn(), + endBridgedParty: vi.fn(), + getBridgedRoom: vi.fn(), + pushPlayback: vi.fn(), + NixampNotConnected: class NixampNotConnected extends Error {}, + NixampConnectionLost: class NixampConnectionLost extends Error {}, +})); + +vi.mock('@/lib/auth', () => auth); +vi.mock('../_store', () => store); +vi.mock('@/lib/nixamp', () => nixamp); +vi.mock('@/lib/watch-party', () => ({ + validatePartyCode: (code: string) => /^[A-Z0-9]{6}$/.test(code), +})); + +const { GET, POST } = await import('./route'); + +const room = { + partyCode: 'ABC123', + nixampSite: 'https://nixamp.test', + eventId: 'event-1', + roomId: 'event-abc', + slug: 'dune-together', + nixampUrl: 'https://nixamp.test/live/dune-together', +}; + +const party = { + code: 'ABC123', + hostId: 'user-1', + mediaTitle: 'Dune (2021)', + playback: { currentTime: 930, isPlaying: true, duration: 9000, lastUpdate: Date.now() }, +}; + +function post(body: unknown): NextRequest { + return new NextRequest('https://bittorrented.test/api/watch-party/nixamp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + auth.getCurrentUser.mockResolvedValue({ id: 'user-1', email: 'a@b.test' }); + store.getParty.mockReturnValue(party); + nixamp.getBridgedRoom.mockResolvedValue(null); + nixamp.bridgeParty.mockResolvedValue(room); + nixamp.pushPlayback.mockResolvedValue(true); + nixamp.endBridgedParty.mockResolvedValue(true); +}); + +describe('GET', () => { + it('tells anybody where the room is, without an account', async () => { + auth.getCurrentUser.mockResolvedValue(null); + nixamp.getBridgedRoom.mockResolvedValue(room); + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.bridged).toBe(true); + expect(body.room.nixampUrl).toBe(room.nixampUrl); + }); + + it('answers "not bridged" rather than an error, because most parties are not', async () => { + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123')); + expect(res.status).toBe(200); + expect((await res.json()).bridged).toBe(false); + }); + + it('will not take a code that is not one', async () => { + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=nope')); + expect(res.status).toBe(400); + }); +}); + +describe('POST', () => { + it('needs somebody signed in', async () => { + auth.getCurrentUser.mockResolvedValue(null); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(401); + expect(nixamp.bridgeParty).not.toHaveBeenCalled(); + }); + + it('refuses a member who is not the host of the party', async () => { + auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'c@d.test' }); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(403); + expect(nixamp.bridgeParty).not.toHaveBeenCalled(); + }); + + it('bridges for the host and answers the room link', async () => { + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.room.nixampUrl).toBe(room.nixampUrl); + expect(nixamp.bridgeParty).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', partyCode: 'ABC123', mediaTitle: 'Dune (2021)' }) + ); + }); + + it('sends nixamp somewhere real when a sync says nothing about where it is', async () => { + const res = await POST(post({ code: 'ABC123', action: 'sync' })); + expect(res.status).toBe(200); + // Falls back to the party's own recorded playback rather than 0, which + // would drag everybody on nixamp back to the start of the film. + expect(nixamp.pushPlayback).toHaveBeenCalledWith( + expect.objectContaining({ positionSeconds: 930, playing: true }) + ); + }); + + it('reports a sync that did not land without calling the party broken', async () => { + nixamp.pushPlayback.mockResolvedValue(false); + const res = await POST(post({ code: 'ABC123', action: 'sync', positionSeconds: 12, playing: false })); + expect(res.status).toBe(200); + expect((await res.json()).synced).toBe(false); + }); + + it('points at the connect flow when no nixamp account is connected', async () => { + nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampNotConnected()); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(409); + expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start'); + }); + + it('says to connect again when the grant has been withdrawn', async () => { + nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampConnectionLost()); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(409); + expect((await res.json()).error).toMatch(/connection has ended/i); + }); + + it('404s for a party this process has never heard of', async () => { + store.getParty.mockReturnValue(undefined); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(404); + }); + + it('will not take an action it does not have', async () => { + const res = await POST(post({ code: 'ABC123', action: 'delete-everything' })); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/watch-party/nixamp/route.ts b/src/app/api/watch-party/nixamp/route.ts new file mode 100644 index 00000000..692fe621 --- /dev/null +++ b/src/app/api/watch-party/nixamp/route.ts @@ -0,0 +1,150 @@ +/** + * The bridge between a watch party here and a room on nixamp. + * + * GET /api/watch-party/nixamp?code=ABC123 where the room is (public) + * POST /api/watch-party/nixamp bridge, sync or end (host only) + * + * The film stays here; the room goes there. Once a party is bridged, anybody + * on nixamp -- the web app, the terminal, the desktop app, a television, an + * agent over MCP -- can find it and be in the room with the people watching + * in this browser. + * + * GET is public on purpose: a member who was handed a code needs the room + * link, and that link is not a secret -- the room's own visibility on nixamp + * is what decides who may be in it. + * + * POST is the host, proven twice: the signed-in user must be the one whose + * nixamp account is connected, and must be the host of the party as the + * in-memory store has it. Neither alone is enough -- the store's hostId can + * be a guest string, and a connected nixamp account says nothing about which + * party is yours. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { validatePartyCode } from '@/lib/watch-party'; +import { + NixampConnectionLost, + NixampNotConnected, + bridgeParty, + endBridgedParty, + getBridgedRoom, + pushPlayback, +} from '@/lib/nixamp'; +import { getParty } from '../_store'; + +interface BridgeBody { + code?: string; + action?: 'bridge' | 'sync' | 'end'; + title?: string; + mediaTitle?: string; + positionSeconds?: number; + playing?: boolean; +} + +function cleanCode(value: string | null | undefined): string | null { + const code = (value ?? '').trim().toUpperCase(); + return code && validatePartyCode(code) ? code : null; +} + +export async function GET(request: NextRequest): Promise { + const code = cleanCode(new URL(request.url).searchParams.get('code')); + if (!code) return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + + const room = await getBridgedRoom(code); + if (!room) { + // Not an error: most parties are never bridged, and the page asks about + // every one it shows. + return NextResponse.json({ success: true, bridged: false }); + } + return NextResponse.json({ + success: true, + bridged: true, + room: { nixampUrl: room.nixampUrl, roomId: room.roomId, slug: room.slug, site: room.nixampSite }, + }); +} + +export async function POST(request: NextRequest): Promise { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json( + { error: 'Sign in, then connect a nixamp account, to put this party on nixamp.' }, + { status: 401 } + ); + } + + let body: BridgeBody; + try { + body = (await request.json()) as BridgeBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const code = cleanCode(body.code); + if (!code) return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + + const party = getParty(code); + if (!party) return NextResponse.json({ error: 'Party not found' }, { status: 404 }); + if (party.hostId !== user.id) { + return NextResponse.json({ error: 'Only the host can put this party on nixamp' }, { status: 403 }); + } + + const origin = new URL(request.url).origin; + const action = body.action ?? 'bridge'; + + try { + if (action === 'bridge') { + const room = await bridgeParty({ + userId: user.id, + partyCode: code, + title: body.title ?? party.mediaTitle ?? `Watch party ${code}`, + mediaTitle: body.mediaTitle ?? party.mediaTitle ?? '', + origin, + }); + return NextResponse.json({ + success: true, + bridged: true, + room: { nixampUrl: room.nixampUrl, roomId: room.roomId, slug: room.slug, site: room.nixampSite }, + }); + } + + if (action === 'sync') { + // Where the host's own player is. Falling back to the party's recorded + // playback so a client that only says "sync" still says something true. + const positionSeconds = + typeof body.positionSeconds === 'number' ? body.positionSeconds : party.playback.currentTime; + const playing = typeof body.playing === 'boolean' ? body.playing : party.playback.isPlaying; + const landed = await pushPlayback({ + userId: user.id, + partyCode: code, + positionSeconds, + playing, + ...(body.mediaTitle ?? party.mediaTitle ? { mediaTitle: body.mediaTitle ?? party.mediaTitle } : {}), + }); + // A sync that did not land is not a failure of the party: everybody in + // this browser is still together. It is only nixamp that is behind. + return NextResponse.json({ success: true, synced: landed }); + } + + if (action === 'end') { + return NextResponse.json({ success: true, ended: await endBridgedParty(user.id, code) }); + } + + return NextResponse.json({ error: 'action must be bridge, sync or end' }, { status: 400 }); + } catch (error) { + if (error instanceof NixampNotConnected) { + return NextResponse.json( + { error: 'Connect your nixamp account first.', connect: '/api/v1/nixamp/oauth/start' }, + { status: 409 } + ); + } + if (error instanceof NixampConnectionLost) { + return NextResponse.json( + { error: 'That nixamp connection has ended. Connect it again.', connect: '/api/v1/nixamp/oauth/start' }, + { status: 409 } + ); + } + console.error('[WatchParty] nixamp bridge failed:', error); + return NextResponse.json({ error: 'Could not reach nixamp' }, { status: 502 }); + } +} diff --git a/src/app/api/watch-party/route.ts b/src/app/api/watch-party/route.ts index b7907e06..319ab4aa 100644 --- a/src/app/api/watch-party/route.ts +++ b/src/app/api/watch-party/route.ts @@ -10,6 +10,7 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; import { createWatchParty, validatePartyCode, @@ -42,8 +43,15 @@ export async function POST(request: NextRequest): Promise { ); } - // Generate a guest ID if not provided (for anonymous users) - const hostId = body.hostId ?? `guest_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + // Whoever is signed in IS the host, ahead of anything the body claims. + // + // A party still needs no account -- an anonymous host gets a guest id as + // before -- but a signed-in one gets their real user id, and that is what + // makes the nixamp bridge possible: putting the party on nixamp has to be + // provably the host's doing, and a guest string proves nothing. + const user = await getCurrentUser(); + const hostId = + user?.id ?? body.hostId ?? `guest_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; // Create the party options const options: CreatePartyOptions = { diff --git a/src/app/settings/connections-section.tsx b/src/app/settings/connections-section.tsx new file mode 100644 index 00000000..f70df701 --- /dev/null +++ b/src/app/settings/connections-section.tsx @@ -0,0 +1,166 @@ +'use client'; + +/** + * Connected accounts — other places that act on your behalf, or you on theirs. + * + * Today that is nixamp: bittorrented.com is an OAuth 2.1 client of nixamp.com, + * so connecting one lets a watch party here become a room there, joinable from + * every nixamp surface. + * + * The name shown is the nixamp HANDLE, never the address. On nixamp the + * account email is the OAuth linking key and is treated as a credential; the + * handle is the name that is safe to show. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { CheckIcon, LinkIcon, LoadingSpinner, TrashIcon } from '@/components/ui/icons'; + +interface Connection { + handle: string; + site: string; + scopes: string[]; +} + +interface ConnectionResponse { + configured?: boolean; + connected?: boolean; + connection?: Connection; + error?: string; +} + +/** The words the callback puts in the URL, in words a person would use. */ +const REASONS: Record = { + access_denied: 'You said not now on nixamp. Nothing was connected.', + state_mismatch: 'That sign-in link had expired. Try connecting again.', + not_authenticated: 'You were signed out partway through. Sign in and try again.', + server_misconfigured: 'This server has no nixamp settings yet.', + exchange_failed: 'nixamp would not finish the connection. Try again.', + missing_code_or_state: 'nixamp sent an incomplete answer. Try again.', +}; + +export function ConnectionsSection(): React.ReactElement { + const searchParams = useSearchParams(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + try { + const res = await fetch('/api/v1/nixamp/connection'); + const body = (await res.json()) as ConnectionResponse; + return res.ok ? body : { connected: false, configured: false }; + } catch { + return { connected: false, configured: false }; + } + }, []); + + useEffect(() => { + let alive = true; + void (async () => { + const answer = await load(); + if (alive) setState(answer); + })(); + return () => { + alive = false; + }; + }, [load]); + + const disconnect = useCallback(async () => { + setBusy(true); + setError(null); + try { + const res = await fetch('/api/v1/nixamp/connection', { method: 'DELETE' }); + if (!res.ok) setError('Could not disconnect. Try again.'); + setState(await load()); + } finally { + setBusy(false); + } + }, [load]); + + const justConnected = searchParams.get('nixamp') === 'connected'; + const refusal = searchParams.get('nixamp_error'); + + if (state === null) { + return ( +
+ Checking your connections… +
+ ); + } + + return ( +
+
+

Connected accounts

+

+ Other places that can act for you here, or that you can act on from here. +

+
+ + {justConnected ? ( +

+ nixamp is connected. +

+ ) : null} + {refusal ? ( +

{REASONS[refusal] ?? `nixamp said: ${refusal}`}

+ ) : null} + +
+
+
+

+ nixamp +

+

+ {state.connected + ? 'A watch party here can be a room on nixamp, joinable from the nixamp app, a terminal, the desktop app or a TV.' + : 'Connect nixamp and a watch party here becomes a room people can join from any nixamp client.'} +

+ {state.connected && state.connection ? ( +

+ Connected as{' '} + @{state.connection.handle} on{' '} + {state.connection.site.replace(/^https?:\/\//, '')} + {state.connection.scopes.length > 0 ? ` · ${state.connection.scopes.join(', ')}` : ''} +

+ ) : null} +
+ + {state.connected ? ( + + ) : ( + // A link and not a fetch: connecting is a round trip through + // nixamp's own consent page, which is the only place the decision + // can honestly be made. + + {state.configured ? 'Connect nixamp' : 'Not configured'} + + )} +
+
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/src/app/settings/settings-content.tsx b/src/app/settings/settings-content.tsx index 76496097..ec2324b9 100644 --- a/src/app/settings/settings-content.tsx +++ b/src/app/settings/settings-content.tsx @@ -11,12 +11,13 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { MainLayout } from '@/components/layout'; import { cn } from '@/lib/utils'; -import { SettingsIcon, UserIcon, TvIcon, VideoIcon, TrashIcon, ExternalLinkIcon, LoadingSpinner, MailIcon } from '@/components/ui/icons'; +import { SettingsIcon, UserIcon, TvIcon, VideoIcon, TrashIcon, ExternalLinkIcon, LoadingSpinner, MailIcon, LinkIcon } from '@/components/ui/icons'; import { useAuth } from '@/hooks/use-auth'; import Link from 'next/link'; import { EmailAccountsSection } from './email-accounts-section'; +import { ConnectionsSection } from './connections-section'; -type SettingsTab = 'account' | 'playback' | 'iptv' | 'email'; +type SettingsTab = 'account' | 'playback' | 'iptv' | 'email' | 'connections'; /** * IPTV Playlist data from API @@ -56,11 +57,12 @@ export function SettingsContent(): React.ReactElement { { id: 'playback' as const, label: 'Playback', icon: VideoIcon }, { id: 'iptv' as const, label: 'IPTV', icon: TvIcon }, { id: 'email' as const, label: 'Email', icon: MailIcon }, + { id: 'connections' as const, label: 'Connections', icon: LinkIcon }, ]; const tabParam = searchParams.get('tab'); const activeTab: SettingsTab = - tabParam === 'email' || tabParam === 'playback' || tabParam === 'iptv' + tabParam === 'email' || tabParam === 'playback' || tabParam === 'iptv' || tabParam === 'connections' ? tabParam : 'account'; @@ -388,6 +390,8 @@ export function SettingsContent(): React.ReactElement { )} {activeTab === 'email' && } + + {activeTab === 'connections' && } diff --git a/src/app/watch-party/page.tsx b/src/app/watch-party/page.tsx index 417820d9..93228095 100644 --- a/src/app/watch-party/page.tsx +++ b/src/app/watch-party/page.tsx @@ -7,8 +7,9 @@ * Free for anyone without requiring login. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { MainLayout } from '@/components/layout'; +import { NixampPanel } from '@/components/watch-party'; import { cn } from '@/lib/utils'; import { PartyIcon, PlusIcon, UsersIcon } from '@/components/ui/icons'; @@ -65,6 +66,9 @@ export default function WatchPartyPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [, setIsMediaModalOpen] = useState(false); + // The host's own player, so its position can be told to nixamp. Everybody + // else follows; only the host states where the film is. + const videoRef = useRef(null); const handleCreateParty = useCallback(async () => { if (!hostName.trim()) { @@ -224,6 +228,7 @@ export default function WatchPartyPage(): React.ReactElement { )}> {party.mediaUrl ? (