Compare commits
63 Commits
prod
...
744b315f24
| Author | SHA1 | Date | |
|---|---|---|---|
| 744b315f24 | |||
| 255e48ffb1 | |||
| b7b3c8e51b | |||
| 9630565aee | |||
| eab3f8be20 | |||
| 11809a7da8 | |||
| 0b1ea8e089 | |||
| 08cfbd4cbb | |||
| e5bb0a3307 | |||
| bb771e630b | |||
| 3c50d6aaf4 | |||
| 9dcab44465 | |||
| 6ddffd8e2b | |||
| c86e5e5568 | |||
| 2f9ddadb91 | |||
| 29bc60d417 | |||
| 92cecb1084 | |||
| ae8f2f33a7 | |||
| 74a9d5752d | |||
| fe25ef05dc | |||
| 339465b417 | |||
| 3a46048b66 | |||
| 740f904d92 | |||
| e902d54a4d | |||
| d621aa3d6a | |||
| f9b055b3b5 | |||
| bb6f5e7823 | |||
| 2408263988 | |||
| 688ff541da | |||
| 07ee112d57 | |||
| fbb5c3ab52 | |||
| e1e6fe0046 | |||
| a66b9898e8 | |||
| baaf162fee | |||
| d7214ca1bc | |||
| 5bebea3f95 | |||
| 947c8261fd | |||
| e0969a1d78 | |||
| fe735faba8 | |||
| b8b4d3b05c | |||
| a051407c1e | |||
| 41fda53817 | |||
| 1a0778d1d6 | |||
| ba378680c6 | |||
| 2cac1770f9 | |||
| dc33d1df33 | |||
| 62dc0b0491 | |||
| 18626a5db8 | |||
| 3c8df9b328 | |||
| 76869c8c90 | |||
| e5fcf9b966 | |||
| 4ec5080edc | |||
| 14e8bd137b | |||
| 998eebb032 | |||
| e2c94d0740 | |||
| 0b929e96da | |||
| 1fff0805f6 | |||
| 48ff0eb8e5 | |||
| 83fcb44670 | |||
| 913c5c8790 | |||
| a330471bec | |||
| d8b61b4528 | |||
| 85ddd93757 |
@@ -16,3 +16,6 @@ POSTGRES_DB=vodSystem
|
|||||||
|
|
||||||
FRONTEND_URL=
|
FRONTEND_URL=
|
||||||
BACKEND_URL=
|
BACKEND_URL=
|
||||||
|
|
||||||
|
STREAM_RING_LENGTH_SECOND=28800
|
||||||
|
STREAM_INGEST_URL=
|
||||||
118
.gitea/workflows/ci.yml
Normal file
118
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.vodsystem.ddns.net
|
||||||
|
IMAGE_PREFIX: dylan/vod-system
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
name: Maven unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: https://github.com/actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
cache: maven
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: ./mvnw --batch-mode --no-transfer-progress test
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
name: Build and push images
|
||||||
|
needs: unit-tests
|
||||||
|
if: gitea.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea container registry
|
||||||
|
uses: https://github.com/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend image
|
||||||
|
uses: https://github.com/docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: src/main/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build and push frontend image
|
||||||
|
uses: https://github.com/docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
file: frontend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build and push rtmp image
|
||||||
|
uses: https://github.com/docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./nginx
|
||||||
|
file: nginx/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to server
|
||||||
|
needs: build-and-push
|
||||||
|
if: gitea.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Copy production compose file to server
|
||||||
|
uses: https://github.com/appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ vars.DEPLOY_HOST }}
|
||||||
|
username: ${{ vars.DEPLOY_USER }}
|
||||||
|
port: 22
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
source: docker-compose.prod.yml
|
||||||
|
target: ${{ vars.DEPLOY_PATH }}
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Pull latest images and restart containers
|
||||||
|
uses: https://github.com/appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ vars.DEPLOY_HOST }}
|
||||||
|
username: ${{ vars.DEPLOY_USER }}
|
||||||
|
port: 22
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
envs: REGISTRY,REGISTRY_USER,REGISTRY_TOKEN,DEPLOY_PATH
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USER: ${{ gitea.actor }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,4 +44,3 @@ build/
|
|||||||
frontend/.flowbite-react
|
frontend/.flowbite-react
|
||||||
|
|
||||||
generated
|
generated
|
||||||
.mvn
|
|
||||||
|
|||||||
3
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||||
144
README.md
144
README.md
@@ -1,33 +1,121 @@
|
|||||||
# Overview
|
# VoD System
|
||||||
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
|
|
||||||
|
|
||||||
- Upload clips effortlessly and securely.
|
A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker.
|
||||||
- Edit and trim videos with intuitive controls.
|
|
||||||
- Compress files to specific file sizes.
|
|
||||||
- Organize your clips for quick access and sharing.
|
|
||||||
|
|
||||||
This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend.
|
This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|------------|---------------------------------------------------------|
|
||||||
|
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
|
||||||
|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
|
||||||
|
| Database | PostgreSQL 15 |
|
||||||
|
| Streaming | nginx-rtmp |
|
||||||
|
| Auth | Google OAuth 2.0 + JWT |
|
||||||
|
| Runtime | Docker Compose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Docker & Docker Compose
|
|
||||||
- JDK 21+
|
|
||||||
- Node.js 16+
|
|
||||||
- FFMPEG
|
|
||||||
- Google Client keys
|
|
||||||
## Steps
|
|
||||||
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
|
|
||||||
2. Spin up docker container with `docker compose up`
|
|
||||||
3. Run `./mvnw clean package` to build the jar
|
|
||||||
4. Run `./mvnw spring-boot:run` to run the backend section
|
|
||||||
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
|
|
||||||
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
|
|
||||||
|
|
||||||
# Future Plans
|
- Docker and Docker Compose
|
||||||
- **Format Handling**
|
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
|
||||||
- Backend conversion of non-MP4 files via FFMPEG for broader format support
|
|
||||||
- **Input Sources**
|
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
|
||||||
- Support for local file uploads and YouTube imports
|
|
||||||
- **Testing**
|
---
|
||||||
- Unit tests, including coverage for FFMPEG-related functionality
|
|
||||||
- **API Documentation**
|
## Getting Started
|
||||||
- Comprehensive and maintainable backend API docs
|
|
||||||
|
### 1. Configure the environment
|
||||||
|
|
||||||
|
Copy the example file and fill in the values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `.env` and set the following:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# OAuth credentials from your Google Cloud Console project
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# A long random string used to sign JWTs — treat it like a password
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
|
||||||
|
# Absolute paths on the host where persistent data will be stored
|
||||||
|
POSTGRES_DATA=/path/to/postgres/data
|
||||||
|
STREAM_DATA=/path/to/stream/data
|
||||||
|
MEDIA_DATA=/path/to/media/data
|
||||||
|
|
||||||
|
# Database credentials — change these from the defaults before deploying
|
||||||
|
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
||||||
|
POSTGRES_USER=myuser
|
||||||
|
POSTGRES_PASSWORD=mypassword
|
||||||
|
POSTGRES_DB=vodSystem
|
||||||
|
|
||||||
|
# The URLs the frontend will use to reach itself and the backend
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
BACKEND_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
|
||||||
|
|
||||||
|
Once everything has started, the interfaces are available at:
|
||||||
|
|
||||||
|
- Frontend: [http://localhost:5173](http://localhost:5173)
|
||||||
|
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
|
||||||
|
- RTMP ingest: `rtmp://localhost:1935/live`
|
||||||
|
|
||||||
|
### 3. Authenticate
|
||||||
|
|
||||||
|
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the backend and frontend separately in your usual workflow.
|
||||||
|
|
||||||
|
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
|
||||||
|
|
||||||
|
The collection is organised into four folders:
|
||||||
|
|
||||||
|
| Folder | Description |
|
||||||
|
|----------|--------------------------------------------------|
|
||||||
|
| Users | Google OAuth login flow, current user |
|
||||||
|
| Media | Upload, compress, clip stream, save section |
|
||||||
|
| Jobs | Poll job status, download completed output |
|
||||||
|
| Streams | Stream history |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Plans
|
||||||
|
|
||||||
|
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
|
||||||
|
- **Input sources** — YouTube imports and additional local upload options
|
||||||
|
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
|
||||||
|
- **API documentation** — comprehensive, auto-generated backend API docs
|
||||||
|
|||||||
22
bruno/VoD-System/Markers/Create Marker.bru
Normal file
22
bruno/VoD-System/Markers/Create Marker.bru
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Create Marker
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/markers
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"message": "Nice play"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal file
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Delete Marker
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/markers/5
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
16
bruno/VoD-System/Markers/Get Marker.bru
Normal file
16
bruno/VoD-System/Markers/Get Marker.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Marker
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/markers/5
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
11
bruno/VoD-System/Markers/Get all markers.bru
Normal file
11
bruno/VoD-System/Markers/Get all markers.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Get all markers
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/markers
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Markers/folder.bru
Normal file
8
bruno/VoD-System/Markers/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Markers
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"duration": 60.0
|
"duration": 60.0,
|
||||||
|
"title": "Hello",
|
||||||
|
"description": "my clip"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
bruno/VoD-System/Media/Save Stream Section By Markers.bru
Normal file
24
bruno/VoD-System/Media/Save Stream Section By Markers.bru
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: Save Stream Section By Markers
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/media/save/markers
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"startMarkerId": 1,
|
||||||
|
"endMarkerId": 2,
|
||||||
|
"title": "Hello",
|
||||||
|
"description": "my clip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"startTime": "2026-06-21T17:27:00Z",
|
"startTime": "2026-07-01T21:09:00Z",
|
||||||
"endTime": "2026-06-21T17:28:00Z"
|
"endTime": "2026-07-01T21:12:00Z",
|
||||||
|
"title": "Hello",
|
||||||
|
"description": "my clip"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
bruno/VoD-System/VoDs/Delete VoD.bru
Normal file
16
bruno/VoD-System/VoDs/Delete VoD.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Delete VoD
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/vods/1
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
16
bruno/VoD-System/VoDs/Downloads/Get VoD Media.bru
Normal file
16
bruno/VoD-System/VoDs/Downloads/Get VoD Media.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get VoD Media
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/vods/1/media
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
15
bruno/VoD-System/VoDs/Downloads/Get VoD Thumbnail.bru
Normal file
15
bruno/VoD-System/VoDs/Downloads/Get VoD Thumbnail.bru
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Get VoD Thumbnail
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/vods/1/thumbnail
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
8
bruno/VoD-System/VoDs/Downloads/folder.bru
Normal file
8
bruno/VoD-System/VoDs/Downloads/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Downloads
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
16
bruno/VoD-System/VoDs/Get VoD.bru
Normal file
16
bruno/VoD-System/VoDs/Get VoD.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get VoD
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/vods/1
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
11
bruno/VoD-System/VoDs/Get all vods.bru
Normal file
11
bruno/VoD-System/VoDs/Get all vods.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Get all vods
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/vods
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
23
bruno/VoD-System/VoDs/Update VoD.bru
Normal file
23
bruno/VoD-System/VoDs/Update VoD.bru
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
meta {
|
||||||
|
name: Update VoD
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
patch {
|
||||||
|
url: {{base_url}}/vods/1
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"title": "My VoD",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
8
bruno/VoD-System/VoDs/folder.bru
Normal file
8
bruno/VoD-System/VoDs/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: VoDs
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
8
bruno/VoD-System/environments/dev.bru
Normal file
8
bruno/VoD-System/environments/dev.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
vars {
|
||||||
|
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
|
||||||
|
uuid:
|
||||||
|
}
|
||||||
|
vars:secret [
|
||||||
|
google_client_id,
|
||||||
|
google_secret
|
||||||
|
]
|
||||||
@@ -22,6 +22,8 @@ services:
|
|||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://host.docker.internal:8080
|
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
57
docker-compose.prod.yml
Normal file
57
docker-compose.prod.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Production deploy: uses pre-built images from the Gitea container registry
|
||||||
|
# instead of building locally. Populated by CI onto the server alongside .env.
|
||||||
|
# Usage: docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: vod_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- ${POSTGRES_DATA}:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
|
||||||
|
container_name: vod_backend
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ${MEDIA_DATA}:/app/media
|
||||||
|
- ${STREAM_DATA}:/app/stream
|
||||||
|
ports:
|
||||||
|
- "8089:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-frontend:latest
|
||||||
|
container_name: vod_frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
|
VITE_FRONTEND_URL: ${FRONTEND_URL}
|
||||||
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
rtmp:
|
||||||
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
|
||||||
|
container_name: vod_rtmp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- WEB_SERVER_URL=http://backend:8080
|
||||||
|
ports:
|
||||||
|
- "1935:1935"
|
||||||
|
volumes:
|
||||||
|
- ${STREAM_DATA}:/stream
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
container_name: vod_frontend
|
container_name: vod_frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${BACKEND_URL}
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
|
VITE_FRONTEND_URL: ${FRONTEND_URL}
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
@@ -44,6 +45,8 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://backend:8080
|
- WEB_SERVER_URL=http://backend:8080
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/small_icon.png" />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<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" />
|
||||||
<title>Vite + React + TS</title>
|
<title>The VoD System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3151
frontend/package-lock.json
generated
3151
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,22 +13,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.12.2",
|
"@react-oauth/google": "^0.12.2",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
|
||||||
"axios": "^1.11.0",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.5.0",
|
|
||||||
"flowbite-react": "^0.11.8",
|
"flowbite-react": "^0.11.8",
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"path": "^0.12.7",
|
|
||||||
"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",
|
|
||||||
"tailwindcss": "^4.1.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -36,9 +31,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5"
|
||||||
"vite-plugin-mkcert": "^1.17.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/small_icon.png
Normal file
BIN
frontend/public/small_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,14 @@ import ClipEdit from './pages/ClipEdit';
|
|||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import MyClips from './pages/MyClips';
|
import MyClips from './pages/MyClips';
|
||||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
import MyVods from './pages/MyVods';
|
||||||
|
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
||||||
|
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
||||||
|
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
|
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 { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
|
|
||||||
|
|
||||||
@@ -26,9 +31,13 @@ function App() {
|
|||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,14 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Box = ({ children, className }: Props) => (
|
|
||||||
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Box;
|
|
||||||
14
frontend/src/components/Card.tsx
Normal file
14
frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Card = ({ children, className }: Props) => (
|
||||||
|
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Card;
|
||||||
@@ -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-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
|
||||||
onClick={() => onClick(item)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
|||||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(v => !v)}
|
onClick={() => setIsOpen(v => !v)}
|
||||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
|
className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
38
frontend/src/components/GoogleSignIn.tsx
Normal file
38
frontend/src/components/GoogleSignIn.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
|
||||||
|
import { useAuth } from "../utils/AuthContext.tsx";
|
||||||
|
import { getUser, login } from "../utils/api/users.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
|
||||||
|
shape?: "rectangular" | "pill";
|
||||||
|
size?: "small" | "medium" | "large";
|
||||||
|
width?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
|
||||||
|
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
|
||||||
|
const { setUser } = useAuth();
|
||||||
|
|
||||||
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
|
if (!response.credential) {
|
||||||
|
console.error("No credential received from Google login.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
login(response.credential)
|
||||||
|
.then(() => getUser().then(setUser))
|
||||||
|
.catch((error) => console.error("Login failed:", error));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GoogleLogin
|
||||||
|
shape={shape}
|
||||||
|
size={size}
|
||||||
|
width={width}
|
||||||
|
useOneTap={false}
|
||||||
|
onSuccess={handleLogin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleSignIn;
|
||||||
13
frontend/src/components/IsLivePulse.tsx
Normal file
13
frontend/src/components/IsLivePulse.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const IsLivePulse = () => {
|
||||||
|
return (
|
||||||
|
<span className={"relative flex h-2 w-2"}>
|
||||||
|
<span
|
||||||
|
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
|
||||||
|
></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IsLivePulse;
|
||||||
57
frontend/src/components/JobProgress.tsx
Normal file
57
frontend/src/components/JobProgress.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import type { JobResponse } from "../utils/types.ts";
|
||||||
|
|
||||||
|
type TrackedJob = {
|
||||||
|
uuid: string;
|
||||||
|
label: string;
|
||||||
|
progress: number;
|
||||||
|
state: JobResponse['state'];
|
||||||
|
errorOutput: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
|
||||||
|
|
||||||
|
// queued → reading source → encoding → writing file → complete
|
||||||
|
const stageOf = (job: TrackedJob): string => {
|
||||||
|
if (job.state === 'FAILED') return "failed";
|
||||||
|
if (job.state === 'SUCCEEDED') return "complete";
|
||||||
|
if (job.state === 'READY') return "queued";
|
||||||
|
if (job.progress < 0.05) return "reading source";
|
||||||
|
if (job.progress < 0.95) return "encoding";
|
||||||
|
return "writing file";
|
||||||
|
};
|
||||||
|
|
||||||
|
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
|
||||||
|
const failed = job.state === 'FAILED';
|
||||||
|
const done = job.state === 'SUCCEEDED';
|
||||||
|
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||||
|
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
|
||||||
|
|
||||||
|
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"h-full rounded-full transition-[width] duration-500 ease-out",
|
||||||
|
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
|
||||||
|
)}
|
||||||
|
style={{ width: `${failed ? 100 : pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
|
||||||
|
{stageOf(job)}
|
||||||
|
</p>
|
||||||
|
<p className="font-data text-[11px] text-faint-mono">
|
||||||
|
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobProgress;
|
||||||
|
export { isSettled };
|
||||||
|
export type { TrackedJob };
|
||||||
17
frontend/src/components/NoAccessPage.tsx
Normal file
17
frontend/src/components/NoAccessPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {Lock} from "lucide-react";
|
||||||
|
|
||||||
|
const NoAccessPage = () => {
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
|
||||||
|
Log in to see this page
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-faint-mono/70 max-w-xs">
|
||||||
|
Sign in with Google to view your clips and account details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoAccessPage;
|
||||||
24
frontend/src/components/PageHeader.tsx
Normal file
24
frontend/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { kickerClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
/** Optional trailing action, right-aligned against the title. */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageHeader = ({ kicker, title, children, className }: Props) => (
|
||||||
|
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className={kickerClass}>{kicker}</p>
|
||||||
|
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -5,11 +5,11 @@ type Props = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Label on the left, control on the right — the shape used for export settings.
|
||||||
const Selector = ({ children, label }: Props) => (
|
const Selector = ({ children, label }: Props) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<label className="w-full text-sm text-gray-600">{label}</label>
|
<label className="font-body text-[13px] text-text-body">{label}</label>
|
||||||
<div className="w-px h-5 bg-gray-200 mx-2" />
|
<div className="w-32 flex-shrink-0">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,90 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
|
||||||
import type {User} from "../utils/types.ts";
|
import {useAuth} from "../utils/AuthContext.tsx";
|
||||||
|
import GoogleSignIn from "./GoogleSignIn.tsx";
|
||||||
|
import {logout} from "../utils/api/users.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
user: User | null;
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
|
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
|
||||||
|
|
||||||
|
const Sidebar = ({ className }: props) => {
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
.then(() =>
|
||||||
|
setUser(null)
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const Sidebar = ({user, className}: props) => {
|
|
||||||
return (
|
return (
|
||||||
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
|
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
||||||
<div className="px-4 pt-5 pb-3">
|
<h2 className={"font-heading text-3xl"}>The VoD System</h2>
|
||||||
<img
|
|
||||||
className="w-36 mx-auto"
|
<nav className="flex flex-col flex-1 items-center">
|
||||||
src="../../public/logo.png"
|
<Divider />
|
||||||
alt="VoD System Logo"
|
<div className="flex flex-col gap-0.5 w-full">
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<nav className="flex flex-col flex-1 px-2 pb-4">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
||||||
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Upload" />
|
||||||
</div>
|
</div>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<div className="flex flex-col gap-0.5 w-full">
|
||||||
<Divider />
|
<div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<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" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{user && (
|
<div className={"p-2"}>
|
||||||
<>
|
{user ? (
|
||||||
|
<div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
<div className={"flex gap-2 items-center pt-1"}>
|
||||||
</>
|
<img
|
||||||
|
className={"w-8 h-8 rounded-full inline-block"}
|
||||||
|
src={user.profilePictureUrl}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
alt={"Profile Picture"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={"flex flex-col"}>
|
||||||
|
<p className={"font-body text-text-strong text-sm"}>
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<p className={"text-[0.625rem] font-body text-muted wrap-break-word"}>
|
||||||
|
Signed in with Google
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Divider />
|
||||||
|
<GoogleSignIn />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
|
||||||
import { login, logout } from "../utils/endpoints.ts";
|
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
|
||||||
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
import type { CredentialResponse } from '@react-oauth/google';
|
|
||||||
|
|
||||||
import MenuButton from "./buttons/MenuButton.tsx";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {useNavigate} from "react-router-dom";
|
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
|
||||||
sidebarToggled: boolean;
|
|
||||||
setSidebarToggled: (toggled: boolean) => void;
|
|
||||||
user: User | null;
|
|
||||||
fetchUser: () => void;
|
|
||||||
isStreaming: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Topbar = ({
|
|
||||||
sidebarToggled,
|
|
||||||
setSidebarToggled,
|
|
||||||
user,
|
|
||||||
fetchUser,
|
|
||||||
isStreaming,
|
|
||||||
className}: props) => {
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
|
||||||
if (!response.credential) {
|
|
||||||
console.error("No credential received from Google login.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
login(response.credential)
|
|
||||||
.then(() => {
|
|
||||||
fetchUser();
|
|
||||||
navigate(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Login failed:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout()
|
|
||||||
.then(() => {
|
|
||||||
fetchUser();
|
|
||||||
navigate("/");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Logout failed:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
|
|
||||||
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
|
||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
{ user ? (
|
|
||||||
<div>
|
|
||||||
<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-red-500 opacity-75" />
|
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}>
|
|
||||||
<DropdownItem item="Profile"
|
|
||||||
onClick={() => navigate("/profile")} />
|
|
||||||
<DropdownItem item="Logout"
|
|
||||||
onClick={() => handleLogout()}
|
|
||||||
className={"text-red-500 font-medium"} />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
) :
|
|
||||||
(
|
|
||||||
<GoogleLogin
|
|
||||||
shape={"pill"}
|
|
||||||
useOneTap={false}
|
|
||||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Topbar;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlueButton;
|
|
||||||
32
frontend/src/components/buttons/Button.tsx
Normal file
32
frontend/src/components/buttons/Button.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
|
||||||
|
|
||||||
|
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: Variant;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hover shifts one step darker within the same family — no transforms.
|
||||||
|
const variants: Record<Variant, string> = {
|
||||||
|
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
|
||||||
|
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
|
||||||
|
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
|
||||||
|
secondary: "bg-panel text-text-control hover:bg-chip-hover",
|
||||||
|
ghost: "text-muted hover:text-text-strong",
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button = ({ variant = "primary", className, ...props }: Props) => (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
variants[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Button;
|
||||||
|
export type { Variant };
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuButton;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import MenuButton from "./MenuButton.tsx";
|
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,9 +13,15 @@ const SidebarButton = ({url, logo, label}: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link className="w-full" to={url}>
|
<Link className="w-full" to={url}>
|
||||||
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
|
<button className={`flex items-center gap-2.5 w-full text-sm
|
||||||
|
font-body p-2 hover:bg-hover hover:text-text-strong
|
||||||
|
rounded-md
|
||||||
|
${isActive ?
|
||||||
|
"bg-card text-text-strong font-medium shadow-sm"
|
||||||
|
:
|
||||||
|
"text-text-primary"}`}>
|
||||||
{logo}{label}
|
{logo}{label}
|
||||||
</MenuButton>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import RangeSlider from 'react-range-slider-input';
|
|
||||||
import 'react-range-slider-input/dist/style.css';
|
|
||||||
import { useRef, type Dispatch, type SetStateAction } from "react";
|
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
videoRef: HTMLVideoElement | null;
|
|
||||||
videoMetadata: VideoMetadata;
|
|
||||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
|
||||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
|
|
||||||
const previousRange = useRef<[number, number]>([0, 0]);
|
|
||||||
|
|
||||||
const handleRangeInput = (val: [number, number]) => {
|
|
||||||
if (!videoRef) return;
|
|
||||||
|
|
||||||
if (previousRange.current[0] !== val[0]) {
|
|
||||||
videoRef.currentTime = val[0];
|
|
||||||
setSliderValue(val[0]);
|
|
||||||
} else if (previousRange.current[1] !== val[1]) {
|
|
||||||
videoRef.currentTime = val[1];
|
|
||||||
setSliderValue(val[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
|
|
||||||
previousRange.current = val;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RangeSlider
|
|
||||||
min={0}
|
|
||||||
max={videoMetadata.duration}
|
|
||||||
step={0.1}
|
|
||||||
onInput={handleRangeInput}
|
|
||||||
className={className}
|
|
||||||
id="range-slider"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
|
|||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
import Selector from "../Selector.tsx";
|
import Selector from "../Selector.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
metadata: VideoMetadata;
|
||||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
// Mirrors CompressionService.buildBitrate so the number shown is the number used:
|
||||||
|
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
|
||||||
|
const BUDGET_MULTIPLIER = 0.9;
|
||||||
|
const AUDIO_RATIO = 0.15;
|
||||||
|
const MAX_AUDIO_BITRATE = 128;
|
||||||
|
|
||||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
type Estimate = { total: number; audio: number; video: number; bpp: number | null };
|
||||||
|
|
||||||
|
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
|
||||||
|
const { fileSize, duration, width, height, fps } = metadata;
|
||||||
|
if (!fileSize || !duration || duration <= 0) return null;
|
||||||
|
|
||||||
|
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
|
||||||
|
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
|
||||||
|
const video = total - audio;
|
||||||
|
|
||||||
|
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
|
||||||
|
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
|
||||||
|
|
||||||
|
return { total, audio, video, bpp };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBitrate = (kbps: number): string =>
|
||||||
|
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
|
||||||
|
|
||||||
|
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
|
||||||
|
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
|
||||||
|
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
|
||||||
|
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
|
||||||
|
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
|
||||||
|
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
|
||||||
|
return { word: "very soft", tone: "text-live-red" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const [width, height] = e.target.value.split(",").map(Number);
|
const [width, height] = e.target.value.split(",").map(Number);
|
||||||
setMetadata(prev => ({ ...prev, width, height }));
|
setMetadata(prev => ({ ...prev, width, height }));
|
||||||
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
|
setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sizeMb = Math.round(metadata.fileSize / 1000);
|
||||||
|
const estimate = estimateBitrate(metadata);
|
||||||
|
const verdict = estimate && verdictOf(estimate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
<div className={clsx("flex flex-col gap-4", className)}>
|
||||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
<p className={kickerClass}>Export settings</p>
|
||||||
|
|
||||||
<Selector label="Resolution">
|
<Selector label="Resolution">
|
||||||
<select id="resolution" name="resolution" defaultValue="1280,720"
|
<select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
|
||||||
onChange={updateRes} className={inputClass}>
|
onChange={updateRes} className={inputClass}>
|
||||||
<option value="3840,2160">2160p</option>
|
<option value="3840,2160">2160p</option>
|
||||||
<option value="2560,1440">1440p</option>
|
<option value="2560,1440">1440p</option>
|
||||||
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label="FPS">
|
<Selector label="Frames per second">
|
||||||
<select id="fps" name="fps" defaultValue="30"
|
<select id="fps" name="fps" value={String(metadata.fps)}
|
||||||
onChange={updateFps} className={inputClass}>
|
onChange={updateFps} className={inputClass}>
|
||||||
<option value="60">60</option>
|
<option value="60">60</option>
|
||||||
<option value="30">30</option>
|
<option value="30">30</option>
|
||||||
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label="File Size Limit (MB)">
|
<Selector label="Size limit (MB)">
|
||||||
<input type="number" min="1" defaultValue="10"
|
<input type="number" min="1" value={sizeMb}
|
||||||
onChange={updateFileSize} className={inputClass}
|
onChange={updateFileSize} className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
|
{/* What that budget actually buys, at this length and resolution. */}
|
||||||
|
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className={kickerClass}>Estimated bitrate</p>
|
||||||
|
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{estimate ? (
|
||||||
|
<>
|
||||||
|
<p className="font-data text-[15px] text-text-primary tabular-nums">
|
||||||
|
{formatBitrate(Math.max(0, estimate.video))}
|
||||||
|
</p>
|
||||||
|
<p className="font-body text-[12px] text-muted">
|
||||||
|
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
|
||||||
|
across {metadata.duration.toFixed(1)}s
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="font-body text-[12px] text-muted">
|
||||||
|
Pick a trim range to see what the size limit buys you.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,53 @@
|
|||||||
import BlueButton from "../buttons/BlueButton.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>;
|
||||||
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
downloadable: boolean;
|
downloadable: boolean;
|
||||||
progress: number
|
progress: number;
|
||||||
|
uploading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
|
// queued → reading source → encoding → writing file → complete
|
||||||
|
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
|
||||||
|
if (downloadable) return "complete";
|
||||||
|
if (!uploading) return "waiting";
|
||||||
|
if (progress < 0.05) return "reading source";
|
||||||
|
if (progress < 0.95) return "encoding";
|
||||||
|
return "writing file";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
|
||||||
|
const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-3"}>
|
<div className="flex flex-col gap-4">
|
||||||
<BlueButton
|
<p className={kickerClass}>Export</p>
|
||||||
onClick={dataSend}>
|
|
||||||
Export
|
|
||||||
</BlueButton>
|
|
||||||
|
|
||||||
<div className="h-10 flex items-center">
|
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
|
||||||
{ downloadable ?
|
{uploading ? "Compressing…" : "Compress and save"}
|
||||||
(<BlueButton className="w-full" onClick={handleDownload}>
|
</Button>
|
||||||
Download
|
|
||||||
</BlueButton>)
|
<div className="flex flex-col gap-1.5">
|
||||||
:(
|
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
|
||||||
<progress
|
<div
|
||||||
value={progress}
|
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
|
||||||
className="w-full h-1.5 rounded bg-gray-200">
|
style={{ width: `${pct}%` }}
|
||||||
</progress>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{downloadable && (
|
||||||
|
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
|
||||||
|
Download the file
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { type Dispatch, type SetStateAction } from "react";
|
import { type Dispatch, type SetStateAction } from "react";
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
|
import { inputClass, labelClass } from "../../utils/styles.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||||
@@ -8,13 +9,14 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
<div className={clsx("flex flex-col gap-2", className)}>
|
||||||
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
<label className={labelClass} htmlFor="clip-title">Title</label>
|
||||||
<input
|
<input
|
||||||
|
id="clip-title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter title"
|
placeholder="Cat walks across the patch bay"
|
||||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
videoRef: HTMLVideoElement | null;
|
|
||||||
videoMetadata: VideoMetadata;
|
|
||||||
sliderValue: number;
|
|
||||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
|
|
||||||
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!videoRef) return;
|
|
||||||
videoRef.currentTime = e.target.valueAsNumber;
|
|
||||||
setSliderValue(e.target.valueAsNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoRef) return;
|
|
||||||
const updateSlider = () => setSliderValue(videoRef.currentTime);
|
|
||||||
videoRef.addEventListener("timeupdate", updateSlider);
|
|
||||||
return () => videoRef.removeEventListener("timeupdate", updateSlider);
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={videoMetadata.duration}
|
|
||||||
value={sliderValue}
|
|
||||||
onChange={updateVideo}
|
|
||||||
step={0.1}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
}, [video]);
|
}, [video]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
<div className={clsx("flex justify-between items-center mb-3", className)}>
|
||||||
<div className="flex gap-1">
|
<div className="flex items-center gap-2 text-text-control">
|
||||||
<Icon size={24} />
|
<Icon size={20} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={updateVolume}
|
onChange={updateVolume}
|
||||||
className="w-20"
|
className="w-20 accent-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
{isPlaying ? <Pause size={22} /> : <Play size={22} />}
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span className="font-data text-[13px] text-text-secondary tabular-nums">
|
||||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
283
frontend/src/components/video/TrimTimeline.tsx
Normal file
283
frontend/src/components/video/TrimTimeline.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { formatTime } from "../../utils/utils.ts";
|
||||||
|
|
||||||
|
type Range = [number, number];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Full length of the source video, in seconds. */
|
||||||
|
duration: number;
|
||||||
|
/** The trim window, in seconds. */
|
||||||
|
range: Range;
|
||||||
|
onRangeChange: (range: Range) => void;
|
||||||
|
/** Playhead position, in seconds. */
|
||||||
|
currentTime: number;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Never zoom in past this, or the handles have nothing left to grab. */
|
||||||
|
const MIN_VIEW_SECONDS = 0.5;
|
||||||
|
/** The shortest trim the handles will allow between them. */
|
||||||
|
const MIN_GAP = 0.1;
|
||||||
|
const ZOOM_STEP = 1.6;
|
||||||
|
|
||||||
|
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
||||||
|
|
||||||
|
/** A readable tick interval that yields roughly 6 labels across the view. */
|
||||||
|
const tickInterval = (span: number): number => {
|
||||||
|
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
|
||||||
|
return steps.find((step) => span / step <= 6) ?? 3600;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
|
||||||
|
const [view, setView] = useState<Range>([0, duration]);
|
||||||
|
// Which thing the pointer is currently dragging, if any.
|
||||||
|
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
|
||||||
|
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const overviewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragOffset = useRef(0);
|
||||||
|
|
||||||
|
// A new source resets the view to the whole thing.
|
||||||
|
useEffect(() => setView([0, duration]), [duration]);
|
||||||
|
|
||||||
|
const [viewStart, viewEnd] = view;
|
||||||
|
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
|
||||||
|
const zoomFactor = duration / viewSpan;
|
||||||
|
|
||||||
|
/** Seconds → percentage across the zoomed track. */
|
||||||
|
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
|
||||||
|
/** Pointer x → seconds, within the zoomed track. */
|
||||||
|
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
|
||||||
|
const rect = element?.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width === 0) return span[0];
|
||||||
|
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
|
||||||
|
return span[0] + ratio * (span[1] - span[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Re-centre the view on `focus`, keeping it inside the source. */
|
||||||
|
const applyZoom = useCallback((nextSpan: number, focus: number) => {
|
||||||
|
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
|
||||||
|
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
|
||||||
|
let start = focus - span * ratio;
|
||||||
|
start = clamp(start, 0, Math.max(0, duration - span));
|
||||||
|
setView([start, start + span]);
|
||||||
|
}, [duration, view]);
|
||||||
|
|
||||||
|
const zoomBy = (factor: number) => {
|
||||||
|
const focus = clamp(currentTime, viewStart, viewEnd);
|
||||||
|
applyZoom(viewSpan / factor, focus);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zoom the view down to the trim range, with a little air on each side. */
|
||||||
|
const zoomToRange = () => {
|
||||||
|
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
|
||||||
|
const padded = Math.min(duration, span * 1.25);
|
||||||
|
const centre = (range[0] + range[1]) / 2;
|
||||||
|
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
|
||||||
|
setView([start, start + padded]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wheel over the track zooms around the pointer rather than scrolling the page.
|
||||||
|
useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
if (event.deltaY === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const focus = toSeconds(event.clientX, track, view);
|
||||||
|
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
|
||||||
|
};
|
||||||
|
|
||||||
|
track.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return () => track.removeEventListener("wheel", onWheel);
|
||||||
|
}, [applyZoom, view, viewSpan]);
|
||||||
|
|
||||||
|
// The move handler reads through refs, so it always sees the current view and
|
||||||
|
// range without having to be torn down and rebuilt on every frame of a drag.
|
||||||
|
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
|
||||||
|
latest.current = { view, range, onRangeChange, onSeek, duration };
|
||||||
|
|
||||||
|
// Listeners are attached on mousedown rather than in an effect: a fast drag
|
||||||
|
// would otherwise lose its first frames waiting for a re-render.
|
||||||
|
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setDragging(kind);
|
||||||
|
|
||||||
|
const onMove = (move: MouseEvent) => {
|
||||||
|
const state = latest.current;
|
||||||
|
const span = state.view[1] - state.view[0];
|
||||||
|
|
||||||
|
if (kind === "window") {
|
||||||
|
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
|
||||||
|
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
|
||||||
|
setView([start, start + span]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = toSeconds(move.clientX, trackRef.current, state.view);
|
||||||
|
if (kind === "playhead") {
|
||||||
|
state.onSeek(at);
|
||||||
|
} else if (kind === "in") {
|
||||||
|
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
|
||||||
|
state.onSeek(at);
|
||||||
|
} else {
|
||||||
|
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
|
||||||
|
state.onSeek(at);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
setDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (duration <= 0) return null;
|
||||||
|
|
||||||
|
const interval = tickInterval(viewSpan);
|
||||||
|
const firstTick = Math.ceil(viewStart / interval) * interval;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
|
||||||
|
|
||||||
|
const windowLeft = (viewStart / duration) * 100;
|
||||||
|
const windowWidth = (viewSpan / duration) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col gap-3 select-none", className)}>
|
||||||
|
{/* Overview — the whole source, with the zoom window drawn on top. */}
|
||||||
|
<div
|
||||||
|
ref={overviewRef}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
|
||||||
|
// Grabbing inside the window drags it; clicking outside jumps it here.
|
||||||
|
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
|
||||||
|
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
|
||||||
|
setView([start, start + viewSpan]);
|
||||||
|
beginDrag("window", event);
|
||||||
|
}}
|
||||||
|
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 bg-olive-light/30"
|
||||||
|
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 w-px bg-accent"
|
||||||
|
style={{ left: `${(currentTime / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
|
||||||
|
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => zoomBy(1 / ZOOM_STEP)}
|
||||||
|
disabled={viewSpan >= duration}
|
||||||
|
title="Zoom out"
|
||||||
|
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => zoomBy(ZOOM_STEP)}
|
||||||
|
disabled={viewSpan <= MIN_VIEW_SECONDS}
|
||||||
|
title="Zoom in"
|
||||||
|
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="font-data text-[11px] text-faint-mono tabular-nums">
|
||||||
|
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
|
||||||
|
{" · "}
|
||||||
|
{formatTime(viewStart)} – {formatTime(viewEnd)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={zoomToRange}
|
||||||
|
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Fit to trim
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView([0, duration])}
|
||||||
|
disabled={viewSpan >= duration}
|
||||||
|
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Whole clip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoomed track */}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
onSeek(toSeconds(event.clientX, trackRef.current, view));
|
||||||
|
beginDrag("playhead", event);
|
||||||
|
}}
|
||||||
|
className="relative h-14 rounded-chip bg-fields cursor-pointer"
|
||||||
|
>
|
||||||
|
{ticks.map((at) => (
|
||||||
|
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
|
||||||
|
<div className="absolute inset-y-0 w-px bg-inactive/70" />
|
||||||
|
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
|
||||||
|
{formatTime(at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Trim band */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 bg-olive-light/35"
|
||||||
|
style={{
|
||||||
|
left: `${clamp(toPct(range[0]), 0, 100)}%`,
|
||||||
|
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playhead */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
|
||||||
|
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* In / out handles — only grabbable while inside the view */}
|
||||||
|
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
|
||||||
|
const pct = toPct(at);
|
||||||
|
if (pct < -2 || pct > 102) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={edge}
|
||||||
|
onMouseDown={(event) => beginDrag(edge, event)}
|
||||||
|
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
|
||||||
|
className={clsx(
|
||||||
|
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
|
||||||
|
dragging === edge && "z-10"
|
||||||
|
)}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
>
|
||||||
|
<div className="h-full w-[3px] bg-olive rounded-full" />
|
||||||
|
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrimTimeline;
|
||||||
|
export type { Range };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
|
import { isThumbnailAvailable } from "../../utils/api/client.ts";
|
||||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type VideoCardProps = {
|
type VideoCardProps = {
|
||||||
@@ -10,33 +10,55 @@ type VideoCardProps = {
|
|||||||
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,
|
||||||
|
mediaApiPath?: string,
|
||||||
|
playerPath?: string,
|
||||||
|
itemLabel?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
|
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
|
||||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
const placeholders: Record<string, React.CSSProperties> = {
|
||||||
|
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
|
||||||
|
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
||||||
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
const [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`;
|
||||||
|
|
||||||
|
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
|
||||||
|
const isVod = itemLabel === 'vod';
|
||||||
|
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
|
||||||
|
|
||||||
|
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
|
||||||
|
const meta = [
|
||||||
|
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
|
||||||
|
fileSize ? formatFileSize(fileSize) : null,
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
// The technical detail, in mono, for when it matters.
|
||||||
|
const facts = [
|
||||||
|
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
|
||||||
|
isVod && fps ? `${Math.round(fps)} fps` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
isThumbnailAvailable(thumbnailUrl)
|
||||||
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [createdAt]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isThumbnailAvailable(id)
|
|
||||||
.then(setThumbnailAvailable)
|
.then(setThumbnailAvailable)
|
||||||
.catch(() => setThumbnailAvailable(false));
|
.catch(() => setThumbnailAvailable(false));
|
||||||
}, []);
|
}, [thumbnailUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
@@ -57,16 +79,29 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={"/video/" + id}>
|
<Link to={`${playerPath}/${id}`}>
|
||||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
<div className={clsx(
|
||||||
<div className="relative overflow-hidden rounded-lg">
|
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div
|
||||||
|
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
|
||||||
|
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
|
||||||
|
>
|
||||||
|
{thumbnailAvailable && (
|
||||||
<img
|
<img
|
||||||
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
src={thumbnailUrl}
|
||||||
alt="Video Thumbnail"
|
alt=""
|
||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover"
|
||||||
|
onError={() => setThumbnailAvailable(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
|
<span className="absolute top-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
|
||||||
|
{slug}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -78,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
|
className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
<div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
|
||||||
{!confirmDelete && (<>
|
{!confirmDelete && (<>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onEdit)}
|
onClick={(e) => stopAndRun(e, onEdit)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<Pencil size={13} /> Edit
|
<Pencil size={13} /> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -97,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} /> Delete
|
<Trash2 size={13} /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -105,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
|
<p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||||
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
className="flex-1 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||||
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
className="flex-1 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -128,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col p-2">
|
<div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
|
||||||
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
<p className={clsx(
|
||||||
{title === "" ? "(No Title)" : title}
|
"line-clamp-2",
|
||||||
|
isVod
|
||||||
|
? "font-heading text-[17.5px] leading-[1.3]"
|
||||||
|
: "font-body text-[13.5px] text-text-strong leading-snug"
|
||||||
|
)}>
|
||||||
|
{title === "" ? "(No title)" : title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 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>
|
||||||
|
|||||||
@@ -1,37 +1,94 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "flowbite-react/plugin/tailwindcss";
|
@plugin "flowbite-react/plugin/tailwindcss";
|
||||||
@source "../.flowbite-react/class-list.json";
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||||
|
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
--font-data: "IBM Plex Mono", monospace;
|
||||||
|
|
||||||
/* Breakpoints */
|
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
||||||
--breakpoint-3xl: 1920px;
|
--color-background: #F7F4EE;
|
||||||
|
--color-sidebar: #F2EEE6;
|
||||||
|
--color-card: #FDFBF7;
|
||||||
|
--color-fields: #F4F0E8;
|
||||||
|
--color-panel: #F1ECE3;
|
||||||
|
--color-dropzone: #F9F6EF;
|
||||||
|
--color-armed: #F6EFE6;
|
||||||
|
--color-live-card: #F0EBE1;
|
||||||
|
--color-player: #2B2620;
|
||||||
|
--color-player-stripe: #3A342C;
|
||||||
|
|
||||||
--color-primary: oklch(0.55 0.21 254);
|
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||||
--color-primary-pressed: oklch(0.45 0.21 254);
|
--color-text-primary: #2B2620;
|
||||||
--color-secondary: oklch(0.94 0.01 250);
|
--color-text-strong: #35302A;
|
||||||
--color-accent: oklch(0.68 0.20 288);
|
--color-text-body: #554D44;
|
||||||
--color-text: oklch(0.17 0.01 270);
|
--color-text-control: #4A423A;
|
||||||
--color-background: oklch(0.97 0.005 250);
|
--color-text-secondary: #6E6559;
|
||||||
--color-surface: oklch(1 0 0);
|
--color-text-table: #7A7166;
|
||||||
--color-border: oklch(0.91 0.008 250);
|
--color-muted: #9A9084;
|
||||||
|
--color-faint-mono: #A79C8E;
|
||||||
|
--color-on-dark: #FBF8F2;
|
||||||
|
--color-on-accent: #FDFBF7;
|
||||||
|
|
||||||
/* Easing */
|
/* ── Accents ─────────────────────────────────────────────────────────── */
|
||||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
--color-accent: #A85A38;
|
||||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
--color-accent-hover: #94492A;
|
||||||
|
--color-accent-light: #C87A52;
|
||||||
|
--color-accent-disabled: #C9AE9C;
|
||||||
|
--color-olive: #6E7A50;
|
||||||
|
--color-olive-hover: #5D6843;
|
||||||
|
--color-olive-light: #8A9668;
|
||||||
|
--color-live-red: #B5432E;
|
||||||
|
|
||||||
|
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||||
|
--color-hairline: #F0EBE2;
|
||||||
|
--color-hover: #E7E0D2;
|
||||||
|
--color-chip: #EAE3D7;
|
||||||
|
--color-chip-hover: #DAD1C0;
|
||||||
|
--color-ring-dashed: #E2DACB;
|
||||||
|
--color-inactive: #DED7CA;
|
||||||
|
--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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
#range-slider .range-slider__range {
|
@keyframes flashBadge {
|
||||||
background: var(--color-primary);
|
0% { opacity: 0; transform: scale(.75); }
|
||||||
|
25% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.4); }
|
||||||
}
|
}
|
||||||
|
|
||||||
#range-slider .range-slider__thumb {
|
|
||||||
background: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
@@ -42,4 +99,12 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
|
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
color: var(--color-text-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-heading {
|
||||||
|
font-family: "Newsreader", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #2B2620;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,16 @@
|
|||||||
// layout/MainLayout.jsx
|
// layout/MainLayout.jsx
|
||||||
import Sidebar from '../components/Sidebar'
|
import Sidebar from '../components/Sidebar'
|
||||||
import Topbar from '../components/Topbar'
|
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import { useState } from "react";
|
||||||
import type {User} from "../utils/types";
|
|
||||||
import { getUser, getStreamStatus } from "../utils/endpoints";
|
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled] = useState(false);
|
||||||
const [user, setUser] = useState<null | User>(null);
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
|
|
||||||
const fetchUser = async () => {
|
|
||||||
try {
|
|
||||||
const userData = await getUser();
|
|
||||||
setUser(userData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUser();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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-[240px_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
|
||||||
user={user}
|
|
||||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||||
<Topbar
|
<div className="row-span-2 overflow-y-auto">
|
||||||
className={"transition-all duration-300"}
|
|
||||||
sidebarToggled={sidebarToggled}
|
|
||||||
setSidebarToggled={setSidebarToggled}
|
|
||||||
user={user}
|
|
||||||
fetchUser={fetchUser}
|
|
||||||
isStreaming={isStreaming}/>
|
|
||||||
<div className="overflow-auto bg-background">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import './index.css'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import {AuthProvider} from "./utils/AuthContext.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Playbar from "./../components/video/Playbar";
|
import Playbar from "./../components/video/Playbar";
|
||||||
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
|
||||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
|
||||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||||
import { compress, getJob } from "../utils/endpoints"
|
import { compress } from "../utils/api/media"
|
||||||
|
import { getJob } from "../utils/api/jobs"
|
||||||
import type { VideoMetadata } from "../utils/types.ts";
|
import type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Card from "../components/Card.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
|
import PageHeader from "../components/PageHeader.tsx";
|
||||||
|
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 = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ const ClipEdit = () => {
|
|||||||
|
|
||||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||||
const [playbackValue, setPlaybackValue] = useState(0);
|
const [playbackValue, setPlaybackValue] = useState(0);
|
||||||
|
const [range, setRange] = useState<Range>([0, 5]);
|
||||||
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -34,6 +38,7 @@ const ClipEdit = () => {
|
|||||||
});
|
});
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [downloadable, setDownloadable] = useState(false);
|
const [downloadable, setDownloadable] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,10 +61,35 @@ 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) return;
|
if (!localFile || uploading) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
let jobId: string;
|
let jobId: string;
|
||||||
try {
|
try {
|
||||||
@@ -67,13 +97,14 @@ const ClipEdit = () => {
|
|||||||
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'}`);
|
||||||
|
setUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollProgress = async (jobId: string, intervalId: number) => {
|
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
||||||
getJob(jobId)
|
getJob(jobId)
|
||||||
.then((job) => {
|
.then((job) => {
|
||||||
setProgress(job.progress);
|
setProgress(job.progress);
|
||||||
@@ -81,14 +112,17 @@ const ClipEdit = () => {
|
|||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
setUploading(false);
|
||||||
} else if (job.isComplete) {
|
} else if (job.isComplete) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setDownloadable(true);
|
setDownloadable(true);
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
setError(`Failed to fetch progress: ${err.message}`);
|
setError(`Failed to fetch progress: ${err.message}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
setUploading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,59 +149,78 @@ const ClipEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||||
|
<PageHeader kicker="Upload" title="Trim and compress" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||||||
|
<div className="flex flex-col gap-[26px]">
|
||||||
|
{/* Player — the video and its transport read as one object. */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-black"}
|
className="w-full aspect-video bg-player object-contain"
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box className={"w-4/5 h-full m-auto"}>
|
|
||||||
<MetadataBox setMetadata={setOutputMetadata} />
|
|
||||||
<ConfigBox setMetadata={setOutputMetadata} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{metadata &&
|
{metadata &&
|
||||||
<Box className={"mt-4 p-5"}>
|
|
||||||
<Playbar
|
<Playbar
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
className={"w-full accent-primary"}
|
className="w-full px-5 pt-3.5"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{metadata &&
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-baseline justify-between gap-4 pb-4">
|
||||||
|
<p className={kickerClass}>Trim</p>
|
||||||
|
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
||||||
|
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
|
||||||
|
{formatTime(range[1] - range[0])} long
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrimTimeline
|
||||||
|
duration={metadata.duration}
|
||||||
|
range={range}
|
||||||
|
onRangeChange={handleRangeChange}
|
||||||
|
currentTime={playbackValue}
|
||||||
|
onSeek={handleSeek}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaybackSlider
|
<p className="pt-3 font-body text-[12px] text-muted">
|
||||||
videoRef={videoRef.current}
|
Scroll over the timeline to zoom, drag the outlined window above to pan.
|
||||||
videoMetadata={metadata}
|
</p>
|
||||||
sliderValue={playbackValue}
|
|
||||||
setSliderValue={setPlaybackValue}
|
|
||||||
className={"w-full accent-primary"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClipRangeSlider
|
|
||||||
videoRef={videoRef.current}
|
|
||||||
videoMetadata={metadata}
|
|
||||||
setSliderValue={setPlaybackValue}
|
|
||||||
setMetadata={setOutputMetadata}
|
|
||||||
className={"w-full mt-2 bg-primary"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={"text-red-600 text-center mt-2"}>
|
<p className="font-body text-[13px] text-live-red pt-3">{error}</p>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Card>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
|
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
||||||
|
<Card className="p-6 flex flex-col gap-4">
|
||||||
|
<p className={kickerClass}>Details</p>
|
||||||
|
<MetadataBox setMetadata={setOutputMetadata} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="bg-panel rounded-panel shadow-panel p-6">
|
||||||
<ExportWidget
|
<ExportWidget
|
||||||
dataSend={sendData}
|
dataSend={sendData}
|
||||||
handleDownload={handleDownload}
|
handleDownload={handleDownload}
|
||||||
downloadable={downloadable}
|
downloadable={downloadable}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
uploading={uploading}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +1,89 @@
|
|||||||
import {useState} from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
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 navigate = useNavigate();
|
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 choose = (selected: File | null) => {
|
||||||
|
setFile(selected);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const press = () => {
|
const press = () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setError("Please choose a file");
|
setError("Pick a file first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/create/new', { state: { file } });
|
navigate('/create/new', { state: { file } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-center p-8 min-h-full">
|
<div className="mx-auto max-w-180 px-13 pt-10 pb-18">
|
||||||
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
<PageHeader kicker="Upload" title="Bring in a file" />
|
||||||
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
|
||||||
|
|
||||||
<input
|
<label
|
||||||
type="file"
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
onChange={(e) => {
|
onDragLeave={() => setDragging(false)}
|
||||||
const selected = e.target.files?.[0] ?? null;
|
onDrop={(e) => {
|
||||||
setFile(selected);
|
e.preventDefault();
|
||||||
setError(null);
|
setDragging(false);
|
||||||
|
choose(e.dataTransfer.files?.[0] ?? null);
|
||||||
}}
|
}}
|
||||||
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
|
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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BlueButton onClick={press}>Upload</BlueButton>
|
{file ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
<div className="flex items-center gap-4 pt-6">
|
||||||
</Box>
|
<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,11 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { getClipById, patchClip } from "../utils/endpoints.ts";
|
import clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import Card from "../components/Card.tsx";
|
||||||
|
import Button from "../components/buttons/Button.tsx";
|
||||||
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
import PageHeader from "../components/PageHeader.tsx";
|
||||||
|
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
const EditClip = () => {
|
const EditClip = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -47,59 +48,51 @@ const EditClip = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||||
<button
|
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||||
onClick={() => navigate(-1)}
|
<ArrowLeft size={15} />
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
<PageHeader kicker="Clip" title="Edit details" />
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-gray-400 text-sm">Loading...</div>
|
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Card className="p-7 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
<label className={labelClass} htmlFor="clip-title">Title</label>
|
||||||
<input
|
<input
|
||||||
|
id="clip-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter title"
|
placeholder="Cat walks across the patch bay"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
<label className={labelClass} htmlFor="clip-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="clip-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter description"
|
placeholder="What's worth remembering about it"
|
||||||
rows={4}
|
rows={4}
|
||||||
className={inputClass + " resize-none"}
|
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-4">
|
||||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
</Button>
|
||||||
: "Save"}
|
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||||
</BlueButton>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
101
frontend/src/pages/EditVod.tsx
Normal file
101
frontend/src/pages/EditVod.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
||||||
|
import Card from "../components/Card.tsx";
|
||||||
|
import Button from "../components/buttons/Button.tsx";
|
||||||
|
import PageHeader from "../components/PageHeader.tsx";
|
||||||
|
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
|
const EditVod = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
getVodById(id)
|
||||||
|
.then((vod) => {
|
||||||
|
if (vod) {
|
||||||
|
setTitle(vod.title ?? "");
|
||||||
|
setDescription(vod.description ?? "");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load vod."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await patchVod(Number(id), { title, description });
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||||
|
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||||
|
<ArrowLeft size={15} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<PageHeader kicker="VoD" title="Edit details" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="font-data text-[13px] text-faint-mono">loading…</p>
|
||||||
|
) : (
|
||||||
|
<Card className="p-7 flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className={labelClass} htmlFor="vod-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="vod-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Tuesday night, patch bay walkthrough"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className={labelClass} htmlFor="vod-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="vod-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What's worth remembering about it"
|
||||||
|
rows={4}
|
||||||
|
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||||
|
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditVod;
|
||||||
@@ -1,78 +1,311 @@
|
|||||||
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
|
import {useAuth} from "../utils/AuthContext.tsx";
|
||||||
|
import Landing from "./Landing.tsx";
|
||||||
|
import {Link, useNavigate} from "react-router-dom";
|
||||||
|
import Card from "../components/Card.tsx";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import IsLivePulse from "../components/IsLivePulse.tsx";
|
||||||
|
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
|
||||||
|
import {getCurrentStream} from "../utils/api/stream.ts";
|
||||||
|
import VideoCard from "../components/video/VideoCard.tsx";
|
||||||
|
import {createMarker, getMarkers} from "../utils/api/markers.ts";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {getVods} from "../utils/api/vods.ts";
|
||||||
|
import {getClips} from "../utils/api/clips.ts";
|
||||||
|
import Button from "../components/buttons/Button.tsx";
|
||||||
|
|
||||||
const features = [
|
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
|
||||||
{
|
|
||||||
icon: Upload,
|
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
|
||||||
title: "Upload & compress",
|
const hour = date.getHours();
|
||||||
description: "Drop in a video file, set your clip range and export settings, then compress.",
|
|
||||||
},
|
if (hour >= 5 && hour < 12) return "morning";
|
||||||
{
|
if (hour >= 12 && hour < 17) return "afternoon";
|
||||||
icon: Scissors,
|
if (hour >= 17 && hour < 22) return "evening";
|
||||||
title: "Trim",
|
return "night";
|
||||||
description: "Set start and end points with a range slider. Preview as you go.",
|
}
|
||||||
},
|
|
||||||
{
|
function getGreetingSubtext(date: Date = new Date()): string {
|
||||||
icon: SlidersHorizontal,
|
const timeOfDay = getTimeOfDay(date);
|
||||||
title: "Export settings",
|
|
||||||
description: "Choose resolution, frame rate, and an optional file size cap.",
|
const messages: Record<TimeOfDay, string[]> = {
|
||||||
},
|
morning: [
|
||||||
{
|
"Good morning.",
|
||||||
icon: Radio,
|
"Coffee first, then clips.",
|
||||||
title: "RTMP streaming",
|
"Here's what's waiting for you.",
|
||||||
description: "Point OBS at your stream key and go live. Find the key in your profile.",
|
],
|
||||||
},
|
afternoon: [
|
||||||
{
|
"Good afternoon.",
|
||||||
icon: LayoutGrid,
|
"A quiet moment for your clips.",
|
||||||
title: "Clip library",
|
"Catching up?",
|
||||||
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
|
],
|
||||||
},
|
evening: [
|
||||||
{
|
"Good evening.",
|
||||||
icon: Play,
|
"Settle in.",
|
||||||
title: "Built-in player",
|
"Time to unwind.",
|
||||||
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
|
],
|
||||||
},
|
night: [
|
||||||
];
|
"Still up?",
|
||||||
|
"Quiet night for it.",
|
||||||
|
"Good night, when you're ready.",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = messages[timeOfDay];
|
||||||
|
return options[Math.floor(Math.random() * options.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeFromISOString(date: string | null): string {
|
||||||
|
if (date == null) {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(date);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionHeaderProps = { title: string; to: string; count: number };
|
||||||
|
|
||||||
|
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
|
||||||
|
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
|
||||||
|
<h2 className={"font-heading text-2xl"}>{title}</h2>
|
||||||
|
{count > 0 && (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
|
||||||
|
>
|
||||||
|
All {count} <span aria-hidden={true}>→</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const formatted: string = new Intl.DateTimeFormat('en-IE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
}).format(now);
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
|
||||||
|
const [ elapsed, setElapsed ] = useState('00:00:00');
|
||||||
|
const [ markers, setMarkers ] = useState<Marker[]>([])
|
||||||
|
const [ clips, setClips ] = useState<Clip[]>([]);
|
||||||
|
const [ vods, setVods ] = useState<Vod[]>([]);
|
||||||
|
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
|
||||||
|
const [ markerBusy, setMarkerBusy ] = useState(false);
|
||||||
|
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isStreaming = streamStatus?.isStreaming ?? false;
|
||||||
|
|
||||||
|
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Fetch Markers & Stream information
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentStream()
|
||||||
|
.then(data => {
|
||||||
|
setStreamStatus(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
getMarkers()
|
||||||
|
.then(data => {
|
||||||
|
setMarkers(data);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get Top 4 Clips
|
||||||
|
getClips()
|
||||||
|
.then(data => {
|
||||||
|
const topSix = [...data]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 4);
|
||||||
|
setClips(topSix);
|
||||||
|
setCounts(prev => ({ ...prev, clips: data.length }));
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get top 3 vods
|
||||||
|
getVods()
|
||||||
|
.then(data => {
|
||||||
|
const topSix = [...data]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
setVods(topSix);
|
||||||
|
setCounts(prev => ({ ...prev, vods: data.length }));
|
||||||
|
})
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleDropMarker = async () => {
|
||||||
|
if (markerBusy) return;
|
||||||
|
setMarkerBusy(true);
|
||||||
|
try {
|
||||||
|
await createMarker(`Marker at ${elapsed}`);
|
||||||
|
setMarkers(await getMarkers());
|
||||||
|
setMarkerNote(`dropped at ${elapsed}`);
|
||||||
|
} catch (err) {
|
||||||
|
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
|
||||||
|
} finally {
|
||||||
|
setMarkerBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamStatus?.startTime == null) {
|
||||||
|
setElapsed('00:00:00');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = new Date(streamStatus?.startTime).getTime();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const diffSec = Math.floor((Date.now() - startMs) / 1000);
|
||||||
|
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(diffSec % 60).padStart(2, '0');
|
||||||
|
setElapsed(`${hh}:${mm}:${ss}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const interval = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [streamStatus?.startTime]);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return (<Landing />)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center px-8 py-12 min-h-full">
|
<div className={"w-10/12 m-auto pt-10"}>
|
||||||
{/* Hero */}
|
<div className={"flex flex-row justify-between items-start pb-8"}>
|
||||||
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
|
<div>
|
||||||
<img
|
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
|
||||||
src="/logo.png"
|
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
|
||||||
alt="VoD System Logo"
|
</div>
|
||||||
className="h-24 mb-6 object-contain"
|
|
||||||
/>
|
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
|
||||||
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
|
Open Stream Panel
|
||||||
A home for your clips.
|
</Link>
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-base text-gray-500 leading-relaxed">
|
|
||||||
Upload, trim, and export video clips. Stream live if you need to.
|
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
|
||||||
|
<div className={"flex flex-row items-center gap-2"}>
|
||||||
|
{isStreaming
|
||||||
|
? <IsLivePulse />
|
||||||
|
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
|
||||||
|
<p className={clsx(
|
||||||
|
"text-xs font-data tracking-[0.12em]",
|
||||||
|
isStreaming ? "text-live-red" : "text-faint-mono"
|
||||||
|
)}>
|
||||||
|
{isStreaming ? "LIVE NOW" : "OFF AIR"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature grid */}
|
<div className={"p-1"}></div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
|
||||||
{features.map(({ icon: Icon, title, description }) => (
|
{isStreaming ? (
|
||||||
<div
|
<>
|
||||||
key={title}
|
<h2 className={"font-heading text-3xl"}>
|
||||||
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
|
||||||
>
|
recording since {getTimeFromISOString(streamStatus?.startTime ?? null)}.</h2>
|
||||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Icon size={18} className="text-primary" />
|
<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>
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
|
<div className={"flex flex-col"}>
|
||||||
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
<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"}>
|
||||||
|
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
|
||||||
|
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Format note */}
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Supports MP4, MOV, WebM, and OGG.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
108
frontend/src/pages/Landing.tsx
Normal file
108
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Card from "../components/Card.tsx";
|
||||||
|
import GoogleSignIn from "../components/GoogleSignIn.tsx";
|
||||||
|
import { kickerClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Point OBS at it",
|
||||||
|
body: "One ingest URL and one stream key. Everything you send lands on disk as it arrives — no upload step, no waiting at the end.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Mark it as it happens",
|
||||||
|
body: "Drop a marker the moment something is worth keeping. Each one remembers its timecode so you don't have to scrub for it later.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Cut it up afterwards",
|
||||||
|
body: "Tick two markers to pull the stretch between them out as a VoD, or take the last minute as a clip. Cutting runs in the background while you keep streaming.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Stand-in for the marker list on the stream panel — the shape of the thing, in miniature. */
|
||||||
|
const previewMarkers = [
|
||||||
|
{ at: "00:10:12", message: "Patch bay walkthrough begins", selected: false },
|
||||||
|
{ at: "00:49:56", message: "Filter finally tracks", selected: true },
|
||||||
|
{ at: "00:50:37", message: "…and it stops tracking", selected: true },
|
||||||
|
{ at: "01:05:01", message: "Cat on the desk", selected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Landing = () => (
|
||||||
|
<div className="mx-auto max-w-[1180px] px-13 pt-16 pb-18">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] gap-12 items-start pb-16">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className={kickerClass}>Personal stream archive</p>
|
||||||
|
|
||||||
|
<h1 className="font-heading text-5xl tracking-[-0.015em] leading-[1.1] max-w-[16ch]">
|
||||||
|
Keep the good bits of everything you stream.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="font-body text-[16px] leading-[1.65] text-text-body max-w-[58ch] text-pretty">
|
||||||
|
The VoD System sits between your encoder and your hard drive. It records
|
||||||
|
every session in full, lets you mark the moments worth keeping while
|
||||||
|
they're happening, and turns those marks into VoDs and clips afterwards.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
<GoogleSignIn shape="rectangular" size="large" width="260" />
|
||||||
|
<p className="font-body text-[12.5px] text-muted">
|
||||||
|
Sign in with Google to reach your library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A quiet look at what's behind the login. */}
|
||||||
|
<Card className="p-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className={kickerClass}>Live markers</p>
|
||||||
|
<p className="font-data text-[12px] text-faint-mono">t = 01:33:35</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{previewMarkers.map((marker) => (
|
||||||
|
<div
|
||||||
|
key={marker.at}
|
||||||
|
className={`flex items-center gap-3 rounded-tile px-3 py-2.5 ${
|
||||||
|
marker.selected ? "bg-armed inset-shadow-armed" : "bg-fields"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-[14px] w-[14px] flex-shrink-0 rounded-[4px] ${
|
||||||
|
marker.selected ? "bg-accent" : "bg-inactive"
|
||||||
|
}`} />
|
||||||
|
<span className="font-data text-[12px] text-accent flex-shrink-0">{marker.at}</span>
|
||||||
|
<span className="font-body text-[12.5px] text-text-strong truncate">{marker.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-body text-[12.5px] text-muted pt-1">
|
||||||
|
Two marks selected — that's a 41-second VoD, one click away.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-[22px]">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<Card key={step.number} className="p-6 flex flex-col gap-2.5">
|
||||||
|
<p className="font-data text-[13px] text-accent">{step.number}</p>
|
||||||
|
<h2 className="font-heading text-[22px] leading-snug">{step.title}</h2>
|
||||||
|
<p className="font-body text-[14px] leading-[1.65] text-text-body text-pretty">
|
||||||
|
{step.body}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-panel rounded-panel p-6 mt-[26px] flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
|
||||||
|
<p className="font-body text-[14px] text-text-body">
|
||||||
|
Built for one person and their own machine. No tiers, no transcoding queue you share with strangers.
|
||||||
|
</p>
|
||||||
|
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">
|
||||||
|
rtmp in · mp4 out
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Landing;
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import type { Clip } from "../utils/types";
|
import clsx from "clsx";
|
||||||
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
import { AuthError } from "../utils/api/client.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Card from "../components/Card.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
import { kickerClass, quietLinkClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
const VideoPlayer = () => {
|
// Both VoDs and clips share the same on-screen shape.
|
||||||
|
type MediaItem = {
|
||||||
|
title: string,
|
||||||
|
createdAt: string,
|
||||||
|
width?: number | null,
|
||||||
|
height?: number | null,
|
||||||
|
fps?: number | null,
|
||||||
|
/** Size on disk, in bytes. */
|
||||||
|
fileSize?: number | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaPlayerProps = {
|
||||||
|
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
|
||||||
|
noun: string,
|
||||||
|
/** Builds the raw media stream URL for the given id. Loaded directly by the
|
||||||
|
* <video> element so the browser can issue HTTP Range requests. */
|
||||||
|
getMediaUrl: (id: string) => string,
|
||||||
|
/** Fetches the media metadata for the given id. */
|
||||||
|
fetchDetails: (id: string) => Promise<MediaItem | null>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -16,10 +38,11 @@ const VideoPlayer = () => {
|
|||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
const mediaUrl = id ? getMediaUrl(id) : undefined;
|
||||||
|
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
const [clip, setClip] = useState<Clip | null>(null);
|
const [item, setItem] = useState<MediaItem | null>(null);
|
||||||
const [timeAgo, setTimeAgo] = useState("");
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
@@ -37,30 +60,34 @@ const VideoPlayer = () => {
|
|||||||
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 ────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) { setError("Clip ID is required."); return; }
|
if (!id) { setError(`${noun} ID is required.`); return; }
|
||||||
|
|
||||||
getVideoBlob(id)
|
fetchDetails(id)
|
||||||
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
.then(setItem)
|
||||||
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
|
||||||
|
|
||||||
getClipById(id)
|
|
||||||
.then(setClip)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
else setError("Failed to load clip details.");
|
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
||||||
});
|
});
|
||||||
}, [id]);
|
|
||||||
|
}, [id, noun, fetchDetails]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clip?.createdAt) return;
|
if (!item?.createdAt) return;
|
||||||
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
|
||||||
update();
|
update();
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [clip]);
|
}, [item]);
|
||||||
|
|
||||||
// ── Controls visibility ─────────────────────────────────────────────────
|
// ── Controls visibility ─────────────────────────────────────────────────
|
||||||
const scheduleHide = useCallback(() => {
|
const scheduleHide = useCallback(() => {
|
||||||
@@ -73,7 +100,6 @@ const VideoPlayer = () => {
|
|||||||
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);
|
||||||
@@ -103,11 +129,13 @@ const VideoPlayer = () => {
|
|||||||
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':
|
||||||
@@ -121,7 +149,15 @@ const VideoPlayer = () => {
|
|||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
}, [showControls, toggleFullscreen]);
|
}, [showControls, showFlash, toggleFullscreen]);
|
||||||
|
|
||||||
|
// Each flash clears itself; keying the node on `id` restarts the animation
|
||||||
|
// when the same action fires twice in a row.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flash) return;
|
||||||
|
const timer = setTimeout(() => setFlash(null), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [flash]);
|
||||||
|
|
||||||
// ── Progress bar seeking ────────────────────────────────────────────────
|
// ── Progress bar seeking ────────────────────────────────────────────────
|
||||||
const seekTo = useCallback((clientX: number) => {
|
const seekTo = useCallback((clientX: number) => {
|
||||||
@@ -175,29 +211,40 @@ const VideoPlayer = () => {
|
|||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
showFlash(video.paused ? 'play' : 'pause');
|
||||||
video.paused ? video.play() : video.pause();
|
video.paused ? video.play() : video.pause();
|
||||||
showControls();
|
showControls();
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
|
||||||
const effectiveVolume = isMuted ? 0 : volume;
|
const effectiveVolume = isMuted ? 0 : volume;
|
||||||
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
||||||
|
|
||||||
|
// Facts worth showing under the player, skipping anything the API didn't send.
|
||||||
|
const facts = [
|
||||||
|
item?.width && item?.height ? `${item.width}×${item.height}` : null,
|
||||||
|
item?.fps ? `${Math.round(item.fps)} fps` : null,
|
||||||
|
duration > 0 ? formatTime(duration) : null,
|
||||||
|
item?.fileSize ? formatFileSize(item.fileSize) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const flashIcons = { play: Play, pause: Pause, back: Rewind, forward: FastForward } as const;
|
||||||
|
const FlashIcon = flash ? flashIcons[flash.kind] : null;
|
||||||
|
|
||||||
if (notAuthenticated) {
|
if (notAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
<div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
|
||||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
||||||
<Lock size={24} className="text-gray-400" />
|
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
|
||||||
</div>
|
<p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
|
||||||
<div>
|
You don't have access to this {noun.toLowerCase()}.
|
||||||
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
className={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={15} />
|
||||||
Go back
|
Go back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,19 +252,19 @@ const VideoPlayer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-4 gap-3">
|
<div className="h-full flex flex-col px-13 py-8 gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
className={clsx(quietLinkClass, "flex items-center gap-1.5 shrink-0")}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={15} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
|
||||||
onMouseMove={showControls}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
@@ -226,7 +273,10 @@ const VideoPlayer = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
src={videoUrl}
|
src={mediaUrl}
|
||||||
|
// Send auth cookies with the cross-origin Range requests.
|
||||||
|
crossOrigin="use-credentials"
|
||||||
|
preload="metadata"
|
||||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -234,23 +284,49 @@ const VideoPlayer = () => {
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
onLoadedMetadata={() => {
|
||||||
|
if (videoRef.current) setDuration(videoRef.current.duration);
|
||||||
|
setMediaLoaded(true);
|
||||||
|
}}
|
||||||
onWaiting={() => setIsWaiting(true)}
|
onWaiting={() => setIsWaiting(true)}
|
||||||
onCanPlay={() => setIsWaiting(false)}
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Buffering spinner */}
|
{/* Buffering spinner */}
|
||||||
{isWaiting && videoUrl && (
|
{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-white/20 border-t-white animate-spin" />
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls overlay — pointer-events-none so video clicks pass through */}
|
{/* Controls overlay — pointer-events-none so video clicks pass through */}
|
||||||
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
{/* Gradient */}
|
{/* Gradients — top one carries the title, bottom one the controls */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-black/55 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/10 to-transparent" />
|
||||||
|
|
||||||
|
{/* Title plate */}
|
||||||
|
<div className={clsx(
|
||||||
|
"absolute inset-x-0 top-0 px-5 pt-4 flex flex-col gap-0.5 transition-transform duration-300",
|
||||||
|
controlsVisible ? "translate-y-0" : "-translate-y-2"
|
||||||
|
)}>
|
||||||
|
<p className="font-data text-[10.5px] uppercase tracking-[0.14em] text-on-dark/55">
|
||||||
|
{noun}
|
||||||
|
</p>
|
||||||
|
<p className="font-heading text-[22px] leading-tight text-on-dark truncate">
|
||||||
|
{item?.title || "(No title)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Controls bar — pointer-events-auto only here */}
|
{/* Controls bar — pointer-events-auto only here */}
|
||||||
<div
|
<div
|
||||||
@@ -262,13 +338,35 @@ const VideoPlayer = () => {
|
|||||||
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-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
setHoverTime(ratio * duration);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoverTime(null)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||||
<div className="absolute inset-y-0 left-0 bg-primary 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-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Time under the cursor */}
|
||||||
|
{hoverTime !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 -translate-x-1/2 rounded-md bg-player/85 px-1.5 py-0.5 font-data text-[11px] text-on-dark tabular-nums pointer-events-none"
|
||||||
|
style={{ left: `${hoverPct}%` }}
|
||||||
|
>
|
||||||
|
{formatTime(hoverTime)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons row */}
|
{/* Buttons row */}
|
||||||
@@ -295,7 +393,7 @@ const VideoPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs font-mono tabular-nums text-white/80">
|
<span className="font-data text-xs tabular-nums text-on-dark/80">
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -309,20 +407,28 @@ const VideoPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
|
||||||
{!videoUrl && !error && (
|
{!mediaLoaded && !error && (
|
||||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
<p className="font-data text-[12px] text-faint-mono">loading video…</p>
|
||||||
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
|
||||||
Loading video...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box className="p-4 flex-shrink-0">
|
<Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
||||||
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
<p className={kickerClass}>{noun}</p>
|
||||||
</Box>
|
<p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
|
||||||
|
{item?.title || "(No title)"}
|
||||||
|
</p>
|
||||||
|
<p className="font-body text-[13px] text-muted pt-1">{timeAgo}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{facts.length > 0 && (
|
||||||
|
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
||||||
|
{facts.join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VideoPlayer;
|
export default MediaPlayer;
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
import VideoCard from "../components/video/VideoCard";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import NoAccessPage from "../components/NoAccessPage";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { getClips, deleteClip } from "../utils/endpoints";
|
import { getClips, deleteClip } from "../utils/api/clips";
|
||||||
|
import { useAuth } from "../utils/AuthContext";
|
||||||
import type { Clip } from "../utils/types";
|
import type { Clip } from "../utils/types";
|
||||||
|
|
||||||
const MyClips = () => {
|
const MyClips = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
const [clips, setClips] = useState<Clip[]>([]);
|
const [clips, setClips] = useState<Clip[]>([]);
|
||||||
const [error, setError] = useState<null | string>(null);
|
const [error, setError] = useState<null | string>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
getClips()
|
getClips()
|
||||||
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||||
.catch((err) => setError(err));
|
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load clips"));
|
||||||
}, []);
|
}, [user]);
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -24,11 +29,16 @@ const MyClips = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
if (loading) return null;
|
||||||
<div className="p-6">
|
if (!user) return <NoAccessPage />;
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
return (
|
||||||
|
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||||
|
<PageHeader kicker="Library" title="Clips">
|
||||||
|
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[18px]">
|
||||||
{clips.map((clip) => (
|
{clips.map((clip) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
@@ -36,6 +46,10 @@ const MyClips = () => {
|
|||||||
title={clip.title}
|
title={clip.title}
|
||||||
duration={clip.duration}
|
duration={clip.duration}
|
||||||
createdAt={clip.createdAt}
|
createdAt={clip.createdAt}
|
||||||
|
width={clip.width}
|
||||||
|
height={clip.height}
|
||||||
|
fps={clip.fps}
|
||||||
|
fileSize={clip.fileSize}
|
||||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||||
onDelete={() => handleDelete(clip.id)}
|
onDelete={() => handleDelete(clip.id)}
|
||||||
/>
|
/>
|
||||||
@@ -43,13 +57,17 @@ const MyClips = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 && !error && (
|
{clips.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||||
<p className="text-base">No clips yet.</p>
|
<h2 className="font-heading text-[25px]">No clips yet.</h2>
|
||||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
||||||
|
Cut one from the stream panel, or{" "}
|
||||||
|
<Link to="/create" className="text-accent hover:text-accent-hover">upload a file</Link>{" "}
|
||||||
|
to trim it down here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/pages/MyVods.tsx
Normal file
76
frontend/src/pages/MyVods.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import VideoCard from "../components/video/VideoCard";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import NoAccessPage from "../components/NoAccessPage";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getVods, deleteVod } from "../utils/api/vods";
|
||||||
|
import { useAuth } from "../utils/AuthContext";
|
||||||
|
import type { Vod } from "../utils/types";
|
||||||
|
|
||||||
|
const MyVods = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [vods, setVods] = useState<Vod[]>([]);
|
||||||
|
const [error, setError] = useState<null | string>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
getVods()
|
||||||
|
.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"));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteVod(id);
|
||||||
|
setVods(prev => prev.filter(v => v.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete vod");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
if (!user) return <NoAccessPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||||
|
<PageHeader kicker="Library" title="VoDs">
|
||||||
|
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]">
|
||||||
|
{vods.map((vod) => (
|
||||||
|
<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="/vod"
|
||||||
|
itemLabel="vod"
|
||||||
|
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
|
||||||
|
onDelete={() => handleDelete(vod.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vods.length === 0 && !error && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||||
|
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
|
||||||
|
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
|
||||||
|
Ranges you save from the stream panel land here, full length and untouched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyVods;
|
||||||
@@ -1,84 +1,68 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import Card from "../components/Card.tsx";
|
||||||
import Box from "../components/Box.tsx";
|
import PageHeader from "../components/PageHeader.tsx";
|
||||||
import { getUser } from "../utils/endpoints.ts";
|
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||||||
import type { User } from "../utils/types.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, setUser] = useState<User | null>(null);
|
const { user, loading } = useAuth();
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getUser().then(setUser).catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!user?.streamKey) return;
|
if (!user?.streamKey) return;
|
||||||
navigator.clipboard.writeText(user.streamKey);
|
navigator.clipboard.writeText(user.streamKey);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 1600);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (loading) return null;
|
||||||
return (
|
if (!user) return <NoAccessPage />;
|
||||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
|
||||||
Please log in to view your profile.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
<PageHeader kicker="Account" title="Profile" />
|
||||||
|
|
||||||
<Box className="p-6 mb-4 flex items-center gap-5">
|
<Card className="p-7 mb-[26px] flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
src={user.profilePictureUrl}
|
src={user.profilePictureUrl}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
alt="Profile picture"
|
alt=""
|
||||||
className="w-20 h-20 rounded-full shadow-sm"
|
className="w-[72px] h-[72px] rounded-full shadow-card"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
<p className="font-heading text-[25px] leading-tight">{user.name}</p>
|
||||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
<p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="font-data text-[11px] text-faint-mono pt-1">
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
member since {formatLocalDate(user.createdAt)}
|
||||||
Member since {formatLocalDate(user.createdAt)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Card>
|
||||||
|
|
||||||
<Box className="p-6">
|
<Card className="p-7">
|
||||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
<h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
|
||||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
|
||||||
Keep it private — anyone with this key can stream to your account.
|
Keep it private: anyone holding it can stream to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<p className={labelClass}>Key</p>
|
||||||
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
<div className="flex items-center gap-2.5 pt-2">
|
||||||
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
|
||||||
|
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
|
||||||
onClick={() => setKeyVisible(v => !v)}
|
{keyVisible ? "Hide" : "Reveal"}
|
||||||
title={keyVisible ? "Hide key" : "Show key"}
|
|
||||||
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
|
||||||
>
|
|
||||||
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button className={chipButtonClass} onClick={handleCopy}>
|
||||||
onClick={handleCopy}
|
{copied ? "Copied" : "Copy"}
|
||||||
title="Copy key"
|
|
||||||
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
|
||||||
>
|
|
||||||
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
599
frontend/src/pages/StreamControl.tsx
Normal file
599
frontend/src/pages/StreamControl.tsx
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import Card from "../components/Card.tsx";
|
||||||
|
import Button from "../components/buttons/Button.tsx";
|
||||||
|
import PageHeader from "../components/PageHeader.tsx";
|
||||||
|
import IsLivePulse from "../components/IsLivePulse.tsx";
|
||||||
|
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||||||
|
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
|
||||||
|
|
||||||
|
import { useAuth } from "../utils/AuthContext.tsx";
|
||||||
|
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
|
||||||
|
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
||||||
|
import { saveSection, clipSection } from "../utils/api/media.ts";
|
||||||
|
import { getJob } from "../utils/api/jobs.ts";
|
||||||
|
import type { Marker, StreamStatus } from "../utils/types.ts";
|
||||||
|
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
|
||||||
|
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
|
||||||
|
|
||||||
|
/** Ten-minute gridlines behind the marker ticks. */
|
||||||
|
const GRID_INTERVAL_SECONDS = 600;
|
||||||
|
|
||||||
|
const useCopy = () => {
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const copy = (key: string, text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { copied, copy };
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamControl = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const { copied, copy } = useCopy();
|
||||||
|
|
||||||
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
|
||||||
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
|
const [markerMessage, setMarkerMessage] = useState("");
|
||||||
|
const [markerBusy, setMarkerBusy] = useState(false);
|
||||||
|
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 [clipName, setClipName] = useState("");
|
||||||
|
const [clipError, setClipError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
|
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<TrackedJob[]>([]);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const refreshMarkers = () => {
|
||||||
|
getMarkers()
|
||||||
|
.then((data) => setMarkers([...data]
|
||||||
|
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming) {
|
||||||
|
setMarkers([]);
|
||||||
|
setSelected([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshMarkers();
|
||||||
|
const interval = setInterval(refreshMarkers, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [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(() => {
|
||||||
|
if (!pendingKey) return;
|
||||||
|
const uuids = pendingKey.split(",");
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
uuids.forEach((uuid) => {
|
||||||
|
getJob(uuid)
|
||||||
|
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
|
||||||
|
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||||||
|
: job)))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pendingKey]);
|
||||||
|
|
||||||
|
// ── Marker helpers ──────────────────────────────────────────────────────
|
||||||
|
const offsetOf = (marker: Marker) =>
|
||||||
|
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
|
||||||
|
|
||||||
|
|
||||||
|
const toggleMarker = (id: number) => {
|
||||||
|
const next = selected.includes(id)
|
||||||
|
? selected.filter((current) => current !== id)
|
||||||
|
: selected.length < 2 ? [...selected, id] : [selected[1], id];
|
||||||
|
|
||||||
|
setSelected(next);
|
||||||
|
setRangeError(null);
|
||||||
|
|
||||||
|
if (next.length !== 2 || startMs === null) return;
|
||||||
|
|
||||||
|
const offsets = next
|
||||||
|
.map((markerId) => markers.find((marker) => marker.id === markerId))
|
||||||
|
.filter((marker): marker is Marker => marker !== undefined)
|
||||||
|
.map(offsetOf)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (offsets.length !== 2) return;
|
||||||
|
setInValue(formatTimecode(offsets[0]));
|
||||||
|
setOutValue(formatTimecode(offsets[1]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 'M' anywhere on the page jumps to the marker field.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "m" && event.key !== "M") return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||||
|
event.preventDefault();
|
||||||
|
markerInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Actions ─────────────────────────────────────────────────────────────
|
||||||
|
const addJob = (uuid: string, label: string) =>
|
||||||
|
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||||
|
|
||||||
|
const handleAddMarker = async () => {
|
||||||
|
if (markerBusy) return;
|
||||||
|
setMarkerBusy(true);
|
||||||
|
setMarkerError(null);
|
||||||
|
try {
|
||||||
|
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
|
||||||
|
setMarkerMessage("");
|
||||||
|
refreshMarkers();
|
||||||
|
} catch (err) {
|
||||||
|
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
|
||||||
|
} finally {
|
||||||
|
setMarkerBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMarker = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteMarker(id);
|
||||||
|
setSelected((prev) => prev.filter((current) => current !== id));
|
||||||
|
refreshMarkers();
|
||||||
|
} catch (err) {
|
||||||
|
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inSeconds = parseTimecode(inValue);
|
||||||
|
const outSeconds = parseTimecode(outValue);
|
||||||
|
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
|
||||||
|
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
|
||||||
|
? formatTime(outSeconds - inSeconds)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSaveRange = async () => {
|
||||||
|
setRangeError(null);
|
||||||
|
|
||||||
|
if (startMs === null) {
|
||||||
|
setRangeError("No stream is running to take a range from.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inSeconds === null || outSeconds === null) {
|
||||||
|
setRangeError("Timecodes read as hh:mm:ss.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outSeconds <= inSeconds) {
|
||||||
|
setRangeError("The out point has to come after the in point.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outSeconds > elapsedSeconds) {
|
||||||
|
setRangeError("That out point hasn't been streamed yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRangeBusy(true);
|
||||||
|
try {
|
||||||
|
const uuid = await saveSection(
|
||||||
|
new Date(startMs + inSeconds * 1000).toISOString(),
|
||||||
|
new Date(startMs + outSeconds * 1000).toISOString(),
|
||||||
|
rangeName || undefined
|
||||||
|
);
|
||||||
|
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
|
||||||
|
clearSelection();
|
||||||
|
} catch (err) {
|
||||||
|
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
|
||||||
|
} finally {
|
||||||
|
setRangeBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelected([]);
|
||||||
|
setInValue("");
|
||||||
|
setOutValue("");
|
||||||
|
setRangeName("");
|
||||||
|
setRangeError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClip = async () => {
|
||||||
|
setClipError(null);
|
||||||
|
try {
|
||||||
|
const uuid = await clipSection(clipDuration, clipName || undefined);
|
||||||
|
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
|
||||||
|
setClipName("");
|
||||||
|
} catch (err) {
|
||||||
|
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Timeline ────────────────────────────────────────────────────────────
|
||||||
|
const gridlines = useMemo(() => {
|
||||||
|
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
|
||||||
|
return Array.from(
|
||||||
|
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
|
||||||
|
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
|
||||||
|
);
|
||||||
|
}, [elapsedSeconds]);
|
||||||
|
|
||||||
|
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
if (!user) return <NoAccessPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||||
|
<PageHeader kicker="Ingest" title="Stream panel" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||||||
|
{/* ── Main column ───────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col gap-[26px]">
|
||||||
|
<Card className="p-7">
|
||||||
|
<div className="flex items-baseline justify-between gap-4 pb-5">
|
||||||
|
<h2 className="font-heading text-[22px]">Server credentials</h2>
|
||||||
|
{isStreaming ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IsLivePulse />
|
||||||
|
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={labelClass}>Ingest URL</p>
|
||||||
|
<div className="flex items-center gap-2.5 pt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={ingestUrl ?? ""}
|
||||||
|
placeholder="asking the server…"
|
||||||
|
className={clsx(monoInputClass, "flex-1")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
|
||||||
|
disabled={!ingestUrl}
|
||||||
|
onClick={() => ingestUrl && copy("url", ingestUrl)}
|
||||||
|
>
|
||||||
|
{copied === "url" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
|
||||||
|
<div className="flex items-center gap-2.5 pt-2">
|
||||||
|
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
|
||||||
|
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
|
||||||
|
</div>
|
||||||
|
<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 (
|
||||||
|
<span
|
||||||
|
key={marker.id}
|
||||||
|
title={marker.message}
|
||||||
|
className={clsx(
|
||||||
|
"absolute inset-y-0 w-[2px]",
|
||||||
|
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
|
||||||
|
)}
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
|
||||||
|
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marker list */}
|
||||||
|
<div className="flex flex-col gap-2 pt-5">
|
||||||
|
{markers.length === 0 && (
|
||||||
|
<p className="font-body text-[13px] text-muted py-2">
|
||||||
|
No markers yet. Drop one the moment something happens — you can cut around it later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{markers.map((marker) => {
|
||||||
|
const isSelected = selected.includes(marker.id);
|
||||||
|
const offset = offsetOf(marker);
|
||||||
|
return (
|
||||||
|
<div key={marker.id} className="group flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMarker(marker.id)}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
|
||||||
|
isSelected
|
||||||
|
? "bg-armed inset-shadow-armed"
|
||||||
|
: "bg-card inset-shadow-hairline hover:bg-fields"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx(
|
||||||
|
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
|
||||||
|
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
|
||||||
|
)}>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
|
||||||
|
{formatTimecode(offset)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex-1 font-body text-sm text-text-strong truncate">
|
||||||
|
{marker.message}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
|
||||||
|
{toRelativeOffset(elapsedSeconds - offset)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteMarker(marker.id)}
|
||||||
|
title="Remove 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"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<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 ────────────────────────────────────────── */}
|
||||||
|
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
||||||
|
{isStreaming && (
|
||||||
|
<>
|
||||||
|
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-heading text-[22px]">Make something from a range</h2>
|
||||||
|
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
||||||
|
{selected.length === 2 && rangeLength
|
||||||
|
? `Two markers selected — ${inValue} to ${outValue}.`
|
||||||
|
: "Tick two markers, or type a timecode by hand."}
|
||||||
|
</p>
|
||||||
|
</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 className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
|
||||||
|
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
||||||
|
Takes the last stretch of stream, counted back from this moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
|
||||||
|
<input
|
||||||
|
id="clip-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={180}
|
||||||
|
value={clipDuration}
|
||||||
|
onChange={(event) => setClipDuration(Number(event.target.value))}
|
||||||
|
className={clsx(monoInputClass, "bg-card")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className={labelClass} htmlFor="clip-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="clip-name"
|
||||||
|
value={clipName}
|
||||||
|
onChange={(event) => setClipName(event.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
className={clsx(inputClass, "bg-card")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="olive"
|
||||||
|
onClick={handleClip}
|
||||||
|
disabled={clipDuration < 1 || clipDuration > 180}
|
||||||
|
className="w-full py-3"
|
||||||
|
>
|
||||||
|
Cut the last {clipDuration}s as a clip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className={kickerClass}>Background jobs</p>
|
||||||
|
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
|
||||||
|
</div>
|
||||||
|
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
|
||||||
|
<p className={kickerClass}>How it works</p>
|
||||||
|
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
|
||||||
|
Tick two markers to fill the range automatically, or type a timecode by hand.
|
||||||
|
Saving queues a background job — you can keep streaming while it runs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StreamControl;
|
||||||
42
frontend/src/utils/AuthContext.tsx
Normal file
42
frontend/src/utils/AuthContext.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
|
||||||
|
import { getUser } from "./api/users.ts";
|
||||||
|
import type {User} from "./types.ts";
|
||||||
|
|
||||||
|
// for what the Auth object will look like
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null;
|
||||||
|
setUser: React.Dispatch<React.SetStateAction<User | null>>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({children}: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, setUser, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
12
frontend/src/utils/api/client.ts
Normal file
12
frontend/src/utils/api/client.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
class AuthError extends Error {
|
||||||
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||||
|
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||||
|
return response.ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { API_URL, AuthError, isThumbnailAvailable };
|
||||||
61
frontend/src/utils/api/clips.ts
Normal file
61
frontend/src/utils/api/clips.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { APIResponse, Clip } from "../types.ts";
|
||||||
|
import { API_URL, AuthError } from "./client.ts";
|
||||||
|
|
||||||
|
const getClips = async (): Promise<Clip[]> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL for the raw media stream. The <video> element loads this directly so the
|
||||||
|
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||||
|
// of downloading the whole file up front.
|
||||||
|
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
||||||
|
|
||||||
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClip = async (id: number): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
||||||
15
frontend/src/utils/api/jobs.ts
Normal file
15
frontend/src/utils/api/jobs.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { APIResponse, JobResponse } from "../types.ts";
|
||||||
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
|
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getJob };
|
||||||
40
frontend/src/utils/api/markers.ts
Normal file
40
frontend/src/utils/api/markers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { APIResponse, Marker } from "../types.ts";
|
||||||
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
|
const getMarkers = async (): Promise<Marker[]> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarker = async (message: string): Promise<Marker> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/markers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMarker = async (id: number): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getMarkers, createMarker, deleteMarker };
|
||||||
72
frontend/src/utils/api/media.ts
Normal file
72
frontend/src/utils/api/media.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { APIResponse, VideoMetadata } from "../types.ts";
|
||||||
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
|
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('title', options.title);
|
||||||
|
if (options.description) formData.append('description', options.description);
|
||||||
|
formData.append('startPoint', options.startPoint.toString());
|
||||||
|
formData.append('duration', options.duration.toString());
|
||||||
|
if (options.fps) formData.append('fps', options.fps.toString());
|
||||||
|
if (options.width) formData.append('width', options.width.toString());
|
||||||
|
if (options.height) formData.append('height', options.height.toString());
|
||||||
|
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/media/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ startTime, endTime, title, description }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/media/save/markers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/media/clip', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ duration, title, description }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { compress, saveSection, saveSectionByMarkers, clipSection };
|
||||||
43
frontend/src/utils/api/stream.ts
Normal file
43
frontend/src/utils/api/stream.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
||||||
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
|
const getStreamStatus = async (): Promise<boolean> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.status === 'success' && result.data?.isStreaming === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentStream = async (): Promise<StreamStatus> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
||||||
|
|
||||||
|
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 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}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };
|
||||||
42
frontend/src/utils/api/users.ts
Normal file
42
frontend/src/utils/api/users.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIResponse, User } from "../types.ts";
|
||||||
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
|
const login = async (googleToken: string): Promise<string> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: googleToken }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUser = async (): Promise<User | null> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') return null;
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { login, logout, getUser };
|
||||||
61
frontend/src/utils/api/vods.ts
Normal file
61
frontend/src/utils/api/vods.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { APIResponse, Vod } from "../types.ts";
|
||||||
|
import { API_URL, AuthError } from "./client.ts";
|
||||||
|
|
||||||
|
const getVods = async (): Promise<Vod[]> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch vod: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL for the raw media stream. The <video> element loads this directly so the
|
||||||
|
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||||
|
// of downloading the whole file up front.
|
||||||
|
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
||||||
|
|
||||||
|
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVod = async (id: number): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
export class AuthError extends Error {
|
|
||||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (googleToken: string): Promise<string> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: googleToken }),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.token;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = async (): Promise<void> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('title', options.title);
|
|
||||||
if (options.description) formData.append('description', options.description);
|
|
||||||
formData.append('startPoint', options.startPoint.toString());
|
|
||||||
formData.append('duration', options.duration.toString());
|
|
||||||
if (options.fps) formData.append('fps', options.fps.toString());
|
|
||||||
if (options.width) formData.append('width', options.width.toString());
|
|
||||||
if (options.height) formData.append('height', options.height.toString());
|
|
||||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
|
||||||
|
|
||||||
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJob = async (uuid: string): Promise<JobResponse> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUser = async (): Promise<User | null> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') return null;
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getClips = async (): Promise<Clip[]> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorResult: APIResponse = await response.json();
|
|
||||||
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
|
||||||
throw new Error(`Failed to fetch clip: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
|
||||||
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.blob();
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteClip = async (id: number): Promise<void> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStreamStatus = async (): Promise<boolean> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
|
||||||
if (!response.ok) return false;
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
return result.status === 'success' && result.data?.isStreaming === true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
|
||||||
return response.ok;
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
compress,
|
|
||||||
getJob,
|
|
||||||
getUser,
|
|
||||||
getClips,
|
|
||||||
getClipById,
|
|
||||||
getVideoBlob,
|
|
||||||
patchClip,
|
|
||||||
deleteClip,
|
|
||||||
getStreamStatus,
|
|
||||||
isThumbnailAvailable,
|
|
||||||
};
|
|
||||||
27
frontend/src/utils/styles.ts
Normal file
27
frontend/src/utils/styles.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Shared class strings for the recurring bits of chrome. Anything used on more
|
||||||
|
// than one page lives here so the pages stay readable and stay in step.
|
||||||
|
|
||||||
|
/** Field well — no border, a soft terracotta ring on focus. */
|
||||||
|
const inputClass =
|
||||||
|
"bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
|
||||||
|
"placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
|
||||||
|
|
||||||
|
/** Same well, for timecodes and other machine facts. */
|
||||||
|
const monoInputClass = inputClass.replace("font-body", "font-data");
|
||||||
|
|
||||||
|
/** Field label — sits 6–10px above its field. */
|
||||||
|
const labelClass = "text-[11.5px] font-body text-muted";
|
||||||
|
|
||||||
|
/** Kicker / eyebrow above a page title or inside a muted panel. */
|
||||||
|
const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
|
||||||
|
|
||||||
|
/** Small chip button: copy, reveal, toggles. */
|
||||||
|
const chipButtonClass =
|
||||||
|
"bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
|
||||||
|
"px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
|
||||||
|
|
||||||
|
/** Quiet text link, used for back links and cancels. */
|
||||||
|
const quietLinkClass =
|
||||||
|
"font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
|
||||||
|
|
||||||
|
export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };
|
||||||
@@ -16,6 +16,7 @@ type APIResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
username: string,
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
@@ -31,6 +32,25 @@ 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 = {
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
duration: number,
|
||||||
|
createdAt: string,
|
||||||
|
width: number | null,
|
||||||
|
height: number | null,
|
||||||
|
fps: number | null,
|
||||||
|
// Bytes, as stored on disk.
|
||||||
|
fileSize: number | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobResponse = {
|
type JobResponse = {
|
||||||
@@ -42,10 +62,41 @@ type JobResponse = {
|
|||||||
createdAt: string,
|
createdAt: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StreamStatus = {
|
||||||
|
isStreaming: boolean,
|
||||||
|
id: number | null,
|
||||||
|
startTime: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamHistoryItem = {
|
||||||
|
id: number,
|
||||||
|
startDate: string | null,
|
||||||
|
endDate: string | null,
|
||||||
|
lastSeen: string | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Marker = {
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
streamId: number,
|
||||||
|
message: string,
|
||||||
|
timestamp: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamMetadata = {
|
||||||
|
url: string,
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
APIResponse,
|
APIResponse,
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
User,
|
User,
|
||||||
Clip,
|
Clip,
|
||||||
JobResponse
|
Vod,
|
||||||
|
JobResponse,
|
||||||
|
StreamStatus,
|
||||||
|
StreamHistoryItem,
|
||||||
|
Marker,
|
||||||
|
StreamMetadata
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
|
|||||||
return `${m}:${pad(s)}`;
|
return `${m}:${pad(s)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sizes in MB/GB, as the design calls for. Input is bytes.
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
const mb = bytes / 1_000_000;
|
||||||
|
if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
|
||||||
|
if (mb >= 10) return `${Math.round(mb)} MB`;
|
||||||
|
return `${mb.toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
|
||||||
|
function formatTimecode(seconds: number): string {
|
||||||
|
const safe = Math.max(0, Math.floor(seconds));
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
|
||||||
|
// half-typed field doesn't reject the user mid-keystroke.
|
||||||
|
function parseTimecode(value: string): number | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
|
||||||
|
|
||||||
|
const parts = trimmed.split(':').map(Number);
|
||||||
|
if (parts.some(isNaN)) return null;
|
||||||
|
|
||||||
|
// Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
|
||||||
|
const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
|
||||||
|
if (m > 59 || s > 59) return null;
|
||||||
|
|
||||||
|
return h * 3600 + m * 60 + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
|
||||||
|
// first few hours — that's the scale you actually think in mid-stream.
|
||||||
|
function toRelativeOffset(secondsAgo: number): string {
|
||||||
|
const m = Math.round(Math.max(0, secondsAgo) / 60);
|
||||||
|
if (m < 180) return `−${m}m`;
|
||||||
|
return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Parses a UTC date string, appending Z if no timezone offset is present so
|
// Parses a UTC date string, appending Z if no timezone offset is present so
|
||||||
// the browser always interprets the value as UTC rather than local time.
|
// the browser always interprets the value as UTC rather than local time.
|
||||||
function stringToDate(dateString: string): Date {
|
function stringToDate(dateString: string): Date {
|
||||||
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { defineConfig } 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";
|
import flowbiteReact from "flowbite-react/plugin/vite";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => { // change to function form
|
||||||
|
const env = loadEnv(mode, process.cwd(), '') // load all env vars
|
||||||
|
|
||||||
// https://vite.dev/config/
|
return {
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss(), flowbiteReact()],
|
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||||
preview: {
|
preview: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
@@ -15,7 +16,8 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: true,
|
host: true,
|
||||||
origin: "http://0.0.0.0:5173",
|
origin: env.VITE_FRONTEND_URL || "http://0.0.0.0:5173", // use it here
|
||||||
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
@@ -24,4 +26,5 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
50
mvnw
vendored
50
mvnw
vendored
@@ -19,7 +19,7 @@
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Apache Maven Wrapper startup batch script, version 3.3.2
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
#
|
#
|
||||||
# Optional ENV vars
|
# Optional ENV vars
|
||||||
# -----------------
|
# -----------------
|
||||||
@@ -105,14 +105,17 @@ trim() {
|
|||||||
printf "%s" "${1}" | tr -d '[:space:]'
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
while IFS="=" read -r key value; do
|
while IFS="=" read -r key value; do
|
||||||
case "${key-}" in
|
case "${key-}" in
|
||||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
esac
|
esac
|
||||||
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
case "${distributionUrl##*/}" in
|
case "${distributionUrl##*/}" in
|
||||||
maven-mvnd-*bin.*)
|
maven-mvnd-*bin.*)
|
||||||
@@ -130,7 +133,7 @@ maven-mvnd-*bin.*)
|
|||||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
;;
|
;;
|
||||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
@@ -227,7 +230,7 @@ if [ -n "${distributionSha256Sum-}" ]; then
|
|||||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
exit 1
|
exit 1
|
||||||
elif command -v sha256sum >/dev/null; then
|
elif command -v sha256sum >/dev/null; then
|
||||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
distributionSha256Result=true
|
distributionSha256Result=true
|
||||||
fi
|
fi
|
||||||
elif command -v shasum >/dev/null; then
|
elif command -v shasum >/dev/null; then
|
||||||
@@ -252,8 +255,41 @@ if command -v unzip >/dev/null; then
|
|||||||
else
|
else
|
||||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
fi
|
fi
|
||||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
|
|
||||||
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
clean || :
|
clean || :
|
||||||
exec_maven "$@"
|
exec_maven "$@"
|
||||||
|
|||||||
56
mvnw.cmd
vendored
56
mvnw.cmd
vendored
@@ -19,7 +19,7 @@
|
|||||||
@REM ----------------------------------------------------------------------------
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
@REM ----------------------------------------------------------------------------
|
@REM ----------------------------------------------------------------------------
|
||||||
@REM Apache Maven Wrapper startup batch script, version 3.3.2
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
@REM
|
@REM
|
||||||
@REM Optional ENV vars
|
@REM Optional ENV vars
|
||||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
@SET __MVNW_ARG0_NAME__=
|
@SET __MVNW_ARG0_NAME__=
|
||||||
@SET MVNW_USERNAME=
|
@SET MVNW_USERNAME=
|
||||||
@SET MVNW_PASSWORD=
|
@SET MVNW_PASSWORD=
|
||||||
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
@GOTO :EOF
|
@GOTO :EOF
|
||||||
: end batch / begin powershell #>
|
: end batch / begin powershell #>
|
||||||
@@ -73,16 +73,30 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
|||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
if ($env:MVNW_REPOURL) {
|
if ($env:MVNW_REPOURL) {
|
||||||
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
}
|
}
|
||||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
if ($env:MAVEN_USER_HOME) {
|
if ($env:MAVEN_USER_HOME) {
|
||||||
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
}
|
}
|
||||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
@@ -134,7 +148,33 @@ if ($distributionSha256Sum) {
|
|||||||
|
|
||||||
# unzip and move
|
# unzip and move
|
||||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
try {
|
try {
|
||||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
FROM tiangolo/nginx-rtmp
|
FROM tiangolo/nginx-rtmp
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends gettext-base && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ rtmp {
|
|||||||
hls_path /stream;
|
hls_path /stream;
|
||||||
hls_fragment_naming system;
|
hls_fragment_naming system;
|
||||||
hls_fragment 3;
|
hls_fragment 3;
|
||||||
hls_playlist_length 28800;
|
hls_playlist_length ${STREAM_RING_LENGTH_SECOND};
|
||||||
|
|
||||||
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
|
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
|
||||||
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# must only run on certain env files, otherwise it replaces too much
|
# must only run on certain env files, otherwise it replaces too much
|
||||||
envsubst '${WEB_SERVER_URL}' \
|
envsubst '${WEB_SERVER_URL} ${STREAM_RING_LENGTH_SECOND}' \
|
||||||
< /etc/nginx/templates/default.conf.template \
|
< /etc/nginx/templates/default.conf.template \
|
||||||
> /etc/nginx/nginx.conf
|
> /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
|||||||
5
pom.xml
5
pom.xml
@@ -85,6 +85,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,19 +2,17 @@
|
|||||||
FROM maven:3.9.6-eclipse-temurin-21 AS builder
|
FROM maven:3.9.6-eclipse-temurin-21 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
|
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN mvn clean package -DskipTests
|
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
|
||||||
# Run stage
|
# Run stage
|
||||||
FROM eclipse-temurin:21-jdk
|
FROM eclipse-temurin:21-jre
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y ffmpeg && \
|
apt-get install -y --no-install-recommends ffmpeg && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import org.springframework.http.MediaTypeFactory;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/clips")
|
@RequestMapping("/api/v1/clips")
|
||||||
@@ -43,12 +43,8 @@ public class ClipController {
|
|||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
||||||
Optional<Clip> clip = clipService.getClipById(id);
|
Clip clip = clipService.getClipById(id);
|
||||||
if (clip.isEmpty()) {
|
ClipResponse clipDTO = convertToDTO(clip);
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
ClipResponse clipDTO = convertToDTO(clip.get());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS,
|
new APIResponse<>(SUCCESS,
|
||||||
@@ -71,9 +67,7 @@ public class ClipController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
||||||
if (!clipService.deleteClip(id)) {
|
clipService.deleteClip(id);
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(
|
new APIResponse<>(
|
||||||
@@ -84,8 +78,8 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/media")
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
|
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadClip(id);
|
Resource resource = clipService.downloadClip(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -93,8 +87,11 @@ public class ClipController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +116,11 @@ public class ClipController {
|
|||||||
clip.getTitle(),
|
clip.getTitle(),
|
||||||
clip.getDescription(),
|
clip.getDescription(),
|
||||||
clip.getDuration(),
|
clip.getDuration(),
|
||||||
clip.getCreatedAt()
|
clip.getCreatedAt(),
|
||||||
|
clip.getWidth(),
|
||||||
|
clip.getHeight(),
|
||||||
|
clip.getFps(),
|
||||||
|
clip.getFileSize()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,46 @@ public class GlobalExceptionHandler {
|
|||||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ClipNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
||||||
|
logger.error("ClipNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ClipMediaUnavailable.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
||||||
|
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Clip media is currently unavailable", null);
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(VodNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
||||||
|
logger.error("VodNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(VodMediaUnavailable.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
||||||
|
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Vod media is currently unavailable", null);
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NotStreaming.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
||||||
|
logger.error("NotStreaming: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MarkerNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
|
||||||
|
logger.error("MarkerNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.MarkerCreateRequest;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.MarkerResponse;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.entities.Marker;
|
||||||
|
import com.ddf.vodsystem.services.MarkerService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/markers")
|
||||||
|
public class MarkerController {
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
|
private final MarkerService markerService;
|
||||||
|
|
||||||
|
public MarkerController(MarkerService markerService) {
|
||||||
|
this.markerService = markerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("")
|
||||||
|
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
|
||||||
|
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Markers retrieved successfully", markers)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
|
||||||
|
Marker marker = markerService.getMarkerById(id);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Marker retrieved successfully", convertToDTO(marker))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("")
|
||||||
|
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
|
||||||
|
Marker marker = markerService.create(request.message());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Marker created successfully", convertToDTO(marker))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
||||||
|
markerService.deleteMarker(id);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Marker deleted successfully", "Marker " + id + " deleted.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MarkerResponse convertToDTO(Marker marker) {
|
||||||
|
return new MarkerResponse(
|
||||||
|
marker.getId(),
|
||||||
|
marker.getUser().getId(),
|
||||||
|
marker.getStream().getId(),
|
||||||
|
marker.getMessage(),
|
||||||
|
marker.getTimestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
|
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.SaveSectionByMarkerRequest;
|
||||||
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
|
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
|
||||||
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
|
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
|
||||||
import com.ddf.vodsystem.dto.Job;
|
import com.ddf.vodsystem.dto.Job;
|
||||||
@@ -29,7 +30,7 @@ public class MediaController {
|
|||||||
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||||
@RequestParam("file")MultipartFile file,
|
@RequestParam("file")MultipartFile file,
|
||||||
@Valid @ModelAttribute ClipOptions options
|
@Valid @ModelAttribute ClipOptions options
|
||||||
) throws IOException, InterruptedException {
|
) throws IOException {
|
||||||
Job job = mediaService.compress(file, options);
|
Job job = mediaService.compress(file, options);
|
||||||
return ResponseEntity.ok(new APIResponse<>(
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
@@ -42,7 +43,26 @@ public class MediaController {
|
|||||||
@PostMapping("/save")
|
@PostMapping("/save")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||||
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
||||||
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
|
Job job = mediaService.saveSection(
|
||||||
|
saveSectionRequest.startTime(),
|
||||||
|
saveSectionRequest.endTime(),
|
||||||
|
saveSectionRequest.title(),
|
||||||
|
saveSectionRequest.description());
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
|
SUCCESS,
|
||||||
|
"Section saving successfully started",
|
||||||
|
new UUIDResponse(job.getUuid())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/save/markers")
|
||||||
|
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
|
||||||
|
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
|
||||||
|
Job job = mediaService.saveSection(
|
||||||
|
saveSectionByMarkerRequest.startMarkerId(),
|
||||||
|
saveSectionByMarkerRequest.endMarkerId(),
|
||||||
|
saveSectionByMarkerRequest.title(),
|
||||||
|
saveSectionByMarkerRequest.description());
|
||||||
return ResponseEntity.ok(new APIResponse<>(
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
"Section saving successfully started",
|
"Section saving successfully started",
|
||||||
@@ -53,7 +73,11 @@ public class MediaController {
|
|||||||
@PostMapping("/clip")
|
@PostMapping("/clip")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||||
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||||
Job job = mediaService.clip(clipSectionRequest.duration());
|
Job job = mediaService.clip(
|
||||||
|
clipSectionRequest.duration(),
|
||||||
|
clipSectionRequest.title(),
|
||||||
|
clipSectionRequest.description()
|
||||||
|
);
|
||||||
return ResponseEntity.ok(new APIResponse<>(
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
"Clipping successfully started",
|
"Clipping successfully started",
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
|
||||||
import com.ddf.vodsystem.controllers.dto.StreamResponse;
|
import com.ddf.vodsystem.controllers.dto.StreamResponse;
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.entities.Stream;
|
import com.ddf.vodsystem.entities.Stream;
|
||||||
import com.ddf.vodsystem.services.StreamService;
|
import com.ddf.vodsystem.services.StreamService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -18,8 +21,21 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -53,13 +69,56 @@ public class StreamController {
|
|||||||
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 —
|
||||||
|
* 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
|
||||||
|
* publishes the nginx-rtmp container.
|
||||||
|
*/
|
||||||
|
private String resolveIngestUrl() {
|
||||||
|
if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
|
||||||
|
return configuredIngestUrl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String host = hostOf(frontendUrl);
|
||||||
|
return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hostOf(String url) {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = url.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String host = URI.create(trimmed).getHost();
|
||||||
|
if (host != null && !host.isBlank()) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
logger.warn("Could not parse frontend.url ({})", trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare "example.net" or "example.net:8080" parses as a path, not a host.
|
||||||
|
String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
|
||||||
|
return bare.isBlank() ? null : bare;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current")
|
||||||
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||||
"Streaming status retrieved",
|
"Streaming status retrieved",
|
||||||
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
new StreamResponse(true, value.getId(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||||
"Streaming status retrieved",
|
"Streaming status retrieved",
|
||||||
new StreamResponse(false, null))));
|
new StreamResponse(false, null, null))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.ddf.vodsystem.entities.User;
|
|||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.UserService;
|
import com.ddf.vodsystem.services.UserService;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -41,14 +42,15 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
|
HttpServletRequest request,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(token.token());
|
String jwt = userService.login(token.token());
|
||||||
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(jwtExpiration / 1000)
|
.maxAge(jwtExpiration / 1000)
|
||||||
.sameSite("None")
|
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||||
.secure(true)
|
.secure(request.isSecure())
|
||||||
.path("/")
|
.path("/")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -60,12 +62,13 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response) {
|
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
||||||
|
HttpServletRequest request) {
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(0)
|
.maxAge(0)
|
||||||
.sameSite("None")
|
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||||
.secure(true)
|
.secure(request.isSecure())
|
||||||
.path("/")
|
.path("/")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
103
src/main/java/com/ddf/vodsystem/controllers/VodController.java
Normal file
103
src/main/java/com/ddf/vodsystem/controllers/VodController.java
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.VodResponse;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.entities.Vod;
|
||||||
|
import com.ddf.vodsystem.services.VodService;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/vods")
|
||||||
|
public class VodController {
|
||||||
|
private final VodService vodService;
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||||
|
|
||||||
|
public VodController(VodService vodService) {
|
||||||
|
this.vodService = vodService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("")
|
||||||
|
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
||||||
|
List<VodResponse> vods = vodService.getUserVods().stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
||||||
|
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
||||||
|
@RequestBody VodUpdateRequest updateFields) {
|
||||||
|
VodResponse vod = convertToDTO(vodService.updateVod(id, updateFields));
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
||||||
|
vodService.deleteVod(id);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod deleted successfully",
|
||||||
|
"Vod with ID " + id + " has been deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
|
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
||||||
|
Resource resource = vodService.downloadVod(id);
|
||||||
|
|
||||||
|
if (resource == null || !resource.exists()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/thumbnail")
|
||||||
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||||
|
Resource resource = vodService.downloadThumbnail(id);
|
||||||
|
|
||||||
|
if (resource == null || !resource.exists()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VodResponse convertToDTO(Vod vod) {
|
||||||
|
return new VodResponse(
|
||||||
|
vod.getId(),
|
||||||
|
vod.getUser().getId(),
|
||||||
|
vod.getTitle(),
|
||||||
|
vod.getDescription(),
|
||||||
|
vod.getDuration(),
|
||||||
|
vod.getCreatedAt(),
|
||||||
|
vod.getWidth(),
|
||||||
|
vod.getHeight(),
|
||||||
|
vod.getFps(),
|
||||||
|
vod.getFileSize()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,9 @@ public record ClipResponse(
|
|||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
Float duration,
|
Float duration,
|
||||||
Instant createdAt
|
Instant createdAt,
|
||||||
|
Integer width,
|
||||||
|
Integer height,
|
||||||
|
Float fps,
|
||||||
|
Long fileSize
|
||||||
) {}
|
) {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user