Compare commits

...

56 Commits

Author SHA1 Message Date
71890520cb chore: console log not right 2025-04-04 22:44:46 +02:00
a5768cfe0a chore: i tripped 2025-04-04 22:37:28 +02:00
17228d20be feat: settings to toggle notifications 2025-04-04 22:35:01 +02:00
502e60d85d feat: send message to channel 2025-04-02 17:03:35 +02:00
1b384de455 feat: follower dm notification thingy 2025-03-31 22:20:52 +02:00
d39d6f8ed7 refactor: some code refactoring 2025-03-31 21:54:14 +02:00
18d69a6aab feat: bullmq poc 2025-03-31 21:45:35 +02:00
648d3296e2 feat: user notifications working, moving to bullmq 2025-03-31 20:40:15 +02:00
836b5b6951 feat: initial workers implementation 2025-03-31 18:16:21 +02:00
e83d0cf713 feat: db self following migration 2025-03-31 16:16:15 +02:00
6974a78201 fix: prevent people from following themselves 2025-03-31 15:31:35 +02:00
185fc910a0 fix: chat connection issues by pinging server 2025-03-28 23:51:27 +01:00
6406df0501 ci: fix builds for chat??? 2025-03-26 17:00:07 +01:00
30d3ad2f2d ci: fix builds finally 2025-03-25 21:13:50 +01:00
dd16124cb5 fix: db builds 2025-03-25 16:59:12 +01:00
6a63fe388e fix: ci builds hopefully 2025-03-25 16:54:23 +01:00
441aa64166 feat: docker stuff 2025-03-23 22:01:13 +01:00
41c50a8ee5 feat: websocket implementation 2025-03-23 21:40:49 +01:00
ede61678aa fix: some prisma shenanigans 2025-03-23 00:43:54 +01:00
f2c0abbcdc feat: initial chat and abstract auth away from next 2025-03-23 00:14:14 +01:00
fbdb43e6df ci: oops copilot sometimes does wrong stuff 2025-03-22 22:31:47 +01:00
6b82975350 feat: bring back schema migrations 2025-03-22 22:29:36 +01:00
72d49df94c ci: caching should work? 2025-03-22 17:48:20 +01:00
9d88f32f6a ci: caching should work? 2025-03-22 17:47:33 +01:00
59b07928c6 chore: Merge pull request #38 from SrIzan10/feat/monorepo
feat: monorepo
2025-03-22 15:41:56 +01:00
9d6bcf25b9 ci: change docker file path 2025-03-22 15:41:11 +01:00
981b0b8b77 feat: move back to yarn and finally fix builds 2025-03-22 15:34:10 +01:00
e57312e48f ci: docker changes 1 2025-03-21 21:18:22 +01:00
e736375d17 fix: some pages not working properly with new db 2025-03-21 21:15:02 +01:00
fe3a146bf1 feat: move to pnpm and implement db repo 2025-03-21 21:08:14 +01:00
f216ddc57d chore: initial prisma migration work 2025-03-21 19:18:26 +01:00
bd29c4848d feat: web now working i guess 2025-03-21 19:07:47 +01:00
089b1fd157 Update package.json 2025-03-21 18:56:14 +01:00
679a741155 ci: update docker 2025-03-21 18:52:09 +01:00
021962b78b chore: move to monorepo folder 2025-03-21 18:48:52 +01:00
Turbobot
9c2b166b8a feat(create-turbo): apply package-manager transform 2025-03-21 18:36:01 +01:00
Turbobot
b935be00f0 feat(create-turbo): apply official-starter transform 2025-03-21 18:36:01 +01:00
b7484bbfa1 ci: use bun instead of node for faster builds 2025-03-21 18:13:15 +01:00
ab6652f2c7 feat: no need to refresh website after onboarding now 2025-03-19 15:24:35 +01:00
5e1609abc2 fix: viewercount to show actual count 2025-03-18 19:24:59 +01:00
9538d23ed1 docs: initial readme 2025-03-17 16:51:44 +01:00
ed81b494f7 feat: secure hls streams 2025-03-17 16:41:54 +01:00
7af4137ff9 fix: workflow ping. test before pushing to prod!! 2025-03-16 19:15:03 +01:00
c702db9121 feat: welcome people to hctv through a workflow 2025-03-16 16:47:08 +01:00
95e821727b fix: viewers stuff 2025-03-16 16:24:33 +01:00
a9924d19e4 feat: show viewers 2025-03-16 16:05:16 +01:00
acd5f5b5f4 revert: "chore: Migrate workflows to Blacksmith (#34)" (#35)
This reverts commit 10db7d5833.
2025-03-16 14:43:14 +01:00
blacksmith-sh[bot]
10db7d5833 chore: Migrate workflows to Blacksmith (#34)
Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com>
2025-03-16 14:23:54 +01:00
51d8e8b6ad feat: small redesign (also docker fixes) 2025-03-16 00:39:24 +01:00
b470c33e9d fix: chat 4 omg wtf 2025-03-15 23:24:23 +01:00
5751ad1c64 fix: chat 3 2025-03-15 23:17:07 +01:00
6289b73498 fix: chat fix 2 2025-03-15 23:03:28 +01:00
10a7cc5ed5 feat: auto deployments!! 2025-03-15 22:51:17 +01:00
23a1ed1624 fix: chat stuff 2025-03-15 22:41:39 +01:00
4fd76deb98 fix: streams not showing up 2025-03-15 17:40:52 +01:00
9837cbb713 feat/nginx-rtmp-move (#33)
* feat: initial and probably final rtmp server backend

* feat: video player and next-ws

* chore: cleanup livekit api routes

* feat: chat

* feat: move to nginx flv

* feat: no viewer streaminfo implementation
2025-03-15 16:58:14 +01:00
151 changed files with 4726 additions and 1538 deletions

View File

@@ -1,34 +1,41 @@
# Ignore node_modules and build output
node_modules
.next
out
# Ignore logs and temporary files
*.log
*.tmp
*.swp
# Ignore local environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Ignore Docker files
Dockerfile
docker-compose.yml
# Ignore git files
# Version control
.git
.gitignore
# Dependencies
**/node_modules
.pnpm-store
# Build outputs
**/dist
**/.next
**/build
**/out
# Development files
**/.env*
!**/.env.example
**/.vscode
**/.idea
**/coverage
**/.turbo
**/.cache
# System files
.DS_Store
**/Thumbs.db
# Logs
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
# Test files
**/__tests__
**/*.test.*
**/*.spec.*
# Ignore editor directories and files
.vscode
.idea
*.sublime-project
*.sublime-workspace
# Ignore other unnecessary files
README.md
dev/
packages/db/generated
dev/
flv-module/

125
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,125 @@
name: Publish Docker image
on:
push:
branches:
- main
jobs:
frontend:
name: Push frontend to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
db:
name: Push db to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive-db
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./packages/db/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
chat:
name: Push chat module to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive-chat
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/chat/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
deploy:
name: Deploy to server
runs-on: ubuntu-latest
needs: [frontend, db, chat]
steps:
- name: Emit a webhook to the server
env:
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
run: |
curl -X POST \
-H "Authorization: $AUTH_HEADER" \
https://webhooks.srizan.dev/hooks/hctv

14
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
*node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
@@ -10,7 +10,7 @@
/coverage
# next.js
/.next/
*.next/
/out/
# production
@@ -27,7 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
.env*
# vercel
.vercel
@@ -37,5 +37,9 @@ yarn-error.log*
next-env.d.ts
certificates
dev/
!dev/docker-compose.yml
dev/psql
dev/redis
.turbo
packages/db/generated/client
*dist

View File

@@ -1,45 +0,0 @@
# Stage 1: Building the code
FROM node:lts-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy app files
COPY . .
# Build app
RUN yarn build
# Stage 2: Production
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy necessary files
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/yarn.lock ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/prisma ./prisma
# Install production dependencies only
RUN apk add --no-cache openssl
RUN yarn install --frozen-lockfile --production && \
yarn cache clean
# Remove unnecessary files
RUN rm -rf /app/.git \
/app/.next/cache \
/app/README.md
EXPOSE 3000
CMD ["yarn", "start"]

View File

@@ -1,24 +1,13 @@
# Sr Izan Stack
# hackclub.tv
Sr Izan Stack is a next.js template which runs on modern technologies, with a focus on developer experience and ease-of-use.
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
## The stack
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
- Framework: [Next.js](https://nextjs.org/)
- Language: [TypeScript](https://www.typescriptlang.org/)
- Styling: [Tailwind CSS](https://tailwindcss.com/)
- UI Library: [shadcn/ui](https://ui.shadcn.com)
- Authentication: [Lucia](https://lucia-auth.com)
- Deployment: [Vercel](https://vercel.com)
- Database: [Supabase Postgres](https://supabase.com) with [Prisma](https://www.prisma.io/)
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
## Why (insert tool here)?
## Features
- **Next.js**: I like the next.js app router because it has a very good developer experience and it's very easy to use.
- **TypeScript**: Don't even need to explain why
- **Tailwind CSS**: I like the utility-first approach and the speed of development it provides
- **shadcn/ui**: Copy-pasting components is so fire (also is Radix UI)
- **Lucia**: The DevEX is amazing and it's very easy to use
- **Vercel**: Next.js and Vercel are like bread and butter, but it's a bit slow with the free tier.
- **MongoDB Atlas**: It has a very generous free tier, and I'm choosing NoSQL because Postgres hates me.
- **Prisma**: Even though there are solid competitors like Drizzle, Prisma is easy to use, understand, and fast enough for my use case.
- High quality video streaming (low latency coming soon)
- Chat with other viewers
- Multiaccount support (database schema laid out, UI not implemented)

28
apps/chat/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

44
apps/chat/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo@^2
COPY . .
RUN turbo prune @hctv/chat --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --concurrency=1
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodeapp
USER nodeapp
COPY --from=installer --chown=nodeapp:nodejs /app/apps ./apps
COPY --from=installer --chown=nodeapp:nodejs /app/packages ./packages
COPY --from=installer --chown=nodeapp:nodejs /app/node_modules ./node_modules
COPY --from=installer --chown=nodeapp:nodejs /app/package.json ./package.json
ENV NODE_ENV=production
WORKDIR /app/apps/chat
EXPOSE 8000
ENTRYPOINT ["node", "dist/index.js"]

8
apps/chat/README.md Normal file
View File

@@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

23
apps/chat/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@hctv/chat",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hctv/hono-ws": "*",
"@hono/node-server": "^1.14.0",
"@hono/node-ws": "^1.1.0",
"@oslojs/encoding": "^1.1.0",
"hono": "^4.7.5"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1",
"typescript": "^5.8.2"
}
}

15
apps/chat/src/3d.txt Normal file
View File

@@ -0,0 +1,15 @@
,---, ___
,--.' | ,--.'|_
| | : | | :,'
: : : : : ' : .---.
: | |,--. ,---. .;__,' / /. ./|
| : ' | / \| | | .-' . ' |
| | /' : / / ':__,'| : /___/ \: |
' : | | |. ' / ' : |__. \ ' .
| | ' | :' ; :__ | | '.'|\ \ '
| : :_:,'' | '.'| ; : ; \ \
| | ,' | : : | , / \ \ |
`--'' \ \ / ---`-' '---"
`----'
This is hctv's chat backend. There's not much here, so go back to where you came from :)

129
apps/chat/src/index.ts Normal file
View File

@@ -0,0 +1,129 @@
import { serve } from '@hono/node-server';
import { createNodeWebSocket, type ModifiedWebSocket } from '@hctv/hono-ws';
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { prisma } from '@hctv/db';
const threed = await readFile('./src/3d.txt', 'utf-8');
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.get('/', async (c) => {
return c.text(threed);
});
app.get('/up', async (c) => {
return c.text('it works');
});
app.get(
'/ws/:username',
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
async onOpen(evt, ws) {
const token = getCookie(c, 'auth_session');
if (!token) {
ws.close();
return;
}
const { user } = await lucia.validateSession(token);
if (!user) {
ws.close();
return;
}
const personalChannel = await getPersonalChannel(user.id);
if (!personalChannel) {
ws.close();
return;
}
const { username } = c.req.param();
ws.targetUsername = username;
ws.user = user;
ws.personalChannel = personalChannel;
if (ws.raw) {
ws.raw.targetUsername = username;
// @ts-ignore
ws.raw.user = user;
ws.raw.personalChannel = personalChannel;
}
await prisma.streamInfo.update({
where: {
username,
},
data: {
viewers: {
increment: 1,
},
},
});
},
async onClose(evt, ws) {
console.log('client disconnected');
const streamInfo = await prisma.streamInfo.findUnique({
where: {
username: ws.targetUsername,
},
select: {
viewers: true,
},
});
if (!streamInfo) return;
await prisma.streamInfo.update({
where: {
username: ws.targetUsername,
},
data: {
viewers: streamInfo.viewers === 0 ? { set: 0 } : { decrement: 1 },
},
});
},
onMessage(evt, ws) {
const msg = JSON.parse(evt.data.toString());
if (msg.type === 'ping') {
ws.send(
JSON.stringify({
type: 'pong',
})
);
return;
} else if (msg.type === 'message') {
ws.wss.clients.forEach((c) => {
const client = c as ModifiedWebSocket;
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
c.send(
JSON.stringify({
user: {
id: ws.user.id,
username: ws.personalChannel.name,
pfpUrl: ws.user.pfpUrl,
},
message: msg.message,
})
);
}
});
}
},
}))
);
const server = serve(
{
fetch: app.fetch,
port: 8000,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
}
);
injectWebSocket(server);

View File

@@ -0,0 +1,17 @@
import { prisma } from "@hctv/db";
export async function getPersonalChannel(id: string) {
const db = await prisma.user.findUnique({
where: {
id,
},
select: {
personalChannel: true,
},
});
if (!db) {
return null;
}
return db.personalChannel;
}

16
apps/chat/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "dist",
"rootDir": "src"
}
}

44
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
RUN yarn global add turbo@^2
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "web" is the name entered in the project's package.json: { name: "web" }
RUN turbo prune @hctv/web --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
CMD node apps/web/server.js

85
apps/web/benchmark.py Normal file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
import asyncio
import aiohttp
import argparse
import time
import random
from tqdm import tqdm
async def simulate_viewer(session, base_url, stream_name, viewer_id, duration):
"""Simulate a viewer watching an HLS stream"""
hls_url = f"{base_url}/hls/{stream_name}.m3u8"
# First request the playlist
try:
start_time = time.time()
end_time = start_time + duration
while time.time() < end_time:
# Get the master playlist
async with session.get(hls_url) as response:
if response.status != 200:
print(f"Viewer {viewer_id}: Failed to get playlist: {response.status}")
return
playlist = await response.text()
# Parse the playlist to find segments
segments = [line for line in playlist.splitlines() if line.endswith('.ts')]
if segments:
# Request a random segment to simulate viewing
segment = random.choice(segments)
segment_url = f"{base_url}/hls/{segment}"
async with session.get(segment_url) as seg_response:
if seg_response.status != 200:
print(f"Viewer {viewer_id}: Failed to get segment: {seg_response.status}")
# Wait a bit before requesting again (simulating segment download)
await asyncio.sleep(2)
except Exception as e:
print(f"Viewer {viewer_id} error: {str(e)}")
async def run_benchmark(base_url, stream_name, num_viewers, duration):
"""Run the benchmark with the specified number of viewers"""
print(f"Starting benchmark with {num_viewers} viewers for {duration} seconds")
all_tasks = [] # Keep track of all tasks
async with aiohttp.ClientSession() as session:
with tqdm(total=num_viewers, desc="Connecting viewers") as pbar:
# Start viewers gradually to avoid overwhelming the server
for i in range(0, num_viewers, 10):
batch = []
for j in range(i, min(i+10, num_viewers)):
task = asyncio.create_task(simulate_viewer(session, base_url, stream_name, j, duration))
batch.append(task)
all_tasks.append(task)
pbar.update(len(batch))
await asyncio.sleep(0.5) # Small delay between batches
print(f"All {num_viewers} viewers connected. Running for {duration} seconds...")
# Wait for all tasks to complete
await asyncio.gather(*all_tasks)
print(f"Benchmark completed after {duration} seconds.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Benchmark NGINX-RTMP HLS streaming with simulated viewers')
parser.add_argument('--url', default='http://localhost:8888', help='Base URL of the NGINX server')
parser.add_argument('--stream', required=True, help='Stream name to connect to')
parser.add_argument('--viewers', type=int, default=100, help='Number of simulated viewers')
parser.add_argument('--duration', type=int, default=60, help='Duration in seconds to run the test')
args = parser.parse_args()
print(f"Benchmarking stream: {args.stream}")
print(f"Server: {args.url}")
print(f"Viewers: {args.viewers}")
print(f"Duration: {args.duration} seconds")
asyncio.run(run_benchmark(args.url, args.stream, args.viewers, args.duration))

BIN
apps/web/bun.lockb Executable file

Binary file not shown.

38
apps/web/next.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
import * as path from 'node:path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LIVE_SERVER_URL =
process.env.NODE_ENV === 'production'
? 'https://backend.hctv.srizan.dev'
: 'http://localhost:8888';
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: 'picsum.photos',
},
{
hostname: 'secure.gravatar.com',
},
],
},
env: {
LIVE_SERVER_URL,
},
reactStrictMode: false,
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
async rewrites() {
return [
{
source: '/api/stream/chat/:path*',
destination: `http://${process.env.NODE_ENV === 'production' ? 'chat' : 'localhost'}:8000/:path*`,
},
];
}
};
export default nextConfig;

76
apps/web/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "@hctv/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
"dev": "next dev --turbo",
"donly": "docker compose --file ../../dev/docker-compose.yml up",
"build": "next build",
"start": "next start",
"lint": "next lint",
"ui:add": "shadcn add",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hookform/resolvers": "^3.9.1",
"@livekit/components-react": "^2.7.0",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.6",
"@slack/web-api": "^7.9.1",
"@uidotdev/usehooks": "^2.4.1",
"arctic": "^3.1.1",
"bullmq": "^5.45.2",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.0",
"ioredis": "^5.6.0",
"livekit-client": "^2.8.0",
"livekit-server-sdk": "^2.9.7",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.2.3",
"next-themes": "^0.4.4",
"pg": "^8.14.1",
"pg-boss": "^10.1.6",
"react": "19",
"react-dom": "19",
"react-hook-form": "^7.54.2",
"sonner": "^1.4.41",
"swr": "^2.3.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"util-utils": "^1.0.3",
"valtio": "^2.1.2",
"ws": "^8.18.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ws": "^8.18.0",
"eslint": "^8",
"eslint-config-next": "15.1.3",
"postcss": "^8",
"shadcn": "^2.1.8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,5 +1,5 @@
import LiveStream from "@/components/app/Livestream/Livestream";
import prisma from "@/lib/db";
import { prisma } from '@hctv/db';
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params;

View File

@@ -0,0 +1,31 @@
import { validateRequest } from '@/lib/auth/validate';
import fsP from 'fs/promises';
import fs from 'fs';
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
const { path } = await params;
const { user } = await validateRequest();
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (path.includes('..')) {
return new Response("nuh uh", { status: 403 });
}
const basePath = '/dev/shm/hls';
const filePath = `${basePath}/${path}`;
const exists = fs.existsSync(filePath);
if (!exists) {
return new Response("Not Found", { status: 404 });
}
const file = await fsP.readFile(filePath);
return new Response(file, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
},
});
}

View File

@@ -0,0 +1,30 @@
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const streamKey = formData.get('name')?.toString() || '';
const key = await prisma.streamKey.findFirst({
where: {
key: streamKey,
},
include: {
channel: true,
},
});
if (!key) {
return new Response('nay', {
status: 403,
});
}
const headers = new Headers();
headers.append('Location', `rtmp://127.0.0.1/channel-live/${key.channel.name}`);
return new Response(null, {
status: 302,
headers: headers,
});
}

View File

@@ -0,0 +1,52 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
const body = await request.json();
const { channel } = body;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const channelInfo = await prisma.channel.findUnique({
where: { name: channel },
include: {
owner: true,
managers: true
}
});
if (!channelInfo) {
return new Response('Channel not found', { status: 404 });
}
const isBroadcaster =
channelInfo.ownerId === user.id ||
channelInfo.managers.some(m => m.id === user.id);
if (!isBroadcaster) {
return new Response('Unauthorized', { status: 401 });
}
const dbUpdate = await prisma.streamKey.upsert({
create: {
key: crypto.randomUUID(),
channelId: channelInfo.id
},
update: {
key: crypto.randomUUID()
},
where: {
channelId: channelInfo.id
}
})
return new Response(JSON.stringify({ key: dbUpdate.key }), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
}

View File

@@ -1,5 +1,6 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { getNotificationQueue } from '@/lib/workers';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
@@ -12,6 +13,17 @@ export async function GET(request: NextRequest) {
if (!username) {
return new Response('Bad Request', { status: 400 });
}
const channelOwner = await prisma.channel.findFirst({
where: {
name: username,
}
})
if (!channelOwner) {
return new Response('Not Found', { status: 404 });
}
if (channelOwner.ownerId === user.id) {
return new Response('you are of course not able to follow yourself', { status: 418 });
}
const isFollowing =
(await prisma.follow.count({
@@ -30,6 +42,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
const queue = getNotificationQueue();
const searchParams = new URL(request.url).searchParams;
const username = searchParams.get('username');
if (!user) {
@@ -38,6 +51,17 @@ export async function POST(request: NextRequest) {
if (!username) {
return new Response('Bad Request', { status: 400 });
}
const channelOwner = await prisma.channel.findFirst({
where: {
name: username,
}
})
if (!channelOwner) {
return new Response('Not Found', { status: 404 });
}
if (channelOwner.ownerId === user.id) {
return new Response('you are of course not able to follow yourself', { status: 418 });
}
const isFollowing =
(await prisma.follow.count({
@@ -77,6 +101,11 @@ export async function POST(request: NextRequest) {
},
},
});
await queue.add(`newFollow:${username}`, {
text: `You started following \`${username}\`!\n_Stream notifications are enabled by default. If you want to disable them, you can do so in \`Profile > Notifications\`._`,
channel: user.slack_id,
});
}
return new Response(JSON.stringify({ following: !isFollowing }), { status: 200 });

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { prisma } from '@hctv/db';
import { resolveChannelNameId } from '@/lib/db/resolve';
export async function GET(
@@ -15,7 +15,7 @@ export async function GET(
const channelId = await resolveChannelNameId(channel);
const count = await db.follow.count({
const count = await prisma.follow.count({
where: {
channelId,
},

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {

View File

@@ -1,4 +1,4 @@
import { validateRequest } from '@/lib/auth';
import { validateRequest } from '@/lib/auth/validate';
import { redirect, RedirectType } from 'next/navigation';
export default async function Layout({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,27 @@
'use client';
import { useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { notifyStreamToggle } from '@/lib/form/actions';
export default function NotifyToggle(props: Props) {
const [toggled, setToggled] = useState(props.toggled);
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async () => {
setIsLoading(true);
notifyStreamToggle(props.channel).then((res) => {
if (res.success) {
setToggled(res.toggle!);
}
});
setIsLoading(false);
};
return <Switch checked={toggled} onCheckedChange={handleToggle} disabled={isLoading} />;
}
interface Props {
channel: string;
toggled: boolean;
}

View File

@@ -0,0 +1,72 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import NotifyToggle from './notifyToggle';
export default async function Page() {
const { user } = await validateRequest();
const following = await prisma.follow.findMany({
where: {
userId: user!.id,
},
include: {
channel: true,
},
});
if (!following.length) {
return (
<div className="flex flex-col items-center justify-center w-full h-full">
<h1 className="text-2xl font-bold">No channels followed</h1>
<p className="text-muted-foreground">Go follow some first?</p>
<Link href={'/'}>
<Button>Back home</Button>
</Link>
</div>
);
}
return (
<div className="container py-10">
<h1 className="text-2xl font-bold mb-6">Followed Channels</h1>
<Table className="max-w-2xl mx-auto outline-surface bg-mantle rounded-md overflow-hidden">
<TableHeader>
<TableRow>
<TableHead>Channel</TableHead>
<TableHead className="w-[100px] text-center">Notifications</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{following.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={channel.channel.pfpUrl} alt={channel.channel.name} />
<AvatarFallback>{channel.channel.name.charAt(0)}</AvatarFallback>
</Avatar>
<Link href={`/${channel.channel.name}`} className="hover:underline">
{channel.channel.name}
</Link>
</div>
</TableCell>
<TableCell className="text-center">
<NotifyToggle channel={channel.channel.name} toggled={channel.notifyStream} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { slack, lucia } from '@/lib/auth';
import { slack, lucia } from '@hctv/auth';
import { cookies as nextCookies } from 'next/headers';
import { decodeIdToken, OAuth2RequestError } from 'arctic';
import { generateIdFromEntropySize } from 'lucia';
import prisma from '@/lib/db';
import { prisma } from '@hctv/db';
export async function GET(request: Request): Promise<Response> {
const cookies = await nextCookies();
@@ -11,6 +11,7 @@ export async function GET(request: Request): Promise<Response> {
const state = url.searchParams.get("state");
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
console.log('invalid state stuff');
return new Response(null, {
status: 400
});

View File

@@ -1,5 +1,5 @@
import { generateState } from "arctic";
import { slack } from "@/lib/auth";
import { slack } from '@hctv/auth';
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {

View File

@@ -19,7 +19,7 @@ export default function OnboardingClient() {
</CardDescription>
</CardHeader>
<CardContent>
<h1 className='text-red-500 animate-pulse animate-bounce'>REFRESH THE SITE AFTER SUBMITTING THE FORM!!</h1>
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
<UniversalForm
fields={[
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
@@ -28,8 +28,7 @@ export default function OnboardingClient() {
schemaName="onboard"
action={onboard}
onActionComplete={() => {
router.refresh();
redirect('/');
window.location.href = '/';
}}
/>
</CardContent>

View File

@@ -1,4 +1,4 @@
import { validateRequest } from "@/lib/auth";
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from "next/navigation";
import OnboardingClient from "./page.client";

View File

@@ -1,7 +1,8 @@
import LandingPage from '@/components/app/LandingPage/LandingPage';
import { Card, CardContent } from '@/components/ui/card';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import ConfusedDino from '@/components/ui/confuseddino';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
import Image from 'next/image';
import Link from 'next/link';
@@ -24,11 +25,17 @@ export default async function Home() {
return <LandingPage />;
}
if (!streams.length) {
return <div>No streams found</div>;
return (
<div className="flex justify-center items-center text-center flex-col pt-4 gap-2">
<h2>No streams found!!</h2>
<p>...maybe start one?</p>
<ConfusedDino className='w-40 h-40' />
</div>
);
}
return (
<div className='p-4'>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{streams.map((stream) => (
<Link href={`/${stream.username}`} key={stream.id}>

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -126,3 +126,61 @@ h1 {
h2 {
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
}
media-controller {
--media-primary-color: #ffffff;
--media-secondary-color: hsla(var(--background), 0.85);
--media-control-hover-background: hsla(var(--accent), 0.85);
--media-control-background: hsla(var(--secondary), 0.85);
--media-loading-icon-color: #ffffff;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid hsl(var(--border));
}
media-control-bar {
background-color: hsla(var(--background), 0.8);
backdrop-filter: blur(8px);
width: 100%;
box-sizing: border-box;
justify-content: space-between;
}
media-time-range {
--media-range-track-height: 6px;
--media-range-thumb-height: 14px;
--media-range-thumb-width: 14px;
--media-range-thumb-border-radius: 50%;
--media-range-bar-color: #ffffff;
--media-range-thumb-background: #ffffff;
--media-preview-background: hsla(var(--card), 0.9);
--media-preview-border-radius: var(--radius);
}
media-time-display {
--media-text-color: #ffffff;
}
media-controller::part(centered-layer) {
background-color: hsla(var(--background), 0.2);
transition: opacity 0.3s ease;
}
media-controller:not([mediapaused])[userinactive]::part(centered-layer) {
opacity: 0;
transition: opacity 1s ease;
}
media-loading-indicator {
--media-loading-icon-width: 48px;
--media-loading-icon-height: 48px;
--media-loading-icon-color: #ffffff;
}
media-play-button:hover,
media-mute-button:hover,
media-fullscreen-button:hover,
media-seek-backward-button:hover,
media-seek-forward-button:hover {
--media-control-hover-background: rgba(255, 255, 255, 0.2);
}

View File

@@ -3,7 +3,7 @@ import { Inter } from 'next/font/google';
import './globals.css';
import Navbar from '@/components/app/NavBar/NavBar';
import { SessionProvider } from '@/lib/providers/SessionProvider';
import { validateRequest } from '@/lib/auth';
import { validateRequest } from '@/lib/auth/validate';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
import { SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -0,0 +1,134 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useParams } from 'next/navigation';
export default function ChatPanel() {
const { username } = useParams();
const [message, setMessage] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
);
socketRef.current = socket;
socket.onopen = () => {
console.log('WebSocket connected');
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
setChatMessages((prev) => [...prev, data]);
} catch (e) {
console.log('Received message confirmation:', event.data);
}
};
socket.onclose = () => {
console.log('WebSocket closed');
};
return () => {
socket.close();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
if (chatMessages.length > 100) {
setChatMessages((prev) => prev.slice(chatMessages.length - 100));
}
}, [chatMessages]);
const sendMessage = () => {
if (!message.trim()) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'message', message }));
setMessage('');
} else {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
);
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'message', message }));
setMessage('');
};
}
};
useEffect(() => {
const interval = setInterval(() => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<div className="md:border flex flex-col w-full min-w-[350px] h-full bg-mantle">
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{chatMessages.map((msg, i) => (
<div key={i} className="flex space-x-2">
<div className="flex items-center gap-2">
<div className="font-bold shrink-0">{msg.user.username}</div>
</div>
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
</div>
))}
</div>
</div>
<div className="p-4 border-t">
<div className="flex space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
sendMessage();
}
}}
placeholder="Type a message"
className="flex-1 bg-transparent focus-visible:ring-offset-0"
/>
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
interface User {
id: string;
username: string;
pfpUrl: string;
}
interface ChatMessage {
user: User;
message: string;
}

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import EditLivestreamDialog from './dialog';
export default async function EditLivestream() {

View File

@@ -8,7 +8,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { StreamInfo } from '@prisma/client';
import { StreamInfo } from '@hctv/db';
import { UniversalForm } from '../UniversalForm/UniversalForm';
import { editStreamInfo } from '@/lib/form/actions';
import RegenerateKey from '../RegenerateKey/RegenerateKey';
@@ -31,7 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Channel } from '@prisma/client';
import type { Channel } from '@hctv/db';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import useSWR, { Fetcher } from 'swr';
import { fetcher } from '@/lib/services/swr';

View File

@@ -0,0 +1,36 @@
'use client';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
export default function LiveStream(props: Props) {
const isMobile = useIsMobile();
return (
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
<div className="flex-1 flex flex-col">
<StreamPlayer />
{isMobile && (
<div className="h-[300px]">
<ChatPanel />
</div>
)}
<UserInfoCard streamInfo={props.streamInfo} />
</div>
{!isMobile && (
<div>
<ChatPanel />
</div>
)}
</div>
);
}
interface Props {
username: string;
streamInfo: StreamInfo & { ownedBy: User };
}

View File

@@ -0,0 +1,107 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
import { Slack } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
export const links = [{ href: '/', name: 'home (placeholder link)' }];
function NavbarLinks() {
return (
<>
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant={'link'}>{link.name}</Button>
</Link>
))}
</>
);
}
export default function Navbar(props: Props) {
const { user } = useSession();
return (
<>
<nav className="flex items-center justify-between h-14 md:h-16 px-2 md:px-4 border-b gap-1 md:gap-3 w-full z-40 fixed top-0 left-0 shadow-md bg-mantle">
<div className="flex items-center space-x-2 md:space-x-5 shrink-0">
<Link href="/" className="flex items-center">
<Button variant={'ghost'} className="px-2 md:px-3 text-sm md:text-base">
hackclub.tv
</Button>
</Link>
<SidebarTrigger />
</div>
<div className="hidden md:flex">
<NavbarLinks />
</div>
{/* Right Side Items */}
<div className="flex items-center gap-1 md:gap-3 shrink-0">
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar className="h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user.pfpUrl} alt={`@${user.id}`} />
<AvatarFallback>{user.pfpUrl}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link href={`/settings/follows`}>
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<ThemeSwitcher />
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/auth/slack">
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
<Slack className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Sign in</span>
<span className="sm:hidden">Login</span>
</Button>
</Link>
)}
</div>
</nav>
</>
);
}
interface Props {
editLivestream: Promise<JSX.Element>;
}

View File

@@ -7,7 +7,7 @@ import { toast } from 'sonner';
import useSWR from 'swr/mutation';
export default function RegenerateKey(props: Props) {
const { error, isMutating, trigger } = useSWR('/api/livekit/broadcasterToken', async (url) =>
const { error, isMutating, trigger } = useSWR('/api/rtmp/streamKey', async (url) =>
defaultFetcher(url, { body: JSON.stringify({ channel: props.channel }), method: 'POST' })
);

View File

@@ -22,11 +22,6 @@ export default function Sidebar({ ...props }: React.ComponentProps<typeof UISide
const { stream, isLoading } = useStreams();
const [followedExpanded, setFollowedExpanded] = React.useState(true);
// console log stream every time it changes
React.useEffect(() => {
console.log('stream info', stream);
}, [stream]);
if (isLoading) return <SidebarSkeleton />;
const liveStreamers = stream?.filter((s) => s.isLive) || [];

View File

@@ -0,0 +1,53 @@
'use client';
import { useParams } from 'next/navigation';
import {
MediaController,
MediaLoadingIndicator,
MediaControlBar,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaMuteButton,
MediaVolumeRange,
MediaFullscreenButton
} from 'media-chrome/react';
import HlsVideo from 'hls-video-element/react'
export default function StreamPlayer() {
const { username } = useParams();
return (
<MediaController className='w-full aspect-video'>
<HlsVideo
src={`/api/rtmp/hls/${username}.m3u8`}
slot="media"
crossOrigin="anonymous"
autoplay
config={{
lowLatencyMode: true,
liveSyncDurationCount: 2, // Use only 1 segment for sync
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 0, // No back buffer
startLevel: -1, // Auto level selection
maxBufferLength: 4, // Maximum buffer length in seconds
maxMaxBufferLength: 6,
debug: false,
}}
/>
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
<MediaControlBar className='w-full px-2'>
<div className="flex items-center gap-2">
<MediaPlayButton />
<MediaMuteButton />
<MediaVolumeRange />
</div>
<div className="flex items-center gap-2">
<MediaFullscreenButton />
</div>
</MediaControlBar>
</MediaController>
);
}

View File

@@ -1,11 +1,12 @@
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import type { StreamInfo, User } from '@prisma/client';
import type { StreamInfo, User } from '@hctv/db';
import FollowButton from './follow';
import FollowCountText from './followCount';
import ViewerCount from './viewerCount';
export default function UserInfoCard(props: Props) {
return (
<div className="bg-mantle rounded-lg p-4">
<div className="bg-mantle p-4 border-b">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-4">
<Avatar className="h-16 w-16">
@@ -17,23 +18,12 @@ export default function UserInfoCard(props: Props) {
<FollowCountText channel={props.streamInfo.username} />
</div>
</div>
<FollowButton channel={props.streamInfo.username} />
<div className="flex items-center space-x-4">
<ViewerCount />
<FollowButton channel={props.streamInfo.username} />
</div>
</div>
<p className="mb-4">markdown description here</p>
{/* <div className="flex items-center space-x-4 text-gray-400">
<div className="flex items-center">
<Users className="h-5 w-5 mr-2" />
<span>1.2K viewers</span>
</div>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white">
<Heart className="h-5 w-5 mr-2" />
Like
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white">
<Share2 className="h-5 w-5 mr-2" />
Share
</Button>
</div> */}
</div>
);
}

View File

@@ -10,40 +10,44 @@ import React from 'react';
export default function FollowButton(props: Props) {
const [ref, isHovering] = useHover();
// const [following, setFollowing] = React.useState(props.isFollowing);
// make a get request to check if the user is following the channel and set it as the initial state. use swr to make the request
const [bye, setBye] = React.useState(false);
const { data: followingData, isLoading: isLoadingFollowing } = useSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url)
);
const [following, setFollowing] = React.useState(false);
const { trigger, data, isMutating } = mutatedUseSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url, { method: 'POST' })
);
React.useEffect(() => {
if (followingData) {
setFollowing(followingData.following);
}
if (followingData === undefined) {
setBye(true);
}
}, [followingData]);
const { trigger, data, isMutating } = mutatedUseSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url, { method: 'POST' })
);
React.useEffect(() => {
if (data) {
setFollowing(data.following);
}
}, [data]);
const followingCn = 'text-destructive';
const notFollowingCn = 'text-white';
return (
<Button
size={'icon'}
onClick={() => trigger()}
disabled={isMutating || isLoadingFollowing}
ref={ref}
variant={following ? 'destructive' : 'default'}
variant='outlineMantle'
className={bye ? 'hidden' : ''}
>
{isHovering && following ? <HeartCrack /> : <Heart />}
{isHovering && following ? <HeartCrack className={followingCn} /> : <Heart className={following ? followingCn : notFollowingCn} />}
</Button>
);
}

View File

@@ -0,0 +1,19 @@
import { useStreams } from "@/lib/providers/StreamInfoProvider";
import { User } from "lucide-react";
import { useParams } from "next/navigation";
export default function ViewerCount() {
const streamInfo = useStreams();
const { username } = useParams();
if (streamInfo.isLoading) return null;
const viewerCount = streamInfo.stream!.find(s => s.username === username)?.viewers;
return (
<div className="flex items-center space-x-2 *:text-destructive">
<span className="text-sm font-semibold"><User /></span>
<span className="text-sm">{viewerCount}</span>
</div>
);
}

View File

@@ -20,6 +20,7 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
outlineMantle: "border border-input bg-mantle hover:bg-accent",
},
size: {
default: "h-10 px-4 py-2",

View File

@@ -0,0 +1,59 @@
import { cn } from "@/lib/utils"
export default function ConfusedDino({ className }: { className?: string }) {
return (
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 153.86 112.82"
className={cn(className, "fill-black dark:fill-white")}
>
<title>confused_dinosaur</title>
<path
d="M1750.68,1812.34a2.49,2.49,0,0,1,1.91-.49c.69,0,1.43-.75,2.12-1.22a23.75,23.75,0,0,1,6.24-3.24,5.16,5.16,0,0,1,1.17-.23c3.38-.16,6.77-.42,10.17-.34,1.43,0,2.86-.13,4.29-.2a2.87,2.87,0,0,1,3,1.88c.62,1.45,1.7,2.72,1.72,4.41,0,3.06-.92,4.15-4,4.6a15.18,15.18,0,0,0-6.21,2,20.77,20.77,0,0,0-2.89,2.11,10,10,0,0,1-5.85,2.22,16,16,0,0,1-9.15-1.06,56.15,56.15,0,0,1-5.4-3.39,64.39,64.39,0,0,0-2.33,8.34,11.67,11.67,0,0,0-.21,5.32c.12.47.15.93.66,1.2s.6.94.72,1.46a16.19,16.19,0,0,0,2.31,5.46,4.07,4.07,0,0,1,.59,1.31,3.94,3.94,0,0,0,2.37,2.68,1.92,1.92,0,0,1,1,1c1.3,3,3.94,3.84,6.86,4.44,4.43.92,8.84-.36,13.23,0,3.15.27,6.17-.38,9.25-.7a33.07,33.07,0,0,1,4.67,0c4.11.21,8-1,11.83-2.1a30,30,0,0,0,6.6-2.42c4.19-2.38,8.11-5.16,11-9.07.64-.87,1.64-1.49,2-2.62.13-.41.67-.34,1.07-.35.88,0,1.76,0,2.63-.11.52,0,1,0,1.11.6s-.38.84-.89.93a8.9,8.9,0,0,0-2.42.52,9.51,9.51,0,0,0-2.84,2.66c-4.34,5-9.5,8.91-15.91,10.81-4.07,1.2-8.11,2.69-12.46,2.64-.77,0-.77.43-.54.94s.51.81.72,1.24a.73.73,0,0,1-.21,1.06.77.77,0,0,1-1.14-.29,7.42,7.42,0,0,1-.84-1.57,1.88,1.88,0,0,0-2.17-1.41,29.15,29.15,0,0,0-5.09.56c-2.73.57-5.5.11-8.25.19-1.56,0-3.11.18-4.66.24-3.89.13-7.83.28-11.45-1.65a6.26,6.26,0,0,1-2.28-1.91,34.32,34.32,0,0,0-3.5-3.63,3.6,3.6,0,0,1-1.08-1.95,3.44,3.44,0,0,0-.71-1.51,14.16,14.16,0,0,1-1.58-3.07,7.14,7.14,0,0,0-1.46-2.84c-.88-.86-.59-2.49-.7-3.79-.32-3.67.9-7.1,1.83-10.56a48.36,48.36,0,0,1,3.66-9.71c1.52-2.93,3-5.9,4.79-8.69,2.13-3.33,4.34-6.6,6.72-9.76a56.68,56.68,0,0,1,8.17-8.22c6.4-5.65,13.87-9.46,21.7-12.67a43.86,43.86,0,0,1,15.53-3.61c.63,0,1.72-.36,1.69.72s-1,.56-1.65.62a54.58,54.58,0,0,0-15.37,3.82,103.07,103.07,0,0,0-16.5,8.7c-2.59,1.6-4.68,3.84-7,5.82-5.22,4.51-8.72,10.35-12.39,16C1753.32,1806.94,1752.22,1809.73,1750.68,1812.34Zm21.06-4.21c-2.11.15-4.24-.12-6.33.32-1.6.33-3.31-.07-4.83.66a30.43,30.43,0,0,0-9,6,1.18,1.18,0,0,1-1,.51c-1-.12-1.08.71-1.3,1.29s-.29,1.43.33,1.75c1.59.84,2.91,2.08,4.53,2.87a12.27,12.27,0,0,0,4.55.87c3.16.26,6.24-.09,8.83-2.14,2.41-1.9,4.91-3.53,8-4a18.7,18.7,0,0,0,2.34-.49,2.56,2.56,0,0,0,1.7-3.89c-.41-.82-.88-1.62-1.21-2.47a2,2,0,0,0-2.17-1.32C1774.7,1808.15,1773.22,1808.13,1771.74,1808.13Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1804.84,1764.83a7.29,7.29,0,0,1,4-1.06c2.31.12,4.63,0,6.94.34a87.53,87.53,0,0,1,14.13,3,172.67,172.67,0,0,1,18.23,6.94c9.43,4.1,17.59,9.88,23.79,18.2a24.9,24.9,0,0,1,3,6.22,49.87,49.87,0,0,1,3,16.62c.07,2.91,0,5.83,0,8.75a115.17,115.17,0,0,1-1.22,13c-.44,3.72-.88,7.43-1.65,11.1q-.59,2.8-1.23,5.6c-.13.56-.39,1.15-1.14.91s-.47-.81-.35-1.38c.75-3.46,1.57-6.91,2.13-10.41a138,138,0,0,0,1.81-30.77,43.27,43.27,0,0,0-3.14-13.68c-3.22-7.91-9.29-13.07-16.13-17.61a68.64,68.64,0,0,0-11.56-6,164.81,164.81,0,0,0-16-6,90.55,90.55,0,0,0-12.23-2.8,55.36,55.36,0,0,0-6.44-.53C1808.84,1765.19,1806.87,1764.83,1804.84,1764.83Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1801,1801.39a15.42,15.42,0,0,0,1.27,7.22.62.62,0,0,1-.26.92,1,1,0,0,1-1.21-.18,2.08,2.08,0,0,1-.41-.86c-1.18-5.31-1.75-10.56,1.12-15.59a14.26,14.26,0,0,1,2.22-2.66,13.88,13.88,0,0,1,14.11-4.14c5.28,1.35,10,5.41,9.86,12-.05,1.9.17,3.82-.78,5.62a5.36,5.36,0,0,1-3.81,3.11c-4.64.94-9.3,1.82-13.9,2.93-2.28.55-4.52,1.28-6.79,1.9a2.34,2.34,0,0,1-.59.09c-.55,0-1.25.11-1.34-.65s.62-.74,1.16-.87c2-.51,3.92-1,5.87-1.58,3.18-.92,6.44-1.49,9.68-2.15,1.83-.37,3.69-.67,5.51-1.09,2.44-.56,3.2-2.6,3.51-4.66a12.42,12.42,0,0,0-1.18-7.45,10.91,10.91,0,0,0-6.1-5.12c-4.54-1.84-8.82-1-12.79,2A13.44,13.44,0,0,0,1801,1801.39Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1740.46,1771.69a16.18,16.18,0,0,1-1.12,6.4c-.37,1.09-.4,2.38-1,3.29-1,1.46-.17,2,.7,3a54.7,54.7,0,0,0,5.25,5.19c.37.33.61.66.33,1.12s-.22,1.07-.9,1.16-.76-.39-1.07-.77c-1.19-1.51-2.21-3.21-4-4.11a2.94,2.94,0,0,1-1.22-1.29,2.58,2.58,0,0,0-1.32-1.3c-1-.35-.79-1.23-.6-2a71.59,71.59,0,0,0,1.86-7.51,14.16,14.16,0,0,0-.21-6.63c-.88-2.65-2.16-3.28-4.83-2.4-1,.33-2,.79-3,1.2-.74.31-1.31.71-1.29,1.66,0,.64-.47,1-1,.68s-.61.14-.76.4c-.37.62-.59,1.7-1.53,1.2-.78-.42-.55-1.46-.16-2.16a10,10,0,0,1,4.57-4.39,11.6,11.6,0,0,1,6.1-.74c2.23.33,3.18,2.23,4.35,3.83A5.79,5.79,0,0,1,1740.46,1771.69Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1811.16,1804.55c-.45,0-1.09,0-1.72,0a3,3,0,0,1-2.58-2.7,9.46,9.46,0,0,0-.2-2.6,1.34,1.34,0,0,1,.06-.71c.33-1,2.92-2.87,4-2.66a21.72,21.72,0,0,0,3.69.17c1.94,0,2.65.75,2.69,2.67a6.08,6.08,0,0,1-1,3.94C1814.83,1804.32,1813.25,1804.77,1811.16,1804.55Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1756.16,1756c.22,2.61-.84,4.93-1.6,7.31a2.12,2.12,0,0,0,.3,2.08c1.82,2.72,3.53,5.5,5.3,8.25.3.47.4.94-.18,1.2a1.1,1.1,0,0,1-1.54-.51c-1.33-3-3.37-5.66-5.19-8.41a3.17,3.17,0,0,1-.28-2.53c.29-1.57,1-3,1.4-4.57a12.23,12.23,0,0,0,.11-5.67,19.09,19.09,0,0,0-4.11-8.64,2.59,2.59,0,0,0-3.36-.85,9.82,9.82,0,0,1-3.77.78,7.1,7.1,0,0,0-4.21,1.82c-.45.37-.88.69-1.3.11a.94.94,0,0,1,.33-1.45c1.68-1,3.36-2.08,5.43-1.93,1.51.11,2.74-.77,4.14-1a3.47,3.47,0,0,1,3.75,1.39,18.86,18.86,0,0,1,4.62,10.16C1756.09,1754.35,1756.11,1755.19,1756.16,1756Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1781.28,1752.19a7.12,7.12,0,0,1-1.11,5,2.64,2.64,0,0,0,.17,2.63c.3.69.63,1.37.87,2.08s.14,1.27-.59,1.48-.85-.45-.94-1c-.19-1.27-1.13-2.26-1.3-3.57a3.48,3.48,0,0,1,.46-2.49c1.75-2.76,1.41-5.62.28-8.46a1.69,1.69,0,0,0-2.46-1,11.27,11.27,0,0,0-1.68.89c-.4.22-.79.25-1.07-.14a.84.84,0,0,1,.11-1.1,4,4,0,0,1,6.73,1A10.27,10.27,0,0,1,1781.28,1752.19Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1761.28,1780.56a6.84,6.84,0,0,1,1-2.52.67.67,0,0,1,.85-.26.6.6,0,0,1,.41.65,3.11,3.11,0,0,0,.36,1.62c.28.45.61.92.1,1.39a1.62,1.62,0,0,1-1.91.56A1.33,1.33,0,0,1,1761.28,1780.56Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1748,1796.72c.06.82,0,1.57-1,1.81a1,1,0,0,1-1.3-.55c-.32-.91-.62-1.88.12-2.71a1.31,1.31,0,0,1,1.67-.15C1748.13,1795.47,1747.91,1796.15,1748,1796.72Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1784.67,1770.84c-.16.51.42,1.5-.59,1.7s-.83-.86-1.18-1.37a2,2,0,0,1-.29-1.37c0-.6,0-1.33.81-1.36s1.16.58,1.25,1.32C1784.7,1770.08,1784.67,1770.4,1784.67,1770.84Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1761.5,1815.55c-.22-1.62,1.06-2.58,1.56-3.85.17-.42.46-.82.6-1.28.21-.65.5-1.19,1.33-1.08.25,0,.44-.12.65-.24,2-1.15,3.67-.32,4.18,1.91a11.65,11.65,0,0,1-.4,5.19,3.32,3.32,0,0,1-1.74,2.18c-.8.42-1.5,1.28-2.59.6-.38-.23-.63.2-.91.4-.86.62-1.28.44-1.52-.65a1.35,1.35,0,0,0-.35-.73A2.76,2.76,0,0,1,1761.5,1815.55Z"
transform="translate(-1724.16 -1741.87)"
/>
</svg>
);
}

View File

@@ -271,7 +271,7 @@ const SidebarTrigger = React.forwardRef<
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
className={cn("h-8 w-8", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,12 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await (await import('@/lib/instrumentation/streamInfo')).default();
}
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerWorkers } = await import('@/lib/workers/register');
await registerWorkers();
console.log('bullmq workers registered');
}
}

View File

@@ -1,7 +1,8 @@
'use server';
import { cookies } from 'next/headers';
import { lucia, validateRequest } from '.';
import { lucia } from '@hctv/auth';
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from 'next/navigation';
export async function logout() {

View File

@@ -1,5 +1,5 @@
import { validateRequest } from ".";
import prisma from "../db";
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
export async function getPersonalChannel(id?: string) {
const { user } = await validateRequest();
@@ -15,4 +15,4 @@ export async function getPersonalChannel(id?: string) {
return null;
}
return db.personalChannel;
}
}

View File

@@ -0,0 +1,46 @@
import { prisma } from "@hctv/db";
import { validateRequest } from "./validate";
export async function resolveOwnedChannels(id?: string) {
const { user } = await validateRequest();
const db = await prisma.user.findUnique({
where: {
id: id ?? user?.id,
},
select: {
ownedChannels: true,
managedChannels: true,
},
});
if (!db) {
return null;
}
const channels = [
...db.ownedChannels.map((channel) => ({
...channel,
isOwner: true,
})),
...db.managedChannels.map((channel) => ({
...channel,
isOwner: false,
})),
];
return channels;
}
export async function resolveFollowedChannels(id?: string) {
const { user } = await validateRequest();
const db = await prisma.follow.findMany({
where: {
userId: id ?? user?.id,
},
include: {
channel: true,
},
});
if (!db) {
return null;
}
return db;
}

View File

@@ -0,0 +1,31 @@
import { cookies } from "next/headers";
import { cache } from "react";
import { lucia } from '@hctv/auth';
export const validateRequest = cache(async () => {
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId)
return {
user: null,
session: null,
};
const { user, session } = await lucia.validateSession(sessionId);
try {
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {
// Next.js throws error attempting to set cookies when rendering page
}
return {
user,
session,
};
});

View File

@@ -0,0 +1,31 @@
import { prisma } from '@hctv/db';
export async function resolveChannelNameId(channelName: string) {
const channel = await prisma.channel.findUnique({
where: {
name: channelName,
},
});
if (!channel) {
return '';
}
return channel.id;
}
export async function resolveUserPersonalChannel(userId: string) {
const channel = await prisma.channel.findFirst({
where: {
personalFor: {
id: userId,
},
},
});
if (!channel) {
return null;
}
return channel;
}

View File

@@ -1,11 +1,12 @@
'use server';
import { revalidatePath } from 'next/cache';
import { validateRequest } from '../auth';
import prisma from '../db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import zodVerify from '../zodVerify';
import { onboardSchema, streamInfoEditSchema } from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import { resolveFollowedChannels } from '../auth/resolve';
export async function editStreamInfo(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -89,5 +90,35 @@ export async function onboard(prev: any, formData: FormData) {
});
await initializeStreamInfo(createdChannel.id);
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
method: 'POST',
body: JSON.stringify({
username: zod.data.username,
}),
})
return { success: true };
}
export async function notifyStreamToggle(channelName: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const followed = await resolveFollowedChannels();
if (!followed) {
return { success: false, error: 'No followed channels' };
}
const channel = followed.find((f) => f.channel.name === channelName);
if (!channel) {
return { success: false, error: 'Channel not found' };
}
await prisma.follow.update({
where: { id: channel.id },
data: { notifyStream: !channel.notifyStream },
});
return { success: true, toggle: !channel.notifyStream };
}

View File

@@ -0,0 +1,147 @@
import { prisma } from '@hctv/db';
import { HttpFlv } from '../types/liveBackendJson';
import { getNotificationQueue } from '../workers';
import client from '../services/slackNotifier';
export default async function runner() {
// if there are no users it explodes so yeah
if ((await prisma.user.count()) === 0) {
return;
}
await initializeStreamInfo();
await syncStream();
setInterval(syncStream, 5000);
}
export async function initializeStreamInfo(channelId?: string) {
const channels = await prisma.channel.findMany({
where: {
id: channelId,
},
include: {
streamInfo: true,
},
});
for (const channel of channels) {
if (!channel.streamInfo.length) {
await prisma.streamInfo.create({
data: {
username: channel.name,
title: 'Untitled',
category: 'Uncategorized',
startedAt: new Date(0),
thumbnail: 'https://picsum.photos/600/400',
viewers: 0,
isLive: false,
channel: {
connect: { id: channel.id },
},
ownedBy: {
connect: { id: channel.ownerId },
},
},
});
}
}
}
export async function syncStream() {
try {
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
if (!response.ok) {
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const httpFlv = data['http-flv'] as HttpFlv;
if (!httpFlv?.servers?.[0]?.applications) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter((c) => !c.publishing).length,
});
}
for (const dbStream of currentLiveStreams) {
const streamStats = activeStreamMap.get(dbStream.username);
if (!streamStats || !streamStats.isLive) {
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
isLive: false,
viewers: 0,
startedAt: new Date(0),
},
});
}
}
for (const stream of activeStreams) {
if (stream.active) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
});
if (existingStream && !existingStream.isLive) {
await prisma.streamInfo.update({
where: { username: stream.name },
data: {
isLive: true,
startedAt: new Date(),
},
});
const subscribedFollowers = await prisma.follow.findMany({
where: {
channelId: existingStream.channelId,
notifyStream: true,
},
include: {
user: true,
},
});
const queue = getNotificationQueue();
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
channel: process.env.NOTIFICATION_CHANNEL_ID!,
unfurl_links: true,
});
for (const follower of subscribedFollowers) {
queue.add(`streamStartDm:${follower.user.id}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
channel: follower.user.slack_id,
unfurl_links: true,
});
}
}
}
}
} catch (error) {
console.error('Error syncing stream status:', error);
}
}

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, ReactNode } from 'react'
import useSWR from 'swr'
import { Channel, StreamInfo } from '@prisma/client'
import type { Channel, StreamInfo } from '@hctv/db'
import { fetcher } from '../services/swr'
const StreamContext = createContext<{

View File

@@ -0,0 +1,30 @@
import Redis from 'ioredis';
const createRedisConnection = () => {
return new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { maxRetriesPerRequest: null });
};
const globalForQueue = global as unknown as {
redisConnection: Redis | null;
};
if (!globalForQueue.redisConnection) {
globalForQueue.redisConnection = null;
}
export function getRedisConnection(): Redis {
if (!globalForQueue.redisConnection) {
console.log('Creating new Redis connection...');
globalForQueue.redisConnection = createRedisConnection();
}
return globalForQueue.redisConnection;
}
export async function closeRedisConnection(): Promise<void> {
// Close Redis connection
if (globalForQueue.redisConnection) {
await globalForQueue.redisConnection.quit();
globalForQueue.redisConnection = null;
console.log('Redis connection closed');
}
}

View File

@@ -0,0 +1,4 @@
import { WebClient } from '@slack/web-api';
const client = new WebClient(process.env.SLACK_NOTIFIER_TOKEN);
export default client;

View File

@@ -0,0 +1,117 @@
/**
* Types for nginx-http-flv module statistics
*/
// Client information
interface Client {
id: number;
address: string;
time: number;
flashver: string;
swfurl?: string;
dropped: number;
avsync: number;
timestamp: number;
publishing: boolean;
active: boolean;
}
// Video metadata
interface VideoMeta {
width: number;
height: number;
frame_rate: number;
codec: string;
profile: string;
compat: number;
level: number;
}
// Audio metadata
interface AudioMeta {
codec: string;
profile: string;
channels: number;
sample_rate: number;
}
// Stream metadata
interface StreamMeta {
video: VideoMeta;
audio: AudioMeta;
}
// Stream information
interface Stream {
name: string;
time: number;
bw_in: number;
bytes_in: number;
bw_out: number;
bytes_out: number;
bw_audio: number;
bw_video: number;
clients: Client[];
records: any[]; // Empty array in the provided example
meta: StreamMeta;
nclients: number;
publishing: boolean;
active: boolean;
}
// Live application section
interface Live {
streams: Stream[];
nclients: number;
}
// Recorder configuration
interface Recorder {
id: string;
flags: string[];
unique: boolean;
append: boolean;
lock_file: boolean;
notify: boolean;
path: string;
max_size: number;
max_frames: number;
interval: number;
suffix: string;
}
// Recorders section
interface Recorders {
count: number;
lists: Recorder[];
}
// Application information
interface Application {
name: string;
live: Live;
recorders: Recorders;
}
// Server information
interface Server {
port: number;
server_index: number;
applications: Application[];
}
// Root HTTP-FLV structure
export interface HttpFlv {
nginx_version: string;
nginx_http_flv_version: string;
compiler: string;
built: string;
pid: number;
uptime: number;
naccepted: number;
bw_in: number;
bytes_in: number;
bw_out: number;
bytes_out: number;
servers: Server[];
}

View File

@@ -0,0 +1,31 @@
import { Queue, Worker } from 'bullmq';
import { getRedisConnection } from '@/lib/services/redis';
// Singleton instances for notifier
const globalForNotifier = global as unknown as {
notificationQueue: Queue | null;
notificationWorker: Worker | null;
};
// Initialize if they don't exist
if (!globalForNotifier.notificationQueue) {
globalForNotifier.notificationQueue = null;
globalForNotifier.notificationWorker = null;
}
// Get or create the notification queue
export function getNotificationQueue(): Queue {
if (!globalForNotifier.notificationQueue) {
globalForNotifier.notificationQueue = new Queue('notifications', {
connection: getRedisConnection(),
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
}
});
}
return globalForNotifier.notificationQueue;
}

View File

@@ -0,0 +1,6 @@
import { registerNotificationWorker } from './worker/notification';
export async function registerWorkers(): Promise<void> {
await registerNotificationWorker();
console.log('All workers registered successfully');
}

Some files were not shown because too many files have changed in this diff Show More