Compare commits
10 Commits
744b315f24
...
fix/start-
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9a982fe9 | |||
| 38942776df | |||
| bf5fe6a35d | |||
| 9033261165 | |||
| 9e82dc8574 | |||
| 1c098028ab | |||
| 9c6002fe77 | |||
| 2f33101ec3 | |||
| da38e21935 | |||
| 3db16cd380 |
@@ -18,4 +18,3 @@ FRONTEND_URL=
|
|||||||
BACKEND_URL=
|
BACKEND_URL=
|
||||||
|
|
||||||
STREAM_RING_LENGTH_SECOND=28800
|
STREAM_RING_LENGTH_SECOND=28800
|
||||||
STREAM_INGEST_URL=
|
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.vodsystem.ddns.net
|
REGISTRY: gitea.defaoite.dev
|
||||||
IMAGE_PREFIX: dylan/vod-system
|
IMAGE_PREFIX: dylan/vod-system
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
extra_hosts:
|
network_mode: host
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://host.docker.internal:8080
|
- WEB_SERVER_URL=http://localhost:8080
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
|
image: gitea.defaoite.dev/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.vodsystem.ddns.net/dylan/vod-system-frontend:latest
|
image: gitea.defaoite.dev/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:5173"
|
- "5173:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rtmp:
|
rtmp:
|
||||||
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
|
image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rtmp:
|
rtmp:
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
FROM node:20-alpine
|
# --- Build stage ---
|
||||||
|
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 . .
|
||||||
EXPOSE 5173
|
RUN npm run build
|
||||||
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;"]
|
||||||
10
frontend/env.sh
Normal file
10
frontend/env.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/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
|
||||||
15
frontend/frontend_nginx.conf
Normal file
15
frontend/frontend_nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
692
frontend/package-lock.json
generated
692
frontend/package-lock.json
generated
@@ -7,14 +7,13 @@
|
|||||||
"": {
|
"": {
|
||||||
"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": {
|
||||||
@@ -915,68 +914,6 @@
|
|||||||
"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",
|
||||||
@@ -1043,12 +980,6 @@
|
|||||||
"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",
|
||||||
@@ -1099,41 +1030,6 @@
|
|||||||
"@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",
|
||||||
@@ -2093,9 +1989,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2359,9 +2255,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2413,81 +2309,6 @@
|
|||||||
"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",
|
||||||
@@ -2583,9 +2404,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2637,23 +2458,6 @@
|
|||||||
"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",
|
||||||
@@ -2738,28 +2542,11 @@
|
|||||||
"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": {
|
||||||
@@ -2786,18 +2573,6 @@
|
|||||||
"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",
|
||||||
@@ -2880,27 +2655,6 @@
|
|||||||
"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",
|
||||||
@@ -2930,22 +2684,6 @@
|
|||||||
"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",
|
||||||
@@ -2973,11 +2711,16 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-js": {
|
||||||
"version": "1.0.3",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
"license": "MIT"
|
"hasInstallScript": true,
|
||||||
|
"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",
|
||||||
@@ -3001,22 +2744,11 @@
|
|||||||
"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"
|
||||||
@@ -3037,15 +2769,6 @@
|
|||||||
"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",
|
||||||
@@ -3246,6 +2969,7 @@
|
|||||||
"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"
|
||||||
@@ -3272,19 +2996,6 @@
|
|||||||
"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",
|
||||||
@@ -3338,34 +3049,6 @@
|
|||||||
"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",
|
||||||
@@ -3380,15 +3063,6 @@
|
|||||||
"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",
|
||||||
@@ -3402,18 +3076,6 @@
|
|||||||
"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",
|
||||||
@@ -3452,36 +3114,6 @@
|
|||||||
"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",
|
||||||
@@ -3550,15 +3182,6 @@
|
|||||||
"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",
|
||||||
@@ -3600,6 +3223,7 @@
|
|||||||
"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"
|
||||||
@@ -3609,6 +3233,7 @@
|
|||||||
"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"
|
||||||
@@ -3617,15 +3242,6 @@
|
|||||||
"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",
|
||||||
@@ -3651,9 +3267,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3730,15 +3346,6 @@
|
|||||||
"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",
|
||||||
@@ -4078,28 +3685,6 @@
|
|||||||
"@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",
|
||||||
@@ -4117,12 +3702,13 @@
|
|||||||
"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.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4205,12 +3791,6 @@
|
|||||||
"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",
|
||||||
@@ -4251,18 +3831,6 @@
|
|||||||
"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",
|
||||||
@@ -4312,26 +3880,6 @@
|
|||||||
"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",
|
||||||
@@ -4353,6 +3901,29 @@
|
|||||||
"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",
|
||||||
@@ -4364,9 +3935,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -4386,12 +3957,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -4401,44 +3972,6 @@
|
|||||||
"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",
|
||||||
@@ -4449,16 +3982,6 @@
|
|||||||
"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",
|
||||||
@@ -4504,29 +4027,6 @@
|
|||||||
"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",
|
||||||
@@ -4572,15 +4072,6 @@
|
|||||||
"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",
|
||||||
@@ -4617,38 +4108,11 @@
|
|||||||
"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": {
|
||||||
@@ -4665,12 +4129,6 @@
|
|||||||
"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",
|
||||||
@@ -4719,22 +4177,11 @@
|
|||||||
"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"
|
||||||
@@ -4747,7 +4194,9 @@
|
|||||||
"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==",
|
||||||
"license": "0BSD"
|
"dev": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -4766,6 +4215,7 @@
|
|||||||
"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",
|
||||||
@@ -4870,9 +4320,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,16 +8,15 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ 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() {
|
||||||
@@ -23,24 +26,27 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route element={<MainLayout />}>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Route>
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
</Routes>
|
<Route path="*" element={<NotFound />} />
|
||||||
</Router>
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/auth/AuthContext.ts
Normal file
20
frontend/src/auth/AuthContext.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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 };
|
||||||
46
frontend/src/auth/AuthProvider.tsx
Normal file
46
frontend/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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;
|
||||||
14
frontend/src/auth/useAuth.ts
Normal file
14
frontend/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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 };
|
||||||
15
frontend/src/components/Box.tsx
Normal file
15
frontend/src/components/Box.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -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-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
|
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", 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 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
|
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<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">
|
<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">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -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 justify-between gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<label className="font-body text-[13px] text-text-body">{label}</label>
|
<label className="w-full text-sm text-text-secondary">{label}</label>
|
||||||
<div className="w-32 flex-shrink-0">{children}</div>
|
<div className="w-px h-5 text-text-secondary mx-2" />
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
||||||
import {useAuth} from "../utils/AuthContext.tsx";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import GoogleSignIn from "./GoogleSignIn.tsx";
|
|
||||||
import {logout} from "../utils/api/users.ts";
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -11,78 +9,41 @@ 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, setUser } = useAuth()
|
const { user } = 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-3xl"}>The VoD System</h2>
|
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
||||||
|
|
||||||
<nav className="flex flex-col flex-1 items-center">
|
<nav className="flex flex-col flex-1 items-center w-3/4">
|
||||||
<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="Upload" />
|
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||||
</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={"p-2"}>
|
<div className={"w-full"}>
|
||||||
{user ? (
|
{user && (
|
||||||
<div>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className={"flex gap-2 items-center pt-1"}>
|
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||||
<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>
|
||||||
|
|||||||
92
frontend/src/components/Topbar.tsx
Normal file
92
frontend/src/components/Topbar.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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;
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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 };
|
|
||||||
15
frontend/src/components/buttons/MenuButton.tsx
Normal file
15
frontend/src/components/buttons/MenuButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal file
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
42
frontend/src/components/video/ClipRangeSlider.tsx
Normal file
42
frontend/src/components/video/ClipRangeSlider.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,49 +2,15 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors CompressionService.buildBitrate so the number shown is the number used:
|
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";
|
||||||
// 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;
|
|
||||||
|
|
||||||
type Estimate = { total: number; audio: number; video: number; bpp: number | null };
|
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||||
|
|
||||||
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 }));
|
||||||
@@ -55,19 +21,15 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
|
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 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-4", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<p className={kickerClass}>Export settings</p>
|
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label="Resolution">
|
<Selector label="Resolution">
|
||||||
<select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
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>
|
||||||
@@ -78,8 +40,8 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label="Frames per second">
|
<Selector label="FPS">
|
||||||
<select id="fps" name="fps" value={String(metadata.fps)}
|
<select id="fps" name="fps" defaultValue="30"
|
||||||
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>
|
||||||
@@ -87,35 +49,11 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label="Size limit (MB)">
|
<Selector label="File Size Limit (MB)">
|
||||||
<input type="number" min="1" value={sizeMb}
|
<input type="number" min="1" defaultValue="10"
|
||||||
onChange={updateFileSize} className={monoInputClass}
|
onChange={updateFileSize} className={inputClass}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
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>;
|
||||||
@@ -10,44 +9,43 @@ type props = {
|
|||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// queued → reading source → encoding → writing file → complete
|
const Spinner = () => (
|
||||||
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
|
<svg
|
||||||
if (downloadable) return "complete";
|
className="animate-spin h-4 w-4"
|
||||||
if (!uploading) return "waiting";
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
if (progress < 0.05) return "reading source";
|
fill="none"
|
||||||
if (progress < 0.95) return "encoding";
|
viewBox="0 0 24 24"
|
||||||
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" />
|
||||||
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
|
</svg>
|
||||||
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-4">
|
<div className={"flex flex-col gap-3"}>
|
||||||
<p className={kickerClass}>Export</p>
|
<PrimaryButton
|
||||||
|
onClick={dataSend}
|
||||||
|
disabled={uploading}
|
||||||
|
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||||
|
>
|
||||||
|
{uploading && <Spinner />}
|
||||||
|
{uploading ? "Uploading..." : "Export"}
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
|
<div className="h-10 flex items-center">
|
||||||
{uploading ? "Compressing…" : "Compress and save"}
|
{ downloadable ?
|
||||||
</Button>
|
(<PrimaryButton className="w-full" onClick={handleDownload}>
|
||||||
|
Download
|
||||||
<div className="flex flex-col gap-1.5">
|
</PrimaryButton>)
|
||||||
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
|
:(
|
||||||
<div
|
<progress
|
||||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
|
value={progress}
|
||||||
style={{ width: `${pct}%` }}
|
className="w-full h-1.5 rounded bg-fields">
|
||||||
/>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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>>;
|
||||||
@@ -9,14 +8,16 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
<div className={clsx("flex flex-col gap-2", className)}>
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
<label className={labelClass} htmlFor="clip-title">Title</label>
|
<p className="w-full font-body text-text-strong font-bold text-sm">
|
||||||
|
Title <span className="text-error">*</span>
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
id="clip-title"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Cat walks across the patch bay"
|
required
|
||||||
|
placeholder="Enter title"
|
||||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
className={inputClass}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
37
frontend/src/components/video/PlaybackSlider.tsx
Normal file
37
frontend/src/components/video/PlaybackSlider.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-3", className)}>
|
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
||||||
<div className="flex items-center gap-2 text-text-control">
|
<div className="flex gap-1">
|
||||||
<Icon size={20} />
|
<Icon size={24} />
|
||||||
<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 accent-accent"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
|
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={22} /> : <Play size={22} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
<span className="font-data text-[13px] text-text-secondary tabular-nums">
|
<span className="font-data text-sm 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>
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, 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,
|
||||||
@@ -23,42 +18,23 @@ type VideoCardProps = {
|
|||||||
itemLabel?: string,
|
itemLabel?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const fallbackThumbnail = "public/default_thumbnail.png";
|
||||||
|
const API_URL = config.apiUrl;
|
||||||
|
|
||||||
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
|
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
||||||
const placeholders: Record<string, React.CSSProperties> = {
|
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
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(() => {
|
||||||
isThumbnailAvailable(thumbnailUrl)
|
const interval = setInterval(() => {
|
||||||
.then(setThumbnailAvailable)
|
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
.catch(() => setThumbnailAvailable(false));
|
}, 1000);
|
||||||
}, [thumbnailUrl]);
|
return () => clearInterval(interval);
|
||||||
|
}, [createdAt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
@@ -80,28 +56,16 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`${playerPath}/${id}`}>
|
<Link to={`${playerPath}/${id}`}>
|
||||||
<div className={clsx(
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
className
|
<img
|
||||||
)}>
|
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
|
||||||
<div
|
onError={() => setImgFailed(true)}
|
||||||
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
|
alt="Video Thumbnail"
|
||||||
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
>
|
/>
|
||||||
{thumbnailAvailable && (
|
|
||||||
<img
|
|
||||||
src={thumbnailUrl}
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
onError={() => setThumbnailAvailable(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
{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>
|
||||||
|
|
||||||
@@ -113,18 +77,18 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
|
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
|
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md 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 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Pencil size={13} /> Edit
|
<Pencil size={13} /> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +96,7 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
|
|||||||
{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 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} /> Delete
|
<Trash2 size={13} /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -140,17 +104,17 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
|
<p className="text-xs text-text-secondary 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 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
|
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||||
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"
|
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -163,28 +127,11 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
|
<div className="flex flex-col p-2">
|
||||||
<p className={clsx(
|
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
|
||||||
"line-clamp-2",
|
{title === "" ? "(No Title)" : title}
|
||||||
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>
|
||||||
|
|||||||
5
frontend/src/config.ts
Normal file
5
frontend/src/config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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 ?? '',
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "flowbite-react/plugin/tailwindcss";
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||||
@@ -11,80 +10,38 @@
|
|||||||
--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;
|
||||||
--color-on-dark: #FBF8F2;
|
|
||||||
--color-on-accent: #FDFBF7;
|
|
||||||
|
|
||||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
/* ── Accents ─────────────────────────────────────────────────────────── */
|
||||||
--color-accent: #A85A38;
|
--color-terracotta: #A85A38;
|
||||||
--color-accent-hover: #94492A;
|
--color-terracotta-hover: #94492A;
|
||||||
--color-accent-light: #C87A52;
|
--color-terracotta-light: #C87A52;
|
||||||
--color-accent-disabled: #C9AE9C;
|
--color-terracotta-disabled: #C9AE9C;
|
||||||
--color-olive: #6E7A50;
|
--color-olive: #6E7A50;
|
||||||
--color-olive-hover: #5D6843;
|
|
||||||
--color-olive-light: #8A9668;
|
/* ── Status ──────────────────────────────────────────────────────────── */
|
||||||
--color-live-red: #B5432E;
|
--color-live: #B5432E;
|
||||||
|
--color-error: #B5432E;
|
||||||
|
|
||||||
|
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
|
||||||
|
--color-on-dark: #FBF8F2;
|
||||||
|
--color-on-accent: #FDFBF7;
|
||||||
|
|
||||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||||
--color-hairline: #F0EBE2;
|
--color-hairline: #F0EBE2;
|
||||||
--color-hover: #E7E0D2;
|
--color-hover: #E7E0D2;
|
||||||
--color-chip: #EAE3D7;
|
--color-selected: #F6EFE6;
|
||||||
--color-chip-hover: #DAD1C0;
|
--color-ring: #E2DACB;
|
||||||
--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 {
|
||||||
@@ -106,5 +63,28 @@ body {
|
|||||||
@utility font-heading {
|
@utility font-heading {
|
||||||
font-family: "Newsreader", serif;
|
font-family: "Newsreader", serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #2B2620;
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,34 @@
|
|||||||
// 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 { useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import { useAuth } from "../auth/useAuth";
|
||||||
|
import { getStreamStatus } from "../utils/api/stream";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = 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"}`}/>
|
||||||
<div className="row-span-2 overflow-y-auto">
|
<Topbar
|
||||||
|
className={"transition-all duration-300"}
|
||||||
|
sidebarToggled={sidebarToggled}
|
||||||
|
setSidebarToggled={setSidebarToggled}
|
||||||
|
isStreaming={isStreaming}/>
|
||||||
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
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>
|
||||||
<AuthProvider>
|
<App />
|
||||||
<App />
|
|
||||||
</AuthProvider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Playbar from "./../components/video/Playbar";
|
import Playbar from "./../components/video/Playbar";
|
||||||
import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
|
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
||||||
|
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 Card from "../components/Card.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
import PageHeader from "../components/PageHeader.tsx";
|
import {config} from "../config.ts";
|
||||||
import { formatTime, formatTimecode } from "../utils/utils.ts";
|
|
||||||
import { kickerClass } from "../utils/styles.ts";
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = config.apiUrl;
|
||||||
|
|
||||||
const ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -25,7 +24,6 @@ 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: "",
|
||||||
@@ -61,39 +59,23 @@ 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);
|
jobId = await compress(localFile, { ...outputMetadata, title });
|
||||||
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'}`);
|
||||||
@@ -149,78 +131,60 @@ const ClipEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||||
<PageHeader kicker="Upload" title="Trim and compress" />
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
||||||
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
<Box className={"w-4/5 h-full m-auto"}>
|
||||||
<div className="flex flex-col gap-[26px]">
|
<MetadataBox setMetadata={setOutputMetadata} />
|
||||||
{/* Player — the video and its transport read as one object. */}
|
<ConfigBox setMetadata={setOutputMetadata} />
|
||||||
<Card className="overflow-hidden">
|
</Box>
|
||||||
<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 &&
|
||||||
<Card className="p-6">
|
<Box className={"mt-4 p-5"}>
|
||||||
<div className="flex items-baseline justify-between gap-4 pb-4">
|
<Playbar
|
||||||
<p className={kickerClass}>Trim</p>
|
video={videoRef.current}
|
||||||
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
videoMetadata={metadata}
|
||||||
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
|
className={"w-full accent-terracotta"}
|
||||||
{formatTime(range[1] - range[0])} long
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TrimTimeline
|
<PlaybackSlider
|
||||||
duration={metadata.duration}
|
videoRef={videoRef.current}
|
||||||
range={range}
|
videoMetadata={metadata}
|
||||||
onRangeChange={handleRangeChange}
|
sliderValue={playbackValue}
|
||||||
currentTime={playbackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
onSeek={handleSeek}
|
className={"w-full accent-terracotta"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="pt-3 font-body text-[12px] text-muted">
|
<ClipRangeSlider
|
||||||
Scroll over the timeline to zoom, drag the outlined window above to pan.
|
videoRef={videoRef.current}
|
||||||
</p>
|
videoMetadata={metadata}
|
||||||
|
setSliderValue={setPlaybackValue}
|
||||||
|
setMetadata={setOutputMetadata}
|
||||||
|
className={"w-full mt-2"}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="font-body text-[13px] text-live-red pt-3">{error}</p>
|
<div className={"text-error text-center mt-2"}>
|
||||||
)}
|
{error}
|
||||||
</Card>
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
}
|
||||||
|
|
||||||
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
|
||||||
<Card className="p-6 flex flex-col gap-4">
|
<ExportWidget
|
||||||
<p className={kickerClass}>Details</p>
|
dataSend={sendData}
|
||||||
<MetadataBox setMetadata={setOutputMetadata} />
|
handleDownload={handleDownload}
|
||||||
</Card>
|
downloadable={downloadable}
|
||||||
|
progress={progress}
|
||||||
<Card className="p-6">
|
uploading={uploading}
|
||||||
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
|
/>
|
||||||
</Card>
|
</Box>
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,89 +1,56 @@
|
|||||||
import { useRef, useState } from "react";
|
import {useState} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import clsx from "clsx";
|
import Box from "../components/Box.tsx";
|
||||||
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("Pick a file first.");
|
setError("Please choose a file");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/create/new', { state: { file } });
|
navigate('/create/new', { state: { file } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-180 px-13 pt-10 pb-18">
|
<div className="flex justify-center p-8">
|
||||||
<PageHeader kicker="Upload" title="Bring in a file" />
|
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||||
|
<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>
|
||||||
|
|
||||||
<label
|
<button
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
onClick={press}
|
||||||
onDragLeave={() => setDragging(false)}
|
className={"hover:bg-hover p-1 rounded-md"}
|
||||||
onDrop={(e) => {
|
>
|
||||||
e.preventDefault();
|
Upload
|
||||||
setDragging(false);
|
</button>
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{file ? (
|
{error && <p className="text-sm font-body text-center">{error}</p>}
|
||||||
<>
|
</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>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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 } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
||||||
import Card from "../components/Card.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import Button from "../components/buttons/Button.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
||||||
import PageHeader from "../components/PageHeader.tsx";
|
|
||||||
import { inputClass, labelClass, quietLinkClass } 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";
|
||||||
|
|
||||||
const EditClip = () => {
|
const EditClip = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -48,51 +47,59 @@ const EditClip = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
<button
|
||||||
<ArrowLeft size={15} />
|
onClick={() => navigate(-1)}
|
||||||
|
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>
|
||||||
|
|
||||||
<PageHeader kicker="Clip" title="Edit details" />
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
<div className="text-muted text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-7 flex flex-col gap-5">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className={labelClass} htmlFor="clip-title">Title</label>
|
<label className="text-sm font-medium text-text-secondary">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="Cat walks across the patch bay"
|
placeholder="Enter title"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className={labelClass} htmlFor="clip-description">Description</label>
|
<label className="text-sm font-medium text-text-secondary">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="clip-description"
|
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="What's worth remembering about it"
|
placeholder="Enter description"
|
||||||
rows={4}
|
rows={4}
|
||||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
className={inputClass + " resize-none"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
{error && <p className="text-sm text-error">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
{saving ? "Saving…" : saved
|
||||||
</Button>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
: "Save"}
|
||||||
|
</PrimaryButton>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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 } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
||||||
import Card from "../components/Card.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import Button from "../components/buttons/Button.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
||||||
import PageHeader from "../components/PageHeader.tsx";
|
|
||||||
import { inputClass, labelClass, quietLinkClass } 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";
|
||||||
|
|
||||||
const EditVod = () => {
|
const EditVod = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -48,51 +47,59 @@ const EditVod = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
<button
|
||||||
<ArrowLeft size={15} />
|
onClick={() => navigate(-1)}
|
||||||
|
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>
|
||||||
|
|
||||||
<PageHeader kicker="VoD" title="Edit details" />
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
<div className="text-muted text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-7 flex flex-col gap-5">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className={labelClass} htmlFor="vod-title">Title</label>
|
<label className="text-sm font-medium text-text-secondary">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="Tuesday night, patch bay walkthrough"
|
placeholder="Enter title"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className={labelClass} htmlFor="vod-description">Description</label>
|
<label className="text-sm font-medium text-text-secondary">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="vod-description"
|
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="What's worth remembering about it"
|
placeholder="Enter description"
|
||||||
rows={4}
|
rows={4}
|
||||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
className={inputClass + " resize-none"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
{error && <p className="text-sm text-error">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
{saving ? "Saving…" : saved
|
||||||
</Button>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
: "Save"}
|
||||||
|
</PrimaryButton>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,311 +1,78 @@
|
|||||||
import {useAuth} from "../utils/AuthContext.tsx";
|
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
|
||||||
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";
|
|
||||||
|
|
||||||
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
|
const features = [
|
||||||
|
{
|
||||||
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
|
icon: Upload,
|
||||||
const hour = date.getHours();
|
title: "Upload & compress",
|
||||||
|
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";
|
{
|
||||||
if (hour >= 17 && hour < 22) return "evening";
|
icon: Scissors,
|
||||||
return "night";
|
title: "Trim",
|
||||||
}
|
description: "Set start and end points with a range slider. Preview as you go.",
|
||||||
|
},
|
||||||
function getGreetingSubtext(date: Date = new Date()): string {
|
{
|
||||||
const timeOfDay = getTimeOfDay(date);
|
icon: SlidersHorizontal,
|
||||||
|
title: "Export settings",
|
||||||
const messages: Record<TimeOfDay, string[]> = {
|
description: "Choose resolution, frame rate, and an optional file size cap.",
|
||||||
morning: [
|
},
|
||||||
"Good morning.",
|
{
|
||||||
"Coffee first, then clips.",
|
icon: Radio,
|
||||||
"Here's what's waiting for you.",
|
title: "RTMP streaming",
|
||||||
],
|
description: "Point OBS at your stream key and go live. Find the key in your profile.",
|
||||||
afternoon: [
|
},
|
||||||
"Good afternoon.",
|
{
|
||||||
"A quiet moment for your clips.",
|
icon: LayoutGrid,
|
||||||
"Catching up?",
|
title: "Clip library",
|
||||||
],
|
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
|
||||||
evening: [
|
},
|
||||||
"Good evening.",
|
{
|
||||||
"Settle in.",
|
icon: Play,
|
||||||
"Time to unwind.",
|
title: "Built-in player",
|
||||||
],
|
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={"w-10/12 m-auto pt-10"}>
|
<div className="flex flex-col items-center px-8 py-12 min-h-full">
|
||||||
<div className={"flex flex-row justify-between items-start pb-8"}>
|
{/* Hero */}
|
||||||
<div>
|
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
|
||||||
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
|
<img
|
||||||
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
|
src="/logo.png"
|
||||||
</div>
|
alt="VoD System Logo"
|
||||||
|
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"}>
|
/>
|
||||||
Open Stream Panel
|
<h1 className="text-4xl font-heading text-text-primary">
|
||||||
</Link>
|
A home for your clips.
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Upload, trim, and export video clips. Stream live if you need to.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
|
{/* Feature grid */}
|
||||||
<div className={"flex flex-row items-center gap-2"}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
||||||
{isStreaming
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
? <IsLivePulse />
|
<div
|
||||||
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
|
key={title}
|
||||||
<p className={clsx(
|
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||||
"text-xs font-data tracking-[0.12em]",
|
>
|
||||||
isStreaming ? "text-live-red" : "text-faint-mono"
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
||||||
)}>
|
<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>
|
||||||
<div className={"mt-4 flex items-center gap-4"}>
|
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
||||||
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
|
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
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, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, 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 Card from "../components/Card.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
|
import { dateToTimeAgo, 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 = {
|
||||||
@@ -60,15 +53,12 @@ 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; }
|
||||||
|
|
||||||
@@ -78,7 +68,6 @@ 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(() => {
|
||||||
@@ -100,6 +89,7 @@ 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);
|
||||||
@@ -129,13 +119,11 @@ 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':
|
||||||
@@ -149,15 +137,7 @@ 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, showFlash, toggleFullscreen]);
|
}, [showControls, 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) => {
|
||||||
@@ -211,40 +191,29 @@ 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-3 text-center p-8">
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||||
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
|
||||||
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
|
<Lock size={24} className="text-muted" />
|
||||||
<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()}.
|
<div>
|
||||||
</p>
|
<p className="text-2xl font-heading text-text-primary">Not authenticated</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={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={15} />
|
<ArrowLeft size={16} />
|
||||||
Go back
|
Go back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,19 +221,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col px-13 py-8 gap-4">
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className={clsx(quietLinkClass, "flex items-center gap-1.5 shrink-0")}
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={15} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-text-primary rounded-xl 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}
|
||||||
@@ -296,37 +265,14 @@ 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-white/20 border-t-white animate-spin" />
|
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark 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'}`}>
|
||||||
{/* Gradients — top one carries the title, bottom one the controls */}
|
{/* Gradient */}
|
||||||
<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-gradient-to-t from-black/75 via-black/10 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
|
||||||
@@ -336,42 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Scrub bar */}
|
{/* Scrub bar */}
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
className="relative h-1 rounded-full bg-on-dark/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-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
<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-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-white 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-on-dark 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-white">
|
<div className="flex items-center gap-3 text-on-dark">
|
||||||
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
<button onClick={togglePlay} className="hover:text-on-dark/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" />}
|
||||||
@@ -379,7 +303,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-white/70 transition-colors">
|
<button onClick={toggleMute} className="hover:text-on-dark/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">
|
||||||
@@ -388,18 +312,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-white cursor-pointer"
|
className="w-16 accent-on-dark cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="font-data text-xs tabular-nums text-on-dark/80">
|
<span className="text-xs font-data 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-white/70 transition-colors">
|
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
|
||||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,26 +331,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
{error && <div className="text-error text-sm mt-3">{error}</div>}
|
||||||
{!mediaLoaded && !error && (
|
{!mediaLoaded && !error && (
|
||||||
<p className="font-data text-[12px] text-faint-mono">loading video…</p>
|
<div className="text-muted text-sm mt-3 flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
|
||||||
|
Loading video...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
<Box className="p-4 flex-shrink-0">
|
||||||
<div className="min-w-0">
|
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
|
||||||
<p className={kickerClass}>{noun}</p>
|
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
|
||||||
<p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
|
</Box>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
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 { Link, useNavigate } from "react-router-dom";
|
import { 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 instanceof Error ? err.message : "Failed to load clips"));
|
.catch((err) => setError(err));
|
||||||
}, [user]);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -29,16 +24,11 @@ const MyClips = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return null;
|
|
||||||
if (!user) return <NoAccessPage />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
<div className="p-6">
|
||||||
<PageHeader kicker="Library" title="Clips">
|
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
||||||
<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]">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{clips.map((clip) => (
|
{clips.map((clip) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
@@ -46,10 +36,6 @@ 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)}
|
||||||
/>
|
/>
|
||||||
@@ -57,17 +43,13 @@ const MyClips = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 && !error && (
|
{clips.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||||
<h2 className="font-heading text-[25px]">No clips yet.</h2>
|
<p className="text-base">No clips yet.</p>
|
||||||
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||||
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="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
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 {
|
||||||
@@ -29,16 +24,11 @@ const MyVods = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return null;
|
|
||||||
if (!user) return <NoAccessPage />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
<div className="p-6">
|
||||||
<PageHeader kicker="Library" title="VoDs">
|
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
|
||||||
<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]">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{vods.map((vod) => (
|
{vods.map((vod) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={vod.id}
|
key={vod.id}
|
||||||
@@ -46,10 +36,6 @@ 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"
|
||||||
@@ -60,15 +46,13 @@ const MyVods = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vods.length === 0 && !error && (
|
{vods.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||||
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
|
<p className="text-base">No VoDs yet.</p>
|
||||||
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||||
Ranges you save from the stream panel land here, full length and untouched.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
{error && <p className="text-sm text-error mt-4">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
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">
|
||||||
Log in to see this page
|
This page does not exist.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-faint-mono/70 max-w-xs">
|
<p className="text-sm text-faint-mono/70 max-w-xs">
|
||||||
Sign in with Google to view your clips and account details.
|
<a onClick={goHomePage} className={"font-bold cursor-pointer"}>Click here</a> to get back to the home page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NoAccessPage;
|
export default NotFound;
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Card from "../components/Card.tsx";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import PageHeader from "../components/PageHeader.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import NoAccessPage from "../components/NoAccessPage.tsx";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
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, loading } = useAuth();
|
const { user } = useAuth();
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -15,54 +13,67 @@ 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), 1600);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return null;
|
if (!user) {
|
||||||
if (!user) return <NoAccessPage />;
|
return (
|
||||||
|
<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="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<PageHeader kicker="Account" title="Profile" />
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
|
||||||
|
|
||||||
<Card className="p-7 mb-[26px] flex items-center gap-5">
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
src={user.profilePictureUrl}
|
src={user.profilePictureUrl}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
alt=""
|
alt="Profile picture"
|
||||||
className="w-[72px] h-[72px] rounded-full shadow-card"
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div>
|
||||||
<p className="font-heading text-[25px] leading-tight">{user.name}</p>
|
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
|
||||||
<p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
|
<p className="text-sm text-text-secondary">@{user.username}</p>
|
||||||
<p className="font-data text-[11px] text-faint-mono pt-1">
|
<p className="text-sm text-text-secondary">{user.email}</p>
|
||||||
member since {formatLocalDate(user.createdAt)}
|
<p className="text-xs text-muted mt-1">
|
||||||
|
Member since {formatLocalDate(user.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Box>
|
||||||
|
|
||||||
<Card className="p-7">
|
<Box className="p-6">
|
||||||
<h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
|
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
|
||||||
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
|
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||||
Keep it private: anyone holding it can stream to your account.
|
Keep it private — anyone with this key can stream to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className={labelClass}>Key</p>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2.5 pt-2">
|
<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-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
|
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||||
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
|
<button
|
||||||
{keyVisible ? "Hide" : "Reveal"}
|
onClick={() => setKeyVisible(v => !v)}
|
||||||
|
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 className={chipButtonClass} onClick={handleCopy}>
|
<button
|
||||||
{copied ? "Copied" : "Copy"}
|
onClick={handleCopy}
|
||||||
|
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>
|
||||||
</Card>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,72 +1,90 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Check, X } from "lucide-react";
|
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
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 { useAuth } from "../auth/useAuth.ts";
|
||||||
import PageHeader from "../components/PageHeader.tsx";
|
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||||
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 { saveSection, clipSection } from "../utils/api/media.ts";
|
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||||
import { getJob } from "../utils/api/jobs.ts";
|
import { getJob } from "../utils/api/jobs.ts";
|
||||||
import type { Marker, StreamStatus } from "../utils/types.ts";
|
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||||
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
|
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
||||||
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
|
|
||||||
|
|
||||||
/** Ten-minute gridlines behind the marker ticks. */
|
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";
|
||||||
const GRID_INTERVAL_SECONDS = 600;
|
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
||||||
|
|
||||||
const useCopy = () => {
|
type TrackedJob = {
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
uuid: string;
|
||||||
|
label: string;
|
||||||
|
progress: number;
|
||||||
|
state: JobResponse['state'];
|
||||||
|
errorOutput: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const copy = (key: string, text: string) => {
|
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||||
navigator.clipboard.writeText(text);
|
<div className="flex items-center gap-2.5">
|
||||||
setCopied(key);
|
<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")}>
|
||||||
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
|
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
||||||
};
|
</div>
|
||||||
|
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return { copied, copy };
|
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||||
|
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, loading } = useAuth();
|
const { user } = 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 [clipName, setClipName] = useState("");
|
const [clipTitle, setClipTitle] = useState("");
|
||||||
|
const [clipDescription, setClipDescription] = useState("");
|
||||||
const [clipError, setClipError] = useState<string | null>(null);
|
const [clipError, setClipError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [startMarkerId, setStartMarkerId] = useState<string>("");
|
||||||
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
|
const [endMarkerId, setEndMarkerId] = useState<string>("");
|
||||||
|
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(() => {});
|
||||||
@@ -76,18 +94,24 @@ const StreamControl = () => {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
if (!user || !streamStatus?.id) {
|
||||||
return () => clearInterval(interval);
|
setStreamDetails(null);
|
||||||
}, []);
|
return;
|
||||||
|
}
|
||||||
|
getStreamHistory(user.id)
|
||||||
|
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user, streamStatus?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!streamDetails?.startDate) return;
|
||||||
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
}, [user]);
|
return () => clearInterval(interval);
|
||||||
|
}, [streamDetails?.startDate]);
|
||||||
|
|
||||||
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(() => {});
|
||||||
};
|
};
|
||||||
@@ -95,7 +119,6 @@ const StreamControl = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStreaming) {
|
if (!isStreaming) {
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
setSelected([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
@@ -103,495 +126,268 @@ 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(() => {
|
||||||
if (!pendingKey) return;
|
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
|
||||||
const uuids = pendingKey.split(",");
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
uuids.forEach((uuid) => {
|
pending.forEach((job) => {
|
||||||
getJob(uuid)
|
getJob(job.uuid)
|
||||||
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
|
.then((result) => {
|
||||||
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
|
||||||
: job)))
|
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||||||
|
: j));
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [pendingKey]);
|
}, [jobs]);
|
||||||
|
|
||||||
// ── Marker helpers ──────────────────────────────────────────────────────
|
const addJob = (uuid: string, label: string) => {
|
||||||
const offsetOf = (marker: Marker) =>
|
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||||
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 (markerBusy) return;
|
if (!markerMessage.trim() || markerBusy) return;
|
||||||
setMarkerBusy(true);
|
setMarkerBusy(true);
|
||||||
setMarkerError(null);
|
setMarkerError(null);
|
||||||
try {
|
try {
|
||||||
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
|
await createMarker(markerMessage.trim());
|
||||||
setMarkerMessage("");
|
setMarkerMessage("");
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
|
setMarkerError(err instanceof Error ? err.message : "Failed to add 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);
|
||||||
setSelected((prev) => prev.filter((current) => current !== id));
|
if (startMarkerId === String(id)) setStartMarkerId("");
|
||||||
|
if (endMarkerId === String(id)) setEndMarkerId("");
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
|
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inSeconds = parseTimecode(inValue);
|
const handleChipClick = (id: number) => {
|
||||||
const outSeconds = parseTimecode(outValue);
|
const idStr = String(id);
|
||||||
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
|
setMarkerSaveError(null);
|
||||||
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
|
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||||
? formatTime(outSeconds - inSeconds)
|
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
|
||||||
|
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 handleSaveRange = async () => {
|
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||||
setRangeError(null);
|
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
||||||
|
|
||||||
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="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||||
<PageHeader kicker="Ingest" title="Stream panel" />
|
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
{/* Hero status bar */}
|
||||||
{/* ── Main column ───────────────────────────────────────── */}
|
<Box className={clsx(
|
||||||
<div className="flex flex-col gap-[26px]">
|
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||||
<Card className="p-7">
|
isStreaming ? "border-l-live" : "border-l-hairline"
|
||||||
<div className="flex items-baseline justify-between gap-4 pb-5">
|
)}>
|
||||||
<h2 className="font-heading text-[22px]">Server credentials</h2>
|
<div className="flex items-center gap-3.5">
|
||||||
{isStreaming ? (
|
<span className="relative flex h-3.5 w-3.5">
|
||||||
<div className="flex items-center gap-2">
|
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
|
||||||
<IsLivePulse />
|
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
|
||||||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
|
</span>
|
||||||
</div>
|
<div>
|
||||||
) : (
|
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
|
{isStreaming && streamDetails?.startDate ? (
|
||||||
)}
|
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||||
</div>
|
) : !isStreaming ? (
|
||||||
|
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className={labelClass}>Ingest URL</p>
|
{isStreaming && elapsed ? (
|
||||||
<div className="flex items-center gap-2.5 pt-2">
|
<div className="text-right">
|
||||||
|
<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"
|
||||||
readOnly
|
value={markerMessage}
|
||||||
value={ingestUrl ?? ""}
|
onChange={(e) => setMarkerMessage(e.target.value)}
|
||||||
placeholder="asking the server…"
|
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
||||||
className={clsx(monoInputClass, "flex-1")}
|
placeholder="What just happened?"
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<button
|
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||||
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
|
Add Marker
|
||||||
disabled={!ingestUrl}
|
</PrimaryButton>
|
||||||
onClick={() => ingestUrl && copy("url", ingestUrl)}
|
|
||||||
>
|
|
||||||
{copied === "url" ? "Copied" : "Copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{markerError && <p className="text-sm text-error">{markerError}</p>}
|
||||||
|
|
||||||
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
|
{markers.length > 0 && (
|
||||||
<div className="flex items-center gap-2.5 pt-2">
|
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||||
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
|
{markers.map((m) => {
|
||||||
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
|
const isStart = startMarkerId === String(m.id);
|
||||||
</div>
|
const isEnd = endMarkerId === String(m.id);
|
||||||
<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 (
|
||||||
<span
|
<div key={m.id} className="group relative flex-shrink-0">
|
||||||
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={() => toggleMarker(marker.id)}
|
onClick={() => handleChipClick(m.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
|
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||||
isSelected
|
isStart && "border-terracotta bg-selected ring-1 ring-terracotta",
|
||||||
? "bg-armed inset-shadow-armed"
|
isEnd && "border-olive bg-selected ring-1 ring-olive",
|
||||||
: "bg-card inset-shadow-hairline hover:bg-fields"
|
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx(
|
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
|
||||||
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
|
<span className="font-data text-[11px] text-muted">
|
||||||
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
|
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
)}>
|
{isStart && <span className="text-terracotta font-medium"> · start</span>}
|
||||||
{isSelected && <Check size={12} strokeWidth={3} />}
|
{isEnd && <span className="text-olive font-medium"> · end</span>}
|
||||||
</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={() => handleDeleteMarker(marker.id)}
|
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||||
title="Remove marker"
|
title="Delete marker"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={10} />
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* ── Side panel ────────────────────────────────────────── */}
|
{/* Forefront actions */}
|
||||||
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
||||||
{isStreaming && (
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<>
|
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||||
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-2">
|
<label className={labelClass}>Duration (seconds)</label>
|
||||||
<h2 className="font-heading text-[22px]">Make something from a range</h2>
|
<input
|
||||||
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
type="number"
|
||||||
{selected.length === 2 && rangeLength
|
min={1}
|
||||||
? `Two markers selected — ${inValue} to ${outValue}.`
|
value={clipDuration}
|
||||||
: "Tick two markers, or type a timecode by hand."}
|
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||||
</p>
|
className={inputClass}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className={labelClass} htmlFor="range-in">In</label>
|
|
||||||
<input
|
|
||||||
id="range-in"
|
|
||||||
value={inValue}
|
|
||||||
onChange={(event) => setInValue(event.target.value)}
|
|
||||||
placeholder="00:00:00"
|
|
||||||
className={clsx(monoInputClass, "bg-card")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className={labelClass} htmlFor="range-out">Out</label>
|
|
||||||
<input
|
|
||||||
id="range-out"
|
|
||||||
value={outValue}
|
|
||||||
onChange={(event) => setOutValue(event.target.value)}
|
|
||||||
placeholder="00:00:00"
|
|
||||||
className={clsx(monoInputClass, "bg-card")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="font-body text-[13.5px] text-text-body">Range length</span>
|
|
||||||
<span className="font-data text-[15px] text-text-primary tabular-nums">
|
|
||||||
{rangeLength ?? "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-2">
|
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||||
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
|
{markers.length < 2 ? (
|
||||||
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p>
|
||||||
Takes the last stretch of stream, counted back from this moment.
|
) : (
|
||||||
</p>
|
<>
|
||||||
</div>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={clsx(
|
||||||
<div className="grid grid-cols-2 gap-3">
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
<div className="flex flex-col gap-1.5">
|
startMarker ? "border-terracotta text-terracotta bg-selected" : "border-dashed border-inactive text-muted"
|
||||||
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
|
)}>
|
||||||
<input
|
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
||||||
id="clip-duration"
|
</span>
|
||||||
type="number"
|
<span className="text-inactive-strong">→</span>
|
||||||
min={1}
|
<span className={clsx(
|
||||||
max={180}
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
value={clipDuration}
|
endMarker ? "border-olive text-olive bg-selected" : "border-dashed border-inactive text-muted"
|
||||||
onChange={(event) => setClipDuration(Number(event.target.value))}
|
)}>
|
||||||
className={clsx(monoInputClass, "bg-card")}
|
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
||||||
/>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||||
<label className={labelClass} htmlFor="clip-name">Name</label>
|
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
<input
|
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
|
||||||
id="clip-name"
|
</>
|
||||||
value={clipName}
|
)}
|
||||||
onChange={(event) => setClipName(event.target.value)}
|
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
||||||
placeholder="Optional"
|
</Box>
|
||||||
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>
|
</div>
|
||||||
</aside>
|
|
||||||
</div>
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL;
|
import {config} from "../../config.ts";
|
||||||
|
|
||||||
|
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"; }
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ 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}`);
|
||||||
@@ -40,4 +28,4 @@ const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> =>
|
|||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };
|
export { getStreamStatus, getCurrentStream, getStreamHistory };
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -32,11 +32,6 @@ 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 = {
|
||||||
@@ -46,11 +41,6 @@ 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 = {
|
||||||
@@ -65,7 +55,6 @@ type JobResponse = {
|
|||||||
type StreamStatus = {
|
type StreamStatus = {
|
||||||
isStreaming: boolean,
|
isStreaming: boolean,
|
||||||
id: number | null,
|
id: number | null,
|
||||||
startTime: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamHistoryItem = {
|
type StreamHistoryItem = {
|
||||||
@@ -83,11 +72,6 @@ type Marker = {
|
|||||||
timestamp: string,
|
timestamp: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamMetadata = {
|
|
||||||
url: string,
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
APIResponse,
|
APIResponse,
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
@@ -97,6 +81,5 @@ export type {
|
|||||||
JobResponse,
|
JobResponse,
|
||||||
StreamStatus,
|
StreamStatus,
|
||||||
StreamHistoryItem,
|
StreamHistoryItem,
|
||||||
Marker,
|
Marker
|
||||||
StreamMetadata
|
|
||||||
}
|
}
|
||||||
@@ -7,45 +7,6 @@ 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 {
|
||||||
@@ -79,4 +40,4 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };
|
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||||
|
|||||||
8
frontend/src/vite-env.d.ts
vendored
8
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
_env_?: {
|
||||||
|
VITE_API_URL?: string;
|
||||||
|
VITE_FRONTEND_URL?: string;
|
||||||
|
VITE_GOOGLE_CLIENT_ID?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
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(), flowbiteReact()],
|
plugins: [react(), tailwindcss()],
|
||||||
preview: {
|
preview: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ 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();
|
||||||
@@ -25,6 +30,11 @@ 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) ->
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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() {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ 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
|
||||||
@@ -30,8 +38,9 @@ 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()
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ 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.HttpHeaders;
|
import org.springframework.http.*;
|
||||||
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")
|
||||||
@@ -26,6 +24,11 @@ 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();
|
||||||
@@ -41,6 +44,12 @@ 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);
|
||||||
@@ -54,6 +63,13 @@ 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) {
|
||||||
@@ -65,6 +81,12 @@ 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);
|
||||||
@@ -78,6 +100,13 @@ 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);
|
||||||
@@ -95,8 +124,15 @@ 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) {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadThumbnail(id);
|
Resource resource = clipService.downloadThumbnail(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -104,6 +140,8 @@ 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);
|
||||||
@@ -116,11 +154,7 @@ 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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ 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());
|
||||||
@@ -25,6 +31,12 @@ 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());
|
||||||
@@ -32,6 +44,12 @@ 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());
|
||||||
@@ -39,6 +57,12 @@ 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());
|
||||||
@@ -46,6 +70,12 @@ 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());
|
||||||
@@ -53,6 +83,12 @@ 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());
|
||||||
@@ -60,6 +96,12 @@ 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());
|
||||||
@@ -67,6 +109,12 @@ 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());
|
||||||
@@ -74,6 +122,12 @@ 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());
|
||||||
@@ -81,6 +135,12 @@ 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());
|
||||||
@@ -88,6 +148,12 @@ 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);
|
||||||
@@ -95,6 +161,12 @@ 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());
|
||||||
@@ -102,6 +174,12 @@ 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());
|
||||||
@@ -109,6 +187,12 @@ 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());
|
||||||
@@ -116,6 +200,12 @@ 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());
|
||||||
@@ -123,6 +213,12 @@ 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());
|
||||||
@@ -130,6 +226,12 @@ 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());
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ 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);
|
||||||
@@ -47,6 +54,15 @@ 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);
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ 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()
|
||||||
@@ -33,6 +38,12 @@ 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);
|
||||||
@@ -42,6 +53,12 @@ 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());
|
||||||
@@ -51,6 +68,12 @@ 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);
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ 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,
|
||||||
@@ -40,6 +48,13 @@ 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 {
|
||||||
@@ -55,6 +70,13 @@ 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 {
|
||||||
@@ -70,6 +92,13 @@ 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 {
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -21,25 +18,20 @@ 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) {
|
||||||
@@ -48,6 +40,14 @@ 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,6 +56,14 @@ 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) {
|
||||||
@@ -63,62 +71,31 @@ 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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
|
* Reports whether the authenticated user is currently streaming.
|
||||||
* needed when the RTMP port is reached through a different host or a non-default port.
|
*
|
||||||
* Otherwise it is derived from the frontend's host, which is where docker-compose
|
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
|
||||||
* publishes the nginx-rtmp container.
|
* the active stream's ID
|
||||||
*/
|
*/
|
||||||
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(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||||
"Streaming status retrieved",
|
"Streaming status retrieved",
|
||||||
new StreamResponse(false, null, null))));
|
new StreamResponse(false, null))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ 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();
|
||||||
@@ -40,6 +46,17 @@ 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,
|
||||||
@@ -61,6 +78,13 @@ 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) {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ 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.HttpHeaders;
|
import org.springframework.http.*;
|
||||||
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")
|
||||||
@@ -26,6 +24,11 @@ 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()
|
||||||
@@ -35,12 +38,25 @@ 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) {
|
||||||
@@ -48,6 +64,12 @@ 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);
|
||||||
@@ -55,6 +77,13 @@ 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);
|
||||||
@@ -72,8 +101,15 @@ 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) {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = vodService.downloadThumbnail(id);
|
Resource resource = vodService.downloadThumbnail(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -81,6 +117,8 @@ 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);
|
||||||
@@ -93,11 +131,7 @@ 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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,5 @@ 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
|
|
||||||
) {}
|
) {}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers.dto;
|
|
||||||
|
|
||||||
/** Where OBS (or anything else speaking RTMP) should publish to. */
|
|
||||||
public record StreamMetadataResponse(String url, Float streamMaxLength) {}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
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
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,5 @@ 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
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,30 @@ 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");
|
||||||
@@ -19,10 +35,16 @@ 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;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,27 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,21 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ 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)
|
||||||
@@ -34,6 +40,12 @@ 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)
|
||||||
|
|||||||
@@ -40,61 +40,132 @@ 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);
|
||||||
@@ -146,6 +217,12 @@ 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));
|
||||||
@@ -155,6 +232,13 @@ 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);
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ 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);
|
||||||
|
|
||||||
@@ -47,6 +55,12 @@ 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();
|
||||||
|
|
||||||
@@ -57,6 +71,13 @@ 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();
|
||||||
|
|
||||||
@@ -72,11 +93,23 @@ 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() {
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ 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);
|
||||||
|
|
||||||
@@ -45,6 +53,13 @@ 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);
|
||||||
|
|
||||||
@@ -56,6 +71,13 @@ 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);
|
||||||
|
|
||||||
@@ -67,6 +89,12 @@ 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();
|
||||||
|
|
||||||
@@ -77,6 +105,14 @@ 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));
|
||||||
@@ -94,6 +130,12 @@ 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() {
|
||||||
|
|||||||
@@ -44,10 +44,21 @@ 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) {
|
||||||
@@ -56,6 +67,13 @@ 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();
|
||||||
@@ -70,6 +88,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ 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);
|
||||||
@@ -43,10 +52,27 @@ 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()) {
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ 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,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ 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);
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ 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,
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ 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);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user