mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
Add database schema updates for user profile fields and streams, seed initial data, and extend components with streaming settings and profile fields.
This commit is contained in:
223
flow.toml
223
flow.toml
@@ -1935,3 +1935,226 @@ pnpm exec wrangler secret list 2>&1 | grep '"name"' | sed 's/.*"name": "\([^"]*\
|
|||||||
'''
|
'''
|
||||||
dependencies = ["pnpm"]
|
dependencies = ["pnpm"]
|
||||||
shortcuts = ["sec"]
|
shortcuts = ["sec"]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stream & Profile Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "update-profile"
|
||||||
|
interactive = true
|
||||||
|
description = "Update nikiv's profile in production database"
|
||||||
|
command = '''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
# Load env
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PROD_DATABASE_URL:-}" ]; then
|
||||||
|
echo "❌ PROD_DATABASE_URL not set in packages/web/.env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Update Profile ==="
|
||||||
|
echo ""
|
||||||
|
read -p "Enter username to update [nikiv]: " USERNAME
|
||||||
|
USERNAME="${USERNAME:-nikiv}"
|
||||||
|
|
||||||
|
read -p "Enter bio: " BIO
|
||||||
|
read -p "Enter website (e.g., nikiv.dev): " WEBSITE
|
||||||
|
read -p "Enter location (optional): " LOCATION
|
||||||
|
|
||||||
|
DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e "
|
||||||
|
const { neon } = require('@neondatabase/serverless');
|
||||||
|
const sql = neon(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const bio = process.argv[2] || null;
|
||||||
|
const website = process.argv[3] || null;
|
||||||
|
const location = process.argv[4] || null;
|
||||||
|
const username = process.argv[5];
|
||||||
|
|
||||||
|
const result = await sql\`
|
||||||
|
UPDATE users
|
||||||
|
SET bio = \${bio}, website = \${website}, location = \${location}, \"updatedAt\" = NOW()
|
||||||
|
WHERE username = \${username}
|
||||||
|
RETURNING id, name, username, bio, website, location
|
||||||
|
\`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log('User not found:', username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Profile updated:');
|
||||||
|
console.log(JSON.stringify(result[0], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
|
" "$BIO" "$WEBSITE" "$LOCATION" "$USERNAME"
|
||||||
|
'''
|
||||||
|
dependencies = ["node", "pnpm"]
|
||||||
|
shortcuts = ["profile"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "deploy-all"
|
||||||
|
description = "Push schema + deploy worker + deploy web"
|
||||||
|
command = '''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== Full Deploy ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
# Load env
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Push schema
|
||||||
|
if [ -n "${PROD_DATABASE_URL:-}" ]; then
|
||||||
|
echo "1/3 Pushing schema..."
|
||||||
|
DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5
|
||||||
|
echo "✓ Schema pushed"
|
||||||
|
else
|
||||||
|
echo "1/3 Skipping schema push (PROD_DATABASE_URL not set)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Deploy worker
|
||||||
|
echo ""
|
||||||
|
echo "2/3 Deploying worker..."
|
||||||
|
cd ../worker
|
||||||
|
pnpm deploy 2>&1 | tail -5
|
||||||
|
echo "✓ Worker deployed"
|
||||||
|
|
||||||
|
# 3. Deploy web
|
||||||
|
echo ""
|
||||||
|
echo "3/3 Deploying web..."
|
||||||
|
cd ../web
|
||||||
|
pnpm deploy 2>&1 | tail -10
|
||||||
|
echo "✓ Web deployed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deploy Complete ==="
|
||||||
|
'''
|
||||||
|
dependencies = ["node", "pnpm"]
|
||||||
|
shortcuts = ["da", "full"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "deploy-web"
|
||||||
|
description = "Deploy web to Cloudflare"
|
||||||
|
command = '''
|
||||||
|
cd packages/web
|
||||||
|
pnpm deploy
|
||||||
|
'''
|
||||||
|
dependencies = ["pnpm"]
|
||||||
|
shortcuts = ["dw"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "deploy-worker"
|
||||||
|
description = "Deploy worker to Cloudflare"
|
||||||
|
command = '''
|
||||||
|
cd packages/worker
|
||||||
|
pnpm deploy
|
||||||
|
'''
|
||||||
|
dependencies = ["pnpm"]
|
||||||
|
shortcuts = ["dwk"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "schema-push"
|
||||||
|
description = "Push Drizzle schema to production (quick)"
|
||||||
|
command = '''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PROD_DATABASE_URL:-}" ]; then
|
||||||
|
echo "❌ PROD_DATABASE_URL not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pushing schema to production..."
|
||||||
|
DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5
|
||||||
|
echo "✓ Done"
|
||||||
|
'''
|
||||||
|
dependencies = ["node", "pnpm"]
|
||||||
|
shortcuts = ["sp", "schema"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "stream-secret"
|
||||||
|
interactive = true
|
||||||
|
description = "Set Cloudflare stream secret (CLOUDFLARE_LIVE_INPUT_UID)"
|
||||||
|
command = '''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
echo "=== Set Stream Secret ==="
|
||||||
|
echo ""
|
||||||
|
echo "Get your Live Input UID from Cloudflare Stream dashboard:"
|
||||||
|
echo "https://dash.cloudflare.com/?to=/:account/stream/inputs"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter CLOUDFLARE_LIVE_INPUT_UID: " LIVE_INPUT_UID
|
||||||
|
|
||||||
|
if [ -n "$LIVE_INPUT_UID" ]; then
|
||||||
|
echo "$LIVE_INPUT_UID" | pnpm exec wrangler secret put CLOUDFLARE_LIVE_INPUT_UID
|
||||||
|
echo "✓ CLOUDFLARE_LIVE_INPUT_UID set"
|
||||||
|
else
|
||||||
|
echo "Skipped"
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
dependencies = ["pnpm"]
|
||||||
|
shortcuts = ["stream"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "show-user"
|
||||||
|
description = "Show user profile from production database"
|
||||||
|
command = '''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd packages/web
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PROD_DATABASE_URL:-}" ]; then
|
||||||
|
echo "❌ PROD_DATABASE_URL not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
USERNAME="${1:-nikiv}"
|
||||||
|
|
||||||
|
DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e "
|
||||||
|
const { neon } = require('@neondatabase/serverless');
|
||||||
|
const sql = neon(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const username = process.argv[2] || 'nikiv';
|
||||||
|
const result = await sql\`
|
||||||
|
SELECT id, name, email, username, image, bio, website, location, tier, \"createdAt\"
|
||||||
|
FROM users WHERE username = \${username}
|
||||||
|
\`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log('User not found:', username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result[0], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
|
" "$USERNAME"
|
||||||
|
'''
|
||||||
|
dependencies = ["node", "pnpm"]
|
||||||
|
shortcuts = ["user"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
chat_messages,
|
chat_messages,
|
||||||
chat_threads,
|
chat_threads,
|
||||||
sessions,
|
sessions,
|
||||||
|
streams,
|
||||||
users,
|
users,
|
||||||
verifications,
|
verifications,
|
||||||
} from "../src/db/schema"
|
} from "../src/db/schema"
|
||||||
@@ -116,7 +117,12 @@ async function ensureTables() {
|
|||||||
ALTER TABLE "users"
|
ALTER TABLE "users"
|
||||||
ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false,
|
ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false,
|
||||||
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(),
|
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(),
|
||||||
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now()
|
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now(),
|
||||||
|
ADD COLUMN IF NOT EXISTS "username" text UNIQUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS "bio" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "website" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "location" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "tier" varchar(32) DEFAULT 'free'
|
||||||
`)
|
`)
|
||||||
await authDb.execute(
|
await authDb.execute(
|
||||||
sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`,
|
sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`,
|
||||||
@@ -264,6 +270,68 @@ async function ensureTables() {
|
|||||||
async function seed() {
|
async function seed() {
|
||||||
await ensureTables()
|
await ensureTables()
|
||||||
|
|
||||||
|
// Create streams table if it doesn't exist
|
||||||
|
await appDb.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "streams" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||||
|
"title" text NOT NULL DEFAULT 'Live Stream',
|
||||||
|
"description" text,
|
||||||
|
"is_live" boolean NOT NULL DEFAULT false,
|
||||||
|
"viewer_count" integer NOT NULL DEFAULT 0,
|
||||||
|
"stream_key" text NOT NULL UNIQUE,
|
||||||
|
"cloudflare_live_input_uid" text,
|
||||||
|
"cloudflare_customer_code" text,
|
||||||
|
"hls_url" text,
|
||||||
|
"webrtc_url" text,
|
||||||
|
"thumbnail_url" text,
|
||||||
|
"started_at" timestamptz,
|
||||||
|
"ended_at" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Add cloudflare columns if they don't exist (for existing tables)
|
||||||
|
await appDb.execute(sql`
|
||||||
|
ALTER TABLE "streams"
|
||||||
|
ADD COLUMN IF NOT EXISTS "cloudflare_live_input_uid" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text
|
||||||
|
`)
|
||||||
|
|
||||||
|
// ========== Seed nikiv user ==========
|
||||||
|
const nikivUserId = "nikiv"
|
||||||
|
const nikivEmail = "nikita.voloboev@gmail.com"
|
||||||
|
|
||||||
|
await authDb
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: nikivUserId,
|
||||||
|
name: "Nikita Voloboev",
|
||||||
|
email: nikivEmail,
|
||||||
|
username: "nikiv",
|
||||||
|
emailVerified: true,
|
||||||
|
image: "https://nikiv.dev/nikiv.jpg",
|
||||||
|
bio: "Building in public. Making tools I want to exist.",
|
||||||
|
website: "nikiv.dev",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: users.id })
|
||||||
|
|
||||||
|
// Create stream for nikiv (HLS URL will come from env variable fallback)
|
||||||
|
const nikivStreamKey = crypto.randomUUID()
|
||||||
|
await appDb
|
||||||
|
.insert(streams)
|
||||||
|
.values({
|
||||||
|
user_id: nikivUserId,
|
||||||
|
title: "Live Coding",
|
||||||
|
description: "Building in public",
|
||||||
|
stream_key: nikivStreamKey,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
|
||||||
|
// ========== Seed demo user ==========
|
||||||
const demoUserId = "demo-user"
|
const demoUserId = "demo-user"
|
||||||
const demoEmail = "demo@ai.chat"
|
const demoEmail = "demo@ai.chat"
|
||||||
|
|
||||||
@@ -342,7 +410,7 @@ async function seed() {
|
|||||||
|
|
||||||
seed()
|
seed()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log("Seed complete: demo user and chat thread ready.")
|
console.log("Seed complete: nikiv user, stream, demo user, and chat thread ready.")
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Link } from "@tanstack/react-router"
|
|
||||||
import { ExternalLink, MapPin, Calendar, Users } from "lucide-react"
|
import { ExternalLink, MapPin, Calendar, Users } from "lucide-react"
|
||||||
|
|
||||||
interface ProfileSidebarProps {
|
interface ProfileSidebarProps {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Video,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type SettingsSection = "preferences" | "profile" | "billing"
|
type SettingsSection = "preferences" | "profile" | "streaming" | "billing"
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -30,6 +31,7 @@ type NavItem = {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
|
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
|
||||||
{ id: "profile", label: "Profile", icon: UserRound },
|
{ id: "profile", label: "Profile", icon: UserRound },
|
||||||
|
{ id: "streaming", label: "Streaming", icon: Video },
|
||||||
{ id: "billing", label: "Manage Billing", icon: CreditCard },
|
{ id: "billing", label: "Manage Billing", icon: CreditCard },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const users = pgTable("users", {
|
|||||||
.$defaultFn(() => false)
|
.$defaultFn(() => false)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
// Profile fields
|
||||||
|
bio: text("bio"),
|
||||||
|
website: text("website"),
|
||||||
|
location: text("location"),
|
||||||
// Access tiers: 'free' | 'creator' | 'dev' - determines feature access
|
// Access tiers: 'free' | 'creator' | 'dev' - determines feature access
|
||||||
tier: varchar("tier", { length: 32 }).notNull().default("free"),
|
tier: varchar("tier", { length: 32 }).notNull().default("free"),
|
||||||
createdAt: timestamp("createdAt")
|
createdAt: timestamp("createdAt")
|
||||||
@@ -247,7 +251,10 @@ export const streams = pgTable("streams", {
|
|||||||
is_live: boolean("is_live").notNull().default(false),
|
is_live: boolean("is_live").notNull().default(false),
|
||||||
viewer_count: integer("viewer_count").notNull().default(0),
|
viewer_count: integer("viewer_count").notNull().default(0),
|
||||||
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
|
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
|
||||||
// Stream endpoints (set by Linux server)
|
// Cloudflare Stream integration
|
||||||
|
cloudflare_live_input_uid: text("cloudflare_live_input_uid"), // Cloudflare Live Input UID for automatic stream detection
|
||||||
|
cloudflare_customer_code: text("cloudflare_customer_code"), // Customer subdomain (optional, defaults to linsa's)
|
||||||
|
// Stream endpoints (can be auto-generated from cloudflare_live_input_uid or set manually)
|
||||||
hls_url: text("hls_url"), // HLS playback URL
|
hls_url: text("hls_url"), // HLS playback URL
|
||||||
webrtc_url: text("webrtc_url"), // WebRTC playback URL
|
webrtc_url: text("webrtc_url"), // WebRTC playback URL
|
||||||
thumbnail_url: text("thumbnail_url"),
|
thumbnail_url: text("thumbnail_url"),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export type StreamPageData = {
|
|||||||
name: string
|
name: string
|
||||||
username: string | null
|
username: string | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
bio?: string | null
|
||||||
|
website?: string | null
|
||||||
|
location?: string | null
|
||||||
|
joinedAt?: string | null
|
||||||
}
|
}
|
||||||
stream: {
|
stream: {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { Route as ApiStripePortalRouteImport } from './routes/api/stripe/portal'
|
|||||||
import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout'
|
import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout'
|
||||||
import { Route as ApiStripeBillingRouteImport } from './routes/api/stripe/billing'
|
import { Route as ApiStripeBillingRouteImport } from './routes/api/stripe/billing'
|
||||||
import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
|
import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
|
||||||
|
import { Route as ApiStreamSettingsRouteImport } from './routes/api/stream.settings'
|
||||||
import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
|
import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
|
||||||
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
|
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
|
||||||
import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$'
|
import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$'
|
||||||
@@ -69,6 +70,7 @@ import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr
|
|||||||
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
||||||
import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers'
|
import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers'
|
||||||
import { Route as ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays'
|
import { Route as ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays'
|
||||||
|
import { Route as ApiStreamsUsernameCheckHlsRouteImport } from './routes/api/streams.$username.check-hls'
|
||||||
import { Route as ApiCreatorUsernameAccessRouteImport } from './routes/api/creator/$username.access'
|
import { Route as ApiCreatorUsernameAccessRouteImport } from './routes/api/creator/$username.access'
|
||||||
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
|
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
|
||||||
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
|
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
|
||||||
@@ -278,6 +280,11 @@ const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
|
|||||||
path: '/api/streams/$username',
|
path: '/api/streams/$username',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStreamSettingsRoute = ApiStreamSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => ApiStreamRoute,
|
||||||
|
} as any)
|
||||||
const ApiStreamReplaysReplayIdRoute =
|
const ApiStreamReplaysReplayIdRoute =
|
||||||
ApiStreamReplaysReplayIdRouteImport.update({
|
ApiStreamReplaysReplayIdRouteImport.update({
|
||||||
id: '/$replayId',
|
id: '/$replayId',
|
||||||
@@ -377,6 +384,12 @@ const ApiStreamsUsernameReplaysRoute =
|
|||||||
path: '/replays',
|
path: '/replays',
|
||||||
getParentRoute: () => ApiStreamsUsernameRoute,
|
getParentRoute: () => ApiStreamsUsernameRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStreamsUsernameCheckHlsRoute =
|
||||||
|
ApiStreamsUsernameCheckHlsRouteImport.update({
|
||||||
|
id: '/check-hls',
|
||||||
|
path: '/check-hls',
|
||||||
|
getParentRoute: () => ApiStreamsUsernameRoute,
|
||||||
|
} as any)
|
||||||
const ApiCreatorUsernameAccessRoute =
|
const ApiCreatorUsernameAccessRoute =
|
||||||
ApiCreatorUsernameAccessRouteImport.update({
|
ApiCreatorUsernameAccessRouteImport.update({
|
||||||
id: '/api/creator/$username/access',
|
id: '/api/creator/$username/access',
|
||||||
@@ -417,7 +430,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/check-hls': typeof ApiCheckHlsRoute
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -440,6 +453,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
|
'/api/stream/settings': typeof ApiStreamSettingsRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
@@ -452,6 +466,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||||
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
||||||
|
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
|
||||||
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
||||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
@@ -481,7 +496,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/check-hls': typeof ApiCheckHlsRoute
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -504,6 +519,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
|
'/api/stream/settings': typeof ApiStreamSettingsRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
@@ -516,6 +532,7 @@ export interface FileRoutesByTo {
|
|||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||||
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
||||||
|
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
|
||||||
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
||||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
@@ -547,7 +564,7 @@ export interface FileRoutesById {
|
|||||||
'/api/check-hls': typeof ApiCheckHlsRoute
|
'/api/check-hls': typeof ApiCheckHlsRoute
|
||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -570,6 +587,7 @@ export interface FileRoutesById {
|
|||||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
|
'/api/stream/settings': typeof ApiStreamSettingsRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
@@ -582,6 +600,7 @@ export interface FileRoutesById {
|
|||||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||||
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
|
||||||
|
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
|
||||||
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
|
||||||
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
|
||||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||||
@@ -637,6 +656,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/flowglad/$'
|
| '/api/flowglad/$'
|
||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
|
| '/api/stream/settings'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
| '/api/stripe/billing'
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
@@ -649,6 +669,7 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/server-funcs'
|
| '/demo/start/server-funcs'
|
||||||
| '/api/canvas/images/$imageId'
|
| '/api/canvas/images/$imageId'
|
||||||
| '/api/creator/$username/access'
|
| '/api/creator/$username/access'
|
||||||
|
| '/api/streams/$username/check-hls'
|
||||||
| '/api/streams/$username/replays'
|
| '/api/streams/$username/replays'
|
||||||
| '/api/streams/$username/viewers'
|
| '/api/streams/$username/viewers'
|
||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
@@ -701,6 +722,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/flowglad/$'
|
| '/api/flowglad/$'
|
||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
|
| '/api/stream/settings'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
| '/api/stripe/billing'
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
@@ -713,6 +735,7 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/server-funcs'
|
| '/demo/start/server-funcs'
|
||||||
| '/api/canvas/images/$imageId'
|
| '/api/canvas/images/$imageId'
|
||||||
| '/api/creator/$username/access'
|
| '/api/creator/$username/access'
|
||||||
|
| '/api/streams/$username/check-hls'
|
||||||
| '/api/streams/$username/replays'
|
| '/api/streams/$username/replays'
|
||||||
| '/api/streams/$username/viewers'
|
| '/api/streams/$username/viewers'
|
||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
@@ -766,6 +789,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/flowglad/$'
|
| '/api/flowglad/$'
|
||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
|
| '/api/stream/settings'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
| '/api/stripe/billing'
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
@@ -778,6 +802,7 @@ export interface FileRouteTypes {
|
|||||||
| '/demo/start/server-funcs'
|
| '/demo/start/server-funcs'
|
||||||
| '/api/canvas/images/$imageId'
|
| '/api/canvas/images/$imageId'
|
||||||
| '/api/creator/$username/access'
|
| '/api/creator/$username/access'
|
||||||
|
| '/api/streams/$username/check-hls'
|
||||||
| '/api/streams/$username/replays'
|
| '/api/streams/$username/replays'
|
||||||
| '/api/streams/$username/viewers'
|
| '/api/streams/$username/viewers'
|
||||||
| '/demo/start/ssr/data-only'
|
| '/demo/start/ssr/data-only'
|
||||||
@@ -809,7 +834,7 @@ export interface RootRouteChildren {
|
|||||||
ApiCheckHlsRoute: typeof ApiCheckHlsRoute
|
ApiCheckHlsRoute: typeof ApiCheckHlsRoute
|
||||||
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
||||||
ApiProfileRoute: typeof ApiProfileRoute
|
ApiProfileRoute: typeof ApiProfileRoute
|
||||||
ApiStreamRoute: typeof ApiStreamRoute
|
ApiStreamRoute: typeof ApiStreamRouteWithChildren
|
||||||
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
||||||
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
||||||
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
|
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
|
||||||
@@ -1128,6 +1153,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStreamsUsernameRouteImport
|
preLoaderRoute: typeof ApiStreamsUsernameRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/stream/settings': {
|
||||||
|
id: '/api/stream/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/api/stream/settings'
|
||||||
|
preLoaderRoute: typeof ApiStreamSettingsRouteImport
|
||||||
|
parentRoute: typeof ApiStreamRoute
|
||||||
|
}
|
||||||
'/api/stream-replays/$replayId': {
|
'/api/stream-replays/$replayId': {
|
||||||
id: '/api/stream-replays/$replayId'
|
id: '/api/stream-replays/$replayId'
|
||||||
path: '/$replayId'
|
path: '/$replayId'
|
||||||
@@ -1261,6 +1293,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport
|
preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport
|
||||||
parentRoute: typeof ApiStreamsUsernameRoute
|
parentRoute: typeof ApiStreamsUsernameRoute
|
||||||
}
|
}
|
||||||
|
'/api/streams/$username/check-hls': {
|
||||||
|
id: '/api/streams/$username/check-hls'
|
||||||
|
path: '/check-hls'
|
||||||
|
fullPath: '/api/streams/$username/check-hls'
|
||||||
|
preLoaderRoute: typeof ApiStreamsUsernameCheckHlsRouteImport
|
||||||
|
parentRoute: typeof ApiStreamsUsernameRoute
|
||||||
|
}
|
||||||
'/api/creator/$username/access': {
|
'/api/creator/$username/access': {
|
||||||
id: '/api/creator/$username/access'
|
id: '/api/creator/$username/access'
|
||||||
path: '/api/creator/$username/access'
|
path: '/api/creator/$username/access'
|
||||||
@@ -1372,6 +1411,18 @@ const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren(
|
|||||||
ApiCanvasRouteChildren,
|
ApiCanvasRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface ApiStreamRouteChildren {
|
||||||
|
ApiStreamSettingsRoute: typeof ApiStreamSettingsRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiStreamRouteChildren: ApiStreamRouteChildren = {
|
||||||
|
ApiStreamSettingsRoute: ApiStreamSettingsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiStreamRouteWithChildren = ApiStreamRoute._addFileChildren(
|
||||||
|
ApiStreamRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface ApiStreamReplaysRouteChildren {
|
interface ApiStreamReplaysRouteChildren {
|
||||||
ApiStreamReplaysReplayIdRoute: typeof ApiStreamReplaysReplayIdRoute
|
ApiStreamReplaysReplayIdRoute: typeof ApiStreamReplaysReplayIdRoute
|
||||||
}
|
}
|
||||||
@@ -1408,11 +1459,13 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface ApiStreamsUsernameRouteChildren {
|
interface ApiStreamsUsernameRouteChildren {
|
||||||
|
ApiStreamsUsernameCheckHlsRoute: typeof ApiStreamsUsernameCheckHlsRoute
|
||||||
ApiStreamsUsernameReplaysRoute: typeof ApiStreamsUsernameReplaysRoute
|
ApiStreamsUsernameReplaysRoute: typeof ApiStreamsUsernameReplaysRoute
|
||||||
ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute
|
ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = {
|
const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = {
|
||||||
|
ApiStreamsUsernameCheckHlsRoute: ApiStreamsUsernameCheckHlsRoute,
|
||||||
ApiStreamsUsernameReplaysRoute: ApiStreamsUsernameReplaysRoute,
|
ApiStreamsUsernameReplaysRoute: ApiStreamsUsernameReplaysRoute,
|
||||||
ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute,
|
ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute,
|
||||||
}
|
}
|
||||||
@@ -1442,7 +1495,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiCheckHlsRoute: ApiCheckHlsRoute,
|
ApiCheckHlsRoute: ApiCheckHlsRoute,
|
||||||
ApiContextItemsRoute: ApiContextItemsRoute,
|
ApiContextItemsRoute: ApiContextItemsRoute,
|
||||||
ApiProfileRoute: ApiProfileRoute,
|
ApiProfileRoute: ApiProfileRoute,
|
||||||
ApiStreamRoute: ApiStreamRoute,
|
ApiStreamRoute: ApiStreamRouteWithChildren,
|
||||||
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
||||||
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
||||||
ApiStreamStatusRoute: ApiStreamStatusRoute,
|
ApiStreamStatusRoute: ApiStreamStatusRoute,
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
|
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
|
||||||
import { VideoPlayer } from "@/components/VideoPlayer"
|
import { VideoPlayer } from "@/components/VideoPlayer"
|
||||||
import { resolveStreamPlayback } from "@/lib/stream/playback"
|
|
||||||
import { JazzProvider } from "@/lib/jazz/provider"
|
import { JazzProvider } from "@/lib/jazz/provider"
|
||||||
import { CommentBox } from "@/components/CommentBox"
|
import { CommentBox } from "@/components/CommentBox"
|
||||||
import { ProfileSidebar } from "@/components/ProfileSidebar"
|
import { ProfileSidebar } from "@/components/ProfileSidebar"
|
||||||
import {
|
|
||||||
getSpotifyNowPlaying,
|
|
||||||
type SpotifyNowPlayingResponse,
|
|
||||||
} from "@/lib/spotify/now-playing"
|
|
||||||
import { authClient } from "@/lib/auth-client"
|
import { authClient } from "@/lib/auth-client"
|
||||||
import { MessageCircle, LogIn, X, User } from "lucide-react"
|
import { MessageCircle, LogIn, X, User } from "lucide-react"
|
||||||
|
|
||||||
@@ -20,34 +15,6 @@ export const Route = createFileRoute("/$username")({
|
|||||||
|
|
||||||
const READY_PULSE_MS = 1200
|
const READY_PULSE_MS = 1200
|
||||||
|
|
||||||
// Hardcoded user for nikiv (hls_url will be updated from API)
|
|
||||||
function makeNikivData(hlsUrl: string): StreamPageData {
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: "nikiv",
|
|
||||||
name: "Nikita Voloboev",
|
|
||||||
username: "nikiv",
|
|
||||||
image: "https://nikiv.dev/nikiv.jpg",
|
|
||||||
bio: "Building in public. Making tools I want to exist.",
|
|
||||||
website: "nikiv.dev",
|
|
||||||
location: null,
|
|
||||||
joinedAt: "2024-01-01",
|
|
||||||
},
|
|
||||||
stream: {
|
|
||||||
id: "nikiv-stream",
|
|
||||||
title: "Live Coding",
|
|
||||||
description: "Building in public",
|
|
||||||
is_live: false,
|
|
||||||
viewer_count: 0,
|
|
||||||
hls_url: hlsUrl,
|
|
||||||
webrtc_url: null,
|
|
||||||
playback: resolveStreamPlayback({ hlsUrl, webrtcUrl: null }),
|
|
||||||
thumbnail_url: null,
|
|
||||||
started_at: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function StreamPage() {
|
function StreamPage() {
|
||||||
const { username } = Route.useParams()
|
const { username } = Route.useParams()
|
||||||
const { data: session } = authClient.useSession()
|
const { data: session } = authClient.useSession()
|
||||||
@@ -58,32 +25,20 @@ function StreamPage() {
|
|||||||
const [hlsLive, setHlsLive] = useState<boolean | null>(null)
|
const [hlsLive, setHlsLive] = useState<boolean | null>(null)
|
||||||
const [hlsUrl, setHlsUrl] = useState<string | null>(null)
|
const [hlsUrl, setHlsUrl] = useState<string | null>(null)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [nowPlaying, setNowPlaying] = useState<SpotifyNowPlayingResponse | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [nowPlayingLoading, setNowPlayingLoading] = useState(false)
|
|
||||||
const [nowPlayingError, setNowPlayingError] = useState(false)
|
|
||||||
const [streamLive, setStreamLive] = useState(false)
|
|
||||||
const [showReadyPulse, setShowReadyPulse] = useState(false)
|
const [showReadyPulse, setShowReadyPulse] = useState(false)
|
||||||
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const hasConnectedOnce = useRef(false)
|
const hasConnectedOnce = useRef(false)
|
||||||
|
|
||||||
// Mobile chat overlay
|
// Mobile overlays
|
||||||
const [showMobileChat, setShowMobileChat] = useState(false)
|
const [showMobileChat, setShowMobileChat] = useState(false)
|
||||||
|
const [showMobileProfile, setShowMobileProfile] = useState(false)
|
||||||
|
|
||||||
const isAuthenticated = !!session?.user
|
const isAuthenticated = !!session?.user
|
||||||
|
|
||||||
|
// Fetch user and stream data from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true
|
let isActive = true
|
||||||
|
|
||||||
// Special handling for nikiv - URL comes from API (secret)
|
|
||||||
// Data and loading state are handled by the HLS check effect
|
|
||||||
if (username === "nikiv") {
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -92,6 +47,9 @@ function StreamPage() {
|
|||||||
const result = await getStreamByUsername(username)
|
const result = await getStreamByUsername(username)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
setData(result)
|
setData(result)
|
||||||
|
if (result?.stream?.hls_url) {
|
||||||
|
setHlsUrl(result.stream.hls_url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@@ -111,34 +69,7 @@ function StreamPage() {
|
|||||||
}
|
}
|
||||||
}, [username])
|
}, [username])
|
||||||
|
|
||||||
// Poll stream status for nikiv from nikiv.dev/api/stream-status
|
// Ready pulse animation
|
||||||
useEffect(() => {
|
|
||||||
if (username !== "nikiv") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isActive = true
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
const status = await getStreamStatus()
|
|
||||||
console.log("[Stream Status] nikiv.dev/api/stream-status:", status)
|
|
||||||
if (isActive) {
|
|
||||||
setStreamLive(status.isLive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch immediately
|
|
||||||
fetchStatus()
|
|
||||||
|
|
||||||
// Poll every 10 seconds
|
|
||||||
const interval = setInterval(fetchStatus, 10000)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [username])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readyPulseTimeoutRef.current) {
|
if (readyPulseTimeoutRef.current) {
|
||||||
clearTimeout(readyPulseTimeoutRef.current)
|
clearTimeout(readyPulseTimeoutRef.current)
|
||||||
@@ -165,218 +96,64 @@ function StreamPage() {
|
|||||||
}, [playerReady])
|
}, [playerReady])
|
||||||
|
|
||||||
const stream = data?.stream ?? null
|
const stream = data?.stream ?? null
|
||||||
// For nikiv, always use HLS directly (no WebRTC) - URL comes from API
|
const activePlayback = hlsUrl
|
||||||
const activePlayback = username === "nikiv" && hlsUrl
|
|
||||||
? { type: "hls" as const, url: hlsUrl }
|
? { type: "hls" as const, url: hlsUrl }
|
||||||
: stream?.playback ?? null
|
: stream?.playback ?? null
|
||||||
|
|
||||||
const isHlsPlaylistLive = (manifest: string) => {
|
// Poll HLS status via server-side API (avoids CORS issues)
|
||||||
const upper = manifest.toUpperCase()
|
|
||||||
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
|
|
||||||
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
|
|
||||||
const hasSegments =
|
|
||||||
upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
|
|
||||||
// Also check for #EXTM3U which is the start of any valid HLS manifest
|
|
||||||
const isValidManifest = upper.includes("#EXTM3U")
|
|
||||||
return isValidManifest && !hasEndlist && !isVod && hasSegments
|
|
||||||
}
|
|
||||||
|
|
||||||
// For nikiv, use server-side API to check HLS (avoids CORS, gets URL from secret)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (username !== "nikiv") return
|
if (!data?.user) return
|
||||||
|
|
||||||
let isActive = true
|
let isActive = true
|
||||||
let isFirstCheck = true
|
|
||||||
|
|
||||||
const checkHlsViaApi = async () => {
|
const checkHls = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/check-hls", { cache: "no-store" })
|
const res = await fetch(`/api/streams/${username}/check-hls`, { cache: "no-store" })
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
|
|
||||||
const apiData = await res.json()
|
const apiData = await res.json()
|
||||||
|
|
||||||
// Update HLS URL from API (comes from server secret)
|
// Update HLS URL if returned from API
|
||||||
if (apiData.hlsUrl && apiData.hlsUrl !== hlsUrl) {
|
if (apiData.hlsUrl && apiData.hlsUrl !== hlsUrl) {
|
||||||
setHlsUrl(apiData.hlsUrl)
|
setHlsUrl(apiData.hlsUrl)
|
||||||
setData(makeNikivData(apiData.hlsUrl))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiData.isLive) {
|
if (apiData.isLive) {
|
||||||
// Stream is live - set connecting state if first time
|
|
||||||
if (!hasConnectedOnce.current) {
|
if (!hasConnectedOnce.current) {
|
||||||
setIsConnecting(true)
|
setIsConnecting(true)
|
||||||
}
|
}
|
||||||
setHlsLive(true)
|
setHlsLive(true)
|
||||||
} else {
|
} else {
|
||||||
// Only set offline if we haven't connected yet
|
|
||||||
// This prevents flickering when HLS check temporarily fails
|
|
||||||
if (!hasConnectedOnce.current) {
|
if (!hasConnectedOnce.current) {
|
||||||
setHlsLive(false)
|
setHlsLive(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore errors - don't change state on network issues
|
// Silently ignore errors
|
||||||
} finally {
|
|
||||||
// Mark loading as done after first check completes
|
|
||||||
if (isActive && isFirstCheck) {
|
|
||||||
isFirstCheck = false
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial check
|
|
||||||
setHlsLive(null)
|
setHlsLive(null)
|
||||||
checkHlsViaApi()
|
checkHls()
|
||||||
|
|
||||||
// Poll every 5 seconds to detect when stream goes live
|
// Poll every 5 seconds
|
||||||
const interval = setInterval(checkHlsViaApi, 5000)
|
const interval = setInterval(checkHls, 5000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isActive = false
|
isActive = false
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [username, hlsUrl])
|
}, [data?.user, username, hlsUrl])
|
||||||
|
|
||||||
// For non-nikiv users, use direct HLS check
|
// Determine if stream is actually live
|
||||||
useEffect(() => {
|
const isActuallyLive = hlsLive === true || Boolean(stream?.is_live)
|
||||||
if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isActive = true
|
|
||||||
|
|
||||||
const checkHlsLive = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(activePlayback.url, {
|
|
||||||
cache: "no-store",
|
|
||||||
mode: "cors",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isActive) return
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
if (!hasConnectedOnce.current) {
|
|
||||||
setHlsLive(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = await res.text()
|
|
||||||
if (!isActive) return
|
|
||||||
|
|
||||||
const live = isHlsPlaylistLive(manifest)
|
|
||||||
if (live) {
|
|
||||||
if (!hasConnectedOnce.current) {
|
|
||||||
setIsConnecting(true)
|
|
||||||
}
|
|
||||||
setHlsLive(true)
|
|
||||||
} else if (!hasConnectedOnce.current) {
|
|
||||||
setHlsLive(false)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore fetch errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHlsLive(null)
|
|
||||||
checkHlsLive()
|
|
||||||
|
|
||||||
const interval = setInterval(checkHlsLive, 5000)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
username,
|
|
||||||
activePlayback?.type,
|
|
||||||
activePlayback?.type === "hls" ? activePlayback.url : null,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isActive = true
|
|
||||||
if (!stream?.hls_url || activePlayback?.type === "hls") {
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHlsLive(null)
|
|
||||||
fetch(stream.hls_url)
|
|
||||||
.then(async (res) => {
|
|
||||||
if (!isActive) return
|
|
||||||
if (!res.ok) {
|
|
||||||
setHlsLive(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const manifest = await res.text()
|
|
||||||
if (!isActive) return
|
|
||||||
setHlsLive(isHlsPlaylistLive(manifest))
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isActive) {
|
|
||||||
setHlsLive(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
}
|
|
||||||
}, [activePlayback?.type, stream?.hls_url])
|
|
||||||
|
|
||||||
// For nikiv, use HLS live check from our API
|
|
||||||
// For other users, use stream?.is_live from the database
|
|
||||||
const isActuallyLive = username === "nikiv"
|
|
||||||
? hlsLive === true
|
|
||||||
: Boolean(stream?.is_live)
|
|
||||||
|
|
||||||
// Only show Spotify when we know stream is offline (not during initial check)
|
|
||||||
const shouldFetchSpotify = username === "nikiv" && !isActuallyLive && hlsLive === false
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldFetchSpotify) {
|
|
||||||
setNowPlaying(null)
|
|
||||||
setNowPlayingLoading(false)
|
|
||||||
setNowPlayingError(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isActive = true
|
|
||||||
|
|
||||||
const fetchNowPlaying = async (showLoading: boolean) => {
|
|
||||||
if (showLoading) {
|
|
||||||
setNowPlayingLoading(true)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await getSpotifyNowPlaying()
|
|
||||||
if (!isActive) return
|
|
||||||
setNowPlaying(response)
|
|
||||||
setNowPlayingError(false)
|
|
||||||
} catch {
|
|
||||||
if (!isActive) return
|
|
||||||
// Silently handle Spotify errors - it's not critical
|
|
||||||
setNowPlayingError(true)
|
|
||||||
} finally {
|
|
||||||
if (isActive && showLoading) {
|
|
||||||
setNowPlayingLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchNowPlaying(true)
|
|
||||||
const interval = setInterval(() => fetchNowPlaying(false), 30000)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [shouldFetchSpotify])
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||||
<div className="text-xl">Loading...</div>
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -405,41 +182,31 @@ function StreamPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowPlayingTrack = nowPlaying?.track ?? null
|
|
||||||
const nowPlayingArtists = nowPlayingTrack?.artists.length
|
|
||||||
? nowPlayingTrack.artists.join(", ")
|
|
||||||
: null
|
|
||||||
const nowPlayingText = nowPlayingTrack
|
|
||||||
? nowPlayingArtists
|
|
||||||
? `${nowPlayingArtists} — ${nowPlayingTrack.title}`
|
|
||||||
: nowPlayingTrack.title
|
|
||||||
: null
|
|
||||||
|
|
||||||
// Callback when player is ready
|
|
||||||
const handlePlayerReady = () => {
|
const handlePlayerReady = () => {
|
||||||
hasConnectedOnce.current = true
|
hasConnectedOnce.current = true
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
setPlayerReady(true)
|
setPlayerReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state during initial check
|
|
||||||
const isChecking = hlsLive === null
|
const isChecking = hlsLive === null
|
||||||
|
|
||||||
|
// Build profile user object
|
||||||
|
const profileUser = {
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
username: data.user.username ?? username,
|
||||||
|
image: data.user.image,
|
||||||
|
bio: data.user.bio ?? null,
|
||||||
|
website: data.user.website ?? null,
|
||||||
|
location: data.user.location ?? null,
|
||||||
|
joinedAt: data.user.joinedAt ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JazzProvider>
|
<JazzProvider>
|
||||||
<LiveNowSidebar currentUsername={username} />
|
|
||||||
<div className="h-screen w-screen bg-black flex flex-col md:flex-row">
|
<div className="h-screen w-screen bg-black flex flex-col md:flex-row">
|
||||||
{/* Main content area */}
|
{/* Main content area - Stream */}
|
||||||
<div className="flex-1 relative min-h-0">
|
<div className="flex-1 relative min-h-0">
|
||||||
|
|
||||||
{/* Viewer count overlay - hidden on mobile */}
|
|
||||||
{isActuallyLive && (
|
|
||||||
<div className="hidden md:block absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">
|
|
||||||
<ViewerCount username={username} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state - checking if stream is live */}
|
|
||||||
{isChecking ? (
|
{isChecking ? (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center text-white">
|
<div className="flex h-full w-full flex-col items-center justify-center text-white">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -448,14 +215,12 @@ function StreamPage() {
|
|||||||
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
|
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
|
||||||
</div>
|
</div>
|
||||||
) : isActuallyLive && activePlayback ? (
|
) : isActuallyLive && activePlayback ? (
|
||||||
/* Stream is live - show the player */
|
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src={activePlayback.url}
|
src={activePlayback.url}
|
||||||
muted={false}
|
muted={false}
|
||||||
onReady={handlePlayerReady}
|
onReady={handlePlayerReady}
|
||||||
/>
|
/>
|
||||||
{/* Loading overlay while connecting */}
|
|
||||||
{(isConnecting || !playerReady) && (
|
{(isConnecting || !playerReady) && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80">
|
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -464,7 +229,6 @@ function StreamPage() {
|
|||||||
<p className="mt-6 text-lg text-white">Connecting to stream...</p>
|
<p className="mt-6 text-lg text-white">Connecting to stream...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Ready pulse */}
|
|
||||||
{showReadyPulse && (
|
{showReadyPulse && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||||
<div className="animate-pulse text-4xl">🔴</div>
|
<div className="animate-pulse text-4xl">🔴</div>
|
||||||
@@ -472,76 +236,43 @@ function StreamPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Stream is offline */
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-white pb-16 md:pb-0">
|
<div className="flex h-full w-full items-center justify-center text-white pb-16 md:pb-0">
|
||||||
{shouldFetchSpotify ? (
|
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
|
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-neutral-400">
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-neutral-400">
|
<span className="h-2 w-2 rounded-full bg-neutral-500" />
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-500" />
|
Offline
|
||||||
Offline
|
</div>
|
||||||
</div>
|
<p className="mt-6 text-2xl md:text-3xl font-semibold">
|
||||||
<p className="mt-6 text-2xl md:text-3xl font-semibold">
|
Not live right now
|
||||||
Not live right now
|
</p>
|
||||||
</p>
|
{profileUser.website && (
|
||||||
<div className="mt-6 text-base md:text-lg text-neutral-300">
|
|
||||||
{nowPlayingLoading ? (
|
|
||||||
<span>Checking Spotify...</span>
|
|
||||||
) : nowPlaying?.isPlaying && nowPlayingTrack ? (
|
|
||||||
<span>
|
|
||||||
Currently playing{" "}
|
|
||||||
{nowPlayingTrack.url ? (
|
|
||||||
<a
|
|
||||||
href={nowPlayingTrack.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-white hover:text-neutral-300 transition-colors"
|
|
||||||
>
|
|
||||||
{nowPlayingText ?? "Spotify"}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-white">
|
|
||||||
{nowPlayingText ?? "Spotify"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://nikiv.dev"
|
href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
|
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
|
||||||
>
|
>
|
||||||
nikiv.dev
|
{profileUser.website.replace(/^https?:\/\//, "")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xl md:text-2xl font-medium text-neutral-400 mb-6">
|
|
||||||
stream soon
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={username === "nikiv" ? "https://nikiv.dev" : "#"}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-2xl md:text-4xl font-medium text-white hover:text-neutral-300 transition-colors"
|
|
||||||
>
|
|
||||||
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Chat sidebar */}
|
{/* Desktop Profile Sidebar with Chat */}
|
||||||
<div className="hidden md:block w-80 h-full border-l border-white/10 flex-shrink-0">
|
<div className="hidden md:flex w-96 h-full flex-shrink-0">
|
||||||
<CommentBox username={username} />
|
<ProfileSidebar
|
||||||
|
user={profileUser}
|
||||||
|
isLive={isActuallyLive}
|
||||||
|
viewerCount={stream?.viewer_count ?? 0}
|
||||||
|
>
|
||||||
|
<CommentBox username={username} />
|
||||||
|
</ProfileSidebar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile bottom bar */}
|
{/* Mobile bottom bar */}
|
||||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-black/90 backdrop-blur-sm border-t border-white/10 px-4 py-3 flex items-center justify-center gap-6">
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-black/90 backdrop-blur-sm border-t border-white/10 px-4 py-3 flex items-center justify-center gap-4">
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<Link
|
<Link
|
||||||
to="/auth"
|
to="/auth"
|
||||||
@@ -559,6 +290,14 @@ function StreamPage() {
|
|||||||
<MessageCircle className="w-4 h-4" />
|
<MessageCircle className="w-4 h-4" />
|
||||||
Chat
|
Chat
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMobileProfile(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile chat overlay */}
|
{/* Mobile chat overlay */}
|
||||||
@@ -579,6 +318,29 @@ function StreamPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile profile overlay */}
|
||||||
|
{showMobileProfile && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-40 bg-black flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||||
|
<span className="text-white font-medium">Profile</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMobileProfile(false)}
|
||||||
|
className="p-2 text-white/70 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<ProfileSidebar
|
||||||
|
user={profileUser}
|
||||||
|
isLive={isActuallyLive}
|
||||||
|
viewerCount={stream?.viewer_count ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</JazzProvider>
|
</JazzProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getDb } from "@/db/connection"
|
||||||
|
import { users } from "@/db/schema"
|
||||||
|
|
||||||
const json = (data: unknown, status = 200) =>
|
const json = (data: unknown, status = 200) =>
|
||||||
new Response(JSON.stringify(data), {
|
new Response(JSON.stringify(data), {
|
||||||
@@ -9,21 +12,52 @@ const json = (data: unknown, status = 200) =>
|
|||||||
// Cloudflare customer subdomain
|
// Cloudflare customer subdomain
|
||||||
const CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc"
|
const CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc"
|
||||||
|
|
||||||
function getHlsUrl(): string {
|
function getEnvFromContext(): { hlsUrl: string; databaseUrl: string | null } {
|
||||||
try {
|
try {
|
||||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||||
}
|
}
|
||||||
const ctx = getServerContext()
|
const ctx = getServerContext()
|
||||||
const liveInputUid = ctx?.cloudflare?.env?.CLOUDFLARE_LIVE_INPUT_UID
|
const liveInputUid = ctx?.cloudflare?.env?.CLOUDFLARE_LIVE_INPUT_UID
|
||||||
|
const databaseUrl = ctx?.cloudflare?.env?.DATABASE_URL ?? process.env.DATABASE_URL ?? null
|
||||||
|
|
||||||
if (liveInputUid) {
|
if (liveInputUid) {
|
||||||
return `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`
|
return {
|
||||||
|
hlsUrl: `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`,
|
||||||
|
databaseUrl,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
// Fallback - should not happen in production
|
|
||||||
throw new Error("CLOUDFLARE_LIVE_INPUT_UID not configured")
|
throw new Error("CLOUDFLARE_LIVE_INPUT_UID not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getNikivProfile(databaseUrl: string | null) {
|
||||||
|
if (!databaseUrl) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = getDb(databaseUrl)
|
||||||
|
const user = await database.query.users.findFirst({
|
||||||
|
where: eq(users.username, "nikiv"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
image: user.image,
|
||||||
|
bio: user.bio ?? null,
|
||||||
|
website: user.website ?? null,
|
||||||
|
location: user.location ?? null,
|
||||||
|
joinedAt: user.createdAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[check-hls] Failed to fetch nikiv profile:", err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHlsPlaylistLive(manifest: string): boolean {
|
function isHlsPlaylistLive(manifest: string): boolean {
|
||||||
const upper = manifest.toUpperCase()
|
const upper = manifest.toUpperCase()
|
||||||
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
|
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
|
||||||
@@ -44,23 +78,27 @@ export const Route = createFileRoute("/api/check-hls")({
|
|||||||
handlers: {
|
handlers: {
|
||||||
GET: async () => {
|
GET: async () => {
|
||||||
try {
|
try {
|
||||||
const hlsUrl = getHlsUrl()
|
const { hlsUrl, databaseUrl } = getEnvFromContext()
|
||||||
const res = await fetch(hlsUrl, {
|
|
||||||
cache: "no-store",
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("[check-hls] Response status:", res.status)
|
// Fetch profile and HLS status in parallel
|
||||||
|
const [profile, hlsRes] = await Promise.all([
|
||||||
|
getNikivProfile(databaseUrl),
|
||||||
|
fetch(hlsUrl, { cache: "no-store" }),
|
||||||
|
])
|
||||||
|
|
||||||
if (!res.ok) {
|
console.log("[check-hls] Response status:", hlsRes.status)
|
||||||
|
|
||||||
|
if (!hlsRes.ok) {
|
||||||
return json({
|
return json({
|
||||||
isLive: false,
|
isLive: false,
|
||||||
hlsUrl,
|
hlsUrl,
|
||||||
status: res.status,
|
profile,
|
||||||
|
status: hlsRes.status,
|
||||||
error: "HLS not available",
|
error: "HLS not available",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = await res.text()
|
const manifest = await hlsRes.text()
|
||||||
const isLive = isHlsPlaylistLive(manifest)
|
const isLive = isHlsPlaylistLive(manifest)
|
||||||
|
|
||||||
console.log("[check-hls] Manifest check:", {
|
console.log("[check-hls] Manifest check:", {
|
||||||
@@ -72,7 +110,8 @@ export const Route = createFileRoute("/api/check-hls")({
|
|||||||
return json({
|
return json({
|
||||||
isLive,
|
isLive,
|
||||||
hlsUrl,
|
hlsUrl,
|
||||||
status: res.status,
|
profile,
|
||||||
|
status: hlsRes.status,
|
||||||
manifestLength: manifest.length,
|
manifestLength: manifest.length,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -80,7 +119,8 @@ export const Route = createFileRoute("/api/check-hls")({
|
|||||||
console.error("[check-hls] Error:", error.message)
|
console.error("[check-hls] Error:", error.message)
|
||||||
return json({
|
return json({
|
||||||
isLive: false,
|
isLive: false,
|
||||||
hlsUrl: getHlsUrl(),
|
hlsUrl: null,
|
||||||
|
profile: null,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
164
packages/web/src/routes/api/stream.settings.ts
Normal file
164
packages/web/src/routes/api/stream.settings.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getDb } from "@/db/connection"
|
||||||
|
import { users, streams } from "@/db/schema"
|
||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
const json = (data: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const getServerContext = () => {
|
||||||
|
try {
|
||||||
|
const { getServerContext: gsc } = require("@tanstack/react-start/server") as {
|
||||||
|
getServerContext: () => { cloudflare?: { env?: Record<string, unknown> } } | null
|
||||||
|
}
|
||||||
|
return gsc()
|
||||||
|
} catch {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDatabaseUrl = () => {
|
||||||
|
const ctx = getServerContext()
|
||||||
|
const env = ctx?.cloudflare?.env as Record<string, string> | undefined
|
||||||
|
if (env?.DATABASE_URL) return env.DATABASE_URL
|
||||||
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||||
|
throw new Error("DATABASE_URL is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from session cookie
|
||||||
|
async function getSessionUser(request: Request) {
|
||||||
|
const cookie = request.headers.get("cookie")
|
||||||
|
if (!cookie) return null
|
||||||
|
|
||||||
|
// Parse session token from cookie
|
||||||
|
const sessionMatch = cookie.match(/better-auth\.session_token=([^;]+)/)
|
||||||
|
if (!sessionMatch) return null
|
||||||
|
|
||||||
|
const sessionToken = decodeURIComponent(sessionMatch[1])
|
||||||
|
const database = getDb(resolveDatabaseUrl())
|
||||||
|
|
||||||
|
// Look up session and get user
|
||||||
|
const result = await database.execute<{ userId: string }>(
|
||||||
|
`SELECT "userId" FROM sessions WHERE token = $1 AND "expiresAt" > NOW()`,
|
||||||
|
[sessionToken]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.rows.length) return null
|
||||||
|
|
||||||
|
const user = await database.query.users.findFirst({
|
||||||
|
where: eq(users.id, result.rows[0].userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/stream/settings")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
// GET - Fetch current user's stream settings
|
||||||
|
GET: async ({ request }) => {
|
||||||
|
const user = await getSessionUser(request)
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = getDb(resolveDatabaseUrl())
|
||||||
|
|
||||||
|
let stream = await database.query.streams.findFirst({
|
||||||
|
where: eq(streams.user_id, user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no stream exists, create one
|
||||||
|
if (!stream) {
|
||||||
|
const streamKey = crypto.randomUUID()
|
||||||
|
const [newStream] = await database
|
||||||
|
.insert(streams)
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
stream_key: streamKey,
|
||||||
|
title: "Live Stream",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
stream = newStream
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: stream.id,
|
||||||
|
title: stream.title,
|
||||||
|
description: stream.description,
|
||||||
|
cloudflare_live_input_uid: stream.cloudflare_live_input_uid,
|
||||||
|
cloudflare_customer_code: stream.cloudflare_customer_code,
|
||||||
|
hls_url: stream.hls_url,
|
||||||
|
stream_key: stream.stream_key,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// PUT - Update stream settings
|
||||||
|
PUT: async ({ request }) => {
|
||||||
|
const user = await getSessionUser(request)
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cloudflare_live_input_uid,
|
||||||
|
cloudflare_customer_code,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
const database = getDb(resolveDatabaseUrl())
|
||||||
|
|
||||||
|
// Get or create stream
|
||||||
|
let stream = await database.query.streams.findFirst({
|
||||||
|
where: eq(streams.user_id, user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
const streamKey = crypto.randomUUID()
|
||||||
|
const [newStream] = await database
|
||||||
|
.insert(streams)
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
stream_key: streamKey,
|
||||||
|
title: title || "Live Stream",
|
||||||
|
description,
|
||||||
|
cloudflare_live_input_uid,
|
||||||
|
cloudflare_customer_code,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
stream = newStream
|
||||||
|
} else {
|
||||||
|
// Update existing stream
|
||||||
|
const [updatedStream] = await database
|
||||||
|
.update(streams)
|
||||||
|
.set({
|
||||||
|
title: title ?? stream.title,
|
||||||
|
description: description ?? stream.description,
|
||||||
|
cloudflare_live_input_uid: cloudflare_live_input_uid ?? stream.cloudflare_live_input_uid,
|
||||||
|
cloudflare_customer_code: cloudflare_customer_code ?? stream.cloudflare_customer_code,
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(streams.id, stream.id))
|
||||||
|
.returning()
|
||||||
|
stream = updatedStream
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
stream: {
|
||||||
|
id: stream.id,
|
||||||
|
title: stream.title,
|
||||||
|
description: stream.description,
|
||||||
|
cloudflare_live_input_uid: stream.cloudflare_live_input_uid,
|
||||||
|
cloudflare_customer_code: stream.cloudflare_customer_code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
139
packages/web/src/routes/api/streams.$username.check-hls.ts
Normal file
139
packages/web/src/routes/api/streams.$username.check-hls.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getDb } from "@/db/connection"
|
||||||
|
import { users, streams } from "@/db/schema"
|
||||||
|
|
||||||
|
const json = (data: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Default Cloudflare customer subdomain (linsa's account)
|
||||||
|
const DEFAULT_CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc"
|
||||||
|
|
||||||
|
const getServerEnv = () => {
|
||||||
|
try {
|
||||||
|
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||||
|
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||||
|
}
|
||||||
|
return getServerContext()?.cloudflare?.env ?? {}
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDatabaseUrl = () => {
|
||||||
|
const env = getServerEnv()
|
||||||
|
if (env.DATABASE_URL) return env.DATABASE_URL
|
||||||
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||||
|
throw new Error("DATABASE_URL is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct Cloudflare Stream HLS URL from live input UID
|
||||||
|
const buildCloudflareHlsUrl = (liveInputUid: string, customerCode?: string | null): string => {
|
||||||
|
const code = customerCode || DEFAULT_CLOUDFLARE_CUSTOMER_CODE
|
||||||
|
return `https://customer-${code}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to env variable for backwards compatibility
|
||||||
|
const resolveEnvCloudflareHlsUrl = (): string | null => {
|
||||||
|
const env = getServerEnv()
|
||||||
|
const liveInputUid = env.CLOUDFLARE_LIVE_INPUT_UID
|
||||||
|
if (liveInputUid) {
|
||||||
|
return buildCloudflareHlsUrl(liveInputUid)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHlsPlaylistLive(manifest: string): boolean {
|
||||||
|
const upper = manifest.toUpperCase()
|
||||||
|
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
|
||||||
|
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||||
|
const hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
|
||||||
|
const isValidManifest = upper.includes("#EXTM3U")
|
||||||
|
const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF")
|
||||||
|
return isValidManifest && (isMasterPlaylist || (!hasEndlist && !isVod && hasSegments))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/streams/$username/check-hls")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ params }) => {
|
||||||
|
const { username } = params
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return json({ error: "Username required" }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = getDb(resolveDatabaseUrl())
|
||||||
|
|
||||||
|
const user = await database.query.users.findFirst({
|
||||||
|
where: eq(users.username, username),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return json({ error: "User not found", isLive: false }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await database.query.streams.findFirst({
|
||||||
|
where: eq(streams.user_id, user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Priority for HLS URL:
|
||||||
|
// 1. Stream's cloudflare_live_input_uid (per-user Cloudflare Stream)
|
||||||
|
// 2. Stream's hls_url (manually configured)
|
||||||
|
// 3. Environment variable (backwards compatibility)
|
||||||
|
let hlsUrl: string | null = null
|
||||||
|
|
||||||
|
if (stream?.cloudflare_live_input_uid) {
|
||||||
|
hlsUrl = buildCloudflareHlsUrl(
|
||||||
|
stream.cloudflare_live_input_uid,
|
||||||
|
stream.cloudflare_customer_code
|
||||||
|
)
|
||||||
|
} else if (stream?.hls_url) {
|
||||||
|
hlsUrl = stream.hls_url
|
||||||
|
} else {
|
||||||
|
hlsUrl = resolveEnvCloudflareHlsUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hlsUrl) {
|
||||||
|
return json({
|
||||||
|
isLive: false,
|
||||||
|
hlsUrl: null,
|
||||||
|
error: "No stream configured. Add your Cloudflare Live Input UID in settings.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(hlsUrl, { cache: "no-store" })
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return json({
|
||||||
|
isLive: false,
|
||||||
|
hlsUrl,
|
||||||
|
status: res.status,
|
||||||
|
error: "HLS not available",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await res.text()
|
||||||
|
const isLive = isHlsPlaylistLive(manifest)
|
||||||
|
|
||||||
|
return json({
|
||||||
|
isLive,
|
||||||
|
hlsUrl,
|
||||||
|
status: res.status,
|
||||||
|
manifestLength: manifest.length,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error
|
||||||
|
console.error("[check-hls] Error:", error.message)
|
||||||
|
return json({
|
||||||
|
isLive: false,
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -82,6 +82,10 @@ const serve = async ({
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
image: user.image,
|
image: user.image,
|
||||||
|
bio: user.bio ?? null,
|
||||||
|
website: user.website ?? null,
|
||||||
|
location: user.location ?? null,
|
||||||
|
joinedAt: user.createdAt?.toISOString() ?? null,
|
||||||
},
|
},
|
||||||
stream: stream
|
stream: stream
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Video,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type SectionId = "preferences" | "profile" | "billing"
|
type SectionId = "preferences" | "profile" | "streaming" | "billing"
|
||||||
|
|
||||||
const PLAN_CARD_NOISE =
|
const PLAN_CARD_NOISE =
|
||||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
|
||||||
@@ -462,6 +465,214 @@ function ProfileSection({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StreamingSection({ username }: { username: string | null | undefined }) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
// Stream settings
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [liveInputUid, setLiveInputUid] = useState("")
|
||||||
|
const [customerCode, setCustomerCode] = useState("")
|
||||||
|
const [streamKey, setStreamKey] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stream/settings", { credentials: "include" })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTitle(data.title || "")
|
||||||
|
setDescription(data.description || "")
|
||||||
|
setLiveInputUid(data.cloudflare_live_input_uid || "")
|
||||||
|
setCustomerCode(data.cloudflare_customer_code || "")
|
||||||
|
setStreamKey(data.stream_key || "")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stream/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cloudflare_live_input_uid: liveInputUid || null,
|
||||||
|
cloudflare_customer_code: customerCode || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Failed to save")
|
||||||
|
} else {
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyStreamKey = () => {
|
||||||
|
navigator.clipboard.writeText(streamKey)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamUrl = username ? `https://linsa.io/${username}` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="streaming" className="scroll-mt-24">
|
||||||
|
<SectionHeader
|
||||||
|
title="Streaming"
|
||||||
|
description="Configure your live stream settings."
|
||||||
|
/>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SettingCard title="Stream Info">
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Stream Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="My Live Stream"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What are you streaming?"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
|
||||||
|
<SettingCard title="Cloudflare Stream">
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="p-3 bg-teal-500/10 border border-teal-500/20 rounded-lg">
|
||||||
|
<p className="text-sm text-teal-300">
|
||||||
|
Enter your Cloudflare Live Input UID to enable automatic stream detection.
|
||||||
|
Your stream will go live automatically when you start streaming.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Live Input UID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={liveInputUid}
|
||||||
|
onChange={(e) => setLiveInputUid(e.target.value)}
|
||||||
|
placeholder="e.g., bb7858eafc85de6c92963f3817477b5d"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
Find this in your Cloudflare Stream dashboard under Live Inputs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Customer Code (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerCode}
|
||||||
|
onChange={(e) => setCustomerCode(e.target.value)}
|
||||||
|
placeholder="Leave empty to use default"
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
Only needed if using your own Cloudflare account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
|
||||||
|
<SettingCard title="Your Stream">
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{streamUrl && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Stream URL</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-teal-400 text-sm">
|
||||||
|
{streamUrl}
|
||||||
|
</code>
|
||||||
|
<a
|
||||||
|
href={streamUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{streamKey && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-white/70">Stream Key</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white/70 text-sm font-mono truncate">
|
||||||
|
{streamKey.slice(0, 8)}...{streamKey.slice(-4)}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyStreamKey}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-teal-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
Use this key to stream to Linsa (coming soon).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-rose-400">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{saved && <span className="text-sm text-teal-400 flex items-center gap-1"><Check className="w-4 h-4" /> Saved</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BillingSection() {
|
function BillingSection() {
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -661,6 +872,8 @@ function SettingsPage() {
|
|||||||
onChangeEmail={openEmailModal}
|
onChangeEmail={openEmailModal}
|
||||||
onChangePassword={openPasswordModal}
|
onChangePassword={openPasswordModal}
|
||||||
/>
|
/>
|
||||||
|
) : activeSection === "streaming" ? (
|
||||||
|
<StreamingSection username={session?.user?.username} />
|
||||||
) : activeSection === "billing" ? (
|
) : activeSection === "billing" ? (
|
||||||
<BillingSection />
|
<BillingSection />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user