Compare commits

...

10 Commits

Author SHA1 Message Date
ad9fa0df13 Revert "chore: Migrate workflows to Blacksmith (#34)"
This reverts commit 10db7d5833.
2025-03-16 14:42:44 +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
35 changed files with 1278 additions and 456 deletions

View File

@@ -31,4 +31,5 @@ docker-compose.yml
# Ignore other unnecessary files
README.md
dev/
dev/
flv-module/

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

@@ -0,0 +1,52 @@
name: Publish Docker image
on:
push:
branches:
- main
jobs:
push_to_registry:
name: Push Docker image 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@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
build-args: |
BUILDKIT_INLINE_CACHE=1
- 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

3
.gitignore vendored
View File

@@ -37,5 +37,4 @@ yarn-error.log*
next-env.d.ts
certificates
dev/
!dev/docker-compose.yml
dev/psql

View File

@@ -33,7 +33,7 @@ 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
yarn cache clean && yarn run prepare
# Remove unnecessary files
RUN rm -rf /app/.git \

85
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))

View File

@@ -8,4 +8,39 @@ services:
volumes:
- ./psql:/var/lib/postgresql/data
ports:
- 5555:5432
- 5555:5432
nginx-rtmp:
# ports:
# - 1935:1935
# - 8888:8888
network_mode: host
environment:
UID: 1000
GID: 1000
API_AUTH: skibiditoilet
volumes:
- ./nginx.conf:/etc/nginx/templates/nginx.conf.template
- ./html:/var/www/html
- /dev/shm/hls:/dev/shm/hls
image: flv-module
entrypoint:
- /bin/sh
- -c
- |
# Process the template file
mkdir -p /usr/local/nginx/conf
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
echo "Setting UID to $${UID} and GID to $${GID}"
usermod -u $${UID} nginx || echo "failed to change uid"
groupmod -g $${GID} nginx || echo "failed to change gid"
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
chown -R nginx:nginx /usr/local/nginx
mkdir -p /var/www/html
chown -R nginx:nginx /var/www/html
echo "testing nginx config..."
/usr/local/nginx/sbin/nginx -t
/usr/local/nginx/sbin/nginx -g 'daemon off;'

0
dev/html/.gitkeep Normal file
View File

74
dev/nginx.conf Normal file
View File

@@ -0,0 +1,74 @@
events {
worker_connections 1024;
}
rtmp {
server {
listen 1935;
application live {
live on;
record off;
on_publish http://localhost:3000/api/rtmp/publish;
}
application channel-live {
live on;
record off;
allow publish 127.0.0.1;
deny publish all;
hls on;
hls_type live;
hls_path /dev/shm/hls;
hls_fragment 2s;
hls_playlist_length 10s;
hls_cleanup on;
hls_variant _low BANDWIDTH=500000;
hls_variant _mid BANDWIDTH=1000000;
hls_variant _hi BANDWIDTH=1500000;
}
}
}
http {
include mime.types;
default_type application/octet-stream;
# performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
map $http_authorization $is_authorized {
default 0;
$API_AUTH 1;
}
server {
listen 8888;
location /stat {
if ($is_authorized = 0) {
return 401 "Unauthorized";
}
rtmp_stat all;
rtmp_stat_format json;
}
location /hls {
alias /dev/shm/hls;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin *;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
}
}
}

56
flv-module/Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
FROM alpine:3.19 as builder
RUN apk add --no-cache \
build-base \
pcre-dev \
zlib-dev \
openssl-dev \
wget \
git && \
wget http://nginx.org/download/nginx-1.26.3.tar.gz && \
tar -zxf nginx-1.26.3.tar.gz && \
git clone https://github.com/winshining/nginx-http-flv-module.git && \
cd nginx-1.26.3 && \
./configure --add-module=../nginx-http-flv-module && \
make -j$(nproc) && make install && \
rm -rf /nginx-1.26.3.tar.gz /nginx-1.26.3 /nginx-http-flv-module
FROM alpine:3.19
COPY --from=builder /usr/local/nginx /usr/local/nginx
# Install runtime dependencies including gettext for envsubst
RUN apk add --no-cache \
pcre \
zlib \
openssl \
ffmpeg \
shadow \
gettext && \
addgroup -S nginx && \
adduser -S -D -H -G nginx -s /sbin/nologin nginx && \
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp && \
chown -R nginx:nginx /usr/local/nginx
# Create directory for template files
RUN mkdir -p /etc/nginx/templates
EXPOSE 80 1935 8888
# Create an entrypoint script to handle environment variable substitution
RUN echo '#!/bin/sh \n\
# Replace environment variables in configuration templates \n\
for template in /etc/nginx/templates/*.conf.template; do \n\
if [ -f "$template" ]; then \n\
output_file="/usr/local/nginx/conf/$(basename $template .template)" \n\
echo "Processing template: $template -> $output_file" \n\
envsubst "$(env | awk -F= "{printf \\\"\\\$%s \\\",\\\$1}")" < $template > $output_file \n\
fi \n\
done \n\
\n\
# Start Nginx \n\
exec "$@"' > /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/local/nginx/sbin/nginx", "-g", "daemon off;"]

View File

@@ -10,7 +10,9 @@ const nextConfig = {
}
]
},
transpilePackages: ['livekit-server-sdk']
env: {
LIVE_SERVER_URL: process.env.NODE_ENV === 'production' ? 'https://backend.hctv.srizan.dev' : 'http://localhost:8888',
}
};
export default nextConfig;

View File

@@ -3,14 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev --experimental-https --turbo",
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev --turbo",
"donly": "docker compose --file dev/docker-compose.yml up",
"setup": "docker compose --file dev/docker-compose.yml up -d && prisma migrate deploy",
"build": "prisma generate && next build",
"start": "prisma migrate deploy && next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev --name",
"ui:add": "shadcn add"
"ui:add": "shadcn add",
"prepare": "next-ws patch"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
@@ -29,15 +31,19 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@uidotdev/usehooks": "^2.4.1",
"arctic": "^3.1.1",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.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.1.6",
"next-themes": "^0.4.4",
"next-ws": "^2.0.4",
"react": "19",
"react-dom": "19",
"react-hook-form": "^7.54.2",
@@ -47,12 +53,14 @@
"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",

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "StreamKey" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
CONSTRAINT "StreamKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "StreamKey_key_key" ON "StreamKey"("key");
-- CreateIndex
CREATE UNIQUE INDEX "StreamKey_channelId_key" ON "StreamKey"("channelId");
-- AddForeignKey
ALTER TABLE "StreamKey" ADD CONSTRAINT "StreamKey_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -46,6 +46,7 @@ model Channel {
managers User[] @relation("ChannelManagers")
streamInfo StreamInfo[]
followers Follow[] @relation("ChannelFollowers")
streamKey StreamKey?
@@index([ownerId])
}
@@ -89,4 +90,12 @@ model Follow {
@@unique([userId, channelId])
@@index([userId])
@@index([channelId])
}
model StreamKey {
id String @id @default(cuid())
key String @unique
channelId String @unique
channel Channel @relation(fields: [channelId], references: [id])
}

View File

@@ -1,102 +0,0 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { Track, VideoQuality } from 'livekit-client';
import { IngressAudioEncodingPreset, IngressAudioOptions, IngressInput, IngressVideoEncodingPreset, IngressVideoOptions, TrackSource, VideoCodec } from 'livekit-server-sdk';
export async function POST(request: Request) {
const { ingressClient, roomService } = await import('@/lib/services/livekit');
try {
const { user } = await validateRequest();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const body = await request.json();
const { channel } = body;
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 });
}
// clean up existing resources
const ingresses = await ingressClient.listIngress();
const channelIngresses = ingresses.filter(ingress => ingress.name === channel);
for (const ingress of channelIngresses) {
await ingressClient.deleteIngress(ingress.ingressId);
}
// reset and create room
const existingRoom = await roomService.listRooms()
.then(rooms => rooms.find(r => r.name === channel));
if (existingRoom) {
await roomService.deleteRoom(existingRoom.name);
}
await roomService.createRoom({ name: channel });
// create new ingress
const ingress = await ingressClient.createIngress(IngressInput.RTMP_INPUT, {
name: channel,
roomName: channel,
participantIdentity: 'streamer',
video: new IngressVideoOptions({
source: TrackSource.CAMERA,
encodingOptions: {
case: 'options',
value: {
videoCodec: VideoCodec.H264_BASELINE,
frameRate: 30,
layers: [
{
quality: VideoQuality.MEDIUM,
width: 1280,
height: 720,
bitrate: 2_500_000, // 2.5 mbps
},
{
quality: VideoQuality.LOW,
width: 640,
height: 360,
bitrate: 500_000, // 500 kbps
}
]
}
},
}),
audio: new IngressAudioOptions({
source: TrackSource.MICROPHONE,
encodingOptions: {
case: 'preset',
value: IngressAudioEncodingPreset.OPUS_STEREO_96KBPS,
}
})
});
return Response.json({
key: ingress.streamKey,
url: ingress.url
});
} catch (error) {
console.error('Broadcaster token error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}

View File

@@ -1,34 +0,0 @@
import { validateRequest } from '@/lib/auth';
import { getPersonalChannel } from '@/lib/auth/personalChannel';
import { AccessToken } from 'livekit-server-sdk';
import { NextRequest } from 'next/server';
import { randomString } from 'util-utils';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
const personalChannel = await getPersonalChannel();
const userSalt = randomString(8);
const room = request.nextUrl.searchParams.get('room');
if (!room) {
return new Response('Room is required', { status: 400 });
}
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_SECRET, {
identity: `${user.id}-${userSalt}`,
name: `${personalChannel!.name}-${userSalt}`,
ttl: 3600,
});
at.addGrant({
room,
roomJoin: true,
canSubscribe: true,
canPublish: false,
canPublishData: false,
});
return Response.json({ token: await at.toJwt() });
}

View File

@@ -0,0 +1,30 @@
import prisma from '@/lib/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";
import prisma from "@/lib/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

@@ -0,0 +1,69 @@
import { lucia } from '@/lib/auth';
import { resolveUserPersonalChannel } from '@/lib/db/resolve';
import type { WebSocket } from 'ws';
export async function SOCKET(
client: ExtendedWebSocket,
request: import('http').IncomingMessage,
server: import('ws').WebSocketServer
) {
const cookies = parseCookieString(request.headers.cookie!);
const { user } = await lucia.validateSession(cookies.auth_session);
if (!user) {
client.close();
return;
}
const personalChannel = await resolveUserPersonalChannel(user.id);
if (!personalChannel) {
client.close();
return;
}
const url = new URL(request.url!, `http://${request.headers.host}`);
const username = url.pathname.split('/').at(-1);
client.targetUsername = username!;
client.on('message', (message) => {
const msg = message.toString();
server.clients.forEach((c) => {
const client = c as ExtendedWebSocket;
if (client.readyState === client.OPEN && client.targetUsername === username) {
c.send(
JSON.stringify({
user: {
id: user.id,
username: personalChannel.name,
pfpUrl: user.pfpUrl,
},
message: msg,
})
);
/* if (msg === 'BOMB') {
for (let i = 0; i < 10000; i++) {
c.send(JSON.stringify({
user: {
id: user.id,
username: personalChannel.name,
pfpUrl: user.pfpUrl,
},
message: 'HIIIII',
}));
}
} */
}
});
});
}
function parseCookieString(cookie: string) {
return cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.split('=');
acc[key.trim()] = value;
return acc;
}, {} as Record<string, string>);
}
interface ExtendedWebSocket extends WebSocket {
targetUsername: string;
}

View File

@@ -1,5 +1,6 @@
import LandingPage from '@/components/app/LandingPage/LandingPage';
import { Card, CardContent } from '@/components/ui/card';
import ConfusedDino from '@/components/ui/confuseddino';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
@@ -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

@@ -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

@@ -4,40 +4,90 @@ import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useChat } from '@livekit/components-react';
import { useParams } from 'next/navigation';
export default function ChatPanel() {
const { username } = useParams();
const [message, setMessage] = useState('');
const chat = useChat();
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/${username}`
);
socketRef.current = socket;
socket.onopen = () => {
console.log('WebSocket connected');
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setChatMessages((prev) => [...prev, data]);
} catch (e) {
console.log('Received message confirmation:', event.data);
}
};
socket.onclose = () => {
console.log('WebSocket closed');
};
return () => {
socket.close();
};
}, []);
// auto scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [chat.chatMessages]);
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(message);
setMessage('');
} else {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/${username}`
);
socket.onopen = () => {
socket.send(message);
setMessage('');
};
}
};
return (
<div className="border-l flex flex-col w-[350px] min-w-[350px] h-full">
<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">
{chat.chatMessages.map((msg, i) => {
const splitName = msg.from?.name?.split('-');
const name = splitName?.slice(0, -1).join('-');
return (
// jank asf, but works (thanks claude)
<div key={i} className="flex space-x-2">
<div className="font-bold shrink-0">{name}</div>
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
{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">
@@ -47,21 +97,13 @@ export default function ChatPanel() {
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
chat.send(message);
setMessage('');
sendMessage();
}
}}
placeholder="Type a message"
className="flex-1 bg-transparent focus-visible:ring-offset-0"
/>
<Button
size="icon"
className="text-black transition-colors"
onClick={() => {
chat.send(message);
setMessage('');
}}
>
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
<Send className="h-4 w-4" />
</Button>
</div>
@@ -69,3 +111,14 @@ export default function ChatPanel() {
</div>
);
}
interface User {
id: string;
username: string;
pfpUrl: string;
}
interface ChatMessage {
user: User;
message: string;
}

View File

@@ -1,33 +1,32 @@
'use client';
import { LiveKitRoom } from '@livekit/components-react';
import { useEffect, useState } from 'react';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User } from '@prisma/client';
import { useIsMobile } from '@/lib/hooks/useMobile';
export default function LiveStream(props: Props) {
const [token, setToken] = useState('');
useEffect(() => {
fetch(`/api/livekit/viewerToken?room=${props.username}`)
.then((res) => res.json())
.then((data) => setToken(data.token));
}, [props.username]);
if (!token) return <div>Loading...</div>;
const isMobile = useIsMobile();
return (
<LiveKitRoom token={token} serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL} connect={true}>
<div className="flex h-[calc(100vh-64px)] w-full">
<div className="flex-1">
<StreamPlayer />
<UserInfoCard streamInfo={props.streamInfo} />
</div>
<ChatPanel />
<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>
</LiveKitRoom>
{!isMobile && (
<div>
<ChatPanel />
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
'use client'
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -37,55 +37,61 @@ export default function Navbar(props: Props) {
const { user } = useSession();
return (
<>
<nav className="flex items-center h-16 px-4 border-b gap-3 w-full z-20 fixed top-0 left-0 shadow-md bg-mantle">
<div className="flex items-center space-x-5">
<SidebarTrigger />
<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'}>hackclub.tv</Button>
<Button variant={'ghost'} className="px-2 md:px-3 text-sm md:text-base">
hackclub.tv
</Button>
</Link>
<SidebarTrigger />
</div>
<MobileNavbarLinks />
<div className="flex-1" />
<div className="hidden md:flex">
<NavbarLinks />
</div>
<div className="flex-1" />
{props.editLivestream}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar>
<AvatarImage src={user.pfpUrl} alt={`@${user.id}`} />
<AvatarFallback>{user.pfpUrl}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<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" className="gap-2">
<Slack className="w-4 h-4" />
Sign in
</Button>
</Link>
)}
{/* 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>
<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>
</>
);
@@ -93,4 +99,4 @@ export default function Navbar(props: Props) {
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

@@ -97,7 +97,7 @@ function StreamerItem({ streamer }: { streamer: StreamInfoResponse[0] }) {
<div className="flex-1">
<p className="font-medium truncate">{streamer.username}</p>
<p className="text-sm truncate">{streamer.category}</p>
{streamer.isLive && (
{false && streamer.isLive && (
<p className="text-sm">
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
</p>

View File

@@ -1,168 +1,53 @@
'use client';
import useFullscreen from '@/lib/hooks/useFullscreen';
import { useParams } from 'next/navigation';
import {
useTracks,
useParticipants,
useConnectionState,
TrackRefContext,
VideoTrack,
StartAudio,
AudioTrack,
} from '@livekit/components-react';
import { getTrackReferenceId } from '@livekit/components-core';
import { Track } from 'livekit-client';
import { LoaderCircleIcon, Minimize, Maximize, VolumeX, Volume2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
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 [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const handleVolumeChange = (newVolume: number, muted: boolean) => {
setVolume(newVolume);
setIsMuted(muted);
};
const containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen(containerRef);
const { username } = useParams();
const tracks = useTracks([
Track.Source.Camera,
Track.Source.Microphone,
Track.Source.ScreenShare,
Track.Source.ScreenShareAudio,
]);
const participants = useParticipants();
const connectionState = useConnectionState();
const [isConnecting, setIsConnecting] = useState(true);
const broadcasterTracks = tracks.filter((track) => track.participant.identity === 'streamer');
const audioTracks = broadcasterTracks.filter(track =>
track.publication.kind === "audio" ||
track.source === Track.Source.Microphone ||
track.source === Track.Source.ScreenShareAudio
);
// very hacky but works
useEffect(() => {
if (connectionState === 'connected') {
const timer = setTimeout(() => setIsConnecting(false), 2000);
return () => clearTimeout(timer);
}
}, [connectionState]);
useEffect(() => {
console.log('participants', participants);
}, [participants]);
if (connectionState === 'connecting' || isConnecting) {
return (
<div className="w-full aspect-video bg-black flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LoaderCircleIcon size={32} className="animate-spin text-white" />
<p className="text-white">Connecting to stream...</p>
return (
<MediaController className='w-full aspect-video'>
<HlsVideo
src={`${process.env.LIVE_SERVER_URL}/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>
);
}
if (connectionState === 'disconnected') {
return (
<div className="w-full aspect-video bg-black flex items-center justify-center">
<p className="text-white">Connection lost. Trying to reconnect...</p>
</div>
);
}
if (!broadcasterTracks.length) {
return (
<div className="w-full aspect-video bg-black flex items-center justify-center">
<p className="text-white">Stream is currently offline</p>
</div>
);
}
const trackRef = broadcasterTracks[0];
return (
<div className="w-full aspect-video bg-black relative group" ref={containerRef}>
<TrackRefContext.Provider value={trackRef}>
<VideoTrack trackRef={trackRef} className="w-full h-full" />
<StartAudio
label="Click to allow audio playback"
className="absolute top-0 h-full w-full bg-gray-2-translucent text-white"
/>
{audioTracks.map((trackRef) => (
<AudioTrack
key={getTrackReferenceId(trackRef)}
trackRef={trackRef}
volume={volume}
muted={isMuted}
/>
))}
</TrackRefContext.Provider>
{/* controls */}
<div className="absolute flex bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<VolumeControl
onChange={handleVolumeChange}
initialVolume={volume}
initialMuted={isMuted}
/>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-primary transition-colors">
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
</div>
</div>
);
}
function VolumeControl({
onChange,
initialVolume = 1,
initialMuted = false,
}: {
onChange: (volume: number, muted: boolean) => void;
initialVolume?: number;
initialMuted?: boolean;
}) {
const [volume, setVolume] = useState(initialVolume);
const [isMuted, setIsMuted] = useState(initialMuted);
const [showVolume, setShowVolume] = useState(false);
const handleVolumeChange = (newVolume: number) => {
setVolume(newVolume);
onChange(newVolume, newVolume === 0);
};
const toggleMute = () => {
setIsMuted(!isMuted);
onChange(volume, !isMuted);
};
return (
<div
className="relative flex items-center gap-2"
onMouseEnter={() => setShowVolume(true)}
onMouseLeave={() => setShowVolume(false)}
>
<button onClick={toggleMute} className="hover:text-primary transition-colors">
{isMuted || volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<div
className={`flex items-center transition-all duration-200 ${
showVolume ? 'w-24 opacity-100' : 'w-0 opacity-0'
}`}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
<div className="flex items-center gap-2">
<MediaFullscreenButton />
</div>
</MediaControlBar>
</MediaController>
);
}

View File

@@ -5,7 +5,7 @@ import FollowCountText from './followCount';
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">

View File

@@ -35,15 +35,17 @@ export default function FollowButton(props: Props) {
}
}, [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'
>
{isHovering && following ? <HeartCrack /> : <Heart />}
{isHovering && following ? <HeartCrack className={followingCn} /> : <Heart className={following ? followingCn : notFollowingCn} />}
</Button>
);
}

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

@@ -1,4 +1,3 @@
'use server'
import db from '@/lib/db';
export async function resolveChannelNameId(channelName: string) {
@@ -13,4 +12,20 @@ export async function resolveChannelNameId(channelName: string) {
}
return channel.id;
}
export async function resolveUserPersonalChannel(userId: string) {
const channel = await db.channel.findFirst({
where: {
personalFor: {
id: userId,
},
},
});
if (!channel) {
return null;
}
return channel;
}

View File

@@ -1,9 +1,9 @@
import prisma from "@/lib/db";
import { roomService } from "@/lib/services/livekit";
import prisma from '@/lib/db';
import { HttpFlv } from '../types/liveBackendJson';
export default async function runner() {
// if there are no users it explodes so yeah
if (await prisma.user.count() === 0) {
if ((await prisma.user.count()) === 0) {
return;
}
await initializeStreamInfo();
@@ -14,11 +14,11 @@ export default async function runner() {
export async function initializeStreamInfo(channelId?: string) {
const channels = await prisma.channel.findMany({
where: {
id: channelId
id: channelId,
},
include: {
streamInfo: true
}
streamInfo: true,
},
});
for (const channel of channels) {
@@ -33,12 +33,12 @@ export async function initializeStreamInfo(channelId?: string) {
viewers: 0,
isLive: false,
channel: {
connect: { id: channel.id }
connect: { id: channel.id },
},
ownedBy: {
connect: { id: channel.ownerId }
}
}
connect: { id: channel.ownerId },
},
},
});
}
}
@@ -46,47 +46,88 @@ export async function initializeStreamInfo(channelId?: string) {
export async function syncStream() {
try {
// get all active rooms
const rooms = await roomService.listRooms();
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
// process each room
for (const room of rooms) {
const isLive = room.numPublishers >= 1;
const originalStreamInfo = await prisma.streamInfo.findUnique({
where: { username: room.name }
});
// upsert stream info
await prisma.streamInfo.upsert({
where: {
username: room.name
},
create: {
username: room.name,
title: 'Untitled',
category: 'Uncategorized',
startedAt: new Date(),
thumbnail: 'https://picsum.photos/600/400',
viewers: 0,
channel: {
connect: { id: room.name }
},
isLive,
ownedBy: {
connect: { id: room.name }
}
},
update: {
isLive,
viewers: room.numParticipants - 1,
startedAt: !isLive ? new Date(0) :
(originalStreamInfo?.isLive ? originalStreamInfo.startedAt : new Date())
}
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;
// Handle case where the RTMP server is not available or doesn't have the expected data structure
if (!httpFlv?.servers?.[0]?.applications) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(app => app.name === 'channel-live');
const activeStreams = channelLiveApp?.live?.streams || [];
// Get all streams that are currently marked as live in the database
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
// Create a map of active streams from the RTMP server
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter(c => !c.publishing).length,
});
}
// Update all streams
for (const dbStream of currentLiveStreams) {
const streamStats = activeStreamMap.get(dbStream.username);
if (!streamStats || !streamStats.isLive) {
// Stream is no longer active, mark it as offline
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
isLive: false,
viewers: 0,
startedAt: new Date(0),
},
});
} else {
// Stream is still active, update viewers
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
viewers: streamStats.viewers,
},
});
}
}
// Process new streams that aren't in the database yet
for (const stream of activeStreams) {
if (stream.active) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
});
if (existingStream && !existingStream.isLive) {
// Stream just went live
await prisma.streamInfo.update({
where: { username: stream.name },
data: {
isLive: true,
startedAt: new Date(),
viewers: stream.clients.filter(c => !c.publishing).length,
},
});
}
}
}
} catch (error) {
console.error('Error syncing room streams:', error);
throw error;
console.error("Error syncing stream status:", error);
}
}
}

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[];
}

226
yarn.lock
View File

@@ -1349,6 +1349,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/node@*":
version "22.13.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4"
integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==
dependencies:
undici-types "~6.20.0"
"@types/node@^20":
version "20.17.12"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.12.tgz#ee3b7d25a522fd95608c1b3f02921c97b93fcbd6"
@@ -1374,6 +1381,13 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/ws@^8.18.0":
version "8.18.0"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5"
integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.19.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz#5f26c0a833b27bcb1aa402b82e76d3b8dda0b247"
@@ -1697,6 +1711,11 @@ bl@^5.0.0:
inherits "^2.0.4"
readable-stream "^3.4.0"
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -1800,6 +1819,11 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001688:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz#4585729d95e6b95be5b439da6ab55250cd125bf9"
integrity sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==
ce-la-react@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/ce-la-react/-/ce-la-react-0.1.3.tgz#ccb34ec24091bd8be3da40ddcedb4a99ae1db72f"
integrity sha512-zZwEEJv9XukeEGbswQXObaDJjYAufOIilSnDg4BWCpKNEYN84H9fpaB+wl+rYKWOIH4wBBPbLnOxKvDIwsL/JQ==
chalk@^4.0.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
@@ -1813,6 +1837,35 @@ chalk@^5.0.0:
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8"
integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==
cheerio-select@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4"
integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==
dependencies:
boolbase "^1.0.0"
css-select "^5.1.0"
css-what "^6.1.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
cheerio@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0.tgz#1ede4895a82f26e8af71009f961a9b8cb60d6a81"
integrity sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==
dependencies:
cheerio-select "^2.1.0"
dom-serializer "^2.0.0"
domhandler "^5.0.3"
domutils "^3.1.0"
encoding-sniffer "^0.2.0"
htmlparser2 "^9.1.0"
parse5 "^7.1.2"
parse5-htmlparser2-tree-adapter "^7.0.0"
parse5-parser-stream "^7.1.2"
undici "^6.19.5"
whatwg-mimetype "^4.0.0"
chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
@@ -1942,6 +1995,22 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
css-select@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==
dependencies:
boolbase "^1.0.0"
css-what "^6.1.0"
domhandler "^5.0.2"
domutils "^3.0.1"
nth-check "^2.0.1"
css-what@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@@ -1952,6 +2021,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
custom-media-element@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/custom-media-element/-/custom-media-element-1.4.2.tgz#5d70d357b7605deadce72341b7c0b97a447908d4"
integrity sha512-AM6FRWqJyW7pWTvXb4uJj6yvHE7C6UutdhJ5o3XO5NEl5aWFcfnpz8/TuW8qr1+/wfbj50wRvdArnSNjTmjmVw==
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -2082,6 +2156,36 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1, domutils@^3.1.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -2111,6 +2215,14 @@ emoji-regex@^9.2.2:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
encoding-sniffer@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5"
integrity sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==
dependencies:
iconv-lite "^0.6.3"
whatwg-encoding "^3.1.1"
enhanced-resolve@^5.15.0:
version "5.18.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz#91eb1db193896b9801251eeff1c6980278b1e404"
@@ -2119,6 +2231,11 @@ enhanced-resolve@^5.15.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"
entities@^4.2.0, entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
error-ex@^1.3.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -2826,6 +2943,30 @@ hasown@^2.0.0, hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hls-video-element@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/hls-video-element/-/hls-video-element-1.5.0.tgz#99d444ecc0a20520b7071f8d673dc5a47f3d07b3"
integrity sha512-ghFzkmd9kmeUjmHuZ/aw55V/n1OQCTy05U7yrs8oN5vmmd/pue+MxbQBXl2oXV7VupLB+LnxzZHycireLUy0VQ==
dependencies:
custom-media-element "^1.4.2"
hls.js "^1.5.11"
media-tracks "^0.3.3"
hls.js@^1.5.11:
version "1.5.20"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.20.tgz#7eb23bb5e2595311d4e2761038ca6882673de7e2"
integrity sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==
htmlparser2@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.1.0"
entities "^4.5.0"
https-proxy-agent@^6.2.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-6.2.1.tgz#0965ab47371b3e531cf6794d1eb148710a992ba7"
@@ -2839,6 +2980,13 @@ human-signals@^4.3.0:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==
iconv-lite@0.6.3, iconv-lite@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -3397,6 +3545,18 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
media-chrome@^4.8.0:
version "4.8.0"
resolved "https://registry.yarnpkg.com/media-chrome/-/media-chrome-4.8.0.tgz#ff881a5466fe02ad07344a370a8a1106988ca245"
integrity sha512-oioEGlluW+1RqknqsszrKHDs3NZ9AaatEaE2kYYOSWxnwvVmhRTfDWT4JeMgtUr5r3i2dAI3e/qbeb1j+a0MhA==
dependencies:
ce-la-react "^0.1.3"
media-tracks@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/media-tracks/-/media-tracks-0.3.3.tgz#cca72bd791dcb76cd6427dfa6b2baa25601ea192"
integrity sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -3490,6 +3650,11 @@ next-themes@^0.4.4:
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.4.tgz#ce6f68a4af543821bbc4755b59c0d3ced55c2d13"
integrity sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==
next-ws@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/next-ws/-/next-ws-2.0.4.tgz#704a61dfcb4911d561f49a38128d184a43a71737"
integrity sha512-r5w6X8J5i7/Cs/cUez7FF+3ZSVAe7aRjuGZ0BuyICKhE71x8RBSlTzluFZ5LmPcXZixGDlKDZoP1brNSPh73eQ==
next@^15.1.6:
version "15.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555"
@@ -3544,6 +3709,13 @@ npm-run-path@^5.1.0:
dependencies:
path-key "^4.0.0"
nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
dependencies:
boolbase "^1.0.0"
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -3707,6 +3879,28 @@ parse-json@^5.2.0:
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
parse5-htmlparser2-tree-adapter@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b"
integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==
dependencies:
domhandler "^5.0.3"
parse5 "^7.0.0"
parse5-parser-stream@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz#d7c20eadc37968d272e2c02660fff92dd27e60e1"
integrity sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==
dependencies:
parse5 "^7.0.0"
parse5@^7.0.0, parse5@^7.1.2:
version "7.2.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a"
integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==
dependencies:
entities "^4.5.0"
path-browserify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
@@ -4119,6 +4313,11 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
"safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
scheduler@^0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
@@ -4736,6 +4935,16 @@ undici-types@~6.19.2:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
undici-types@~6.20.0:
version "6.20.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
undici@^6.19.5:
version "6.21.2"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.2.tgz#49c5884e8f9039c65a89ee9018ef3c8e2f1f4928"
integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
@@ -4819,6 +5028,18 @@ webrtc-adapter@^9.0.0:
dependencies:
sdp "^3.2.0"
whatwg-encoding@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==
dependencies:
iconv-lite "0.6.3"
whatwg-mimetype@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e"
@@ -4906,6 +5127,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.18.1:
version "8.18.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb"
integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"