15 Commits

Author SHA1 Message Date
744b315f24 fix incorrect stream metadata url
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-08 21:36:56 +01:00
255e48ffb1 fix no clips on home page login
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-08 21:23:18 +01:00
b7b3c8e51b add stream metadata response from api
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-03 18:48:21 +01:00
9630565aee fix incorrect var name
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 17:18:52 +01:00
eab3f8be20 restyle the rest of the pages
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 16:32:00 +01:00
11809a7da8 add recent vods and clips to home page
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 15:17:06 +01:00
0b1ea8e089 add live now panel with time elapsed
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 21:52:43 +01:00
08cfbd4cbb add start date to StreamResponse for frontend
All checks were successful
CI / Maven unit tests (push) Successful in 27s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 20:50:29 +01:00
e5bb0a3307 remove unused Vite dependencies
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 19:01:59 +01:00
bb771e630b rename Box to Card
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 22:22:41 +01:00
3c50d6aaf4 add cringe home message
All checks were successful
CI / Maven unit tests (push) Successful in 20s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:28:34 +01:00
9dcab44465 add titles to homepage
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:23:34 +01:00
6ddffd8e2b fix sidebar spacing issues
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:53:13 +01:00
c86e5e5568 add colour and remove topbar
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:20:26 +01:00
2f9ddadb91 add new fonts
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 19:14:20 +01:00
97 changed files with 3036 additions and 1982 deletions

View File

@@ -17,4 +17,5 @@ POSTGRES_DB=vodSystem
FRONTEND_URL= FRONTEND_URL=
BACKEND_URL= BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800 STREAM_RING_LENGTH_SECOND=28800
STREAM_INGEST_URL=

View File

@@ -4,7 +4,7 @@ on:
push: push:
env: env:
REGISTRY: gitea.defaoite.dev REGISTRY: gitea.vodsystem.ddns.net
IMAGE_PREFIX: dylan/vod-system IMAGE_PREFIX: dylan/vod-system
jobs: jobs:

View File

@@ -20,11 +20,12 @@ services:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vod_rtmp container_name: vod_rtmp
network_mode: host extra_hosts:
- "host.docker.internal:host-gateway"
env_file: env_file:
- .env - .env
environment: environment:
- WEB_SERVER_URL=http://localhost:8080 - WEB_SERVER_URL=http://host.docker.internal:8080
ports: ports:
- "1935:1935" - "1935:1935"
volumes: volumes:

View File

@@ -15,7 +15,7 @@ services:
restart: unless-stopped restart: unless-stopped
backend: backend:
image: gitea.defaoite.dev/dylan/vod-system-backend:latest image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
container_name: vod_backend container_name: vod_backend
depends_on: depends_on:
- postgres - postgres
@@ -29,7 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: gitea.defaoite.dev/dylan/vod-system-frontend:latest image: gitea.vodsystem.ddns.net/dylan/vod-system-frontend:latest
container_name: vod_frontend container_name: vod_frontend
environment: environment:
VITE_API_URL: ${BACKEND_URL} VITE_API_URL: ${BACKEND_URL}
@@ -38,11 +38,11 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "5173:80" - "5173:5173"
restart: unless-stopped restart: unless-stopped
rtmp: rtmp:
image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
container_name: vod_rtmp container_name: vod_rtmp
env_file: env_file:
- .env - .env

View File

@@ -37,7 +37,7 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "5173:80" - "5173:5173"
restart: unless-stopped restart: unless-stopped
rtmp: rtmp:

View File

@@ -1,19 +1,9 @@
# --- Build stage --- FROM node:20-alpine
FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci RUN --mount=type=cache,target=/root/.npm npm ci
COPY . . COPY . .
RUN npm run build EXPOSE 5173
CMD ["npm", "run", "dev"]
# --- Production stage ---
FROM nginx:1.27-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
# Inject envs at runtime
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,10 +0,0 @@
#!/bin/sh
set -eu
cat <<EOF > /usr/share/nginx/html/env-config.js
window._env_ = {
VITE_API_URL: "${VITE_API_URL:-}",
VITE_FRONTEND_URL: "${VITE_FRONTEND_URL:-}",
VITE_GOOGLE_CLIENT_ID: "${VITE_GOOGLE_CLIENT_ID:-}"
};
EOF

View File

@@ -1,15 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -9,7 +9,6 @@
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/env-config.js"></script>
<title>The VoD System</title> <title>The VoD System</title>
</head> </head>
<body> <body>

View File

@@ -7,13 +7,14 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {
@@ -914,6 +915,68 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom/node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz",
"integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -980,6 +1043,12 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@iarna/toml": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
"license": "ISC"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1030,6 +1099,41 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@react-oauth/google": { "node_modules/@react-oauth/google": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz",
@@ -1989,9 +2093,9 @@
} }
}, },
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2255,9 +2359,9 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2309,6 +2413,81 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@typescript-eslint/types": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz",
"integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz",
"integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.26.0",
"@typescript-eslint/visitor-keys": "8.26.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.0.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.65.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
@@ -2404,9 +2583,9 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2458,6 +2637,23 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz",
"integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.26.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2542,11 +2738,28 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"license": "MIT"
},
"node_modules/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
"integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
@@ -2573,6 +2786,18 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -2655,6 +2880,27 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2684,6 +2930,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/comment-json": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
"integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==",
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"core-util-is": "^1.0.3",
"esprima": "^4.0.1",
"has-own-prop": "^2.0.0",
"repeat-string": "^1.6.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2711,16 +2973,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-js": { "node_modules/core-util-is": {
"version": "3.49.0", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"hasInstallScript": true, "license": "MIT"
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
@@ -2744,11 +3001,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debounce": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz",
"integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2769,6 +3037,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge-ts": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.4.tgz",
"integrity": "sha512-fxqo6nHGQ9zOVgI4KXqtWXJR/yCLtC7aXIVq+6jc8tHPFUxlFmuUcm2kC4vztQ+LJxQ3gER/XAWearGYQ8niGA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2969,7 +3246,6 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2996,6 +3272,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -3049,6 +3338,34 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3063,6 +3380,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3076,6 +3402,18 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3114,6 +3452,36 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/flowbite-react": {
"version": "0.11.9",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.11.9.tgz",
"integrity": "sha512-S8KwzbR2PcVv+iJDpIp62NrIjSkKIm6ZrwdssK0k4mDU8SvYSuecKstATrIxdHAIL3/FiT4VGNuCoTUSj4OcEQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "1.6.9",
"@floating-ui/react": "0.27.3",
"@iarna/toml": "2.2.5",
"@typescript-eslint/typescript-estree": "8.26.0",
"chokidar": "4.0.3",
"classnames": "2.5.1",
"comment-json": "4.2.5",
"debounce": "2.2.0",
"deepmerge-ts": "7.1.4",
"klona": "2.0.6",
"package-manager-detector": "0.2.9",
"recast": "0.23.11",
"tailwind-merge-v2": "npm:tailwind-merge@2.6.0",
"tailwind-merge-v3": "npm:tailwind-merge@3.0.1"
},
"bin": {
"flowbite-react": "dist/cli/bin.js"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"tailwindcss": "^3 || ^4"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3182,6 +3550,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-own-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz",
"integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3223,7 +3600,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -3233,7 +3609,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -3242,6 +3617,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3267,9 +3651,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3346,6 +3730,15 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3685,6 +4078,28 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3702,13 +4117,12 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3791,6 +4205,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/package-manager-detector": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.9.tgz",
"integrity": "sha512-+vYvA/Y31l8Zk8dwxHhL3JfTuHPm6tlxM2A3GeQyl7ovYnSp1+mzAxClxaOr0qO1TtPxbQxetI7v5XqKLJZk7Q==",
"license": "MIT"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3831,6 +4251,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -3880,6 +4312,26 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -3901,29 +4353,6 @@
"react": "^19.2.8" "react": "^19.2.8"
} }
}, },
"node_modules/react-range-slider-input": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz",
"integrity": "sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
},
"peerDependencies": {
"react": ">=17 || >=18",
"react-dom": ">=17 || >=18"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3935,9 +4364,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -3957,12 +4386,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.2", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.2" "react-router": "7.18.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -3972,6 +4401,44 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
"integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.16.1",
"esprima": "~4.0.0",
"source-map": "~0.6.1",
"tiny-invariant": "^1.3.3",
"tslib": "^2.0.1"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/repeat-string": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3982,6 +4449,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -4027,6 +4504,29 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4072,6 +4572,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4108,11 +4617,38 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tailwind-merge-v2": {
"name": "tailwind-merge",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-merge-v3": {
"name": "tailwind-merge",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.1.tgz",
"integrity": "sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
@@ -4129,6 +4665,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4177,11 +4719,22 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.12" "node": ">=18.12"
@@ -4194,9 +4747,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -4215,7 +4766,6 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -4320,9 +4870,9 @@
} }
}, },
"node_modules/typescript-eslint/node_modules/brace-expansion": { "node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -8,15 +8,16 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"postinstall": "flowbite-react patch"
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -15,9 +15,6 @@ import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx"; import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
function App() { function App() {
@@ -26,27 +23,24 @@ function App() {
}, []); }, []);
return ( return (
<GoogleOAuthProvider clientId={config.googleClientId}> <GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<AuthProvider> <Router>
<Router> <Routes>
<Routes> <Route element={<MainLayout />}>
<Route element={<MainLayout />}> <Route path="/" element={<Home />} />
<Route path="/" element={<Home />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/create" element={<ClipUpload />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-vods" element={<MyVods />} />
<Route path="/my-vods" element={<MyVods />} /> <Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} /> <Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} /> <Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/vods/:id/edit" element={<EditVod />} /> <Route path="/profile" element={<Profile />} />
<Route path="/profile" element={<Profile />} /> <Route path="/stream" element={<StreamControl />} />
<Route path="/stream" element={<StreamControl />} /> </Route>
<Route path="*" element={<NotFound />} /> </Routes>
</Route> </Router>
</Routes>
</Router>
</AuthProvider>
</ GoogleOAuthProvider> </ GoogleOAuthProvider>
); );
} }

View File

@@ -1,20 +0,0 @@
import { createContext } from "react";
import type { User } from "../utils/types.ts";
type AuthContextValue = {
/** The currently authenticated user, or null when logged out. */
user: User | null;
/** True until the initial "who am I" request has resolved. */
loading: boolean;
/** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

@@ -1,46 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
await refresh();
}, [refresh]);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

@@ -1,14 +0,0 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext.ts";
import type { AuthContextValue } from "./AuthContext.ts";
/** Access the centralised auth state. Must be used within an <AuthProvider>. */
const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export { useAuth };

View File

@@ -1,15 +0,0 @@
import clsx from "clsx";
import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
{children}
</div>
);
export default Box;

View File

@@ -0,0 +1,14 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
{children}
</div>
);
export default Card;

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li <li
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)} className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
onClick={() => onClick(item)} onClick={() => onClick(item)}
> >
{item} {item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}> <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button <button
onClick={() => setIsOpen(v => !v)} onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150" className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
> >
{label} {label}
</button> </button>
{isOpen && ( {isOpen && (
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-background border border-hairline shadow-md z-50 py-1 overflow-hidden"> <ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{children} {children}
</ul> </ul>
)} )}

View File

@@ -0,0 +1,38 @@
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
import { useAuth } from "../utils/AuthContext.tsx";
import { getUser, login } from "../utils/api/users.ts";
type Props = {
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
shape?: "rectangular" | "pill";
size?: "small" | "medium" | "large";
width?: string;
};
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
const { setUser } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => getUser().then(setUser))
.catch((error) => console.error("Login failed:", error));
};
return (
<GoogleLogin
shape={shape}
size={size}
width={width}
useOneTap={false}
onSuccess={handleLogin}
/>
);
};
export default GoogleSignIn;

View File

@@ -0,0 +1,13 @@
const IsLivePulse = () => {
return (
<span className={"relative flex h-2 w-2"}>
<span
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
></span>
</span>
);
}
export default IsLivePulse;

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import type { JobResponse } from "../utils/types.ts";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
// queued → reading source → encoding → writing file → complete
const stageOf = (job: TrackedJob): string => {
if (job.state === 'FAILED') return "failed";
if (job.state === 'SUCCEEDED') return "complete";
if (job.state === 'READY') return "queued";
if (job.progress < 0.05) return "reading source";
if (job.progress < 0.95) return "encoding";
return "writing file";
};
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
const failed = job.state === 'FAILED';
const done = job.state === 'SUCCEEDED';
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-[width] duration-500 ease-out",
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
)}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
{stageOf(job)}
</p>
<p className="font-data text-[11px] text-faint-mono">
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
</p>
</div>
</div>
);
};
export default JobProgress;
export { isSettled };
export type { TrackedJob };

View File

@@ -1,24 +1,17 @@
import { Lock } from 'lucide-react'; import {Lock} from "lucide-react";
import {useNavigate} from "react-router-dom";
const NotFound = () => {
const navigate = useNavigate();
const goHomePage = () => {
navigate("/")
}
const NoAccessPage = () => {
return ( return (
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6"> <div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} /> <Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono"> <h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
This page does not exist. Log in to see this page
</h2> </h2>
<p className="text-sm text-faint-mono/70 max-w-xs"> <p className="text-sm text-faint-mono/70 max-w-xs">
<a onClick={goHomePage} className={"font-bold cursor-pointer"}>Click here</a> to get back to the home page. Sign in with Google to view your clips and account details.
</p> </p>
</div> </div>
); );
} }
export default NotFound; export default NoAccessPage;

View File

@@ -0,0 +1,24 @@
import type React from "react";
import clsx from "clsx";
import { kickerClass } from "../utils/styles.ts";
type Props = {
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
kicker: string;
title: string;
/** Optional trailing action, right-aligned against the title. */
children?: React.ReactNode;
className?: string;
};
const PageHeader = ({ kicker, title, children, className }: Props) => (
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
<div className="flex flex-col gap-1">
<p className={kickerClass}>{kicker}</p>
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
</div>
{children}
</header>
);
export default PageHeader;

View File

@@ -5,11 +5,11 @@ type Props = {
label: string; label: string;
}; };
// Label on the left, control on the right — the shape used for export settings.
const Selector = ({ children, label }: Props) => ( const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-4">
<label className="w-full text-sm text-text-secondary">{label}</label> <label className="font-body text-[13px] text-text-body">{label}</label>
<div className="w-px h-5 text-text-secondary mx-2" /> <div className="w-32 flex-shrink-0">{children}</div>
{children}
</div> </div>
); );

View File

@@ -1,7 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react'; import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import { useAuth } from "../auth/useAuth.ts"; import {useAuth} from "../utils/AuthContext.tsx";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
type props = { type props = {
className?: string className?: string
@@ -9,41 +11,78 @@ type props = {
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />; const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({className}: props) => { const Sidebar = ({ className }: props) => {
const { user } = useAuth(); const { user, setUser } = useAuth()
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
return ( return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}> <aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2> <h2 className={"font-heading text-3xl"}>The VoD System</h2>
<nav className="flex flex-col flex-1 items-center w-3/4"> <nav className="flex flex-col flex-1 items-center">
<Divider /> <Divider />
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" /> <SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" /> <SidebarButton url="/create" logo={<Plus size={18}/>} label="Upload" />
</div> </div>
<Divider /> <Divider />
{user && ( {user && (
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<div> <div>
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" /> <SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" /> <SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</div> </div>
</div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<div className={"w-full"}> <div className={"p-2"}>
{user && ( {user ? (
<> <div>
<Divider /> <Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" /> <div className={"flex gap-2 items-center pt-1"}>
</> <img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt={"Profile Picture"}
/>
<div className={"flex flex-col"}>
<p className={"font-body text-text-strong text-sm"}>
{user.name}
</p>
<p className={"text-[0.625rem] font-body text-muted wrap-break-word"}>
Signed in with Google
</p>
</div>
<button
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
) : (
<div>
<Divider />
<GoogleSignIn />
</div>
)} )}
</div> </div>
</nav> </nav>

View File

@@ -1,92 +0,0 @@
import { Menu, X } from 'lucide-react';
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() => navigate("/"))
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div className={"hover:bg-hover rounded-lg p-0.5"}>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
/>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-error font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
)
}
export default Topbar;

View File

@@ -0,0 +1,32 @@
import React from "react";
import clsx from "clsx";
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
};
// Hover shifts one step darker within the same family — no transforms.
const variants: Record<Variant, string> = {
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
secondary: "bg-panel text-text-control hover:bg-chip-hover",
ghost: "text-muted hover:text-text-strong",
};
const Button = ({ variant = "primary", className, ...props }: Props) => (
<button
className={clsx(
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
"disabled:cursor-not-allowed",
variants[variant],
className
)}
{...props}
/>
);
export default Button;
export type { Variant };

View File

@@ -1,15 +0,0 @@
import React from "react";
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
{...props}
/>
);
};
export default MenuButton;

View File

@@ -1,14 +0,0 @@
import React from "react";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const PrimaryButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={`bg-terracotta text-on-accent font-body text-sm rounded-lg hover:bg-terracotta-hover disabled:bg-terracotta-disabled transition-colors duration-150 h-10 px-4 ${className}`}
{...props}
/>
);
};
export default PrimaryButton;

View File

@@ -16,9 +16,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
<button className={`flex items-center gap-2.5 w-full text-sm <button className={`flex items-center gap-2.5 w-full text-sm
font-body p-2 hover:bg-hover hover:text-text-strong font-body p-2 hover:bg-hover hover:text-text-strong
rounded-md rounded-md
${isActive ? ${isActive ?
"bg-card text-text-strong font-medium shadow-sm" "bg-card text-text-strong font-medium shadow-sm"
: :
"text-text-primary"}`}> "text-text-primary"}`}>
{logo}{label} {logo}{label}
</button> </button>
@@ -26,4 +26,4 @@ const SidebarButton = ({url, logo, label}: Props) => {
) )
} }
export default SidebarButton; export default SidebarButton;

View File

@@ -1,42 +0,0 @@
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import { useRef, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
const previousRange = useRef<[number, number]>([0, 0]);
const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return;
if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0];
setSliderValue(val[0]);
} else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1];
setSliderValue(val[1]);
}
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
previousRange.current = val;
};
return (
<RangeSlider
min={0}
max={videoMetadata.duration}
step={0.1}
onInput={handleRangeInput}
className={className}
id="range-slider"
/>
);
}

View File

@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = { type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
const inputClass = "border border-hairline bg-card rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"; // Mirrors CompressionService.buildBitrate so the number shown is the number used:
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
const BUDGET_MULTIPLIER = 0.9;
const AUDIO_RATIO = 0.15;
const MAX_AUDIO_BITRATE = 128;
export default function ConfigBox({ setMetadata, className }: Props) { type Estimate = { total: number; audio: number; video: number; bpp: number | null };
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
const { fileSize, duration, width, height, fps } = metadata;
if (!fileSize || !duration || duration <= 0) return null;
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
const video = total - audio;
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
return { total, audio, video, bpp };
};
const formatBitrate = (kbps: number): string =>
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
return { word: "very soft", tone: "text-live-red" };
};
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number); const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height })); setMetadata(prev => ({ ...prev, width, height }));
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
}; };
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 })); setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
}; };
const sizeMb = Math.round(metadata.fileSize / 1000);
const estimate = estimateBitrate(metadata);
const verdict = estimate && verdictOf(estimate);
return ( return (
<div className={clsx("flex flex-col gap-3 p-6", className)}> <div className={clsx("flex flex-col gap-4", className)}>
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2> <p className={kickerClass}>Export settings</p>
<Selector label="Resolution"> <Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720" <select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
onChange={updateRes} className={inputClass}> onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option> <option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option> <option value="2560,1440">1440p</option>
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="FPS"> <Selector label="Frames per second">
<select id="fps" name="fps" defaultValue="30" <select id="fps" name="fps" value={String(metadata.fps)}
onChange={updateFps} className={inputClass}> onChange={updateFps} className={inputClass}>
<option value="60">60</option> <option value="60">60</option>
<option value="30">30</option> <option value="30">30</option>
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="File Size Limit (MB)"> <Selector label="Size limit (MB)">
<input type="number" min="1" defaultValue="10" <input type="number" min="1" value={sizeMb}
onChange={updateFileSize} className={inputClass} onChange={updateFileSize} className={monoInputClass}
/> />
</Selector> </Selector>
{/* What that budget actually buys, at this length and resolution. */}
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Estimated bitrate</p>
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
</div>
{estimate ? (
<>
<p className="font-data text-[15px] text-text-primary tabular-nums">
{formatBitrate(Math.max(0, estimate.video))}
</p>
<p className="font-body text-[12px] text-muted">
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
across {metadata.duration.toFixed(1)}s
</p>
</>
) : (
<p className="font-body text-[12px] text-muted">
Pick a trim range to see what the size limit buys you.
</p>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import React from "react"; import React from "react";
import Button from "../buttons/Button.tsx";
import { kickerClass } from "../../utils/styles.ts";
type props = { type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>; dataSend: React.MouseEventHandler<HTMLButtonElement>;
@@ -9,43 +10,44 @@ type props = {
uploading: boolean; uploading: boolean;
} }
const Spinner = () => ( // queued → reading source → encoding → writing file → complete
<svg const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
className="animate-spin h-4 w-4" if (downloadable) return "complete";
xmlns="http://www.w3.org/2000/svg" if (!uploading) return "waiting";
fill="none" if (progress < 0.05) return "reading source";
viewBox="0 0 24 24" if (progress < 0.95) return "encoding";
> return "writing file";
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> };
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
); const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return ( return (
<div className={"flex flex-col gap-3"}> <div className="flex flex-col gap-4">
<PrimaryButton <p className={kickerClass}>Export</p>
onClick={dataSend}
disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</PrimaryButton>
<div className="h-10 flex items-center"> <Button onClick={dataSend} disabled={uploading} className="w-full py-3">
{ downloadable ? {uploading ? "Compressing…" : "Compress and save"}
(<PrimaryButton className="w-full" onClick={handleDownload}> </Button>
Download
</PrimaryButton>) <div className="flex flex-col gap-1.5">
:( <div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<progress <div
value={progress} className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
className="w-full h-1.5 rounded bg-fields"> style={{ width: `${pct}%` }}
</progress> />
) </div>
} <div className="flex items-baseline justify-between gap-3">
<p className="font-data text-[11px] text-muted">{stageOf(progress, uploading, downloadable)}</p>
<p className="font-data text-[11px] text-faint-mono">{downloadable ? "done" : `${pct}%`}</p>
</div>
</div> </div>
{downloadable && (
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
Download the file
</Button>
)}
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = { type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -8,16 +9,14 @@ type Props = {
}; };
const MetadataBox = ({ setMetadata, className }: Props) => ( const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}> <div className={clsx("flex flex-col gap-2", className)}>
<p className="w-full font-body text-text-strong font-bold text-sm"> <label className={labelClass} htmlFor="clip-title">Title</label>
Title <span className="text-error">*</span>
</p>
<input <input
id="clip-title"
type="text" type="text"
required placeholder="Cat walks across the patch bay"
placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors" className={inputClass}
/> />
</div> </div>
); );

View File

@@ -1,37 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
sliderValue: number;
setSliderValue: Dispatch<SetStateAction<number>>;
className?: string;
};
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber);
};
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={className}
/>
);
}

View File

@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}, [video]); }, [video]);
return ( return (
<div className={clsx("flex justify-between items-center mb-2", className)}> <div className={clsx("flex justify-between items-center mb-3", className)}>
<div className="flex gap-1"> <div className="flex items-center gap-2 text-text-control">
<Icon size={24} /> <Icon size={20} />
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={volume} value={volume}
onChange={updateVolume} onChange={updateVolume}
className="w-20" className="w-20 accent-accent"
/> />
</div> </div>
<button <button
onClick={togglePlay} onClick={togglePlay}
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body" className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
> >
{isPlaying ? <Pause size={24} /> : <Play size={24} />} {isPlaying ? <Pause size={22} /> : <Play size={22} />}
</button> </button>
<span className="font-data text-sm text-text-secondary tabular-nums"> <span className="font-data text-[13px] text-text-secondary tabular-nums">
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,283 @@
import { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Range = [number, number];
type Props = {
/** Full length of the source video, in seconds. */
duration: number;
/** The trim window, in seconds. */
range: Range;
onRangeChange: (range: Range) => void;
/** Playhead position, in seconds. */
currentTime: number;
onSeek: (seconds: number) => void;
className?: string;
};
/** Never zoom in past this, or the handles have nothing left to grab. */
const MIN_VIEW_SECONDS = 0.5;
/** The shortest trim the handles will allow between them. */
const MIN_GAP = 0.1;
const ZOOM_STEP = 1.6;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
/** A readable tick interval that yields roughly 6 labels across the view. */
const tickInterval = (span: number): number => {
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
return steps.find((step) => span / step <= 6) ?? 3600;
};
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
const [view, setView] = useState<Range>([0, duration]);
// Which thing the pointer is currently dragging, if any.
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const overviewRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef(0);
// A new source resets the view to the whole thing.
useEffect(() => setView([0, duration]), [duration]);
const [viewStart, viewEnd] = view;
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
const zoomFactor = duration / viewSpan;
/** Seconds → percentage across the zoomed track. */
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
/** Pointer x → seconds, within the zoomed track. */
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width === 0) return span[0];
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return span[0] + ratio * (span[1] - span[0]);
};
/** Re-centre the view on `focus`, keeping it inside the source. */
const applyZoom = useCallback((nextSpan: number, focus: number) => {
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
let start = focus - span * ratio;
start = clamp(start, 0, Math.max(0, duration - span));
setView([start, start + span]);
}, [duration, view]);
const zoomBy = (factor: number) => {
const focus = clamp(currentTime, viewStart, viewEnd);
applyZoom(viewSpan / factor, focus);
};
/** Zoom the view down to the trim range, with a little air on each side. */
const zoomToRange = () => {
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
const padded = Math.min(duration, span * 1.25);
const centre = (range[0] + range[1]) / 2;
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
setView([start, start + padded]);
};
// Wheel over the track zooms around the pointer rather than scrolling the page.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
event.preventDefault();
const focus = toSeconds(event.clientX, track, view);
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
};
track.addEventListener("wheel", onWheel, { passive: false });
return () => track.removeEventListener("wheel", onWheel);
}, [applyZoom, view, viewSpan]);
// The move handler reads through refs, so it always sees the current view and
// range without having to be torn down and rebuilt on every frame of a drag.
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
latest.current = { view, range, onRangeChange, onSeek, duration };
// Listeners are attached on mousedown rather than in an effect: a fast drag
// would otherwise lose its first frames waiting for a re-render.
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setDragging(kind);
const onMove = (move: MouseEvent) => {
const state = latest.current;
const span = state.view[1] - state.view[0];
if (kind === "window") {
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
setView([start, start + span]);
return;
}
const at = toSeconds(move.clientX, trackRef.current, state.view);
if (kind === "playhead") {
state.onSeek(at);
} else if (kind === "in") {
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
state.onSeek(at);
} else {
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
state.onSeek(at);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDragging(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
if (duration <= 0) return null;
const interval = tickInterval(viewSpan);
const firstTick = Math.ceil(viewStart / interval) * interval;
const ticks: number[] = [];
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
const windowLeft = (viewStart / duration) * 100;
const windowWidth = (viewSpan / duration) * 100;
return (
<div className={clsx("flex flex-col gap-3 select-none", className)}>
{/* Overview — the whole source, with the zoom window drawn on top. */}
<div
ref={overviewRef}
onMouseDown={(event) => {
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
// Grabbing inside the window drags it; clicking outside jumps it here.
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
setView([start, start + viewSpan]);
beginDrag("window", event);
}}
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
>
<div
className="absolute inset-y-0 bg-olive-light/30"
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 w-px bg-accent"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
/>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={viewSpan >= duration}
title="Zoom out"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
−
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={viewSpan <= MIN_VIEW_SECONDS}
title="Zoom in"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
+
</button>
<span className="font-data text-[11px] text-faint-mono tabular-nums">
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
{" · "}
{formatTime(viewStart)} – {formatTime(viewEnd)}
</span>
<div className="flex-1" />
<button
onClick={zoomToRange}
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
>
Fit to trim
</button>
<button
onClick={() => setView([0, duration])}
disabled={viewSpan >= duration}
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
>
Whole clip
</button>
</div>
{/* Zoomed track */}
<div
ref={trackRef}
onMouseDown={(event) => {
onSeek(toSeconds(event.clientX, trackRef.current, view));
beginDrag("playhead", event);
}}
className="relative h-14 rounded-chip bg-fields cursor-pointer"
>
{ticks.map((at) => (
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
<div className="absolute inset-y-0 w-px bg-inactive/70" />
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
{formatTime(at)}
</span>
</div>
))}
{/* Trim band */}
<div
className="absolute inset-y-0 bg-olive-light/35"
style={{
left: `${clamp(toPct(range[0]), 0, 100)}%`,
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
}}
/>
{/* Playhead */}
<div
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
/>
{/* In / out handles — only grabbable while inside the view */}
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
const pct = toPct(at);
if (pct < -2 || pct > 102) return null;
return (
<div
key={edge}
onMouseDown={(event) => beginDrag(edge, event)}
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
className={clsx(
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
dragging === edge && "z-10"
)}
style={{ left: `${pct}%` }}
>
<div className="h-full w-[3px] bg-olive rounded-full" />
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
</div>
);
})}
</div>
</div>
);
};
export default TrimTimeline;
export type { Range };

View File

@@ -1,15 +1,20 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void, onEdit?: () => void,
onDelete?: () => void, onDelete?: () => void,
className?: string, className?: string,
@@ -18,23 +23,42 @@ type VideoCardProps = {
itemLabel?: string, itemLabel?: string,
} }
const fallbackThumbnail = "public/default_thumbnail.png"; const API_URL = import.meta.env.VITE_API_URL;
const API_URL = config.apiUrl;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => { // Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const placeholders: Record<string, React.CSSProperties> = {
const [imgFailed, setImgFailed] = useState(false); vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
};
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`; const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
const isVod = itemLabel === 'vod';
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
const meta = [
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
fileSize ? formatFileSize(fileSize) : null,
].filter(Boolean).join(" · ");
// The technical detail, in mono, for when it matters.
const facts = [
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
isVod && fps ? `${Math.round(fps)} fps` : null,
].filter(Boolean);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { isThumbnailAvailable(thumbnailUrl)
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); .then(setThumbnailAvailable)
}, 1000); .catch(() => setThumbnailAvailable(false));
return () => clearInterval(interval); }, [thumbnailUrl]);
}, [createdAt]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -56,16 +80,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
return ( return (
<Link to={`${playerPath}/${id}`}> <Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}> <div className={clsx(
<div className="relative overflow-hidden rounded-lg"> "group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
<img className
src={imgFailed ? fallbackThumbnail : thumbnailUrl} )}>
onError={() => setImgFailed(true)} <div
alt="Video Thumbnail" className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
/> >
{thumbnailAvailable && (
<img
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none"> <span className="absolute top-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{slug}
</span>
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
@@ -77,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
> >
<button <button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })} onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors" className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
> >
<MoreVertical size={14} /> <MoreVertical size={14} />
</button> </button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden"> <div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{!confirmDelete && (<> {!confirmDelete && (<>
{onEdit && ( {onEdit && (
<button <button
onClick={(e) => stopAndRun(e, onEdit)} onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover" className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
> >
<Pencil size={13} /> Edit <Pencil size={13} /> Edit
</button> </button>
@@ -96,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && ( {onDelete && (
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover" className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
> >
<Trash2 size={13} /> Delete <Trash2 size={13} /> Delete
</button> </button>
@@ -104,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p> <p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors" className="flex-1 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
> >
Delete Delete
</button> </button>
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors" className="flex-1 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
> >
Cancel Cancel
</button> </button>
@@ -127,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
)} )}
</div> </div>
<div className="flex flex-col p-2"> <div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2"> <p className={clsx(
{title === "" ? "(No Title)" : title} "line-clamp-2",
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
</p> </p>
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
<p
className="font-body text-[12.5px] text-muted"
title={`${dateToTimeAgo(stringToDate(createdAt))} — ${formatLocalDate(createdAt)}`}
>
{meta}
</p>
{facts.length > 0 && (
<p className="font-data text-[10.5px] text-faint-mono truncate">
{facts.join(" · ")}
</p>
)}
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -1,5 +0,0 @@
export const config = {
apiUrl: window._env_?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
frontendUrl: window._env_?.VITE_FRONTEND_URL ?? import.meta.env.VITE_FRONTEND_URL ?? '',
googleClientId: window._env_?.VITE_GOOGLE_CLIENT_ID ?? import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '',
};

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@theme { @theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */ /* ── Fonts ───────────────────────────────────────────────────────────── */
@@ -10,38 +11,80 @@
--color-sidebar: #F2EEE6; --color-sidebar: #F2EEE6;
--color-card: #FDFBF7; --color-card: #FDFBF7;
--color-fields: #F4F0E8; --color-fields: #F4F0E8;
--color-panel: #F1ECE3;
--color-dropzone: #F9F6EF; --color-dropzone: #F9F6EF;
--color-armed: #F6EFE6;
--color-live-card: #F0EBE1;
--color-player: #2B2620;
--color-player-stripe: #3A342C;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620; --color-text-primary: #2B2620;
--color-text-strong: #35302A; --color-text-strong: #35302A;
--color-text-body: #554D44; --color-text-body: #554D44;
--color-text-control: #4A423A;
--color-text-secondary: #6E6559; --color-text-secondary: #6E6559;
--color-text-table: #7A7166;
--color-muted: #9A9084; --color-muted: #9A9084;
--color-faint-mono: #A79C8E; --color-faint-mono: #A79C8E;
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-terracotta: #A85A38;
--color-terracotta-hover: #94492A;
--color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #B5432E;
--color-error: #B5432E;
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
--color-on-dark: #FBF8F2; --color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7; --color-on-accent: #FDFBF7;
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-accent: #A85A38;
--color-accent-hover: #94492A;
--color-accent-light: #C87A52;
--color-accent-disabled: #C9AE9C;
--color-olive: #6E7A50;
--color-olive-hover: #5D6843;
--color-olive-light: #8A9668;
--color-live-red: #B5432E;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2; --color-hairline: #F0EBE2;
--color-hover: #E7E0D2; --color-hover: #E7E0D2;
--color-selected: #F6EFE6; --color-chip: #EAE3D7;
--color-ring: #E2DACB; --color-chip-hover: #DAD1C0;
--color-ring-dashed: #E2DACB;
--color-inactive: #DED7CA; --color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9; --color-inactive-strong: #C4BAA9;
/* ── Elevation — soft shadows instead of borders ─────────────────────── */
--shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35);
--shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45);
--shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3);
--shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5);
--shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55);
--shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55);
--inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08);
--inset-shadow-armed: inset 0 0 0 1.5px #A85A38;
--inset-shadow-focus: inset 0 0 0 1.5px #C87A52;
--inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB;
/* ── Shape ───────────────────────────────────────────────────────────── */
--radius-hero: 16px;
--radius-panel: 15px;
--radius-tile: 13px;
--radius-thumb: 12px;
--radius-field: 10px;
--radius-btn: 9px;
--radius-chip: 8px;
/* ── Motion — the live dot, plus the player's one-shot action badge ──── */
--animate-live-pulse: livePulse 2.2s ease-in-out infinite;
--animate-flash-badge: flashBadge 500ms ease-out forwards;
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .45; transform: scale(1.35); }
}
@keyframes flashBadge {
0% { opacity: 0; transform: scale(.75); }
25% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.4); }
}
} }
:root { :root {
@@ -63,28 +106,5 @@ body {
@utility font-heading { @utility font-heading {
font-family: "Newsreader", serif; font-family: "Newsreader", serif;
font-weight: 400; font-weight: 400;
color: var(--color-text-primary); color: #2B2620;
} }
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {
height: 8px;
background: var(--color-fields);
border-radius: 8px;
}
#range-slider .range-slider__range {
background: var(--color-terracotta-light);
}
#range-slider .range-slider__thumb {
width: 18px;
height: 18px;
background: var(--color-terracotta);
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
}
#range-slider .range-slider__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
}

View File

@@ -1,37 +1,19 @@
// layout/MainLayout.jsx // layout/MainLayout.jsx
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react"; import { useState } from "react";
import { useAuth } from "../auth/useAuth";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false); const [sidebarToggled] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar <div className="row-span-2 overflow-y-auto">
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
}; };
export default MainLayout; export default MainLayout;

View File

@@ -1,10 +1,13 @@
import './index.css'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx' import App from './App.tsx'
import {AuthProvider} from "./utils/AuthContext.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,18 +1,19 @@
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar"; import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider"; import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media" import { compress } from "../utils/api/media"
import { getJob } from "../utils/api/jobs" import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
import {config} from "../config.ts"; import PageHeader from "../components/PageHeader.tsx";
import { formatTime, formatTimecode } from "../utils/utils.ts";
import { kickerClass } from "../utils/styles.ts";
const API_URL = config.apiUrl; const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => { const ClipEdit = () => {
const location = useLocation(); const location = useLocation();
@@ -24,6 +25,7 @@ const ClipEdit = () => {
const [metadata, setMetadata] = useState<VideoMetadata | null>(null); const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [range, setRange] = useState<Range>([0, 5]);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
title: "", title: "",
description: "", description: "",
@@ -59,23 +61,39 @@ const ClipEdit = () => {
fps: 30, fps: 30,
fileSize: localFile?.size ?? 0, fileSize: localFile?.size ?? 0,
}); });
// Start with the whole thing trimmed in — narrowing is the common move.
setRange([0, video.duration]);
setOutputMetadata(prev => ({ ...prev, startPoint: 0, duration: video.duration }));
}; };
// Keep the playhead in step with the video while it plays.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => setPlaybackValue(video.currentTime);
video.addEventListener("timeupdate", onTime);
return () => video.removeEventListener("timeupdate", onTime);
}, [videoUrl]);
const handleRangeChange = useCallback((next: Range) => {
setRange(next);
setOutputMetadata(prev => ({ ...prev, startPoint: next[0], duration: next[1] - next[0] }));
}, []);
const handleSeek = useCallback((seconds: number) => {
setPlaybackValue(seconds);
if (videoRef.current) videoRef.current.currentTime = seconds;
}, []);
const sendData = async () => { const sendData = async () => {
if (!localFile || uploading) return; if (!localFile || uploading) return;
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
setUploading(true); setUploading(true);
setError(null); setError(null);
let jobId: string; let jobId: string;
try { try {
jobId = await compress(localFile, { ...outputMetadata, title }); jobId = await compress(localFile, outputMetadata);
setUploadedId(jobId); setUploadedId(jobId);
} catch (err: unknown) { } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -131,60 +149,78 @@ const ClipEdit = () => {
}; };
return ( return (
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}> <div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<video <PageHeader kicker="Upload" title="Trim and compress" />
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-text-primary"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Box className={"w-4/5 h-full m-auto"}> <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
<MetadataBox setMetadata={setOutputMetadata} /> <div className="flex flex-col gap-[26px]">
<ConfigBox setMetadata={setOutputMetadata} /> {/* Player — the video and its transport read as one object. */}
</Box> <Card className="overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="w-full aspect-video bg-player object-contain"
onLoadedMetadata={handleVideoMetadataLoaded}
/>
{metadata &&
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className="w-full px-5 pt-3.5"
/>
}
</Card>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Card className="p-6">
<Playbar <div className="flex items-baseline justify-between gap-4 pb-4">
video={videoRef.current} <p className={kickerClass}>Trim</p>
videoMetadata={metadata} <p className="font-data text-[12px] text-faint-mono tabular-nums">
className={"w-full accent-terracotta"} in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
/> {formatTime(range[1] - range[0])} long
</p>
</div>
<PlaybackSlider <TrimTimeline
videoRef={videoRef.current} duration={metadata.duration}
videoMetadata={metadata} range={range}
sliderValue={playbackValue} onRangeChange={handleRangeChange}
setSliderValue={setPlaybackValue} currentTime={playbackValue}
className={"w-full accent-terracotta"} onSeek={handleSeek}
/> />
<ClipRangeSlider <p className="pt-3 font-body text-[12px] text-muted">
videoRef={videoRef.current} Scroll over the timeline to zoom, drag the outlined window above to pan.
videoMetadata={metadata} </p>
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2"}
/>
{error && ( {error && (
<div className={"text-error text-center mt-2"}> <p className="font-body text-[13px] text-live-red pt-3">{error}</p>
{error} )}
</div> </Card>
)} }
</Box> </div>
}
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}> <aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
<ExportWidget <Card className="p-6 flex flex-col gap-4">
dataSend={sendData} <p className={kickerClass}>Details</p>
handleDownload={handleDownload} <MetadataBox setMetadata={setOutputMetadata} />
downloadable={downloadable} </Card>
progress={progress}
uploading={uploading} <Card className="p-6">
/> <ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
</Box> </Card>
<div className="bg-panel rounded-panel shadow-panel p-6">
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</div>
</aside>
</div>
</div> </div>
); );
}; };

View File

@@ -1,56 +1,89 @@
import {useState} from "react"; import { useRef, useState } from "react";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Box from "../components/Box.tsx"; import clsx from "clsx";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { kickerClass } from "../utils/styles.ts";
const formatSize = (bytes: number): string => {
const mb = bytes / 1_000_000;
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`;
};
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const choose = (selected: File | null) => {
setFile(selected);
setError(null);
};
const press = () => { const press = () => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Pick a file first.");
return; return;
} }
navigate('/create/new', { state: { file } }); navigate('/create/new', { state: { file } });
}; };
return ( return (
<div className="flex justify-center p-8"> <div className="mx-auto max-w-180 px-13 pt-10 pb-18">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md"> <PageHeader kicker="Upload" title="Bring in a file" />
<div
className="cursor-pointer rounded-lg border-2
border-dashed border-hairline bg-dropzone
text-center text-sm text-text-secondary
hover:bg-hover transition-colors duration-150
min-h-50 flex flex-col items-center justify-center gap-2"
>
<label className="cursor-pointer flex flex-col items-center justify-center gap-2 w-full h-full">
Choose file
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className="hidden"
/>
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<button <label
onClick={press} onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
className={"hover:bg-hover p-1 rounded-md"} onDragLeave={() => setDragging(false)}
> onDrop={(e) => {
Upload e.preventDefault();
</button> setDragging(false);
choose(e.dataTransfer.files?.[0] ?? null);
}}
className={clsx(
"flex aspect-16/7 cursor-pointer flex-col items-center justify-center gap-2 rounded-hero px-8 text-center transition-colors duration-150",
dragging ? "bg-armed inset-shadow-armed" : "bg-dropzone inset-shadow-dashed hover:bg-fields"
)}
>
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => choose(e.target.files?.[0] ?? null)}
/>
{error && <p className="text-sm font-body text-center">{error}</p>} {file ? (
</Box> <>
<p className="font-heading text-[25px]">{file.name}</p>
<p className="font-data text-[12px] text-faint-mono">{formatSize(file.size)}</p>
<p className="font-body text-[13px] text-muted pt-1">Click to pick a different one.</p>
</>
) : (
<>
<p className={kickerClass}>Video file</p>
<p className="font-heading text-[25px]">Drop a file here</p>
<p className="font-body text-[14.5px] text-text-body max-w-[42ch] text-pretty">
Or click anywhere in this box to browse. You'll trim and compress it on the next screen.
</p>
</>
)}
</label>
<div className="flex items-center gap-4 pt-6">
<Button onClick={press} disabled={!file} className="px-6 py-3">
{file ? "Trim and compress" : "Waiting for a file"}
</Button>
{file && (
<Button variant="ghost" onClick={() => { choose(null); if (inputRef.current) inputRef.current.value = ""; }}>
Clear
</Button>
)}
</div>
{error && <p className="font-body text-[13px] text-live-red pt-3">{error}</p>}
</div> </div>
) )
}; };

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { getClipById, patchClip } from "../utils/api/clips.ts"; import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"; import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
const EditClip = () => { const EditClip = () => {
const { id } = useParams(); const { id } = useParams();
@@ -47,59 +48,51 @@ const EditClip = () => {
}; };
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button <button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
onClick={() => navigate(-1)} <ArrowLeft size={15} />
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back Back
</button> </button>
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1> <PageHeader kicker="Clip" title="Edit details" />
{loading ? ( {loading ? (
<div className="text-muted text-sm">Loading...</div> <p className="font-data text-[13px] text-faint-mono">loading…</p>
) : ( ) : (
<Box className="p-6 flex flex-col gap-4"> <Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-secondary">Title</label> <label className={labelClass} htmlFor="clip-title">Title</label>
<input <input
id="clip-title"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title" placeholder="Cat walks across the patch bay"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-secondary">Description</label> <label className={labelClass} htmlFor="clip-description">Description</label>
<textarea <textarea
id="clip-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description" placeholder="What's worth remembering about it"
rows={4} rows={4}
className={inputClass + " resize-none"} className={clsx(inputClass, "resize-none leading-[1.65]")}
/> />
</div> </div>
{error && <p className="text-sm text-error">{error}</p>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-4">
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28"> <Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved {saving ? "Saving…" : saved ? "Saved" : "Save changes"}
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> </Button>
: "Save"} <Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
</PrimaryButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
>
Cancel
</button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { getVodById, patchVod } from "../utils/api/vods.ts"; import { getVodById, patchVod } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"; import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
const EditVod = () => { const EditVod = () => {
const { id } = useParams(); const { id } = useParams();
@@ -47,59 +48,51 @@ const EditVod = () => {
}; };
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button <button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
onClick={() => navigate(-1)} <ArrowLeft size={15} />
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back Back
</button> </button>
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1> <PageHeader kicker="VoD" title="Edit details" />
{loading ? ( {loading ? (
<div className="text-muted text-sm">Loading...</div> <p className="font-data text-[13px] text-faint-mono">loading…</p>
) : ( ) : (
<Box className="p-6 flex flex-col gap-4"> <Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-secondary">Title</label> <label className={labelClass} htmlFor="vod-title">Title</label>
<input <input
id="vod-title"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title" placeholder="Tuesday night, patch bay walkthrough"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-secondary">Description</label> <label className={labelClass} htmlFor="vod-description">Description</label>
<textarea <textarea
id="vod-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description" placeholder="What's worth remembering about it"
rows={4} rows={4}
className={inputClass + " resize-none"} className={clsx(inputClass, "resize-none leading-[1.65]")}
/> />
</div> </div>
{error && <p className="text-sm text-error">{error}</p>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-4">
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28"> <Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved {saving ? "Saving…" : saved ? "Saved" : "Save changes"}
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> </Button>
: "Save"} <Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
</PrimaryButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
>
Cancel
</button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,78 +1,311 @@
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react"; import {useAuth} from "../utils/AuthContext.tsx";
import Landing from "./Landing.tsx";
import {Link, useNavigate} from "react-router-dom";
import Card from "../components/Card.tsx";
import {useEffect, useState} from "react";
import IsLivePulse from "../components/IsLivePulse.tsx";
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
import {getCurrentStream} from "../utils/api/stream.ts";
import VideoCard from "../components/video/VideoCard.tsx";
import {createMarker, getMarkers} from "../utils/api/markers.ts";
import clsx from "clsx";
import {getVods} from "../utils/api/vods.ts";
import {getClips} from "../utils/api/clips.ts";
import Button from "../components/buttons/Button.tsx";
const features = [ type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
{
icon: Upload, function getTimeOfDay(date: Date = new Date()): TimeOfDay {
title: "Upload & compress", const hour = date.getHours();
description: "Drop in a video file, set your clip range and export settings, then compress.",
}, if (hour >= 5 && hour < 12) return "morning";
{ if (hour >= 12 && hour < 17) return "afternoon";
icon: Scissors, if (hour >= 17 && hour < 22) return "evening";
title: "Trim", return "night";
description: "Set start and end points with a range slider. Preview as you go.", }
},
{ function getGreetingSubtext(date: Date = new Date()): string {
icon: SlidersHorizontal, const timeOfDay = getTimeOfDay(date);
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.", const messages: Record<TimeOfDay, string[]> = {
}, morning: [
{ "Good morning.",
icon: Radio, "Coffee first, then clips.",
title: "RTMP streaming", "Here's what's waiting for you.",
description: "Point OBS at your stream key and go live. Find the key in your profile.", ],
}, afternoon: [
{ "Good afternoon.",
icon: LayoutGrid, "A quiet moment for your clips.",
title: "Clip library", "Catching up?",
description: "Browse all your clips. Watch, rename, edit the description, or delete.", ],
}, evening: [
{ "Good evening.",
icon: Play, "Settle in.",
title: "Built-in player", "Time to unwind.",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.", ],
}, night: [
]; "Still up?",
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
function getTimeFromISOString(date: string | null): string {
if (date == null) {
return "--:--";
}
const d = new Date(date);
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(d);
}
type SectionHeaderProps = { title: string; to: string; count: number };
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
<h2 className={"font-heading text-2xl"}>{title}</h2>
{count > 0 && (
<Link
to={to}
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
>
All {count} <span aria-hidden={true}>→</span>
</Link>
)}
</div>
);
const Home = () => { const Home = () => {
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
const { user } = useAuth();
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
const [ elapsed, setElapsed ] = useState('00:00:00');
const [ markers, setMarkers ] = useState<Marker[]>([])
const [ clips, setClips ] = useState<Clip[]>([]);
const [ vods, setVods ] = useState<Vod[]>([]);
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
const [ markerBusy, setMarkerBusy ] = useState(false);
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
const isStreaming = streamStatus?.isStreaming ?? false;
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
const navigate = useNavigate();
// Fetch Markers & Stream information
useEffect(() => {
getCurrentStream()
.then(data => {
setStreamStatus(data);
});
getMarkers()
.then(data => {
setMarkers(data);
})
// Get Top 4 Clips
getClips()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4);
setClips(topSix);
setCounts(prev => ({ ...prev, clips: data.length }));
})
// Get top 3 vods
getVods()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
setVods(topSix);
setCounts(prev => ({ ...prev, vods: data.length }));
})
}, [user]);
const handleDropMarker = async () => {
if (markerBusy) return;
setMarkerBusy(true);
try {
await createMarker(`Marker at ${elapsed}`);
setMarkers(await getMarkers());
setMarkerNote(`dropped at ${elapsed}`);
} catch (err) {
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
} finally {
setMarkerBusy(false);
}
};
useEffect(() => {
if (streamStatus?.startTime == null) {
setElapsed('00:00:00');
return;
}
const startMs = new Date(streamStatus?.startTime).getTime();
const tick = () => {
const diffSec = Math.floor((Date.now() - startMs) / 1000);
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
const ss = String(diffSec % 60).padStart(2, '0');
setElapsed(`${hh}:${mm}:${ss}`);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [streamStatus?.startTime]);
if (user == null) {
return (<Landing />)
}
return ( return (
<div className="flex flex-col items-center px-8 py-12 min-h-full"> <div className={"w-10/12 m-auto pt-10"}>
{/* Hero */} <div className={"flex flex-row justify-between items-start pb-8"}>
<div className="flex flex-col items-center text-center max-w-2xl mb-14"> <div>
<img <p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
src="/logo.png" <h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
alt="VoD System Logo" </div>
className="h-24 mb-6 object-contain"
/> <Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
<h1 className="text-4xl font-heading text-text-primary"> Open Stream Panel
A home for your clips. </Link>
</h1>
<p className="text-text-secondary">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div> </div>
{/* Feature grid */} <Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10"> <div className={"flex flex-row items-center gap-2"}>
{features.map(({ icon: Icon, title, description }) => ( {isStreaming
<div ? <IsLivePulse />
key={title} : <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm" <p className={clsx(
> "text-xs font-data tracking-[0.12em]",
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"> isStreaming ? "text-live-red" : "text-faint-mono"
<Icon size={18} className="text-terracotta" /> )}>
{isStreaming ? "LIVE NOW" : "OFF AIR"}
</p>
</div>
<div className={"p-1"}></div>
{isStreaming ? (
<>
<h2 className={"font-heading text-3xl"}>
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
recording since {getTimeFromISOString(streamStatus?.startTime ?? null)}.</h2>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{elapsed}</h2>
<p className={"text-xs text-muted"}>elapsed</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{markers.length}</h2>
<p className={"text-xs text-muted"}>markers dropped</p>
</div>
</div> </div>
<div>
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p> <div className={"mt-4 flex items-center gap-4"}>
<p className="text-sm text-text-secondary leading-relaxed">{description}</p> <Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
</div> </div>
</div> </>
))} ) : (
<>
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
Point OBS at the ingest URL with your stream key and this block turns over to
elapsed time and markers.
</p>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
<p className={"text-xs text-muted"}>vods saved</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
<p className={"text-xs text-muted"}>clips cut</p>
</div>
</div>
<div className={"mt-4"}>
<Link to={"/stream"}>
<Button variant={"secondary"}>Open the stream panel</Button>
</Link>
</div>
</>
)}
</Card>
<div className={"flex flex-col"}>
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
<div className={"grid grid-cols-3 gap-5.5"}>
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods"
playerPath="/video"
itemLabel="vod"
onEdit={() => navigate(`/video/${vod.id}/edit`)}
/>
))}
</div>
</div>
<div className={"flex flex-col mt-9"}>
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
<div className={"grid grid-cols-4 gap-4.5"}>
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
mediaApiPath="/api/v1/clips"
playerPath="/video"
itemLabel="clip"
onEdit={() => navigate(`/video/${clip.id}/edit`)}
/>
))}
</div>
</div> </div>
{/* Format note */}
<p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,108 @@
import Card from "../components/Card.tsx";
import GoogleSignIn from "../components/GoogleSignIn.tsx";
import { kickerClass } from "../utils/styles.ts";
const steps = [
{
number: "01",
title: "Point OBS at it",
body: "One ingest URL and one stream key. Everything you send lands on disk as it arrives — no upload step, no waiting at the end.",
},
{
number: "02",
title: "Mark it as it happens",
body: "Drop a marker the moment something is worth keeping. Each one remembers its timecode so you don't have to scrub for it later.",
},
{
number: "03",
title: "Cut it up afterwards",
body: "Tick two markers to pull the stretch between them out as a VoD, or take the last minute as a clip. Cutting runs in the background while you keep streaming.",
},
];
/** Stand-in for the marker list on the stream panel — the shape of the thing, in miniature. */
const previewMarkers = [
{ at: "00:10:12", message: "Patch bay walkthrough begins", selected: false },
{ at: "00:49:56", message: "Filter finally tracks", selected: true },
{ at: "00:50:37", message: "…and it stops tracking", selected: true },
{ at: "01:05:01", message: "Cat on the desk", selected: false },
];
const Landing = () => (
<div className="mx-auto max-w-[1180px] px-13 pt-16 pb-18">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] gap-12 items-start pb-16">
<div className="flex flex-col gap-5">
<p className={kickerClass}>Personal stream archive</p>
<h1 className="font-heading text-5xl tracking-[-0.015em] leading-[1.1] max-w-[16ch]">
Keep the good bits of everything you stream.
</h1>
<p className="font-body text-[16px] leading-[1.65] text-text-body max-w-[58ch] text-pretty">
The VoD System sits between your encoder and your hard drive. It records
every session in full, lets you mark the moments worth keeping while
they're happening, and turns those marks into VoDs and clips afterwards.
</p>
<div className="flex flex-col gap-3 pt-2">
<GoogleSignIn shape="rectangular" size="large" width="260" />
<p className="font-body text-[12.5px] text-muted">
Sign in with Google to reach your library.
</p>
</div>
</div>
{/* A quiet look at what's behind the login. */}
<Card className="p-6 flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Live markers</p>
<p className="font-data text-[12px] text-faint-mono">t = 01:33:35</p>
</div>
<div className="flex flex-col gap-2">
{previewMarkers.map((marker) => (
<div
key={marker.at}
className={`flex items-center gap-3 rounded-tile px-3 py-2.5 ${
marker.selected ? "bg-armed inset-shadow-armed" : "bg-fields"
}`}
>
<span className={`h-[14px] w-[14px] flex-shrink-0 rounded-[4px] ${
marker.selected ? "bg-accent" : "bg-inactive"
}`} />
<span className="font-data text-[12px] text-accent flex-shrink-0">{marker.at}</span>
<span className="font-body text-[12.5px] text-text-strong truncate">{marker.message}</span>
</div>
))}
</div>
<p className="font-body text-[12.5px] text-muted pt-1">
Two marks selected — that's a 41-second VoD, one click away.
</p>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-[22px]">
{steps.map((step) => (
<Card key={step.number} className="p-6 flex flex-col gap-2.5">
<p className="font-data text-[13px] text-accent">{step.number}</p>
<h2 className="font-heading text-[22px] leading-snug">{step.title}</h2>
<p className="font-body text-[14px] leading-[1.65] text-text-body text-pretty">
{step.body}
</p>
</Card>
))}
</div>
<div className="bg-panel rounded-panel p-6 mt-[26px] flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
<p className="font-body text-[14px] text-text-body">
Built for one person and their own machine. No tiers, no transcoding queue you share with strangers.
</p>
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">
rtmp in · mp4 out
</p>
</div>
</div>
);
export default Landing;

View File

@@ -1,14 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
import clsx from "clsx";
import { AuthError } from "../utils/api/client.ts"; import { AuthError } from "../utils/api/client.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
import { kickerClass, quietLinkClass } from "../utils/styles.ts";
// Both VoDs and clips share the same on-screen shape. // Both VoDs and clips share the same on-screen shape.
type MediaItem = { type MediaItem = {
title: string, title: string,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
}; };
type MediaPlayerProps = { type MediaPlayerProps = {
@@ -53,12 +60,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [hoverTime, setHoverTime] = useState<number | null>(null);
/** Transient badge that flashes in the middle of the frame on play/pause/seek. */
const [flash, setFlash] = useState<{ id: number; kind: "play" | "pause" | "back" | "forward" } | null>(null);
const showFlash = useCallback((kind: "play" | "pause" | "back" | "forward") => {
setFlash({ id: Date.now(), kind });
}, []);
// ── Data loading ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
// which lets the browser fetch it incrementally via Range requests. We only
// need to fetch the metadata here; it shares the media endpoint's auth, so a
// 401/403 here also stands in for the stream being inaccessible.
useEffect(() => { useEffect(() => {
if (!id) { setError(`${noun} ID is required.`); return; } if (!id) { setError(`${noun} ID is required.`); return; }
@@ -68,6 +78,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (e instanceof AuthError) setNotAuthenticated(true); if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`); else setError(`Failed to load ${noun.toLowerCase()} details.`);
}); });
}, [id, noun, fetchDetails]); }, [id, noun, fetchDetails]);
useEffect(() => { useEffect(() => {
@@ -89,7 +100,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide(); scheduleHide();
}, [scheduleHide]); }, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
@@ -119,11 +129,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5); video.currentTime = Math.max(0, video.currentTime - 5);
showFlash('back');
showControls(); showControls();
break; break;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5); video.currentTime = Math.min(video.duration, video.currentTime + 5);
showFlash('forward');
showControls(); showControls();
break; break;
case 'KeyM': case 'KeyM':
@@ -137,7 +149,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]); }, [showControls, showFlash, toggleFullscreen]);
// Each flash clears itself; keying the node on `id` restarts the animation
// when the same action fires twice in a row.
useEffect(() => {
if (!flash) return;
const timer = setTimeout(() => setFlash(null), 500);
return () => clearTimeout(timer);
}, [flash]);
// ── Progress bar seeking ──────────────────────────────────────────────── // ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => { const seekTo = useCallback((clientX: number) => {
@@ -191,29 +211,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const togglePlay = () => { const togglePlay = () => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
showFlash(video.paused ? 'play' : 'pause');
video.paused ? video.play() : video.pause(); video.paused ? video.play() : video.pause();
showControls(); showControls();
}; };
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume; const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2; const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
// Facts worth showing under the player, skipping anything the API didn't send.
const facts = [
item?.width && item?.height ? `${item.width}×${item.height}` : null,
item?.fps ? `${Math.round(item.fps)} fps` : null,
duration > 0 ? formatTime(duration) : null,
item?.fileSize ? formatFileSize(item.fileSize) : null,
].filter(Boolean);
const flashIcons = { play: Play, pause: Pause, back: Rewind, forward: FastForward } as const;
const FlashIcon = flash ? flashIcons[flash.kind] : null;
if (notAuthenticated) { if (notAuthenticated) {
return ( return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8"> <div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center"> <Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<Lock size={24} className="text-muted" /> <h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
</div> <p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
<div> You don't have access to this {noun.toLowerCase()}.
<p className="text-2xl font-heading text-text-primary">Not authenticated</p> </p>
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150" className={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
> >
<ArrowLeft size={16} /> <ArrowLeft size={15} />
Go back Go back
</button> </button>
</div> </div>
@@ -221,19 +252,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
} }
return ( return (
<div className="h-full flex flex-col p-4 gap-3"> <div className="h-full flex flex-col px-13 py-8 gap-4">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150" className={clsx(quietLinkClass, "flex items-center gap-1.5 shrink-0")}
> >
<ArrowLeft size={16} /> <ArrowLeft size={15} />
Back Back
</button> </button>
{/* Player */} {/* Player */}
<div <div
ref={containerRef} ref={containerRef}
className="relative bg-text-primary rounded-xl overflow-hidden flex-1 min-h-0 select-none" className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls} onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)} onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay} onClick={togglePlay}
@@ -265,14 +296,37 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Buffering spinner */} {/* Buffering spinner */}
{isWaiting && mediaUrl && ( {isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" /> <div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
{/* Action flash — a badge that swells and fades on play, pause and seek. */}
{flash && FlashIcon && (
<div key={flash.id} className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="animate-flash-badge flex h-18.5 w-18.5 items-center justify-center rounded-full bg-player/55 backdrop-blur-[2px] text-on-dark">
<FlashIcon size={30} fill="currentColor" strokeWidth={1} />
</div>
</div> </div>
)} )}
{/* Controls overlay — pointer-events-none so video clicks pass through */} {/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}> <div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */} {/* Gradients — top one carries the title, bottom one the controls */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" /> <div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-black/55 to-transparent" />
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/10 to-transparent" />
{/* Title plate */}
<div className={clsx(
"absolute inset-x-0 top-0 px-5 pt-4 flex flex-col gap-0.5 transition-transform duration-300",
controlsVisible ? "translate-y-0" : "-translate-y-2"
)}>
<p className="font-data text-[10.5px] uppercase tracking-[0.14em] text-on-dark/55">
{noun}
</p>
<p className="font-heading text-[22px] leading-tight text-on-dark truncate">
{item?.title || "(No title)"}
</p>
</div>
{/* Controls bar — pointer-events-auto only here */} {/* Controls bar — pointer-events-auto only here */}
<div <div
@@ -282,20 +336,42 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Scrub bar */} {/* Scrub bar */}
<div <div
ref={progressRef} ref={progressRef}
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150" className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }} onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setHoverTime(ratio * duration);
}}
onMouseLeave={() => setHoverTime(null)}
> >
<div className="absolute inset-y-0 left-0 bg-on-dark/35 rounded-full" style={{ width: `${bufferedPct}%` }} /> <div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
{/* Ghost fill up to the cursor, so the scrub reads before you commit */}
{hoverTime !== null && (
<div className="absolute inset-y-0 left-0 bg-white/25 rounded-full" style={{ width: `${hoverPct}%` }} />
)}
<div className="absolute inset-y-0 left-0 bg-accent-light rounded-full" style={{ width: `${progressPct}%` }} />
<div <div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity" className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }} style={{ left: `calc(${progressPct}% - 6px)` }}
/> />
{/* Time under the cursor */}
{hoverTime !== null && (
<div
className="absolute bottom-4 -translate-x-1/2 rounded-md bg-player/85 px-1.5 py-0.5 font-data text-[11px] text-on-dark tabular-nums pointer-events-none"
style={{ left: `${hoverPct}%` }}
>
{formatTime(hoverTime)}
</div>
)}
</div> </div>
{/* Buttons row */} {/* Buttons row */}
<div className="flex items-center gap-3 text-on-dark"> <div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors"> <button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying {isPlaying
? <Pause size={20} fill="currentColor" /> ? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />} : <Play size={20} fill="currentColor" />}
@@ -303,7 +379,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Volume — slider expands on hover */} {/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol"> <div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors"> <button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} /> <VolumeIcon size={18} />
</button> </button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200"> <div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
@@ -312,18 +388,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
min={0} max={1} step={0.02} min={0} max={1} step={0.02}
value={effectiveVolume} value={effectiveVolume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
className="w-16 accent-on-dark cursor-pointer" className="w-16 accent-white cursor-pointer"
/> />
</div> </div>
</div> </div>
<span className="text-xs font-data tabular-nums text-on-dark/80"> <span className="font-data text-xs tabular-nums text-on-dark/80">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors"> <button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />} {isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
@@ -331,18 +407,26 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</div> </div>
{error && <div className="text-error text-sm mt-3">{error}</div>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
{!mediaLoaded && !error && ( {!mediaLoaded && !error && (
<div className="text-muted text-sm mt-3 flex items-center gap-2"> <p className="font-data text-[12px] text-faint-mono">loading video…</p>
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
Loading video...
</div>
)} )}
<Box className="p-4 flex-shrink-0"> <Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p> <div className="min-w-0">
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p> <p className={kickerClass}>{noun}</p>
</Box> <p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
{item?.title || "(No title)"}
</p>
<p className="font-body text-[13px] text-muted pt-1">{timeAgo}</p>
</div>
{facts.length > 0 && (
<p className="font-data text-[12px] text-faint-mono tabular-nums">
{facts.join(" · ")}
</p>
)}
</Card>
</div> </div>
); );
}; };

View File

@@ -1,19 +1,24 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/api/clips";
import { useAuth } from "../utils/AuthContext";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const { user, loading } = useAuth();
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load clips"));
}, []); }, [user]);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -24,11 +29,16 @@ const MyClips = () => {
} }
}; };
return ( if (loading) return null;
<div className="p-6"> if (!user) return <NoAccessPage />;
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="Clips">
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
</PageHeader>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[18px]">
{clips.map((clip) => ( {clips.map((clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
@@ -36,6 +46,10 @@ const MyClips = () => {
title={clip.title} title={clip.title}
duration={clip.duration} duration={clip.duration}
createdAt={clip.createdAt} createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
@@ -43,13 +57,17 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<p className="text-base">No clips yet.</p> <h2 className="font-heading text-[25px]">No clips yet.</h2>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Cut one from the stream panel, or{" "}
<Link to="/create" className="text-accent hover:text-accent-hover">upload a file</Link>{" "}
to trim it down here.
</p>
</div> </div>
)} )}
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>} {error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
</div> </div>
); );
} }

View File

@@ -1,19 +1,24 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { useAuth } from "../utils/AuthContext";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const { user, loading } = useAuth();
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods")); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []); }, [user]);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -24,11 +29,16 @@ const MyVods = () => {
} }
}; };
return ( if (loading) return null;
<div className="p-6"> if (!user) return <NoAccessPage />;
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="VoDs">
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
</PageHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]">
{vods.map((vod) => ( {vods.map((vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
@@ -36,6 +46,10 @@ const MyVods = () => {
title={vod.title} title={vod.title}
duration={vod.duration} duration={vod.duration}
createdAt={vod.createdAt} createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods" mediaApiPath="/api/v1/vods"
playerPath="/vod" playerPath="/vod"
itemLabel="vod" itemLabel="vod"
@@ -46,13 +60,15 @@ const MyVods = () => {
</div> </div>
{vods.length === 0 && !error && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<p className="text-base">No VoDs yet.</p> <h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Ranges you save from the stream panel land here, full length and untouched.
</p>
</div> </div>
)} )}
{error && <p className="text-sm text-error mt-4">{error}</p>} {error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
</div> </div>
); );
}; };

View File

@@ -1,11 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react"; import Card from "../components/Card.tsx";
import Box from "../components/Box.tsx"; import PageHeader from "../components/PageHeader.tsx";
import { useAuth } from "../auth/useAuth.ts"; import NoAccessPage from "../components/NoAccessPage.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { formatLocalDate } from "../utils/utils.ts"; import { formatLocalDate } from "../utils/utils.ts";
import { labelClass, chipButtonClass } from "../utils/styles.ts";
const Profile = () => { const Profile = () => {
const { user } = useAuth(); const { user, loading } = useAuth();
const [keyVisible, setKeyVisible] = useState(false); const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -13,67 +15,54 @@ const Profile = () => {
if (!user?.streamKey) return; if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey); navigator.clipboard.writeText(user.streamKey);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 1600);
}; };
if (!user) { if (loading) return null;
return ( if (!user) return <NoAccessPage />;
<div className="flex justify-center items-center h-full text-muted text-base">
Please log in to view your profile.
</div>
);
}
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1> <PageHeader kicker="Account" title="Profile" />
<Box className="p-6 mb-4 flex items-center gap-5"> <Card className="p-7 mb-[26px] flex items-center gap-5">
<img <img
src={user.profilePictureUrl} src={user.profilePictureUrl}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
alt="Profile picture" alt=""
className="w-20 h-20 rounded-full shadow-sm" className="w-[72px] h-[72px] rounded-full shadow-card"
/> />
<div> <div className="flex flex-col gap-0.5">
<p className="text-2xl font-heading text-text-primary">{user.name}</p> <p className="font-heading text-[25px] leading-tight">{user.name}</p>
<p className="text-sm text-text-secondary">@{user.username}</p> <p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
<p className="text-sm text-text-secondary">{user.email}</p> <p className="font-data text-[11px] text-faint-mono pt-1">
<p className="text-xs text-muted mt-1"> member since {formatLocalDate(user.createdAt)}
Member since {formatLocalDate(user.createdAt)}
</p> </p>
</div> </div>
</Box> </Card>
<Box className="p-6"> <Card className="p-7">
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2> <h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
<p className="text-sm text-text-secondary mb-4"> <p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
Use this key in OBS or any RTMP-compatible software to start streaming. Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
Keep it private — anyone with this key can stream to your account. Keep it private: anyone holding it can stream to your account.
</p> </p>
<div className="flex items-center gap-2"> <p className={labelClass}>Key</p>
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden"> <div className="flex items-center gap-2.5 pt-2">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)} <div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
</div> </div>
<button <button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
onClick={() => setKeyVisible(v => !v)} {keyVisible ? "Hide" : "Reveal"}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
<button <button className={chipButtonClass} onClick={handleCopy}>
onClick={handleCopy} {copied ? "Copied" : "Copy"}
title="Copy key"
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
</button> </button>
</div> </div>
</Box> </Card>
</div> </div>
); );
}; };

View File

@@ -1,90 +1,72 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react"; import { Check, X } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Box from "../components/Box.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; import Card from "../components/Card.tsx";
import { useAuth } from "../auth/useAuth.ts"; import Button from "../components/buttons/Button.tsx";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts"; import PageHeader from "../components/PageHeader.tsx";
import IsLivePulse from "../components/IsLivePulse.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"; import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts"; import { saveSection, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts"; import { getJob } from "../utils/api/jobs.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts"; import type { Marker, StreamStatus } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts"; import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"; /** Ten-minute gridlines behind the marker ticks. */
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]"; const GRID_INTERVAL_SECONDS = 600;
type TrackedJob = { const useCopy = () => {
uuid: string; const [copied, setCopied] = useState<string | null>(null);
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => ( const copy = (key: string, text: string) => {
<div className="flex items-center gap-2.5"> navigator.clipboard.writeText(text);
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-terracotta/10" : "bg-olive/10")}> setCopied(key);
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} /> setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
</div> };
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => { return { copied, copy };
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-error mt-0.5 flex-shrink-0" />
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-olive flex-shrink-0" />
<p className="text-text-body">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-terracotta animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-text-secondary mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" />
</div>
</div>
);
}; };
const StreamControl = () => { const StreamControl = () => {
const { user } = useAuth(); const { user, loading } = useAuth();
const { copied, copy } = useCopy();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null); const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]); const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState(""); const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false); const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null); const [markerError, setMarkerError] = useState<string | null>(null);
const markerInputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<number[]>([]);
const [inValue, setInValue] = useState("");
const [outValue, setOutValue] = useState("");
const [rangeName, setRangeName] = useState("");
const [rangeError, setRangeError] = useState<string | null>(null);
const [rangeBusy, setRangeBusy] = useState(false);
const [clipDuration, setClipDuration] = useState(30); const [clipDuration, setClipDuration] = useState(30);
const [clipTitle, setClipTitle] = useState(""); const [clipName, setClipName] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipError, setClipError] = useState<string | null>(null); const [clipError, setClipError] = useState<string | null>(null);
const [startMarkerId, setStartMarkerId] = useState<string>(""); const [keyVisible, setKeyVisible] = useState(false);
const [endMarkerId, setEndMarkerId] = useState<string>(""); const [ingestUrl, setIngestUrl] = useState<string | null>(null);
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
const [jobs, setJobs] = useState<TrackedJob[]>([]); const [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false; const isStreaming = streamStatus?.isStreaming ?? false;
const startMs = streamStatus?.startTime ? stringToDate(streamStatus.startTime).getTime() : null;
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
const elapsed = formatTimecode(elapsedSeconds);
// ── Polling ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -94,24 +76,18 @@ const StreamControl = () => {
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
if (!user || !streamStatus?.id) {
setStreamDetails(null);
return;
}
getStreamHistory(user.id)
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000); const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [streamDetails?.startDate]); }, []);
useEffect(() => {
if (!user) return;
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
}, [user]);
const refreshMarkers = () => { const refreshMarkers = () => {
getMarkers() getMarkers()
.then((data) => setMarkers(data .then((data) => setMarkers([...data]
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime()))) .sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {}); .catch(() => {});
}; };
@@ -119,6 +95,7 @@ const StreamControl = () => {
useEffect(() => { useEffect(() => {
if (!isStreaming) { if (!isStreaming) {
setMarkers([]); setMarkers([]);
setSelected([]);
return; return;
} }
refreshMarkers(); refreshMarkers();
@@ -126,268 +103,495 @@ const StreamControl = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]); }, [isStreaming, streamStatus?.id]);
// One interval for the whole pending set, rebuilt only when that set changes.
const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(",");
useEffect(() => { useEffect(() => {
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED'); if (!pendingKey) return;
if (pending.length === 0) return; const uuids = pendingKey.split(",");
const interval = setInterval(() => { const interval = setInterval(() => {
pending.forEach((job) => { uuids.forEach((uuid) => {
getJob(job.uuid) getJob(uuid)
.then((result) => { .then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
setJobs((prev) => prev.map((j) => j.uuid === job.uuid ? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput } : job)))
: j));
})
.catch(() => {}); .catch(() => {});
}); });
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [jobs]); }, [pendingKey]);
const addJob = (uuid: string, label: string) => { // ── Marker helpers ──────────────────────────────────────────────────────
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]); const offsetOf = (marker: Marker) =>
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
const toggleMarker = (id: number) => {
const next = selected.includes(id)
? selected.filter((current) => current !== id)
: selected.length < 2 ? [...selected, id] : [selected[1], id];
setSelected(next);
setRangeError(null);
if (next.length !== 2 || startMs === null) return;
const offsets = next
.map((markerId) => markers.find((marker) => marker.id === markerId))
.filter((marker): marker is Marker => marker !== undefined)
.map(offsetOf)
.sort((a, b) => a - b);
if (offsets.length !== 2) return;
setInValue(formatTimecode(offsets[0]));
setOutValue(formatTimecode(offsets[1]));
}; };
// 'M' anywhere on the page jumps to the marker field.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== "m" && event.key !== "M") return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
event.preventDefault();
markerInputRef.current?.focus();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────
const addJob = (uuid: string, label: string) =>
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
const handleAddMarker = async () => { const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return; if (markerBusy) return;
setMarkerBusy(true); setMarkerBusy(true);
setMarkerError(null); setMarkerError(null);
try { try {
await createMarker(markerMessage.trim()); await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
setMarkerMessage(""); setMarkerMessage("");
refreshMarkers(); refreshMarkers();
} catch (err) { } catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker"); setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
} finally { } finally {
setMarkerBusy(false); setMarkerBusy(false);
} }
}; };
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Tap two markers above to pick a start and end");
return;
}
try {
const uuid = await saveSectionByMarkers(
Number(startMarkerId),
Number(endMarkerId),
markerSaveTitle || undefined,
markerSaveDescription || undefined
);
addJob(uuid, "Save (by markers)");
setStartMarkerId("");
setEndMarkerId("");
setMarkerSaveTitle("");
setMarkerSaveDescription("");
} catch (err) {
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
}
};
const handleDeleteMarker = async (id: number) => { const handleDeleteMarker = async (id: number) => {
try { try {
await deleteMarker(id); await deleteMarker(id);
if (startMarkerId === String(id)) setStartMarkerId(""); setSelected((prev) => prev.filter((current) => current !== id));
if (endMarkerId === String(id)) setEndMarkerId("");
refreshMarkers(); refreshMarkers();
} catch (err) { } catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker"); setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
} }
}; };
const handleChipClick = (id: number) => { const inSeconds = parseTimecode(inValue);
const idStr = String(id); const outSeconds = parseTimecode(outValue);
setMarkerSaveError(null); // In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
if (startMarkerId === idStr) { setStartMarkerId(""); return; } const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
if (endMarkerId === idStr) { setEndMarkerId(""); return; } ? formatTime(outSeconds - inSeconds)
if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
setEndMarkerId(idStr);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
Please log in to control your stream.
</div>
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null; : null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId); const handleSaveRange = async () => {
const endMarker = markers.find((m) => String(m.id) === endMarkerId); setRangeError(null);
if (startMs === null) {
setRangeError("No stream is running to take a range from.");
return;
}
if (inSeconds === null || outSeconds === null) {
setRangeError("Timecodes read as hh:mm:ss.");
return;
}
if (outSeconds <= inSeconds) {
setRangeError("The out point has to come after the in point.");
return;
}
if (outSeconds > elapsedSeconds) {
setRangeError("That out point hasn't been streamed yet.");
return;
}
setRangeBusy(true);
try {
const uuid = await saveSection(
new Date(startMs + inSeconds * 1000).toISOString(),
new Date(startMs + outSeconds * 1000).toISOString(),
rangeName || undefined
);
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
clearSelection();
} catch (err) {
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
} finally {
setRangeBusy(false);
}
};
const clearSelection = () => {
setSelected([]);
setInValue("");
setOutValue("");
setRangeName("");
setRangeError(null);
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipName || undefined);
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
setClipName("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
}
};
// ── Timeline ────────────────────────────────────────────────────────────
const gridlines = useMemo(() => {
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
return Array.from(
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
);
}, [elapsedSeconds]);
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
if (loading) return null;
if (!user) return <NoAccessPage />;
return ( return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5"> <div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1> <PageHeader kicker="Ingest" title="Stream panel" />
{/* Hero status bar */} <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
<Box className={clsx( {/* ── Main column ───────────────────────────────────────── */}
"p-6 flex items-center justify-between gap-6 border-l-4", <div className="flex flex-col gap-[26px]">
isStreaming ? "border-l-live" : "border-l-hairline" <Card className="p-7">
)}> <div className="flex items-baseline justify-between gap-4 pb-5">
<div className="flex items-center gap-3.5"> <h2 className="font-heading text-[22px]">Server credentials</h2>
<span className="relative flex h-3.5 w-3.5"> {isStreaming ? (
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />} <div className="flex items-center gap-2">
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} /> <IsLivePulse />
</span> <p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
<div> </div>
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p> ) : (
{isStreaming && streamDetails?.startDate ? ( <p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p> )}
) : !isStreaming ? ( </div>
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
{isStreaming && elapsed ? ( <p className={labelClass}>Ingest URL</p>
<div className="text-right"> <div className="flex items-center gap-2.5 pt-2">
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
<p className={labelClass}>elapsed</p>
</div>
) : (
<Radio size={28} className="text-inactive" />
)}
</Box>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-olive flex-shrink-0" />
<input <input
type="text" type="text"
value={markerMessage} readOnly
onChange={(e) => setMarkerMessage(e.target.value)} value={ingestUrl ?? ""}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()} placeholder="asking the server…"
placeholder="What just happened?" className={clsx(monoInputClass, "flex-1")}
className={inputClass}
/> />
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap"> <button
Add Marker className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
</PrimaryButton> disabled={!ingestUrl}
onClick={() => ingestUrl && copy("url", ingestUrl)}
>
{copied === "url" ? "Copied" : "Copy"}
</button>
</div> </div>
{markerError && <p className="text-sm text-error">{markerError}</p>}
{markers.length > 0 && ( <p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5"> <div className="flex items-center gap-2.5 pt-2">
{markers.map((m) => { <div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
const isStart = startMarkerId === String(m.id); {keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
const isEnd = endMarkerId === String(m.id); </div>
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
</button>
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
{copied === "key" ? "Copied" : "Copy"}
</button>
</div>
<p className="pt-3 font-body text-[13px] text-muted">
Keep this private — anyone holding the key can stream to your account.
</p>
</Card>
{isStreaming ? (
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Live markers</h2>
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
</div>
<div className="flex items-center gap-3">
<input
ref={markerInputRef}
type="text"
value={markerMessage}
onChange={(event) => setMarkerMessage(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
placeholder="What just happened? (optional)"
className={clsx(inputClass, "flex-1")}
/>
<Button
variant="dark"
onClick={handleAddMarker}
disabled={markerBusy}
className="whitespace-nowrap px-5 py-3"
>
+ Drop marker at {elapsed}
</Button>
</div>
<p className="pt-2.5 font-body text-[13px] text-muted">
or press{" "}
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
{" "}to jump here
</p>
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
{/* Timeline overview */}
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
{gridlines.map((left, index) => (
<span
key={index}
className="absolute inset-y-0 w-px bg-inactive/60"
style={{ left: `${left}%` }}
/>
))}
{markers.map((marker) => {
const left = elapsedSeconds > 0
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
: 0;
return ( return (
<div key={m.id} className="group relative flex-shrink-0"> <span
key={marker.id}
title={marker.message}
className={clsx(
"absolute inset-y-0 w-[2px]",
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
)}
style={{ left: `${left}%` }}
/>
);
})}
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
</div>
{/* Marker list */}
<div className="flex flex-col gap-2 pt-5">
{markers.length === 0 && (
<p className="font-body text-[13px] text-muted py-2">
No markers yet. Drop one the moment something happens — you can cut around it later.
</p>
)}
{markers.map((marker) => {
const isSelected = selected.includes(marker.id);
const offset = offsetOf(marker);
return (
<div key={marker.id} className="group flex items-center gap-1">
<button <button
onClick={() => handleChipClick(m.id)} onClick={() => toggleMarker(marker.id)}
className={clsx( className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150", "flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
isStart && "border-terracotta bg-selected ring-1 ring-terracotta", isSelected
isEnd && "border-olive bg-selected ring-1 ring-olive", ? "bg-armed inset-shadow-armed"
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover" : "bg-card inset-shadow-hairline hover:bg-fields"
)} )}
> >
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span> <span className={clsx(
<span className="font-data text-[11px] text-muted"> "flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })} isSelected ? "bg-accent text-on-accent" : "bg-inactive"
{isStart && <span className="text-terracotta font-medium"> · start</span>} )}>
{isEnd && <span className="text-olive font-medium"> · end</span>} {isSelected && <Check size={12} strokeWidth={3} />}
</span>
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
{formatTimecode(offset)}
</span>
<span className="flex-1 font-body text-sm text-text-strong truncate">
{marker.message}
</span>
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
{toRelativeOffset(elapsedSeconds - offset)}
</span> </span>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }} onClick={() => handleDeleteMarker(marker.id)}
title="Delete marker" title="Remove marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-error text-on-accent transition-colors duration-150" className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
> >
<X size={10} /> <X size={14} />
</button> </button>
</div> </div>
); );
})} })}
</div> </div>
)} </Card>
</Box> ) : (
<Card className="p-8 flex flex-col gap-2">
<p className={kickerClass}>Off air</p>
<h2 className="font-heading text-[27px] leading-[1.25]">Nothing is coming in right now.</h2>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty">
Point OBS at the ingest URL above with your stream key, and this panel fills
in — elapsed time, markers, and the range cutter.
</p>
</Card>
)}
</div>
{/* Forefront actions */} {/* ── Side panel ────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch"> <aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
<Box className="p-6 flex flex-col gap-3"> {isStreaming && (
<CardHeader icon={Scissors} title="Clip the last few seconds" /> <>
<div className="flex flex-col gap-1.5"> <div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<label className={labelClass}>Duration (seconds)</label> <div className="flex flex-col gap-2">
<input <h2 className="font-heading text-[22px]">Make something from a range</h2>
type="number" <p className="font-body text-[13.5px] leading-[1.6] text-text-body">
min={1} {selected.length === 2 && rangeLength
value={clipDuration} ? `Two markers selected — ${inValue} to ${outValue}.`
onChange={(e) => setClipDuration(Number(e.target.value))} : "Tick two markers, or type a timecode by hand."}
className={inputClass} </p>
/> </div>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-error">{clipError}</p>}
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
</Box>
<Box className="p-6 flex flex-col gap-3"> <div className="grid grid-cols-2 gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" /> <div className="flex flex-col gap-1.5">
{markers.length < 2 ? ( <label className={labelClass} htmlFor="range-in">In</label>
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p> <input
) : ( id="range-in"
<> value={inValue}
<div className="flex items-center gap-2 flex-wrap"> onChange={(event) => setInValue(event.target.value)}
<span className={clsx( placeholder="00:00:00"
"text-xs font-medium px-2.5 py-1 rounded-full border", className={clsx(monoInputClass, "bg-card")}
startMarker ? "border-terracotta text-terracotta bg-selected" : "border-dashed border-inactive text-muted" />
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-inactive-strong">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-olive text-olive bg-selected" : "border-dashed border-inactive text-muted"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
</div> </div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} /> <div className="flex flex-col gap-1.5">
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} /> <label className={labelClass} htmlFor="range-out">Out</label>
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>} <input
</> id="range-out"
)} value={outValue}
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton> onChange={(event) => setOutValue(event.target.value)}
</Box> placeholder="00:00:00"
</div> className={clsx(monoInputClass, "bg-card")}
/>
</div>
</div>
<Box className="p-5 flex flex-col gap-3"> <div className="flex items-baseline justify-between gap-3">
<CardHeader icon={Activity} title="Recent actions" /> <span className="font-body text-[13.5px] text-text-body">Range length</span>
{jobs.length === 0 ? ( <span className="font-data text-[15px] text-text-primary tabular-nums">
<p className="text-sm text-muted">Clip and save actions will show up here.</p> {rangeLength ?? "—"}
) : ( </span>
<div className="flex flex-col gap-3"> </div>
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-name">Name</label>
<input
id="range-name"
value={rangeName}
onChange={(event) => setRangeName(event.target.value)}
placeholder="Give it a name"
className={clsx(inputClass, "bg-card")}
/>
</div>
{rangeError && <p className="font-body text-[13px] text-live-red">{rangeError}</p>}
<div className="flex flex-col gap-2.5">
<Button
onClick={handleSaveRange}
disabled={rangeBusy || rangeLength === null}
className="w-full py-3"
>
{rangeBusy ? "Queueing…" : "Save range as VOD"}
</Button>
<Button variant="ghost" onClick={clearSelection} className="w-full">
Clear selection
</Button>
</div>
</div> </div>
)}
</Box> <div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
</> <div className="flex flex-col gap-2">
)} <h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
Takes the last stretch of stream, counted back from this moment.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
<input
id="clip-duration"
type="number"
min={1}
max={180}
value={clipDuration}
onChange={(event) => setClipDuration(Number(event.target.value))}
className={clsx(monoInputClass, "bg-card")}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-name">Name</label>
<input
id="clip-name"
value={clipName}
onChange={(event) => setClipName(event.target.value)}
placeholder="Optional"
className={clsx(inputClass, "bg-card")}
/>
</div>
</div>
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
<Button
variant="olive"
onClick={handleClip}
disabled={clipDuration < 1 || clipDuration > 180}
className="w-full py-3"
>
Cut the last {clipDuration}s as a clip
</Button>
</div>
</>
)}
{jobs.length > 0 && (
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Background jobs</p>
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
</div>
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
</div>
)}
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
<p className={kickerClass}>How it works</p>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
Tick two markers to fill the range automatically, or type a timecode by hand.
Saving queues a background job — you can keep streaming while it runs.
</p>
</div>
</aside>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,42 @@
import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
import { getUser } from "./api/users.ts";
import type {User} from "./types.ts";
// for what the Auth object will look like
interface AuthContextValue {
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
loading: boolean;
}
interface AuthProviderProps {
children: ReactNode;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({children}: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
getUser()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
return (
<AuthContext.Provider value={{ user, setUser, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,6 +1,4 @@
import {config} from "../../config.ts"; const API_URL = import.meta.env.VITE_API_URL;
const API_URL = config.apiUrl;
class AuthError extends Error { class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; } constructor() { super("Not authenticated"); this.name = "AuthError"; }

View File

@@ -18,6 +18,18 @@ const getCurrentStream = async (): Promise<StreamStatus> => {
return result.data; return result.data;
}; };
// The RTMP endpoint to point OBS at. Comes from the server so it stays right
// across environments rather than being baked into the bundle.
const getIngestUrl = async (): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/stream/metadata', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch ingest URL: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch ingest URL: ${result.message}`);
return result.data.url;
};
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => { const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' }); const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`); if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
@@ -28,4 +40,4 @@ const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> =>
return result.data; return result.data;
}; };
export { getStreamStatus, getCurrentStream, getStreamHistory }; export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };

View File

@@ -0,0 +1,27 @@
// Shared class strings for the recurring bits of chrome. Anything used on more
// than one page lives here so the pages stay readable and stay in step.
/** Field well — no border, a soft terracotta ring on focus. */
const inputClass =
"bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
"placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
/** Same well, for timecodes and other machine facts. */
const monoInputClass = inputClass.replace("font-body", "font-data");
/** Field label — sits 6–10px above its field. */
const labelClass = "text-[11.5px] font-body text-muted";
/** Kicker / eyebrow above a page title or inside a muted panel. */
const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
/** Small chip button: copy, reveal, toggles. */
const chipButtonClass =
"bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
"px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
/** Quiet text link, used for back links and cancels. */
const quietLinkClass =
"font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };

View File

@@ -32,6 +32,11 @@ type Clip = {
description: string, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type Vod = { type Vod = {
@@ -41,6 +46,11 @@ type Vod = {
description: string, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type JobResponse = { type JobResponse = {
@@ -55,6 +65,7 @@ type JobResponse = {
type StreamStatus = { type StreamStatus = {
isStreaming: boolean, isStreaming: boolean,
id: number | null, id: number | null,
startTime: string | null
} }
type StreamHistoryItem = { type StreamHistoryItem = {
@@ -72,6 +83,11 @@ type Marker = {
timestamp: string, timestamp: string,
} }
type StreamMetadata = {
url: string,
length: number
}
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
@@ -81,5 +97,6 @@ export type {
JobResponse, JobResponse,
StreamStatus, StreamStatus,
StreamHistoryItem, StreamHistoryItem,
Marker Marker,
StreamMetadata
} }

View File

@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`; return `${m}:${pad(s)}`;
} }
// Sizes in MB/GB, as the design calls for. Input is bytes.
function formatFileSize(bytes: number): string {
const mb = bytes / 1_000_000;
if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
if (mb >= 10) return `${Math.round(mb)} MB`;
return `${mb.toFixed(1)} MB`;
}
// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
function formatTimecode(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
}
// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
// half-typed field doesn't reject the user mid-keystroke.
function parseTimecode(value: string): number | null {
const trimmed = value.trim();
if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
const parts = trimmed.split(':').map(Number);
if (parts.some(isNaN)) return null;
// Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
if (m > 59 || s > 59) return null;
return h * 3600 + m * 60 + s;
}
// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
// first few hours — that's the scale you actually think in mid-stream.
function toRelativeOffset(secondsAgo: number): string {
const m = Math.round(Math.max(0, secondsAgo) / 60);
if (m < 180) return `−${m}m`;
return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
}
// Parses a UTC date string, appending Z if no timezone offset is present so // Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time. // the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date { function stringToDate(dateString: string): Date {
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate }; export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };

View File

@@ -1,9 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window {
_env_?: {
VITE_API_URL?: string;
VITE_FRONTEND_URL?: string;
VITE_GOOGLE_CLIENT_ID?: string;
};
}

View File

@@ -1,12 +1,13 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite";
export default defineConfig(({ mode }) => { // change to function form export default defineConfig(({ mode }) => { // change to function form
const env = loadEnv(mode, process.cwd(), '') // load all env vars const env = loadEnv(mode, process.cwd(), '') // load all env vars
return { return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), flowbiteReact()],
preview: { preview: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,

View File

@@ -10,11 +10,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableAsync @EnableAsync
public class VodSystemApplication { public class VodSystemApplication {
/**
* Application entry point; boots the Spring context.
*
* @param args command-line arguments passed to Spring Boot
*/
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(VodSystemApplication.class, args); SpringApplication.run(VodSystemApplication.class, args);
} }

View File

@@ -14,11 +14,6 @@ import java.util.concurrent.Executor;
public class AsyncConfig implements AsyncConfigurer { public class AsyncConfig implements AsyncConfigurer {
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class); private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
/**
* Provides the bounded thread pool that runs ffmpeg-backed {@code @Async} media tasks.
*
* @return the configured {@link Executor} registered as {@code ffmpegTaskExecutor}
*/
@Bean(name = "ffmpegTaskExecutor") @Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() { public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
@@ -30,11 +25,6 @@ public class AsyncConfig implements AsyncConfigurer {
return executor; return executor;
} }
/**
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
*
* @return the async uncaught-exception handler
*/
@Override @Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> return (ex, method, params) ->

View File

@@ -12,11 +12,6 @@ public class CorsConfig {
@Value("${frontend.url}") @Value("${frontend.url}")
private String frontendUrl; private String frontendUrl;
/**
* Configures CORS for the API, allowing credentialed requests from the configured frontend origin.
*
* @return a {@link WebMvcConfigurer} that registers the CORS mappings for {@code /api/v1/**}
*/
@Bean @Bean
public WebMvcConfigurer corsConfigurer() { public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() { return new WebMvcConfigurer() {

View File

@@ -22,14 +22,6 @@ public class SecurityConfig {
this.jwtFilter = jwtFilter; this.jwtFilter = jwtFilter;
} }
/**
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
* installed, and per-endpoint authorization rules.
*
* @param http the HTTP security builder to configure
* @return the built {@link SecurityFilterChain}
* @throws Exception if the security configuration cannot be built
*/
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@@ -38,9 +30,8 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
.requestMatchers("/api/v1/media/**").authenticated() .requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll() .requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()

View File

@@ -6,12 +6,14 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.*; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/api/v1/clips") @RequestMapping("/api/v1/clips")
@@ -24,11 +26,6 @@ public class ClipController {
this.clipService = clipService; this.clipService = clipService;
} }
/**
* Lists all clips belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's clips as {@link ClipResponse} DTOs
*/
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() { public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser(); List<Clip> clips = clipService.getClipsByUser();
@@ -44,12 +41,6 @@ public class ClipController {
); );
} }
/**
* Retrieves a single clip owned by the authenticated user.
*
* @param id the ID of the clip to retrieve
* @return {@code 200 OK} wrapping the clip as a {@link ClipResponse} DTO
*/
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(id); Clip clip = clipService.getClipById(id);
@@ -63,13 +54,6 @@ public class ClipController {
); );
} }
/**
* Applies a partial update to a clip's metadata; null fields are left unchanged.
*
* @param id the ID of the clip to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated clip as a {@link ClipResponse} DTO
*/
@PatchMapping("/{id}") @PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id, public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) { @RequestBody ClipUpdateRequest updateFields) {
@@ -81,12 +65,6 @@ public class ClipController {
); );
} }
/**
* Deletes a clip and its associated files.
*
* @param id the ID of the clip to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(id); clipService.deleteClip(id);
@@ -100,13 +78,6 @@ public class ClipController {
); );
} }
/**
* Streams the clip's video file inline, supporting HTTP range requests.
*
* @param id the ID of the clip to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE) @GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id); Resource resource = clipService.downloadClip(id);
@@ -124,15 +95,8 @@ public class ClipController {
.body(resource); .body(resource);
} }
/**
* Streams the clip's thumbnail image with a public cache policy.
*
* @param id the ID of the clip whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail") @GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id); Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -140,8 +104,6 @@ public class ClipController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);
@@ -154,7 +116,11 @@ public class ClipController {
clip.getTitle(), clip.getTitle(),
clip.getDescription(), clip.getDescription(),
clip.getDuration(), clip.getDuration(),
clip.getCreatedAt() clip.getCreatedAt(),
clip.getWidth(),
clip.getHeight(),
clip.getFps(),
clip.getFileSize()
); );
} }
} }

View File

@@ -18,12 +18,6 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String ERROR = "error"; private static final String ERROR = "error";
/**
* Handles malformed multipart requests, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MultipartException.class }) @ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) { public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage()); logger.error("MultipartException: {}", ex.getMessage());
@@ -31,12 +25,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
/**
* Handles requests missing a required file part, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MissingServletRequestPartException.class }) @ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) { public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage()); logger.error("MissingServletRequestPartException: {}", ex.getMessage());
@@ -44,12 +32,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
/**
* Handles unsupported request content types, returning {@code 415 Unsupported Media Type}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class }) @ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage()); logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
@@ -57,12 +39,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response); return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
} }
/**
* Handles invalid arguments, returning {@code 400 Bad Request} with the exception message.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) { public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage()); logger.error("IllegalArgumentException: {}", ex.getMessage());
@@ -70,12 +46,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
/**
* Handles lookups for a non-existent job, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFound.class) @ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) { public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage()); logger.error("JobNotFound: {}", ex.getMessage());
@@ -83,12 +53,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
} }
/**
* Handles requests for a job that is still running, returning {@code 202 Accepted}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFinished.class) @ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) { public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage()); logger.error("JobNotFinished: {}", ex.getMessage());
@@ -96,12 +60,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
} }
/**
* Handles ffmpeg processing failures, returning {@code 500 Internal Server Error}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(FFMPEGException.class) @ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) { public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage()); logger.error("FFMPEGException: {}", ex.getMessage());
@@ -109,12 +67,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} }
/**
* Handles unauthenticated or unauthorized access, returning {@code 401 Unauthorized}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotAuthenticated.class) @ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) { public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage()); logger.error("NotAuthenticated: {}", ex.getMessage());
@@ -122,12 +74,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
} }
/**
* Handles an unrecognised stream key, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(KeyNotFound.class) @ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) { public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage()); logger.error("KeyNotFound: {}", ex.getMessage());
@@ -135,12 +81,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
} }
/**
* Handles an attempt to start a stream while one is already active, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(AlreadyStreaming.class) @ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) { public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage()); logger.error("AlreadyStreaming: {}", ex.getMessage());
@@ -148,12 +88,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
} }
/**
* Handles file storage failures, returning {@code 500 Internal Server Error}.
*
* @param e the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(StorageException.class) @ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) { public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e); logger.error("Storage operation failed", e);
@@ -161,12 +95,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} }
/**
* Handles lookups for a non-existent clip, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipNotFound.class) @ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) { public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage()); logger.error("ClipNotFound: {}", ex.getMessage());
@@ -174,12 +102,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
} }
/**
* Handles a clip whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipMediaUnavailable.class) @ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) { public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage()); logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
@@ -187,12 +109,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
} }
/**
* Handles lookups for a non-existent VoD, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodNotFound.class) @ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) { public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage()); logger.error("VodNotFound: {}", ex.getMessage());
@@ -200,12 +116,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
} }
/**
* Handles a VoD whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodMediaUnavailable.class) @ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) { public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage()); logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
@@ -213,12 +123,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
} }
/**
* Handles actions that require an active stream when none exists, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotStreaming.class) @ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) { public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage()); logger.error("NotStreaming: {}", ex.getMessage());
@@ -226,12 +130,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
} }
/**
* Handles lookups for a non-existent marker, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(MarkerNotFound.class) @ExceptionHandler(MarkerNotFound.class)
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) { public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
logger.error("MarkerNotFound: {}", ex.getMessage()); logger.error("MarkerNotFound: {}", ex.getMessage());

View File

@@ -28,13 +28,6 @@ public class JobController {
this.jobRegistryService = jobRegistryService; this.jobRegistryService = jobRegistryService;
} }
/**
* Retrieves the status and progress of an asynchronous job.
*
* @param uuid the UUID of the job to look up
* @return {@code 200 OK} wrapping the job's state as a {@link JobResponse} DTO
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
*/
@GetMapping("/{uuid}") @GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) { public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid); Job job = jobRegistryService.getJob(uuid);
@@ -54,15 +47,6 @@ public class JobController {
)); ));
} }
/**
* Downloads the output file produced by a completed job.
*
* @param uuid the UUID of the job whose output to download
* @return {@code 200 OK} with the output file as a downloadable resource
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
* @throws NotReadyException if the job has not yet produced a downloadable file
* @throws ClipNotFound if the job's output file no longer exists on disk
*/
@GetMapping("/{uuid}/download") @GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) { public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid); Job job = jobRegistryService.getJob(uuid);

View File

@@ -22,11 +22,6 @@ public class MarkerController {
this.markerService = markerService; this.markerService = markerService;
} }
/**
* Lists all markers belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's markers as {@link MarkerResponse} DTOs
*/
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() { public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream() List<MarkerResponse> markers = markerService.getUserMarkers().stream()
@@ -38,12 +33,6 @@ public class MarkerController {
); );
} }
/**
* Retrieves a single marker owned by the authenticated user.
*
* @param id the ID of the marker to retrieve
* @return {@code 200 OK} wrapping the marker as a {@link MarkerResponse} DTO
*/
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) { public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(id); Marker marker = markerService.getMarkerById(id);
@@ -53,12 +42,6 @@ public class MarkerController {
); );
} }
/**
* Creates a marker at the current moment on the user's active stream.
*
* @param request the marker payload carrying the marker message
* @return {@code 200 OK} wrapping the created marker as a {@link MarkerResponse} DTO
*/
@PostMapping("") @PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) { public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message()); Marker marker = markerService.create(request.message());
@@ -68,12 +51,6 @@ public class MarkerController {
); );
} }
/**
* Deletes a marker owned by the authenticated user.
*
* @param id the ID of the marker to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id); markerService.deleteMarker(id);

View File

@@ -26,14 +26,6 @@ public class MediaController {
this.mediaService = mediaService; this.mediaService = mediaService;
} }
/**
* Starts an asynchronous job to compress an uploaded video file.
*
* @param file the uploaded video to compress
* @param options trim window, resolution, fps, and target size for the output
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if saving the upload to temporary storage fails
*/
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress( public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file, @RequestParam("file")MultipartFile file,
@@ -48,13 +40,6 @@ public class MediaController {
} }
/**
* Starts an asynchronous job to save a section of the user's stream between two timestamps as a VoD.
*
* @param saveSectionRequest the start/end timestamps and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save") @PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save( public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException { @RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
@@ -70,13 +55,6 @@ public class MediaController {
)); ));
} }
/**
* Starts an asynchronous job to save the section of the user's stream between two markers as a VoD.
*
* @param saveSectionByMarkerRequest the start/end marker IDs and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save/markers") @PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers( public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException { @Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
@@ -92,13 +70,6 @@ public class MediaController {
)); ));
} }
/**
* Starts an asynchronous job to save the last few seconds of the user's stream as a clip.
*
* @param clipSectionRequest the clip duration in seconds and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/clip") @PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip( public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException { @RequestBody ClipSectionRequest clipSectionRequest) throws IOException {

View File

@@ -1,14 +1,17 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse; import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService; import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -18,20 +21,25 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class); private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
/** Matches the port and application name in nginx/default.conf.template. */
private static final int RTMP_PORT = 1935;
private static final String RTMP_APPLICATION = "live";
private final StreamService streamService; private final StreamService streamService;
@Value("${stream.ingest.url:}")
private String configuredIngestUrl;
@Value("${frontend.url:}")
private String frontendUrl;
@Value("${stream.max-length}")
private Float streamLength;
public StreamController(StreamService streamService) { public StreamController(StreamService streamService) {
this.streamService = streamService; this.streamService = streamService;
} }
/**
* Starts a new stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish} RTMP callback when a broadcast begins.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} wrapping the newly started {@link Stream}
*/
// Called by nginx on_publish // Called by nginx on_publish
@PostMapping("/start") @PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
@@ -40,14 +48,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
} }
/**
* Ends the active stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish_done} RTMP callback when a broadcast stops.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the stream was ended
*/
// Called by nginx on_publish_done // Called by nginx on_publish_done
@PostMapping("/stop") @PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
@@ -56,14 +56,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
} }
/**
* Records a liveness heartbeat for the active stream, keeping it from being reaped as stale.
* <p>
* Called periodically by nginx via its {@code on_update} RTMP callback.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the heartbeat was recorded
*/
// Called by nginx on_update // Called by nginx on_update
@PostMapping("/heartbeat") @PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
@@ -71,31 +63,62 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
} }
/**
* Retrieves the stream history for the given user.
*
* @param userId the ID of the user whose stream history to retrieve; must be the authenticated user
* @return {@code 200 OK} wrapping the user's past and current {@link Stream}s
*/
@GetMapping("/history/{userId}") @GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) { public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId); List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
} }
@GetMapping("/metadata")
public ResponseEntity<APIResponse<StreamMetadataResponse>> getIngestEndpoint() {
return ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Stream information endpoint retrieved",
new StreamMetadataResponse(resolveIngestUrl(), streamLength)));
}
/** /**
* Reports whether the authenticated user is currently streaming. * The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
* * needed when the RTMP port is reached through a different host or a non-default port.
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live, * Otherwise it is derived from the frontend's host, which is where docker-compose
* the active stream's ID * publishes the nginx-rtmp container.
*/ */
private String resolveIngestUrl() {
if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
return configuredIngestUrl.trim();
}
String host = hostOf(frontendUrl);
return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
}
private static String hostOf(String url) {
if (url == null || url.isBlank()) {
return null;
}
String trimmed = url.trim();
try {
String host = URI.create(trimmed).getHost();
if (host != null && !host.isBlank()) {
return host;
}
} catch (IllegalArgumentException e) {
logger.warn("Could not parse frontend.url ({})", trimmed);
}
// A bare "example.net" or "example.net:8080" parses as a path, not a host.
String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
return bare.isBlank() ? null : bare;
}
@GetMapping("/current") @GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() { public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS, return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved", "Streaming status retrieved",
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS, new StreamResponse(true, value.getId(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved", "Streaming status retrieved",
new StreamResponse(false, null)))); new StreamResponse(false, null, null))));
} }
} }

View File

@@ -27,12 +27,6 @@ public class UserController {
this.userService = userService; this.userService = userService;
} }
/**
* Returns the currently authenticated user's profile.
*
* @return {@code 200 OK} wrapping the authenticated {@link User}
* @throws NotAuthenticated if no valid session is present
*/
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() { public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -46,17 +40,6 @@ public class UserController {
); );
} }
/**
* Authenticates a user from a Google ID token and issues a session JWT.
* <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
@@ -78,13 +61,6 @@ public class UserController {
); );
} }
/**
* Logs the user out by clearing the session cookie.
*
* @param response the response the expired cookie is written to
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) { HttpServletRequest request) {

View File

@@ -6,12 +6,14 @@ import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Vod; import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.services.VodService; import com.ddf.vodsystem.services.VodService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.*; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/api/v1/vods") @RequestMapping("/api/v1/vods")
@@ -24,11 +26,6 @@ public class VodController {
this.vodService = vodService; this.vodService = vodService;
} }
/**
* Lists all VoDs belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's VoDs as {@link VodResponse} DTOs
*/
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() { public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream() List<VodResponse> vods = vodService.getUserVods().stream()
@@ -38,25 +35,12 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
} }
/**
* Retrieves a single VoD owned by the authenticated user.
*
* @param id the ID of the VoD to retrieve
* @return {@code 200 OK} wrapping the VoD as a {@link VodResponse} DTO
*/
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) { public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id)); VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
} }
/**
* Applies a partial update to a VoD's metadata; null fields are left unchanged.
*
* @param id the ID of the VoD to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated VoD as a {@link VodResponse} DTO
*/
@PatchMapping("/{id}") @PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id, public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) { @RequestBody VodUpdateRequest updateFields) {
@@ -64,12 +48,6 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
} }
/**
* Deletes a VoD and its associated files.
*
* @param id the ID of the VoD to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id); vodService.deleteVod(id);
@@ -77,13 +55,6 @@ public class VodController {
"Vod with ID " + id + " has been deleted")); "Vod with ID " + id + " has been deleted"));
} }
/**
* Streams the VoD's video file inline, supporting HTTP range requests.
*
* @param id the ID of the VoD to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE) @GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id); Resource resource = vodService.downloadVod(id);
@@ -101,15 +72,8 @@ public class VodController {
.body(resource); .body(resource);
} }
/**
* Streams the VoD's thumbnail image with a public cache policy.
*
* @param id the ID of the VoD whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail") @GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = vodService.downloadThumbnail(id); Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -117,8 +81,6 @@ public class VodController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);
@@ -131,7 +93,11 @@ public class VodController {
vod.getTitle(), vod.getTitle(),
vod.getDescription(), vod.getDescription(),
vod.getDuration(), vod.getDuration(),
vod.getCreatedAt() vod.getCreatedAt(),
vod.getWidth(),
vod.getHeight(),
vod.getFps(),
vod.getFileSize()
); );
} }
} }

View File

@@ -8,5 +8,9 @@ public record ClipResponse(
String title, String title,
String description, String description,
Float duration, Float duration,
Instant createdAt Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {} ) {}

View File

@@ -0,0 +1,4 @@
package com.ddf.vodsystem.controllers.dto;
/** Where OBS (or anything else speaking RTMP) should publish to. */
public record StreamMetadataResponse(String url, Float streamMaxLength) {}

View File

@@ -1,7 +1,10 @@
package com.ddf.vodsystem.controllers.dto; package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record StreamResponse( public record StreamResponse(
boolean isStreaming, boolean isStreaming,
Long id Long id,
Instant startTime
) { ) {
} }

View File

@@ -8,5 +8,9 @@ public record VodResponse(
String title, String title,
String description, String description,
Float duration, Float duration,
Instant createdAt Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {} ) {}

View File

@@ -10,11 +10,6 @@ public class CommandOutput {
private List<String> output = new ArrayList<>(); private List<String> output = new ArrayList<>();
private int exitCode; private int exitCode;
/**
* Appends a line to the captured command output.
*
* @param line the output line to append
*/
public void addLine(String line) { public void addLine(String line) {
output.add(line); output.add(line);
} }

View File

@@ -4,30 +4,14 @@ public class ProgressTracker {
private float progress = 0.0f; private float progress = 0.0f;
private boolean isComplete = false; private boolean isComplete = false;
/**
* Returns the current progress.
*
* @return the progress as a fraction in {@code [0, 1]}
*/
public synchronized float getProgress() { public synchronized float getProgress() {
return progress; return progress;
} }
/**
* Returns whether the tracked task has been marked complete.
*
* @return {@code true} if complete
*/
public synchronized boolean isComplete() { public synchronized boolean isComplete() {
return isComplete; return isComplete;
} }
/**
* Updates the current progress.
*
* @param newProgress the progress as a fraction in {@code [0, 1]}
* @throws IllegalArgumentException if {@code newProgress} is outside {@code [0, 1]}
*/
public synchronized void setProgress(float newProgress) { public synchronized void setProgress(float newProgress) {
if (newProgress < 0 || newProgress > 1) { if (newProgress < 0 || newProgress > 1) {
throw new IllegalArgumentException("Progress must be between 0 and 1"); throw new IllegalArgumentException("Progress must be between 0 and 1");
@@ -35,16 +19,10 @@ public class ProgressTracker {
this.progress = newProgress; this.progress = newProgress;
} }
/**
* Marks the tracked task as complete.
*/
public synchronized void markComplete() { public synchronized void markComplete() {
this.isComplete = true; this.isComplete = true;
} }
/**
* Resets progress to zero and clears the complete flag.
*/
public synchronized void reset() { public synchronized void reset() {
this.progress = 0.0f; this.progress = 0.0f;
this.isComplete = false; this.isComplete = false;

View File

@@ -12,11 +12,6 @@ public class ClipMediaUnavailable extends RuntimeException {
this.clipId = clipId; this.clipId = clipId;
} }
/**
* Returns the ID of the clip whose media was unavailable.
*
* @return the affected clip's ID
*/
public Long getClipId() { public Long getClipId() {
return clipId; return clipId;
} }

View File

@@ -12,11 +12,6 @@ public class VodMediaUnavailable extends RuntimeException {
this.vodId = vodId; this.vodId = vodId;
} }
/**
* Returns the ID of the VoD whose media was unavailable.
*
* @return the affected VoD's ID
*/
public Long getVodId() { public Long getVodId() {
return vodId; return vodId;
} }

View File

@@ -10,12 +10,6 @@ import java.util.List;
@Repository @Repository
public interface ClipRepository extends JpaRepository<Clip, Long> { public interface ClipRepository extends JpaRepository<Clip, Long> {
/**
* Finds all clips owned by the given user.
*
* @param user the owning user
* @return the user's clips
*/
@Query("SELECT c FROM Clip c WHERE c.user = ?1") @Query("SELECT c FROM Clip c WHERE c.user = ?1")
List<Clip> findByUser(User user); List<Clip> findByUser(User user);
} }

View File

@@ -11,21 +11,9 @@ import java.util.List;
@Repository @Repository
public interface MarkerRepository extends JpaRepository<Marker, Long> { public interface MarkerRepository extends JpaRepository<Marker, Long> {
/**
* Finds all markers owned by the given user.
*
* @param user the owning user
* @return the user's markers
*/
@Query("SELECT m FROM Marker m WHERE m.user = ?1") @Query("SELECT m FROM Marker m WHERE m.user = ?1")
List<Marker> findByUser(User user); List<Marker> findByUser(User user);
/**
* Finds all markers timestamped at or before the given instant.
*
* @param time the cutoff instant (inclusive)
* @return markers at or before {@code time}
*/
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1") @Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
List<Marker> findAllBefore(Instant time); List<Marker> findAllBefore(Instant time);
} }

View File

@@ -11,27 +11,7 @@ import java.util.Optional;
@Repository @Repository
public interface StreamRepository extends JpaRepository<Stream, Long> { public interface StreamRepository extends JpaRepository<Stream, Long> {
/**
* Finds the user's active (not-yet-ended) stream, if any.
*
* @param user the owning user
* @return the active stream, or empty if none
*/
Optional<Stream> findByUserAndEndDateIsNull(User user); Optional<Stream> findByUserAndEndDateIsNull(User user);
/**
* Finds all streams belonging to the given user.
*
* @param user the owning user
* @return the user's streams
*/
List<Stream> findByUser(User user); List<Stream> findByUser(User user);
/**
* Finds active streams whose last heartbeat predates the given cutoff, i.e. stale streams.
*
* @param cutoff the last-seen cutoff; streams last seen before this are returned
* @return the stale active streams
*/
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff); List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
} }

View File

@@ -9,21 +9,9 @@ import java.util.Optional;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
/**
* Finds a user by their Google account ID.
*
* @param googleId the Google subject identifier
* @return the matching user, or empty if none
*/
@Query("SELECT u FROM User u WHERE u.googleId = ?1") @Query("SELECT u FROM User u WHERE u.googleId = ?1")
Optional<User> findByGoogleId(String googleId); Optional<User> findByGoogleId(String googleId);
/**
* Finds a user by their stream key.
*
* @param streamKey the stream key to match
* @return the matching user, or empty if none
*/
@Query("SELECT u FROM User u WHERE u.streamKey = ?1") @Query("SELECT u FROM User u WHERE u.streamKey = ?1")
Optional<User> findByStreamKey(String streamKey); Optional<User> findByStreamKey(String streamKey);
} }

View File

@@ -11,12 +11,6 @@ import java.util.List;
@Repository @Repository
public interface VodRepository extends JpaRepository<Vod, Long> public interface VodRepository extends JpaRepository<Vod, Long>
{ {
/**
* Finds all VoDs owned by the given user.
*
* @param user the owning user
* @return the user's VoDs
*/
@Query("SELECT v FROM Vod v WHERE v.user = ?1") @Query("SELECT v FROM Vod v WHERE v.user = ?1")
List<Vod> findByUser(User user); List<Vod> findByUser(User user);
} }

View File

@@ -26,12 +26,6 @@ public class JwtService {
this.algorithm = Algorithm.HMAC256(jwtSecretKey); this.algorithm = Algorithm.HMAC256(jwtSecretKey);
} }
/**
* Generates a signed JWT carrying the given user ID, valid for the configured expiry.
*
* @param userId the user ID to embed as a claim
* @return the signed JWT string
*/
public String generateToken(Long userId) { public String generateToken(Long userId) {
return JWT.create() return JWT.create()
.withClaim(USER_ID_CLAIM, userId) .withClaim(USER_ID_CLAIM, userId)
@@ -40,12 +34,6 @@ public class JwtService {
.sign(algorithm); .sign(algorithm);
} }
/**
* Verifies a JWT's signature, issuer, and expiry, returning the embedded user ID.
*
* @param token the JWT to validate
* @return the user ID claim if the token is valid and unexpired, otherwise {@code null}
*/
public Long validateTokenAndGetUserId(String token) { public Long validateTokenAndGetUserId(String token) {
try { try {
JWTVerifier verifier = JWT.require(algorithm) JWTVerifier verifier = JWT.require(algorithm)

View File

@@ -40,132 +40,61 @@ public class DirectoryService {
private static final String CLIPS_DIR_NAME = "clips"; private static final String CLIPS_DIR_NAME = "clips";
private static final String THUMBNAIL_DIR_NAME = "thumbnails"; private static final String THUMBNAIL_DIR_NAME = "thumbnails";
/**
* Returns the directory for temporary upload inputs.
*
* @return the configured temporary input directory path
*/
public Path getTempInputDir() { public Path getTempInputDir() {
return Path.of(tempInputsDir); return Path.of(tempInputsDir);
} }
/**
* Returns the directory for temporary processing outputs.
*
* @return the configured temporary output directory path
*/
public Path getTempOutputDir() { public Path getTempOutputDir() {
return Path.of(tempOutputsDir); return Path.of(tempOutputsDir);
} }
/**
* Returns the given user's root media directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's media directory path
* @throws IOException if the directory cannot be created
*/
public Path getUserDir(Long userId) throws IOException { public Path getUserDir(Long userId) throws IOException {
Path userDir = Path.of(usersDir + File.separator + userId); Path userDir = Path.of(usersDir + File.separator + userId);
Files.createDirectories(userDir); Files.createDirectories(userDir);
return userDir; return userDir;
} }
/**
* Returns the given user's VoDs directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's VoDs directory path
* @throws IOException if the directory cannot be created
*/
public Path getVodsDir(Long userId) throws IOException { public Path getVodsDir(Long userId) throws IOException {
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME); Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
Files.createDirectories(vodDir); Files.createDirectories(vodDir);
return vodDir; return vodDir;
} }
/**
* Returns the given user's clips directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's clips directory path
* @throws IOException if the directory cannot be created
*/
public Path getClipsDir(Long userId) throws IOException { public Path getClipsDir(Long userId) throws IOException {
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME); Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
Files.createDirectories(clipsDir); Files.createDirectories(clipsDir);
return clipsDir; return clipsDir;
} }
/**
* Returns the given user's thumbnails directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's thumbnails directory path
* @throws IOException if the directory cannot be created
*/
public Path getThumbnailsDir(Long userId) throws IOException { public Path getThumbnailsDir(Long userId) throws IOException {
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME); Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
Files.createDirectories(thumbnailsDir); Files.createDirectories(thumbnailsDir);
return thumbnailsDir; return thumbnailsDir;
} }
/**
* Returns the directory holding a stream's recorded HLS segments, creating it if necessary.
*
* @param streamKey the stream key naming the segment directory
* @return the stream's segment directory path
* @throws IOException if the directory cannot be created
*/
public Path getStreamDir(String streamKey) throws IOException { public Path getStreamDir(String streamKey) throws IOException {
Path streamFolder = Path.of(streamsDir, streamKey); Path streamFolder = Path.of(streamsDir, streamKey);
Files.createDirectories(streamFolder); Files.createDirectories(streamFolder);
return streamFolder; return streamFolder;
} }
/**
* Converts an absolute path into one relative to the users media root, for storage in the database.
*
* @param path the absolute path to relativise
* @return the path relative to the users media directory
*/
public Path relativisePath(Path path) { public Path relativisePath(Path path) {
Path media = Path.of(usersDir).toAbsolutePath(); Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = path.toAbsolutePath(); Path newPath = path.toAbsolutePath();
return media.relativize(newPath); return media.relativize(newPath);
} }
/**
* Resolves a stored relative path back into an absolute path under the users media root.
*
* @param path the relative path, as stored in the database
* @return the absolute path on disk
*/
public Path resolvePath(String path) { public Path resolvePath(String path) {
Path media = Path.of(usersDir).toAbsolutePath(); Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = Path.of(path); Path newPath = Path.of(path);
return media.resolve(newPath); return media.resolve(newPath);
} }
/**
* Writes an uploaded file to the given path, creating parent directories and overwriting any existing file.
*
* @param path the destination path
* @param multipartFile the uploaded file to save
* @throws IOException if writing the file fails
*/
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException { public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
Files.createDirectories(path.getParent()); Files.createDirectories(path.getParent());
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
} }
/**
* Copies a file, creating parent directories and overwriting any existing target.
*
* @param source the file to copy from
* @param target the destination path
* @throws IOException if the copy fails
*/
public void copyFile(Path source, Path target) throws IOException { public void copyFile(Path source, Path target) throws IOException {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
@@ -217,12 +146,6 @@ public class DirectoryService {
} }
} }
/**
* Creates the temporary and users media directories on startup and verifies they are usable.
*
* @throws IOException if a directory cannot be created
* @throws IllegalStateException if any required directory is not a readable, writable directory
*/
@PostConstruct @PostConstruct
public void createDirectoriesIfNotExist() throws IOException { public void createDirectoriesIfNotExist() throws IOException {
Files.createDirectories(Path.of(tempInputsDir)); Files.createDirectories(Path.of(tempInputsDir));
@@ -232,13 +155,6 @@ public class DirectoryService {
checkDirPermissions(); checkDirPermissions();
} }
/**
* Scheduled cleanup that deletes stale files from the temporary input and output directories.
* <p>
* Runs every 30 minutes; files older than 3 hours are removed.
*
* @throws IOException if listing a temporary directory fails
*/
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
public void cleanTempDirectories() throws IOException { public void cleanTempDirectories() throws IOException {
cleanUpDirectory(tempInputsDir); cleanUpDirectory(tempInputsDir);

View File

@@ -32,14 +32,6 @@ public class MarkerService {
this.markerRepository = markerRepository; this.markerRepository = markerRepository;
} }
/**
* Returns the marker with the given ID, verifying ownership by the current user.
*
* @param id the ID of the marker to retrieve
* @return the matching {@link Marker}
* @throws MarkerNotFound if no marker with {@code id} exists
* @throws NotAuthenticated if the current user does not own the marker
*/
public Marker getMarkerById(Long id) { public Marker getMarkerById(Long id) {
Optional<Marker> marker = markerRepository.findById(id); Optional<Marker> marker = markerRepository.findById(id);
@@ -55,12 +47,6 @@ public class MarkerService {
return marker.get(); return marker.get();
} }
/**
* Returns all markers belonging to the currently authenticated user.
*
* @return list of the current user's markers
* @throws NotAuthenticated if no user session is present
*/
public List<Marker> getUserMarkers() { public List<Marker> getUserMarkers() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -71,13 +57,6 @@ public class MarkerService {
return markerRepository.findByUser(user.get()); return markerRepository.findByUser(user.get());
} }
/**
* Creates a marker timestamped now on the current user's active stream.
*
* @param message the marker's descriptive message
* @return the persisted {@link Marker}
* @throws NotStreaming if the user has no active stream to mark
*/
public Marker create(String message) { public Marker create(String message) {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
@@ -93,23 +72,11 @@ public class MarkerService {
return markerRepository.saveAndFlush(marker); return markerRepository.saveAndFlush(marker);
} }
/**
* Deletes a marker owned by the current user.
*
* @param id the ID of the marker to delete
* @throws MarkerNotFound if no marker with {@code id} exists
* @throws NotAuthenticated if the current user does not own the marker
*/
public void deleteMarker(Long id) { public void deleteMarker(Long id) {
Marker marker = getMarkerById(id); Marker marker = getMarkerById(id);
markerRepository.delete(marker); markerRepository.delete(marker);
} }
/**
* Scheduled cleanup that deletes markers older than the configured maximum stream length.
* <p>
* Runs periodically (every 6 minutes) to purge markers left behind by streams that have ended.
*/
@Scheduled(fixedDelay = 360_000) @Scheduled(fixedDelay = 360_000)
@Transactional @Transactional
public void deleteOldMarkers() { public void deleteOldMarkers() {

View File

@@ -30,14 +30,6 @@ public class StreamService {
this.userService = userService; this.userService = userService;
} }
/**
* Starts a new stream for the user identified by the given stream key.
*
* @param streamKey the stream key resolving to the broadcasting user
* @return the newly persisted {@link Stream}
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws AlreadyStreaming if the user already has an active stream
*/
public Stream startStream(String streamKey) { public Stream startStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -53,13 +45,6 @@ public class StreamService {
return streamRepository.saveAndFlush(stream); return streamRepository.saveAndFlush(stream);
} }
/**
* Ends the active stream for the user identified by the given stream key.
*
* @param streamKey the stream key resolving to the broadcasting user
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws IllegalStateException if the user has no active stream
*/
public void endStream(String streamKey) { public void endStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -71,13 +56,6 @@ public class StreamService {
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
/**
* Records a liveness heartbeat on the active stream, refreshing its last-seen time.
*
* @param streamKey the stream key resolving to the broadcasting user
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws IllegalStateException if the user has no active stream
*/
public void heartbeatStream(String streamKey) { public void heartbeatStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -89,12 +67,6 @@ public class StreamService {
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
/**
* Returns the current user's active (not-yet-ended) stream, if any.
*
* @return the active {@link Stream}, or empty if the user is not currently streaming
* @throws NotAuthenticated if no user session is present
*/
public Optional<Stream> getActiveStream() { public Optional<Stream> getActiveStream() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -105,14 +77,6 @@ public class StreamService {
return streamRepository.findByUserAndEndDateIsNull(user.get()); return streamRepository.findByUserAndEndDateIsNull(user.get());
} }
/**
* Returns the full stream history for the given user.
*
* @param userId the ID of the user whose history to retrieve
* @return the user's streams, past and present
* @throws IllegalArgumentException if no user with {@code userId} exists
* @throws NotAuthenticated if no user is authenticated, or the caller is not that user
*/
public List<Stream> getStreamHistory(Long userId) { public List<Stream> getStreamHistory(Long userId) {
User streamUser = userService.getUserById(userId) User streamUser = userService.getUserById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
@@ -130,12 +94,6 @@ public class StreamService {
return streamRepository.findByUser(streamUser); return streamRepository.findByUser(streamUser);
} }
/**
* Scheduled sweep that ends streams which have missed heartbeats past the timeout.
* <p>
* Runs every 30 seconds; any active stream whose last heartbeat is older than
* {@value #HEARTBEAT_TIMEOUT_SECONDS} seconds is marked ended.
*/
@Scheduled(fixedDelay = 30_000) @Scheduled(fixedDelay = 30_000)
@Transactional @Transactional
public void endStaleStreams() { public void endStaleStreams() {

View File

@@ -44,21 +44,10 @@ public class UserService {
this.jwtService = jwtService; this.jwtService = jwtService;
} }
/**
* Looks up a user by their database ID.
*
* @param userId the ID of the user to find
* @return the matching {@link User}, or empty if none exists
*/
public Optional<User> getUserById(Long userId) { public Optional<User> getUserById(Long userId) {
return userRepository.findById(userId); return userRepository.findById(userId);
} }
/**
* Returns the user backing the current Spring Security authentication, if authenticated.
*
* @return the authenticated {@link User}, or empty if no user is authenticated
*/
public Optional<User> getLoggedInUser() { public Optional<User> getLoggedInUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) { if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
@@ -67,13 +56,6 @@ public class UserService {
return Optional.empty(); return Optional.empty();
} }
/**
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
*
* @param idToken the Google ID token to verify
* @return a signed JWT for the authenticated user
* @throws NotAuthenticated if the token is invalid or its subject is missing
*/
public String login(String idToken) { public String login(String idToken) {
GoogleIdToken googleIdToken = getGoogleIdToken(idToken); GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
String googleId = googleIdToken.getPayload().getSubject(); String googleId = googleIdToken.getPayload().getSubject();
@@ -88,12 +70,6 @@ public class UserService {
return jwtService.generateToken(user.getId()); return jwtService.generateToken(user.getId());
} }
/**
* Looks up a user by their stream key.
*
* @param streamKey the stream key to match
* @return the matching {@link User}, or empty if none exists
*/
public Optional<User> getUserByStreamKey(String streamKey) { public Optional<User> getUserByStreamKey(String streamKey) {
return userRepository.findByStreamKey(streamKey); return userRepository.findByStreamKey(streamKey);
} }

View File

@@ -16,15 +16,6 @@ import java.util.regex.Pattern;
public class CommandRunner { public class CommandRunner {
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
/**
* Runs an external command, capturing its merged stdout/stderr and streaming each line to a callback.
*
* @param command the command and its arguments
* @param onOutput invoked for each output line as it is read, or {@code null} to skip streaming
* @return the captured output and exit code
* @throws IOException if the process fails to start or exits with a non-zero code
* @throws InterruptedException if the current thread is interrupted while awaiting the process
*/
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException { public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
@@ -52,27 +43,10 @@ public class CommandRunner {
return commandOutput; return commandOutput;
} }
/**
* Runs an external command, capturing its output without streaming.
*
* @param command the command and its arguments
* @return the captured output and exit code
* @throws IOException if the process fails to start or exits with a non-zero code
* @throws InterruptedException if the current thread is interrupted while awaiting the process
*/
public CommandOutput run(List<String> command) throws IOException, InterruptedException { public CommandOutput run(List<String> command) throws IOException, InterruptedException {
return run(command, null); return run(command, null);
} }
/**
* Parses an ffmpeg progress line and updates the tracker with the fraction completed.
* <p>
* Lines without an {@code out_time_ms} field are ignored.
*
* @param line a single line of ffmpeg {@code -progress} output
* @param progress the tracker to update
* @param length the total media length in seconds, used as the progress denominator
*/
public void setProgress(String line, ProgressTracker progress, float length) { public void setProgress(String line, ProgressTracker progress, float length) {
Matcher matcher = timePattern.matcher(line); Matcher matcher = timePattern.matcher(line);
if (matcher.find()) { if (matcher.find()) {

View File

@@ -27,17 +27,6 @@ public class CompressionService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Compresses (and optionally trims/rescales) a video with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the compressed output
* @param clipOptions the trim window, target resolution/fps, and size driving the ffmpeg command
* @param progress the tracker updated as encoding progresses
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> compress(Path inputFile, public CompletableFuture<CommandOutput> compress(Path inputFile,
Path outputFile, Path outputFile,

View File

@@ -26,14 +26,6 @@ public class MetadataService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Probes a video file with ffprobe and returns its metadata (duration, dimensions, fps, size).
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param file the video file to probe
* @return a future completing with the parsed {@link ClipOptions}, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) { public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
logger.info("Getting metadata for file {}", file); logger.info("Getting metadata for file {}", file);

View File

@@ -21,17 +21,6 @@ public class RemuxService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Re-encodes a video to H.264/AAC in an MP4 container with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the remuxed MP4
* @param remuxProgress the tracker updated as encoding progresses
* @param length the media length in seconds, used to compute progress
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> remux(File inputFile, public CompletableFuture<CommandOutput> remux(File inputFile,
File outputFile, File outputFile,

View File

@@ -20,16 +20,6 @@ public class ThumbnailService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Extracts a single frame from a video as a thumbnail image with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the thumbnail image
* @param timeInVideo the timestamp in seconds at which to capture the frame
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) { public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
logger.info("Creating thumbnail at {} seconds", timeInVideo); logger.info("Creating thumbnail at {} seconds", timeInVideo);

View File

@@ -4,6 +4,7 @@ spring.application.name=vodSystem
spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB spring.servlet.multipart.max-request-size=2GB
stream.max-length=${STREAM_RING_LENGTH_SECOND} stream.max-length=${STREAM_RING_LENGTH_SECOND}
stream.ingest.url=${STREAM_INGEST_URL:}
# Database # Database
spring.datasource.url=${POSTGRES_URL} spring.datasource.url=${POSTGRES_URL}