Merge pull request 'Update prod to master' (#4) from master into prod
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
15
.env.example
15
.env.example
@@ -1,3 +1,18 @@
|
|||||||
|
# Secrets
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
|
|
||||||
|
# Volumes
|
||||||
|
POSTGRES_DATA=
|
||||||
|
STREAM_DATA=
|
||||||
|
MEDIA_DATA=
|
||||||
|
|
||||||
|
# DB Credentials
|
||||||
|
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
||||||
|
POSTGRES_USER=myuser
|
||||||
|
POSTGRES_PASSWORD=mypassword
|
||||||
|
POSTGRES_DB=vodSystem
|
||||||
|
|
||||||
|
FRONTEND_URL=
|
||||||
|
BACKEND_URL=
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -39,6 +39,9 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
### Generated Files ###
|
### Generated Files ###
|
||||||
videos/
|
/videos
|
||||||
|
/outputs
|
||||||
|
frontend/.flowbite-react
|
||||||
|
|
||||||
generated
|
generated
|
||||||
|
.mvn
|
||||||
|
|||||||
19
.mvn/wrapper/maven-wrapper.properties
vendored
19
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,19 +0,0 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
# or more contributor license agreements. See the NOTICE file
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
wrapperVersion=3.3.2
|
|
||||||
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
|
|
||||||
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal file
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Delete Clip
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/clips/6
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal file
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Clip Media
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/clips/5/media
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal file
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Clip Thumbnail
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/clips/5/thumbnail
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Downloads
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
16
bruno/VoD-System/Clips/Get Clip.bru
Normal file
16
bruno/VoD-System/Clips/Get Clip.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Clip
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/clips/5
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Get all clips
|
name: Get all clips
|
||||||
type: http
|
type: http
|
||||||
seq: 1
|
seq: 2
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
|
|||||||
22
bruno/VoD-System/Clips/Update Clip.bru
Normal file
22
bruno/VoD-System/Clips/Update Clip.bru
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Update Clip
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
patch {
|
||||||
|
url: {{base_url}}/clips/5
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"title": "NOT BZ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Clips/folder.bru
Normal file
8
bruno/VoD-System/Clips/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Clips
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Converted Download
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/download/output/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Original Download
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/download/input/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Download
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Edit
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/edit/{{uuid}}
|
|
||||||
body: formUrlEncoded
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:form-urlencoded {
|
|
||||||
startPoint: 10
|
|
||||||
duration: 40
|
|
||||||
title: best possible title
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Process
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/process/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Progress
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/progress/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Edit
|
|
||||||
}
|
|
||||||
15
bruno/VoD-System/Jobs/Download Video.bru
Normal file
15
bruno/VoD-System/Jobs/Download Video.bru
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Download Video
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/jobs/{{uuid}}/download
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
16
bruno/VoD-System/Jobs/Get Job.bru
Normal file
16
bruno/VoD-System/Jobs/Get Job.bru
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Job
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/jobs/{{uuid}}
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Jobs/folder.bru
Normal file
8
bruno/VoD-System/Jobs/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Jobs
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
21
bruno/VoD-System/Media/Clip Stream.bru
Normal file
21
bruno/VoD-System/Media/Clip Stream.bru
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
meta {
|
||||||
|
name: Clip Stream
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/media/clip
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"duration": 60.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||||
|
}
|
||||||
22
bruno/VoD-System/Media/Compress.bru
Normal file
22
bruno/VoD-System/Media/Compress.bru
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Compress
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/media/compress
|
||||||
|
body: multipartForm
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4)
|
||||||
|
title: hello
|
||||||
|
startPoint: 10
|
||||||
|
duration: 30
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||||
|
}
|
||||||
22
bruno/VoD-System/Media/Save Stream Section.bru
Normal file
22
bruno/VoD-System/Media/Save Stream Section.bru
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Save Stream Section
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/media/save
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"startTime": "2026-06-21T17:27:00Z",
|
||||||
|
"endTime": "2026-06-21T17:28:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Media/folder.bru
Normal file
8
bruno/VoD-System/Media/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Media
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Converted Metadata
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/metadata/converted/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Original Metadata
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/metadata/original/{{uuid}}
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Metadata
|
|
||||||
}
|
|
||||||
20
bruno/VoD-System/Streams/History Streams.bru
Normal file
20
bruno/VoD-System/Streams/History Streams.bru
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: History Streams
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/stream/history/1
|
||||||
|
body: none
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
auth:bearer {
|
||||||
|
token: {{token}}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
}
|
||||||
8
bruno/VoD-System/Streams/folder.bru
Normal file
8
bruno/VoD-System/Streams/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Streams
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Upload
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/upload
|
|
||||||
body: multipartForm
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:multipart-form {
|
|
||||||
file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4)
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
bru.setEnvVar("uuid", res.body.data.uuid);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{base_url}}/auth/user
|
url: {{base_url}}/users/me
|
||||||
body: none
|
body: none
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{base_url}}/auth/login
|
url: {{base_url}}/users/login
|
||||||
body: json
|
body: json
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
8
bruno/VoD-System/Users/folder.bru
Normal file
8
bruno/VoD-System/Users/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Users
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
7
bruno/VoD-System/collection.bru
Normal file
7
bruno/VoD-System/collection.bru
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
auth {
|
||||||
|
mode: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
auth:bearer {
|
||||||
|
token: {{token}}
|
||||||
|
}
|
||||||
32
docker-compose.dev.yml
Normal file
32
docker-compose.dev.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Local dev: only infrastructure services
|
||||||
|
# Backend and frontend run directly on the host
|
||||||
|
# Usage: docker compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
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
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
rtmp:
|
||||||
|
build:
|
||||||
|
context: ./nginx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: vod_rtmp
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||||
|
ports:
|
||||||
|
- "1935:1935"
|
||||||
|
volumes:
|
||||||
|
- ${STREAM_DATA}:/stream
|
||||||
|
pull_policy: build
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
container_name: vod_postgres
|
container_name: vod_postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: myuser
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: mypassword
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: vodSystem
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ${POSTGRES_DATA}:/var/lib/postgresql/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -21,6 +18,9 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ${MEDIA_DATA}:/app/media
|
||||||
|
- ${STREAM_DATA}:/app/stream
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -30,13 +30,27 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_frontend
|
container_name: vod_frontend
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
rtmp:
|
||||||
|
build:
|
||||||
|
context: ./nginx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: vod_rtmp
|
||||||
|
environment:
|
||||||
|
- WEB_SERVER_URL=http://backend:8080
|
||||||
|
ports:
|
||||||
|
- "1935:1935"
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
- ${STREAM_DATA}:/stream
|
||||||
|
pull_policy: build
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
1
frontend/.dockerignore
Normal file
1
frontend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -2,7 +2,7 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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 VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||||
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
|
import Profile from "./pages/Profile.tsx";
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ function App() {
|
|||||||
<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="/video/:id" element={<VideoPlayer />} />
|
||||||
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
type props = {
|
type Props = {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Box = ({children, className}: props) => {
|
const Box = ({ children, className }: Props) => (
|
||||||
return (
|
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||||
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default Box;
|
export default Box;
|
||||||
@@ -1,60 +1,51 @@
|
|||||||
import React, {useEffect, useRef} from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
type DropdownItemProps = {
|
type DropdownItemProps = {
|
||||||
item: string;
|
item: string;
|
||||||
onClick: (item: string) => void;
|
onClick: (item: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
|
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||||
return (
|
<li
|
||||||
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
|
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||||
onClick={() => onClick(item)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownProps = {
|
type DropdownProps = {
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Dropdown = ({ label, children, className }: DropdownProps) => {
|
const Dropdown = ({ label, children, className }: DropdownProps) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: { target: any }) {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(event.target)) {
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "relative inline-block text-left")}
|
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||||
ref={ref}>
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleDropdown}
|
onClick={() => setIsOpen(v => !v)}
|
||||||
className={"inline-flex justify-between w-full rounded-xl px-4 py-2 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50"}
|
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"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul className={"absolute w-30 origin-top-right rounded-md bg-white shadow-lg font-medium"}>
|
<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">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export { Dropdown, DropdownItem };
|
export { Dropdown, DropdownItem };
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: String;
|
label: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Selector = ({children, label}: props) => {
|
const Selector = ({ children, label }: Props) => (
|
||||||
return (
|
<div className="flex items-center gap-2">
|
||||||
<div className={"flex items-center gap-2"}>
|
<label className="w-full text-sm text-gray-600">{label}</label>
|
||||||
<label
|
<div className="w-px h-5 bg-gray-200 mx-2" />
|
||||||
className={"w-full"}>
|
|
||||||
{ label }
|
|
||||||
</label>
|
|
||||||
<div className={"w-px h-6 bg-gray-400 mx-3"} />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default Selector;
|
export default Selector;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
||||||
import Box from "./Box.tsx";
|
|
||||||
import type {User} from "../utils/types.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -9,34 +8,43 @@ type props = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
|
||||||
|
|
||||||
const Sidebar = ({user, className}: props) => {
|
const Sidebar = ({user, className}: props) => {
|
||||||
return (
|
return (
|
||||||
<Box className={clsx("h-screen flex flex-col mr-5", className)}>
|
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
|
||||||
|
<div className="px-4 pt-5 pb-3">
|
||||||
<img
|
<img
|
||||||
className={"w-45 mx-auto"}
|
className="w-36 mx-auto"
|
||||||
src={"../../public/logo.png"}
|
src="../../public/logo.png"
|
||||||
alt={"VoD System Logo"}
|
alt="VoD System Logo"
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
url={"/"}
|
|
||||||
logo={<Home size={20}/>}
|
|
||||||
label={"Home"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SidebarButton
|
|
||||||
url={"/create"}
|
|
||||||
logo={<Plus size={20}/>}
|
|
||||||
label={"Create Clip"}
|
|
||||||
/>
|
/>
|
||||||
|
</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="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<SidebarButton
|
<>
|
||||||
url={"/my-clips"}
|
<Divider />
|
||||||
logo={<Film size={20}/>}
|
<div className="flex flex-col gap-0.5">
|
||||||
label={"My Clips"}
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||||
/>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type props = {
|
|||||||
setSidebarToggled: (toggled: boolean) => void;
|
setSidebarToggled: (toggled: boolean) => void;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
fetchUser: () => void;
|
fetchUser: () => void;
|
||||||
|
isStreaming: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const Topbar = ({
|
|||||||
setSidebarToggled,
|
setSidebarToggled,
|
||||||
user,
|
user,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
isStreaming,
|
||||||
className}: props) => {
|
className}: props) => {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -56,7 +58,7 @@ const Topbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "flex justify-between")}>
|
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
|
||||||
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
@@ -69,7 +71,19 @@ const Topbar = ({
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown label={user.name}>
|
<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"
|
<DropdownItem item="Logout"
|
||||||
onClick={() => handleLogout()}
|
onClick={() => handleLogout()}
|
||||||
className={"text-red-500 font-medium"} />
|
className={"text-red-500 font-medium"} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`bg-primary text-text rounded-lg hover:bg-primary-pressed transition-colors duration-100 h-10 ${className}`}
|
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx("p-2 rounded-lg hover:bg-gray-300 hover:text-black transition-colors duration-200", className)}
|
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import MenuButton from "./MenuButton.tsx";
|
import MenuButton from "./MenuButton.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
type props = {
|
type Props = {
|
||||||
url: string;
|
url: string;
|
||||||
logo: React.ReactNode;
|
logo: React.ReactNode;
|
||||||
label: String;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarButton = ({url, logo, label}: props) => {
|
const SidebarButton = ({url, logo, label}: Props) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link className="w-full" to={url}>
|
||||||
className={"w-full"}
|
<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"}`}>
|
||||||
to={url}
|
|
||||||
>
|
|
||||||
<MenuButton className={"flex items-center gap-2 w-full"}>
|
|
||||||
{logo}{label}
|
{logo}{label}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,42 +1,32 @@
|
|||||||
import RangeSlider from 'react-range-slider-input';
|
import RangeSlider from 'react-range-slider-input';
|
||||||
import 'react-range-slider-input/dist/style.css';
|
import 'react-range-slider-input/dist/style.css';
|
||||||
import {useRef} from "react";
|
import { useRef, type Dispatch, type SetStateAction } from "react";
|
||||||
import clsx from 'clsx';
|
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
videoRef: HTMLVideoElement | null;
|
videoRef: HTMLVideoElement | null;
|
||||||
videoMetadata: VideoMetadata;
|
videoMetadata: VideoMetadata;
|
||||||
setSliderValue: Function;
|
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||||
setMetadata: Function;
|
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClipRangeSlider({videoRef,
|
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
|
||||||
videoMetadata,
|
const previousRange = useRef<[number, number]>([0, 0]);
|
||||||
setSliderValue,
|
|
||||||
setMetadata,
|
|
||||||
className}: Props) {
|
|
||||||
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
|
|
||||||
|
|
||||||
const handleRangeSliderInput = (val: [number, number]) => {
|
const handleRangeInput = (val: [number, number]) => {
|
||||||
if (!videoRef) return;
|
if (!videoRef) return;
|
||||||
|
|
||||||
if (previousRangeSliderInput.current[0] != val[0]) {
|
if (previousRange.current[0] !== val[0]) {
|
||||||
videoRef.currentTime = val[0];
|
videoRef.currentTime = val[0];
|
||||||
setSliderValue(val[0]);
|
setSliderValue(val[0]);
|
||||||
} else if (previousRangeSliderInput.current[1] != val[1]) {
|
} else if (previousRange.current[1] !== val[1]) {
|
||||||
videoRef.currentTime = val[1];
|
videoRef.currentTime = val[1];
|
||||||
setSliderValue(val[1]);
|
setSliderValue(val[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMetadata((prevState: VideoMetadata) => ({
|
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
|
||||||
...prevState,
|
previousRange.current = val;
|
||||||
startPoint: val[0],
|
|
||||||
duration: val[1] - val[0]
|
|
||||||
}
|
|
||||||
))
|
|
||||||
previousRangeSliderInput.current = val;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
|
|||||||
min={0}
|
min={0}
|
||||||
max={videoMetadata.duration}
|
max={videoMetadata.duration}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
onInput={handleRangeSliderInput}
|
onInput={handleRangeInput}
|
||||||
className={clsx(className)}
|
className={className}
|
||||||
id={"range-slider"}
|
id="range-slider"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,36 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
type prop = {
|
type Props = {
|
||||||
setMetadata: Function;
|
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function ConfigBox({setMetadata, className}: prop) {
|
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";
|
||||||
|
|
||||||
|
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const vals = e.target.value.split(",");
|
const [width, height] = e.target.value.split(",").map(Number);
|
||||||
setMetadata((prevState: VideoMetadata) => ({
|
setMetadata(prev => ({ ...prev, width, height }));
|
||||||
...prevState,
|
};
|
||||||
width: parseInt(vals[0]),
|
|
||||||
height: parseInt(vals[1])
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setMetadata((prevState: VideoMetadata) => ({
|
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
|
||||||
...prevState,
|
};
|
||||||
fps: parseInt(e.target.value)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setMetadata((prevState: VideoMetadata) => ({
|
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
|
||||||
...prevState,
|
};
|
||||||
fileSize: parseInt(e.target.value) * 1000
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-2 p-10", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<h2 className={"text-xl font-bold"}>Export</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label={"Resolution"}>
|
<Selector label="Resolution">
|
||||||
<select id="resolution"
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
name="resolution"
|
onChange={updateRes} className={inputClass}>
|
||||||
defaultValue="1280,720"
|
|
||||||
onChange={updateRes}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
|
||||||
<option value="3840,2160">2160p</option>
|
<option value="3840,2160">2160p</option>
|
||||||
<option value="2560,1440">1440p</option>
|
<option value="2560,1440">1440p</option>
|
||||||
<option value="1920,1080">1080p</option>
|
<option value="1920,1080">1080p</option>
|
||||||
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label={"FPS"}>
|
<Selector label="FPS">
|
||||||
<select id="fps"
|
<select id="fps" name="fps" defaultValue="30"
|
||||||
name="fps"
|
onChange={updateFps} className={inputClass}>
|
||||||
defaultValue="30"
|
|
||||||
onChange={updateFps}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
|
||||||
<option value="60">60</option>
|
<option value="60">60</option>
|
||||||
<option value="30">30</option>
|
<option value="30">30</option>
|
||||||
<option value="15">15</option>
|
<option value="15">15</option>
|
||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label={"File Size Limit (MB)"}>
|
<Selector label="File Size Limit (MB)">
|
||||||
<input type="number"
|
<input type="number" min="1" defaultValue="10"
|
||||||
min="1"
|
onChange={updateFileSize} className={inputClass}
|
||||||
defaultValue="10"
|
|
||||||
onChange={updateFileSize}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
|
||||||
/>
|
/>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -16,18 +16,20 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props)
|
|||||||
Export
|
Export
|
||||||
</BlueButton>
|
</BlueButton>
|
||||||
|
|
||||||
|
<div className="h-10 flex items-center">
|
||||||
{ downloadable ?
|
{ downloadable ?
|
||||||
(<BlueButton
|
(<BlueButton className="w-full" onClick={handleDownload}>
|
||||||
onClick={handleDownload}>
|
|
||||||
Download
|
Download
|
||||||
</BlueButton>)
|
</BlueButton>)
|
||||||
:(
|
:(
|
||||||
<progress
|
<progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className={"bg-gray-300 rounded-lg h-1"}>
|
className="w-full h-1.5 rounded bg-gray-200">
|
||||||
</progress> )
|
</progress>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { type Dispatch, type SetStateAction } from "react";
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
|
|
||||||
type MetadataBoxProps = {
|
type Props = {
|
||||||
setMetadata: Function
|
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
return (
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
|
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||||
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
|
|
||||||
|
|
||||||
<p className={"w-full font-bold text-xl "}>Title</p>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter title"
|
placeholder="Enter title"
|
||||||
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
...prevState,
|
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"
|
||||||
title: e.target.value
|
|
||||||
}))}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default MetadataBox;
|
export default MetadataBox;
|
||||||
@@ -1,52 +1,37 @@
|
|||||||
import {useEffect} from "react";
|
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||||
import clsx from 'clsx';
|
|
||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
videoRef: HTMLVideoElement | null;
|
videoRef: HTMLVideoElement | null;
|
||||||
videoMetadata: VideoMetadata;
|
videoMetadata: VideoMetadata;
|
||||||
sliderValue: number;
|
sliderValue: number;
|
||||||
setSliderValue: Function;
|
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlaybackSlider({videoRef,
|
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
|
||||||
videoMetadata,
|
|
||||||
sliderValue,
|
|
||||||
setSliderValue,
|
|
||||||
className}: Props) {
|
|
||||||
|
|
||||||
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!videoRef) return;
|
if (!videoRef) return;
|
||||||
|
|
||||||
videoRef.currentTime = e.target.valueAsNumber;
|
videoRef.currentTime = e.target.valueAsNumber;
|
||||||
setSliderValue(e.target.valueAsNumber);
|
setSliderValue(e.target.valueAsNumber);
|
||||||
}
|
};
|
||||||
|
|
||||||
// update slider
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoRef) return;
|
if (!videoRef) return;
|
||||||
|
const updateSlider = () => setSliderValue(videoRef.currentTime);
|
||||||
const updateSlider = () => {
|
|
||||||
setSliderValue(videoRef.currentTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
videoRef.addEventListener("timeupdate", updateSlider);
|
videoRef.addEventListener("timeupdate", updateSlider);
|
||||||
|
return () => videoRef.removeEventListener("timeupdate", updateSlider);
|
||||||
return () => {
|
|
||||||
videoRef.removeEventListener("timeupdate", updateSlider);
|
|
||||||
};
|
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={"range"}
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={videoMetadata.duration}
|
max={videoMetadata.duration}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
onChange={updateVideo}
|
onChange={updateVideo}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className={clsx(className)}
|
className={className}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ import clsx from 'clsx';
|
|||||||
import type { VideoMetadata } from "../../utils/types.ts";
|
import type { VideoMetadata } from "../../utils/types.ts";
|
||||||
import { formatTime } from "../../utils/utils.ts";
|
import { formatTime } from "../../utils/utils.ts";
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
video: HTMLVideoElement | null;
|
video: HTMLVideoElement | null;
|
||||||
videoMetadata: VideoMetadata;
|
videoMetadata: VideoMetadata;
|
||||||
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [volume, setVolume] = useState(100);
|
const [volume, setVolume] = useState(100);
|
||||||
|
|
||||||
|
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
video.play();
|
video.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let Icon;
|
|
||||||
// update icon
|
|
||||||
if (volume == 0) {
|
|
||||||
Icon = VolumeX;
|
|
||||||
} else if (volume < 50) {
|
|
||||||
Icon = Volume1;
|
|
||||||
} else {
|
|
||||||
Icon = Volume2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
const newVolume = parseInt(e.target.value);
|
||||||
|
video.volume = newVolume / 100;
|
||||||
|
setVolume(newVolume);
|
||||||
|
};
|
||||||
|
|
||||||
let volume = parseInt(e.target.value)
|
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
|
||||||
|
|
||||||
video.volume = volume / 100;
|
|
||||||
setVolume(volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const handlePlay = () => setIsPlaying(true);
|
const handlePlay = () => setIsPlaying(true);
|
||||||
const handlePause = () => setIsPlaying(false);
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
|
||||||
video.addEventListener("play", handlePlay);
|
video.addEventListener("play", handlePlay);
|
||||||
video.addEventListener("pause", handlePause);
|
video.addEventListener("pause", handlePause);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener("play", handlePlay);
|
video.removeEventListener("play", handlePlay);
|
||||||
video.removeEventListener("pause", handlePause);
|
video.removeEventListener("pause", handlePause);
|
||||||
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
||||||
<div className={"flex gap-1"}>
|
<div className="flex gap-1">
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
<input
|
<input
|
||||||
type='range'
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
onChange={updateVolume}
|
|
||||||
value={volume}
|
value={volume}
|
||||||
className={"w-20"}
|
onChange={updateVolume}
|
||||||
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className={"hover:bg-gray-300 rounded-full p-1"}
|
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
|
<span>
|
||||||
<label>
|
|
||||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||||
</label>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,79 +1,138 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
|
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
|
||||||
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type VideoCardProps = {
|
type VideoCardProps = {
|
||||||
id: number,
|
id: number,
|
||||||
title: string,
|
title: string,
|
||||||
duration: number,
|
duration: number,
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
className?: string
|
onEdit?: () => void,
|
||||||
|
onDelete?: () => void,
|
||||||
|
className?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const VideoCard = ({
|
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
|
||||||
id,
|
|
||||||
title,
|
|
||||||
duration,
|
|
||||||
createdAt,
|
|
||||||
className
|
|
||||||
}: VideoCardProps) => {
|
|
||||||
|
|
||||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
setTimeout(() => {
|
useEffect(() => {
|
||||||
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
|
const interval = setInterval(() => {
|
||||||
|
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [createdAt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isThumbnailAvailable(id)
|
isThumbnailAvailable(id)
|
||||||
.then((available) => {
|
.then(setThumbnailAvailable)
|
||||||
setThumbnailAvailable(available);
|
.catch(() => setThumbnailAvailable(false));
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setThumbnailAvailable(false);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={"/video/" + id}>
|
<Link to={"/video/" + id}>
|
||||||
<div className={clsx("flex flex-col", className)}>
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
<div className={"relative inline-block"}>
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
|
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
||||||
alt="Video Thumbnail"
|
alt="Video Thumbnail"
|
||||||
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="
|
<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">
|
||||||
absolute
|
|
||||||
top-2
|
|
||||||
left-2
|
|
||||||
bg-black bg-opacity-60
|
|
||||||
text-white
|
|
||||||
px-2
|
|
||||||
py-1
|
|
||||||
rounded
|
|
||||||
pointer-events-none
|
|
||||||
text-sm
|
|
||||||
z-1
|
|
||||||
">
|
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</p>
|
</span>
|
||||||
|
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute top-1.5 right-1.5"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
{!confirmDelete && (<>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Pencil size={13} /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col p-2">
|
||||||
<div className={"flex flex-col justify-between p-2"}>
|
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||||
<p>{title == "" ? "(No Title)" : title}</p>
|
{title === "" ? "(No Title)" : title}
|
||||||
<p
|
|
||||||
className={"text-gray-600 text-sm"}
|
|
||||||
>
|
|
||||||
{timeAgo}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
@plugin "flowbite-react/plugin/tailwindcss";
|
@plugin "flowbite-react/plugin/tailwindcss";
|
||||||
@source "../.flowbite-react/class-list.json";
|
@source "../.flowbite-react/class-list.json";
|
||||||
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-display: "Satoshi", "sans-serif";
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
|
||||||
/* Breakpoints */
|
/* Breakpoints */
|
||||||
--breakpoint-3xl: 1920px;
|
--breakpoint-3xl: 1920px;
|
||||||
|
|
||||||
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
|
--color-primary: oklch(0.55 0.21 254);
|
||||||
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
|
|
||||||
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
|
|
||||||
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
|
|
||||||
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
|
|
||||||
|
|
||||||
--color-primary-pressed: oklch(0.45 0.21 254);
|
--color-primary-pressed: oklch(0.45 0.21 254);
|
||||||
|
--color-secondary: oklch(0.94 0.01 250);
|
||||||
|
--color-accent: oklch(0.68 0.20 288);
|
||||||
|
--color-text: oklch(0.17 0.01 270);
|
||||||
|
--color-background: oklch(0.97 0.005 250);
|
||||||
|
--color-surface: oklch(1 0 0);
|
||||||
|
--color-border: oklch(0.91 0.008 250);
|
||||||
|
|
||||||
/* Easing */
|
/* Easing */
|
||||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||||
@@ -31,66 +31,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Helvetica;
|
font-family: "Inter", ui-sans-serif, 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;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*a {*/
|
body {
|
||||||
/* font-weight: 500;*/
|
background-color: var(--color-background);
|
||||||
/* color: #646cff;*/
|
}
|
||||||
/* text-decoration: inherit;*/
|
|
||||||
/*}*/
|
|
||||||
/*a:hover {*/
|
|
||||||
/* color: #535bf2;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*body {*/
|
|
||||||
/* margin: 0;*/
|
|
||||||
/* display: flex;*/
|
|
||||||
/* place-items: center;*/
|
|
||||||
/* min-width: 320px;*/
|
|
||||||
/* min-height: 100vh;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*h1 {*/
|
|
||||||
/* font-size: 3.2em;*/
|
|
||||||
/* line-height: 1.1;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*button {*/
|
|
||||||
/* border-radius: 8px;*/
|
|
||||||
/* border: 1px solid transparent;*/
|
|
||||||
/* padding: 0.6em 1.2em;*/
|
|
||||||
/* font-size: 1em;*/
|
|
||||||
/* font-weight: 500;*/
|
|
||||||
/* font-family: inherit;*/
|
|
||||||
/* background-color: #1a1a1a;*/
|
|
||||||
/* cursor: pointer;*/
|
|
||||||
/* transition: border-color 0.25s;*/
|
|
||||||
/*}*/
|
|
||||||
/*button:hover {*/
|
|
||||||
/* border-color: #646cff;*/
|
|
||||||
/*}*/
|
|
||||||
/*button:focus,*/
|
|
||||||
/*button:focus-visible {*/
|
|
||||||
/* outline: 4px auto -webkit-focus-ring-color;*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
/*@media (prefers-color-scheme: light) {*/
|
|
||||||
/* :root {*/
|
|
||||||
/* color: #213547;*/
|
|
||||||
/* background-color: #ffffff;*/
|
|
||||||
/* }*/
|
|
||||||
/* a:hover {*/
|
|
||||||
/* color: #747bff;*/
|
|
||||||
/* }*/
|
|
||||||
/* button {*/
|
|
||||||
/* background-color: #f9f9f9;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import Topbar from '../components/Topbar'
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import type {User} from "../utils/types";
|
import type {User} from "../utils/types";
|
||||||
import { getUser } from "../utils/endpoints";
|
import { getUser, getStreamStatus } from "../utils/endpoints";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
const [user, setUser] = useState<null | User>(null);
|
const [user, setUser] = useState<null | User>(null);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +24,14 @@ const MainLayout = () => {
|
|||||||
fetchUser();
|
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-[240px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -33,8 +42,9 @@ const MainLayout = () => {
|
|||||||
sidebarToggled={sidebarToggled}
|
sidebarToggled={sidebarToggled}
|
||||||
setSidebarToggled={setSidebarToggled}
|
setSidebarToggled={setSidebarToggled}
|
||||||
user={user}
|
user={user}
|
||||||
fetchUser={fetchUser}/>
|
fetchUser={fetchUser}
|
||||||
<div className="overflow-auto">
|
isStreaming={isStreaming}/>
|
||||||
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Playbar from "./../components/video/Playbar";
|
import Playbar from "./../components/video/Playbar";
|
||||||
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
||||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
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 {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints"
|
import { compress, getJob } from "../utils/endpoints"
|
||||||
import type { VideoMetadata } from "../utils/types.ts";
|
import type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const { id } = useParams();
|
const location = useLocation();
|
||||||
|
const localFile: File = location.state?.file;
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const videoUrl = `/api/v1/download/input/${id}`
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
|
const [uploadedId, setUploadedId] = useState<string | null>(null);
|
||||||
|
|
||||||
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 [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||||
// default values
|
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
startPoint: 0,
|
startPoint: 0,
|
||||||
@@ -31,41 +36,66 @@ const ClipEdit = () => {
|
|||||||
const [downloadable, setDownloadable] = useState(false);
|
const [downloadable, setDownloadable] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localFile) return;
|
||||||
|
const url = URL.createObjectURL(localFile);
|
||||||
|
setVideoUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [localFile]);
|
||||||
|
|
||||||
|
const handleVideoMetadataLoaded = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
setMetadata({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
startPoint: 0,
|
||||||
|
duration: video.duration,
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
fps: 30,
|
||||||
|
fileSize: localFile?.size ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sendData = async () => {
|
const sendData = async () => {
|
||||||
if (!id) return;
|
if (!localFile) return;
|
||||||
|
|
||||||
editFile(id, outputMetadata)
|
let jobId: string;
|
||||||
.then(() => {
|
try {
|
||||||
|
jobId = await compress(localFile, outputMetadata);
|
||||||
processFile(id)
|
setUploadedId(jobId);
|
||||||
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
|
} catch (err: unknown) {
|
||||||
|
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
})
|
return;
|
||||||
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
|
|
||||||
|
|
||||||
const interval = setInterval(async() => await pollProgress(id, interval), 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollProgress = async (id: string, intervalId: number) => {
|
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||||
getProgress(id)
|
};
|
||||||
.then((progress) => {
|
|
||||||
setProgress(progress.process.progress);
|
|
||||||
|
|
||||||
if (progress.process.complete) {
|
const pollProgress = async (jobId: string, intervalId: number) => {
|
||||||
|
getJob(jobId)
|
||||||
|
.then((job) => {
|
||||||
|
setProgress(job.progress);
|
||||||
|
|
||||||
|
if (job.state === 'FAILED') {
|
||||||
|
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
} else if (job.isComplete) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setDownloadable(true);
|
setDownloadable(true);
|
||||||
} else {
|
|
||||||
setDownloadable(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);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const response = await fetch(`/api/v1/download/output/${id}`);
|
if (!uploadedId) return;
|
||||||
|
|
||||||
|
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Download failed');
|
console.error('Download failed');
|
||||||
@@ -77,43 +107,27 @@ const ClipEdit = () => {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${id}.mp4`;
|
a.download = `${uploadedId}.mp4`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
getMetadata(id)
|
|
||||||
.then((data) => setMetadata(data ?? null))
|
|
||||||
.catch((err) => console.error("Metadata fetch failed:", err));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"grid grid-cols-[7fr_3fr]"}>
|
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
|
src={videoUrl}
|
||||||
<source src={videoUrl} type="video/mp4" />
|
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||||
<source src={videoUrl} type="video/webm" />
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
<source src={videoUrl} type="video/ogg" />
|
/>
|
||||||
Your browser does not support the video tag. Bzzzz.
|
|
||||||
</video>
|
|
||||||
|
|
||||||
|
|
||||||
<Box className={"w-4/5 h-full m-auto"}>
|
<Box className={"w-4/5 h-full m-auto"}>
|
||||||
<MetadataBox
|
<MetadataBox setMetadata={setOutputMetadata} />
|
||||||
setMetadata={setOutputMetadata}
|
<ConfigBox setMetadata={setOutputMetadata} />
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
setMetadata={setOutputMetadata}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
{metadata &&
|
{metadata &&
|
||||||
<Box className={"mt-4 p-5"}>
|
<Box className={"mt-4 p-5"}>
|
||||||
<Playbar
|
<Playbar
|
||||||
@@ -156,6 +170,6 @@ const ClipEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ClipEdit;
|
export default ClipEdit;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
|
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
|
|
||||||
@@ -8,90 +7,35 @@ const ClipUpload = () => {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [error, setError] = useState<null | string>(null);
|
const [error, setError] = useState<null | string>(null);
|
||||||
const [progress, setProgress] = useState<number>(0);
|
|
||||||
|
|
||||||
const isVideoFileSupported = (file: File): boolean => {
|
const press = () => {
|
||||||
const video = document.createElement("video");
|
|
||||||
|
|
||||||
if (file.type && video.canPlayType(file.type) !== "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
const extensionToMime: Record<string, string> = {
|
|
||||||
mp4: "video/mp4",
|
|
||||||
webm: "video/webm",
|
|
||||||
ogg: "video/ogg",
|
|
||||||
mov: "video/quicktime",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (extension && extensionToMime[extension]) {
|
|
||||||
return video.canPlayType(extensionToMime[extension]) !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const press = (() => {
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setError("Please choose a file");
|
setError("Please choose a file");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
navigate('/create/new', { state: { file } });
|
||||||
|
};
|
||||||
uploadFile(file)
|
|
||||||
.then(uuid => {
|
|
||||||
|
|
||||||
if (isVideoFileSupported(file)) {
|
|
||||||
navigate(`/create/${uuid}`)
|
|
||||||
} else {
|
|
||||||
convertFile(uuid);
|
|
||||||
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const pollProgress = async (id: string, intervalId: number) => {
|
|
||||||
getProgress(id)
|
|
||||||
.then((progress) => {
|
|
||||||
setProgress(progress.conversion.progress);
|
|
||||||
|
|
||||||
if (progress.conversion.complete) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
navigate(`/create/${id}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
setError(`Failed to fetch progress: ${err.message}`);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={"flex flex-col justify-between gap-3 p-5"}>
|
<div className="flex items-start justify-center p-8 min-h-full">
|
||||||
|
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selected = e.target.files?.[0] ?? null;
|
const selected = e.target.files?.[0] ?? null;
|
||||||
setFile(selected);
|
setFile(selected);
|
||||||
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"}
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BlueButton
|
<BlueButton onClick={press}>Upload</BlueButton>
|
||||||
onClick={press}>
|
|
||||||
Upload
|
|
||||||
</BlueButton>
|
|
||||||
|
|
||||||
<label className={"text-center text-red-500"}>{error}</label>
|
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||||
<progress
|
|
||||||
value={progress}
|
|
||||||
className={"bg-gray-300 rounded-lg h-1"}>
|
|
||||||
</progress>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
108
frontend/src/pages/EditClip.tsx
Normal file
108
frontend/src/pages/EditClip.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
|
import { getClipById, patchClip } from "../utils/endpoints.ts";
|
||||||
|
import Box from "../components/Box.tsx";
|
||||||
|
import BlueButton from "../components/buttons/BlueButton.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";
|
||||||
|
|
||||||
|
const EditClip = () => {
|
||||||
|
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;
|
||||||
|
getClipById(id)
|
||||||
|
.then((clip) => {
|
||||||
|
if (clip) {
|
||||||
|
setTitle(clip.title ?? "");
|
||||||
|
setDescription(clip.description ?? "");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load clip."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await patchClip(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="px-8 py-10 max-w-2xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-400 text-sm">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter title"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter description"
|
||||||
|
rows={4}
|
||||||
|
className={inputClass + " resize-none"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
|
{saving ? "Saving…" : saved
|
||||||
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
|
: "Save"}
|
||||||
|
</BlueButton>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditClip;
|
||||||
@@ -1,43 +1,78 @@
|
|||||||
const Home = () => {
|
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Upload,
|
||||||
|
title: "Upload & compress",
|
||||||
|
description: "Drop in a video file, set your clip range and export settings, then compress.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Scissors,
|
||||||
|
title: "Trim",
|
||||||
|
description: "Set start and end points with a range slider. Preview as you go.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
title: "Export settings",
|
||||||
|
description: "Choose resolution, frame rate, and an optional file size cap.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
title: "RTMP streaming",
|
||||||
|
description: "Point OBS at your stream key and go live. Find the key in your profile.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LayoutGrid,
|
||||||
|
title: "Clip library",
|
||||||
|
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Play,
|
||||||
|
title: "Built-in player",
|
||||||
|
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
return (
|
return (
|
||||||
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
|
<div className="flex flex-col items-center px-8 py-12 min-h-full">
|
||||||
{/* Logo */}
|
{/* Hero */}
|
||||||
|
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="VoD System Logo"
|
alt="VoD System Logo"
|
||||||
className={"h-44 mb-8 object-contain"}
|
className="h-24 mb-6 object-contain"
|
||||||
/>
|
/>
|
||||||
|
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
|
||||||
{/* Description Container */}
|
A home for your clips.
|
||||||
<div className={"max-w-xl text-center"}>
|
</h1>
|
||||||
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
|
<p className="text-base text-gray-500 leading-relaxed">
|
||||||
What is the VoD System?
|
Upload, trim, and export video clips. Stream live if you need to.
|
||||||
</h2>
|
|
||||||
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
|
|
||||||
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:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
|
|
||||||
<li>Upload clips effortlessly and securely.</li>
|
|
||||||
<li>Edit and trim videos with intuitive controls.</li>
|
|
||||||
<li>Compress files to specific file sizes.</li>
|
|
||||||
<li>Organize your clips for quick access and sharing.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p className={"text-lg leading-relaxed text-gray-600"}>
|
|
||||||
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
|
|
||||||
making video clip management faster and more enjoyable than ever.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Support Note */}
|
{/* Feature grid */}
|
||||||
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
||||||
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
Support for additional video formats will be added in future updates.
|
<div
|
||||||
|
key={title}
|
||||||
|
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format note */}
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Supports MP4, MOV, WebM, and OGG.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
import VideoCard from "../components/video/VideoCard";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getClips } from "../utils/endpoints";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getClips, deleteClip } from "../utils/endpoints";
|
||||||
import type { Clip } from "../utils/types";
|
import type { Clip } from "../utils/types";
|
||||||
|
|
||||||
const MyClips = () => {
|
const MyClips = () => {
|
||||||
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();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getClips()
|
getClips()
|
||||||
.then((data) => setClips(data))
|
.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));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteClip(id);
|
||||||
|
setClips(prev => prev.filter(c => c.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete clip");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-row"}>
|
<div className="p-6">
|
||||||
|
<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">
|
||||||
{clips.map((clip) => (
|
{clips.map((clip) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
|
key={clip.id}
|
||||||
id={clip.id}
|
id={clip.id}
|
||||||
title={clip.title}
|
title={clip.title}
|
||||||
duration={clip.duration}
|
duration={clip.duration}
|
||||||
createdAt={clip.createdAt}
|
createdAt={clip.createdAt}
|
||||||
className={"w-40 m-5"}
|
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||||
|
onDelete={() => handleDelete(clip.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error}
|
{clips.length === 0 && !error && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<p className="text-base">No clips yet.</p>
|
||||||
|
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
frontend/src/pages/Profile.tsx
Normal file
86
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
|
import Box from "../components/Box.tsx";
|
||||||
|
import { getUser } from "../utils/endpoints.ts";
|
||||||
|
import type { User } from "../utils/types.ts";
|
||||||
|
import { formatLocalDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then(setUser).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!user?.streamKey) return;
|
||||||
|
navigator.clipboard.writeText(user.streamKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
|
Please log in to view your profile.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||||
|
|
||||||
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
|
<img
|
||||||
|
src={user.profilePictureUrl}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
alt="Profile picture"
|
||||||
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||||
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Member since {formatLocalDate(user.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||||
|
Keep it private — anyone with this key can stream to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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">
|
||||||
|
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setKeyVisible(v => !v)}
|
||||||
|
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
|
||||||
|
onClick={handleCopy}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
@@ -1,76 +1,326 @@
|
|||||||
import {useEffect, useState} from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {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 type { Clip } from "../utils/types";
|
import type { Clip } from "../utils/types";
|
||||||
import {getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
||||||
import Box from "../components/Box.tsx"
|
import Box from "../components/Box.tsx";
|
||||||
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
|
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const VideoPlayer = () => {
|
const VideoPlayer = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Data
|
||||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
const [clip, setClip] = useState<Clip | null>(null);
|
const [clip, setClip] = useState<Clip | null>(null);
|
||||||
const [timeAgo, setTimeAgo] = useState<String>("");
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
|
// Playback
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [bufferedPct, setBufferedPct] = useState(0);
|
||||||
|
const [isWaiting, setIsWaiting] = useState(false);
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
// UI
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
// ── Data loading ────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!id) { setError("Clip ID is required."); return; }
|
||||||
setError("Clip ID is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideoBlob(id)
|
getVideoBlob(id)
|
||||||
.then((blob) => {
|
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||||
const url = URL.createObjectURL(blob);
|
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||||
setVideoUrl(url);
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
getClipById(id)
|
getClipById(id)
|
||||||
.then((fetchedClip) => {setClip(fetchedClip)})
|
.then(setClip)
|
||||||
.catch((err) => {
|
.catch((e) => {
|
||||||
console.error("Error fetching clip:", err);
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
setError("Failed to load clip details. Please try again later.");
|
else setError("Failed to load clip details.");
|
||||||
});
|
});
|
||||||
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Update timeAgo live every second
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clip || !clip.createdAt) return;
|
if (!clip?.createdAt) return;
|
||||||
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
|
||||||
const update = () => {
|
update();
|
||||||
const date = stringToDate(clip.createdAt);
|
|
||||||
setTimeAgo(dateToTimeAgo(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
update(); // initial update
|
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
return () => clearInterval(interval); // cleanup
|
|
||||||
}, [clip]);
|
}, [clip]);
|
||||||
|
|
||||||
|
// ── Controls visibility ─────────────────────────────────────────────────
|
||||||
|
const scheduleHide = useCallback(() => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
scheduleHide();
|
||||||
|
}, [scheduleHide]);
|
||||||
|
|
||||||
|
// ── Fullscreen ──────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
document.fullscreenElement
|
||||||
|
? document.exitFullscreen()
|
||||||
|
: containerRef.current.requestFullscreen();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts ──────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
switch (e.code) {
|
||||||
|
case 'Space': case 'KeyK':
|
||||||
|
e.preventDefault();
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||||
|
showControls();
|
||||||
|
break;
|
||||||
|
case 'KeyM':
|
||||||
|
video.muted = !video.muted;
|
||||||
|
setIsMuted(video.muted);
|
||||||
|
break;
|
||||||
|
case 'KeyF':
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [showControls, toggleFullscreen]);
|
||||||
|
|
||||||
|
// ── Progress bar seeking ────────────────────────────────────────────────
|
||||||
|
const seekTo = useCallback((clientX: number) => {
|
||||||
|
const rect = progressRef.current?.getBoundingClientRect();
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!rect || !video || !duration) return;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
video.currentTime = ratio * duration;
|
||||||
|
setCurrentTime(ratio * duration);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const onMove = (e: MouseEvent) => seekTo(e.clientX);
|
||||||
|
const onUp = () => setIsDragging(false);
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, seekTo]);
|
||||||
|
|
||||||
|
// ── Video element event handlers ────────────────────────────────────────
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
if (video.buffered.length > 0) {
|
||||||
|
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setIsMuted(v === 0);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = v;
|
||||||
|
videoRef.current.muted = v === 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.muted = !video.muted;
|
||||||
|
setIsMuted(video.muted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.paused ? video.play() : video.pause();
|
||||||
|
showControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
const effectiveVolume = isMuted ? 0 : volume;
|
||||||
|
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
||||||
|
|
||||||
|
if (notAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className={"w-9/10 m-auto"}>
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||||
<video
|
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
className={"w-full h-full rounded-lg m-auto"}
|
<Lock size={24} className="text-gray-400" />
|
||||||
controls
|
</div>
|
||||||
autoPlay
|
<div>
|
||||||
src={videoUrl}
|
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||||
onError={(e) => {
|
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
|
||||||
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
|
</div>
|
||||||
}}
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Your browser does not support the video tag.
|
<ArrowLeft size={16} />
|
||||||
</video>
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{error && <div className="text-red-500 mt-2">{error}</div>}
|
return (
|
||||||
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
<Box className={"p-2 m-2 flex flex-col"}>
|
{/* Player */}
|
||||||
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
|
<div
|
||||||
<p>{timeAgo}</p>
|
ref={containerRef}
|
||||||
|
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
|
onMouseMove={showControls}
|
||||||
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{ cursor: controlsVisible ? 'default' : 'none' }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
src={videoUrl}
|
||||||
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
|
onPause={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||||
|
onWaiting={() => setIsWaiting(true)}
|
||||||
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Buffering spinner */}
|
||||||
|
{isWaiting && videoUrl && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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'}`}>
|
||||||
|
{/* Gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||||
|
|
||||||
|
{/* Controls bar — pointer-events-auto only here */}
|
||||||
|
<div
|
||||||
|
className="relative px-4 pb-4 pt-10 pointer-events-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Scrub bar */}
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
|
>
|
||||||
|
<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}%` }} />
|
||||||
|
<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"
|
||||||
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons row */}
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||||
|
{isPlaying
|
||||||
|
? <Pause size={20} fill="currentColor" />
|
||||||
|
: <Play size={20} fill="currentColor" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Volume — slider expands on hover */}
|
||||||
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
|
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||||
|
<VolumeIcon size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0} max={1} step={0.02}
|
||||||
|
value={effectiveVolume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-16 accent-white cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||||
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
|
{!videoUrl && !error && (
|
||||||
|
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||||
|
<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">
|
||||||
|
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,208 +1,86 @@
|
|||||||
import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts";
|
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
/**
|
export class AuthError extends Error {
|
||||||
* Login function
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
* @param GoogleToken - The Google token received from the frontend.
|
}
|
||||||
* @return A promise that resolves to a JWT
|
|
||||||
*/
|
const login = async (googleToken: string): Promise<string> => {
|
||||||
const login = async (GoogleToken: string): Promise<string> => {
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
const response = await fetch(API_URL + '/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ token: googleToken }),
|
||||||
},
|
credentials: 'include',
|
||||||
body: JSON.stringify({ token: GoogleToken }),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||||
throw new Error(`Login failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error(`Login failed: ${result.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data.token;
|
return result.data.token;
|
||||||
}
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async (): Promise<void> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/auth/logout', {
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include'
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||||
throw new Error(`Logout failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (result.status === "error") {
|
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||||
throw new Error(`Logout failed: ${result.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a file to the backend.
|
|
||||||
* @param file - The file to upload.
|
|
||||||
*/
|
|
||||||
const uploadFile = async (file: File): Promise<string> => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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/upload', {
|
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||||
|
|
||||||
if (result.status == "error") {
|
const result: APIResponse = await response.json();
|
||||||
throw new Error(`Failed to upload file: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||||
}
|
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||||
* Submits metadata changes to the backend.
|
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||||
* @param uuid - The UUID of the video file to edit.
|
|
||||||
* @param videoMetadata - The metadata to update.
|
|
||||||
*/
|
|
||||||
const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(videoMetadata)) {
|
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
formData.append(key, value.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: formData.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to edit file: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error(`Failed to edit file: ${result.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers file processing.
|
|
||||||
* @param uuid - The UUID of the video file to process.
|
|
||||||
*/
|
|
||||||
const processFile = async (uuid: string) => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to process file: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error("Failed to process file: " + result.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertFile = async (uuid: string) => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/convert/${uuid}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to convert file: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error("Failed to convert file: " + result.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the processing progress percentage.
|
|
||||||
* @param uuid - The UUID of the video file.
|
|
||||||
*/
|
|
||||||
const getProgress = async (uuid: string): Promise<ProgressResult> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/progress/${uuid}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch progress: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error(`Failed to fetch progress: ${result.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error('Invalid progress data received');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const getUser = async (): Promise<User | null> => {
|
||||||
* Fetches original metadata from the backend.
|
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
||||||
* @param uuid - The UUID of the video file.
|
if (!response.ok) return null;
|
||||||
*/
|
|
||||||
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
|
|
||||||
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const result: APIResponse = await response.json();
|
||||||
throw new Error(`Failed to fetch metadata: ${response.status}`);
|
if (result.status === 'error') return null;
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
throw new Error(`Failed to fetch metadata: ${result.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user information. Returns null if not authenticated.
|
|
||||||
*/
|
|
||||||
const getUser = async (): Promise<null | User > => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.status === "error") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(result.data);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all clips for the current user.
|
|
||||||
*/
|
|
||||||
const getClips = async (): Promise<Clip[]> => {
|
const getClips = async (): Promise<Clip[]> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||||
|
|
||||||
@@ -211,69 +89,84 @@ const getClips = async (): Promise<Clip[]> => {
|
|||||||
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch {
|
};
|
||||||
throw new Error('Failed to parse response');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a clip by its ID.
|
|
||||||
* @param id
|
|
||||||
*/
|
|
||||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",});
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
throw new Error(`Failed to fetch clip: ${response.status}`);
|
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try{
|
|
||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (error: unknown) {
|
|
||||||
throw new Error(`Failed to parse clip response:
|
|
||||||
${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",});
|
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch video: ${id}: ${response.status}`)
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
} catch {
|
};
|
||||||
throw new Error(`Failed to convert Clip Return Object to 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 isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
|
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
||||||
if (!response.ok) {
|
return response.ok;
|
||||||
return false;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
uploadFile,
|
compress,
|
||||||
editFile,
|
getJob,
|
||||||
processFile,
|
|
||||||
convertFile,
|
|
||||||
getProgress,
|
|
||||||
getMetadata,
|
|
||||||
getUser,
|
getUser,
|
||||||
getClips,
|
getClips,
|
||||||
getClipById,
|
getClipById,
|
||||||
getVideoBlob,
|
getVideoBlob,
|
||||||
isThumbnailAvailable
|
patchClip,
|
||||||
|
deleteClip,
|
||||||
|
getStreamStatus,
|
||||||
|
isThumbnailAvailable,
|
||||||
};
|
};
|
||||||
@@ -17,8 +17,11 @@ type APIResponse = {
|
|||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
name: string,
|
name: string,
|
||||||
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
profilePictureUrl: string
|
profilePictureUrl: string,
|
||||||
|
streamKey: string,
|
||||||
|
createdAt: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Clip = {
|
type Clip = {
|
||||||
@@ -30,15 +33,13 @@ type Clip = {
|
|||||||
createdAt: string,
|
createdAt: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressResult = {
|
type JobResponse = {
|
||||||
process: {
|
uuid: string,
|
||||||
progress: number,
|
progress: number,
|
||||||
complete: boolean
|
isComplete: boolean,
|
||||||
};
|
state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED',
|
||||||
conversion: {
|
errorOutput: string | null,
|
||||||
progress: number,
|
createdAt: string,
|
||||||
complete: boolean
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -46,5 +47,5 @@ export type {
|
|||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
User,
|
User,
|
||||||
Clip,
|
Clip,
|
||||||
ProgressResult
|
JobResponse
|
||||||
}
|
}
|
||||||
@@ -2,76 +2,42 @@ function formatTime(seconds: number): string {
|
|||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
const padded = (n: number) => n.toString().padStart(2, '0');
|
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
|
||||||
|
return `${m}:${pad(s)}`;
|
||||||
if (h > 0) {
|
|
||||||
return `${h}:${padded(m)}:${padded(s)}`;
|
|
||||||
} else {
|
|
||||||
return `${m}:${padded(s)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
function stringToDate(dateString: string): Date {
|
function stringToDate(dateString: string): Date {
|
||||||
const validIso = dateString.substring(0, 23);
|
const normalized = dateString.replace(/(\.\d{3})\d+/, "$1");
|
||||||
const date = new Date(validIso);
|
const withTz = /[Zz]$|[+-]\d{2}:\d{2}$|[+-]\d{4}$/.test(normalized) ? normalized : `${normalized}Z`;
|
||||||
if (isNaN(date.getTime())) {
|
const date = new Date(withTz);
|
||||||
throw new Error("Invalid date string");
|
if (isNaN(date.getTime())) throw new Error("Invalid date string");
|
||||||
}
|
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
function dateToTimeAgo(date: Date): string {
|
function dateToTimeAgo(date: Date): string {
|
||||||
const now = new Date();
|
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000);
|
const units: [number, string][] = [
|
||||||
|
[31536000, 'year'],
|
||||||
if (secondsAgo < 60) {
|
[2592000, 'month'],
|
||||||
return `${secondsAgo} seconds ago`;
|
[86400, 'day'],
|
||||||
} else if (secondsAgo < 3600) {
|
[3600, 'hour'],
|
||||||
const minutes = Math.floor(secondsAgo / 60);
|
[60, 'minute'],
|
||||||
|
];
|
||||||
if (minutes === 1) {
|
for (const [threshold, unit] of units) {
|
||||||
return "1 minute ago";
|
if (secondsAgo >= threshold) {
|
||||||
}
|
const count = Math.floor(secondsAgo / threshold);
|
||||||
|
return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
|
||||||
return `${minutes} minutes ago`;
|
|
||||||
} else if (secondsAgo < 86400) {
|
|
||||||
const hours = Math.floor(secondsAgo / 3600);
|
|
||||||
|
|
||||||
if (hours === 1) {
|
|
||||||
return "1 hour ago";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${hours} hours ago`;
|
|
||||||
} else if (secondsAgo < 2592000) {
|
|
||||||
const days = Math.floor(secondsAgo / 86400);
|
|
||||||
|
|
||||||
if (days === 1) {
|
|
||||||
return "1 day ago";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${days} days ago`;
|
|
||||||
} else if (secondsAgo < 31536000) {
|
|
||||||
const months = Math.floor(secondsAgo / 2592000);
|
|
||||||
|
|
||||||
if (months === 1) {
|
|
||||||
return "1 month ago";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${months} months ago`;
|
|
||||||
} else {
|
|
||||||
const years = Math.floor(secondsAgo / 31536000);
|
|
||||||
|
|
||||||
if (years === 1) {
|
|
||||||
return "1 year ago";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${years} years ago`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const s = Math.max(0, secondsAgo);
|
||||||
export {
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
formatTime,
|
|
||||||
stringToDate,
|
|
||||||
dateToTimeAgo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||||
|
|||||||
9
nginx/Dockerfile
Normal file
9
nginx/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM tiangolo/nginx-rtmp
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
48
nginx/default.conf.template
Normal file
48
nginx/default.conf.template
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
rtmp {
|
||||||
|
server {
|
||||||
|
listen 1935;
|
||||||
|
chunk_size 4096;
|
||||||
|
|
||||||
|
application live {
|
||||||
|
live on;
|
||||||
|
record off;
|
||||||
|
|
||||||
|
allow publish all;
|
||||||
|
allow play all;
|
||||||
|
|
||||||
|
hls on;
|
||||||
|
hls_nested on;
|
||||||
|
hls_path /stream;
|
||||||
|
hls_fragment_naming system;
|
||||||
|
hls_fragment 3;
|
||||||
|
hls_playlist_length 28800;
|
||||||
|
|
||||||
|
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
|
||||||
|
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
||||||
|
on_update ${WEB_SERVER_URL}/api/v1/stream/heartbeat;
|
||||||
|
notify_update_timeout 30s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve the HLS segments over HTTP
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 8088;
|
||||||
|
|
||||||
|
location /hls {
|
||||||
|
types {
|
||||||
|
application/vnd.apple.mpegurl m3u8; # proper MIME for HLS playlists
|
||||||
|
video/mp2t ts; # MPEG-TS segment MIME
|
||||||
|
}
|
||||||
|
root /stream;
|
||||||
|
add_header Cache-Control no-cache; # prevent stale segments
|
||||||
|
add_header Access-Control-Allow-Origin *; # CORS for browser playback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
nginx/docker-entrypoint.sh
Normal file
9
nginx/docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# must only run on certain env files, otherwise it replaces too much
|
||||||
|
envsubst '${WEB_SERVER_URL}' \
|
||||||
|
< /etc/nginx/templates/default.conf.template \
|
||||||
|
> /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# daemon off required otherwise the container would exit immediately after nginx forks to background.
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
4
pom.xml
4
pom.xml
@@ -81,6 +81,10 @@
|
|||||||
<artifactId>google-api-client</artifactId>
|
<artifactId>google-api-client</artifactId>
|
||||||
<version>2.8.0</version>
|
<version>2.8.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package com.ddf.vodsystem.configuration;
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.AsyncConfigurer;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class AsyncConfig {
|
public class AsyncConfig implements AsyncConfigurer {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||||
|
|
||||||
@Bean(name = "ffmpegTaskExecutor")
|
@Bean(name = "ffmpegTaskExecutor")
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
@@ -19,4 +24,10 @@ public class AsyncConfig {
|
|||||||
executor.initialize();
|
executor.initialize();
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||||
|
return (ex, method, params) ->
|
||||||
|
logger.error("Async task '{}' failed: {}", method.getName(), ex.getMessage(), ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class CorsConfig {
|
|||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
registry.addMapping("/api/v1/**")
|
registry.addMapping("/api/v1/**")
|
||||||
.allowedOrigins(frontendUrl)
|
.allowedOrigins(frontendUrl)
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
||||||
.requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated()
|
.requestMatchers("/api/v1/media/**").authenticated()
|
||||||
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
|
.requestMatchers("/api/v1/jobs/**").authenticated()
|
||||||
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
|
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
||||||
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll()
|
.requestMatchers("/api/v1/users/login").permitAll()
|
||||||
.requestMatchers("/api/v1/metadata/**").permitAll()
|
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.ClipDTO;
|
import com.ddf.vodsystem.controllers.dto.ClipResponse;
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
|
||||||
import com.ddf.vodsystem.entities.Clip;
|
import com.ddf.vodsystem.entities.Clip;
|
||||||
import com.ddf.vodsystem.services.ClipService;
|
import com.ddf.vodsystem.services.ClipService;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
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.*;
|
||||||
|
|
||||||
@@ -15,15 +20,16 @@ import java.util.Optional;
|
|||||||
public class ClipController {
|
public class ClipController {
|
||||||
private final ClipService clipService;
|
private final ClipService clipService;
|
||||||
private static final String SUCCESS = "success";
|
private static final String SUCCESS = "success";
|
||||||
|
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||||
|
|
||||||
public ClipController(ClipService clipService) {
|
public ClipController(ClipService clipService) {
|
||||||
this.clipService = clipService;
|
this.clipService = clipService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<ClipDTO>>> getClips() {
|
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
|
||||||
List<Clip> clips = clipService.getClipsByUser();
|
List<Clip> clips = clipService.getClipsByUser();
|
||||||
List<ClipDTO> clipDTOs = clips.stream()
|
List<ClipResponse> clipDTOs = clips.stream()
|
||||||
.map(this::convertToDTO)
|
.map(this::convertToDTO)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -36,13 +42,13 @@ public class ClipController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipDTO>> getClipById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
||||||
Optional<Clip> clip = clipService.getClipById(id);
|
Optional<Clip> clip = clipService.getClipById(id);
|
||||||
if (clip.isEmpty()) {
|
if (clip.isEmpty()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
ClipDTO clipDTO = convertToDTO(clip.get());
|
ClipResponse clipDTO = convertToDTO(clip.get());
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS,
|
new APIResponse<>(SUCCESS,
|
||||||
@@ -52,6 +58,17 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
|
||||||
|
@RequestBody ClipUpdateRequest updateFields) {
|
||||||
|
Clip clip = clipService.updateClip(id, updateFields);
|
||||||
|
ClipResponse clipDTO = convertToDTO(clip);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Clip updated successfully", clipDTO)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
||||||
if (!clipService.deleteClip(id)) {
|
if (!clipService.deleteClip(id)) {
|
||||||
@@ -67,14 +84,42 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ClipDTO convertToDTO(Clip clip) {
|
@GetMapping("/{id}/media")
|
||||||
ClipDTO dto = new ClipDTO();
|
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
|
||||||
dto.setId(clip.getId());
|
Resource resource = clipService.downloadClip(id);
|
||||||
dto.setUserId(clip.getUser().getId());
|
|
||||||
dto.setTitle(clip.getTitle());
|
if (resource == null || !resource.exists()) {
|
||||||
dto.setDescription(clip.getDescription());
|
return ResponseEntity.notFound().build();
|
||||||
dto.setDuration(clip.getDuration());
|
}
|
||||||
dto.setCreatedAt(clip.getCreatedAt());
|
|
||||||
return dto;
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/thumbnail")
|
||||||
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||||
|
Resource resource = clipService.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 ClipResponse convertToDTO(Clip clip) {
|
||||||
|
return new ClipResponse(
|
||||||
|
clip.getId(),
|
||||||
|
clip.getUser().getId(),
|
||||||
|
clip.getTitle(),
|
||||||
|
clip.getDescription(),
|
||||||
|
clip.getDuration(),
|
||||||
|
clip.getCreatedAt()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.services.DownloadService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
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.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("api/v1/download")
|
|
||||||
public class DownloadController {
|
|
||||||
private final DownloadService downloadService;
|
|
||||||
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public DownloadController(DownloadService downloadService) {
|
|
||||||
this.downloadService = downloadService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/output/{uuid}")
|
|
||||||
public ResponseEntity<Resource> downloadFile(@PathVariable String uuid) {
|
|
||||||
Resource resource = downloadService.downloadOutput(uuid);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/input/{uuid}")
|
|
||||||
public ResponseEntity<Resource> downloadInput(@PathVariable String uuid) {
|
|
||||||
Resource resource = downloadService.downloadInput(uuid);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/clip/{id}")
|
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
|
|
||||||
Resource resource = downloadService.downloadClip(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/thumbnail/{id}")
|
|
||||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
|
||||||
Resource resource = downloadService.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.JobStatus;
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import com.ddf.vodsystem.services.EditService;
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("api/v1")
|
|
||||||
public class EditController {
|
|
||||||
private final EditService editService;
|
|
||||||
private static final String SUCCESS = "success";
|
|
||||||
|
|
||||||
public EditController(EditService editService) {
|
|
||||||
this.editService = editService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("edit/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<Void>> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) {
|
|
||||||
editService.edit(uuid, clipOptions);
|
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/process/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
|
|
||||||
editService.process(uuid);
|
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/convert/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) {
|
|
||||||
editService.convert(uuid);
|
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/progress/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<JobStatus>> getStatus(@PathVariable("uuid") String uuid) {
|
|
||||||
JobStatus status = editService.getStatus(uuid);
|
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.exceptions.FFMPEGException;
|
import com.ddf.vodsystem.exceptions.*;
|
||||||
import com.ddf.vodsystem.exceptions.JobNotFinished;
|
|
||||||
import com.ddf.vodsystem.exceptions.JobNotFound;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||||
@@ -76,4 +73,25 @@ public class GlobalExceptionHandler {
|
|||||||
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(KeyNotFound.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
|
||||||
|
logger.error("KeyNotFound: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AlreadyStreaming.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
|
||||||
|
logger.error("AlreadyStreaming: {}", ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(StorageException.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
|
||||||
|
logger.error("Storage operation failed", e);
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.JobResponse;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.dto.Job;
|
||||||
|
import com.ddf.vodsystem.exceptions.ClipNotFound;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotReadyException;
|
||||||
|
import com.ddf.vodsystem.services.JobRegistryService;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("api/v1/jobs")
|
||||||
|
public class JobController {
|
||||||
|
private final JobRegistryService jobRegistryService;
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||||
|
|
||||||
|
public JobController(JobRegistryService jobRegistryService){
|
||||||
|
this.jobRegistryService = jobRegistryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{uuid}")
|
||||||
|
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
||||||
|
Job job = jobRegistryService.getJob(uuid);
|
||||||
|
JobResponse jobResponse = new JobResponse(
|
||||||
|
job.getUuid(),
|
||||||
|
job.getProgressTracker().getProgress(),
|
||||||
|
job.getProgressTracker().isComplete(),
|
||||||
|
job.getState(),
|
||||||
|
job.getErrorOutput(),
|
||||||
|
job.getCreatedAt()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
|
SUCCESS,
|
||||||
|
"Job successfully retreived",
|
||||||
|
jobResponse
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{uuid}/download")
|
||||||
|
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
||||||
|
Job job = jobRegistryService.getJob(uuid);
|
||||||
|
|
||||||
|
if (job.getDownload() == null) {
|
||||||
|
throw new NotReadyException("Job " + uuid + " not ready for download.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new FileSystemResource(job.getDownload());
|
||||||
|
|
||||||
|
if (!resource.exists()) {
|
||||||
|
throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
|
||||||
|
import com.ddf.vodsystem.dto.Job;
|
||||||
|
import com.ddf.vodsystem.dto.ClipOptions;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.services.MediaService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/media")
|
||||||
|
public class MediaController {
|
||||||
|
private final MediaService mediaService;
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
|
public MediaController(MediaService mediaService) {
|
||||||
|
this.mediaService = mediaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||||
|
@RequestParam("file")MultipartFile file,
|
||||||
|
@Valid @ModelAttribute ClipOptions options
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Job job = mediaService.compress(file, options);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
|
SUCCESS,
|
||||||
|
"Compression successfully started",
|
||||||
|
new UUIDResponse(job.getUuid())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PostMapping("/save")
|
||||||
|
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||||
|
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
||||||
|
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
|
SUCCESS,
|
||||||
|
"Section saving successfully started",
|
||||||
|
new UUIDResponse(job.getUuid())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clip")
|
||||||
|
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||||
|
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||||
|
Job job = mediaService.clip(clipSectionRequest.duration());
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
|
SUCCESS,
|
||||||
|
"Clipping successfully started",
|
||||||
|
new UUIDResponse(job.getUuid())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
|
||||||
import com.ddf.vodsystem.services.JobService;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v1/metadata")
|
|
||||||
public class MetadataController {
|
|
||||||
private final JobService jobService;
|
|
||||||
|
|
||||||
public MetadataController(JobService jobService) {
|
|
||||||
this.jobService = jobService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/original/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
|
|
||||||
ClipOptions originalMetadata = jobService.getJob(uuid).getInputClipOptions();
|
|
||||||
|
|
||||||
if (originalMetadata == null) {
|
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
||||||
.body(new APIResponse<>("error", "Original metadata not found", null));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/converted/{uuid}")
|
|
||||||
public ResponseEntity<APIResponse<ClipOptions>> getConvertedMetadata(@PathVariable String uuid) {
|
|
||||||
ClipOptions convertedMetadata = jobService.getJob(uuid).getOutputClipOptions();
|
|
||||||
|
|
||||||
if (convertedMetadata == null) {
|
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
||||||
.body(new APIResponse<>("error", "Converted metadata not found", null));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.StreamResponse;
|
||||||
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.entities.Stream;
|
||||||
|
import com.ddf.vodsystem.services.StreamService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/stream")
|
||||||
|
public class StreamController {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
|
private final StreamService streamService;
|
||||||
|
|
||||||
|
public StreamController(StreamService streamService) {
|
||||||
|
this.streamService = streamService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by nginx on_publish
|
||||||
|
@PostMapping("/start")
|
||||||
|
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
|
||||||
|
logger.info("Stream start callback for key: {}", streamKey);
|
||||||
|
Stream stream = streamService.startStream(streamKey);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by nginx on_publish_done
|
||||||
|
@PostMapping("/stop")
|
||||||
|
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
|
||||||
|
logger.info("Stream stop callback for key: {}", streamKey);
|
||||||
|
streamService.endStream(streamKey);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by nginx on_update
|
||||||
|
@PostMapping("/heartbeat")
|
||||||
|
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
|
||||||
|
streamService.heartbeatStream(streamKey);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history/{userId}")
|
||||||
|
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
||||||
|
List<Stream> history = streamService.getStreamHistory(userId);
|
||||||
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/current")
|
||||||
|
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
||||||
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
|
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||||
|
"Streaming status retrieved",
|
||||||
|
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||||
|
"Streaming status retrieved",
|
||||||
|
new StreamResponse(false, null))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
|
||||||
import com.ddf.vodsystem.services.UploadService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("api/v1/upload")
|
|
||||||
public class UploadController {
|
|
||||||
|
|
||||||
private final UploadService uploadService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public UploadController(UploadService uploadService) {
|
|
||||||
this.uploadService = uploadService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping()
|
|
||||||
public ResponseEntity<APIResponse<Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
|
|
||||||
if (file == null || file.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Invalid file");
|
|
||||||
}
|
|
||||||
|
|
||||||
String uuid = uploadService.upload(file);
|
|
||||||
APIResponse<Object> response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid));
|
|
||||||
return new ResponseEntity<>(response, HttpStatus.OK);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.dto.TokenDTO;
|
import com.ddf.vodsystem.controllers.dto.Token;
|
||||||
import com.ddf.vodsystem.entities.User;
|
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;
|
||||||
@@ -14,18 +14,19 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/auth/")
|
@RequestMapping("/api/v1/users")
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@Value("${jwt.expiration}")
|
@Value("${jwt.expiration}")
|
||||||
private long jwtExpiration;
|
private long jwtExpiration;
|
||||||
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
public UserController(UserService userService) {
|
public UserController(UserService userService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/user")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<APIResponse<User>> user() {
|
public ResponseEntity<APIResponse<User>> user() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
|
|
||||||
@@ -34,14 +35,14 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>("success", "User retrieved successfully", user.get())
|
new APIResponse<>(SUCCESS, "User retrieved successfully", user.get())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<TokenDTO>> login(@RequestBody TokenDTO tokenDTO,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(tokenDTO.getToken());
|
String jwt = userService.login(token.token());
|
||||||
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
@@ -54,7 +55,7 @@ public class UserController {
|
|||||||
response.addHeader("Set-Cookie", cookie.toString());
|
response.addHeader("Set-Cookie", cookie.toString());
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>("success", "Logged in successfully", new TokenDTO(jwt))
|
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ public class UserController {
|
|||||||
response.addHeader("Set-Cookie", cookie.toString());
|
response.addHeader("Set-Cookie", cookie.toString());
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>("success", "Logged out successfully", null)
|
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ClipResponse(
|
||||||
|
Long id,
|
||||||
|
Long userId,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
Float duration,
|
||||||
|
Instant createdAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ClipSectionRequest(
|
||||||
|
@NotNull float duration
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record ClipUpdateRequest (
|
||||||
|
String title,
|
||||||
|
String description
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.JobState;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record JobResponse (
|
||||||
|
String uuid,
|
||||||
|
float progress,
|
||||||
|
boolean isComplete,
|
||||||
|
JobState state,
|
||||||
|
String errorOutput,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record SaveSectionRequest(
|
||||||
|
@NotNull Instant startTime,
|
||||||
|
@NotNull Instant endTime) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record StreamResponse(
|
||||||
|
boolean isStreaming,
|
||||||
|
Long id
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record Token(
|
||||||
|
String token
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record UUIDResponse(String uuid) {}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class ClipDTO {
|
|
||||||
private Long id;
|
|
||||||
private Long userId;
|
|
||||||
private String title;
|
|
||||||
private String description;
|
|
||||||
private Float duration;
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class ClipOptions {
|
public class ClipOptions {
|
||||||
|
@NotBlank
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
private Float startPoint;
|
private Float startPoint;
|
||||||
|
|
||||||
|
@NotNull @Positive
|
||||||
private Float duration;
|
private Float duration;
|
||||||
|
|
||||||
|
@DecimalMin("1.0") @DecimalMax("120.0")
|
||||||
private Float fps;
|
private Float fps;
|
||||||
|
|
||||||
|
@Positive
|
||||||
private Integer width;
|
private Integer width;
|
||||||
|
|
||||||
|
@Positive
|
||||||
private Integer height;
|
private Integer height;
|
||||||
|
|
||||||
|
@Positive
|
||||||
private Float fileSize;
|
private Float fileSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class Job {
|
public class Job {
|
||||||
private String uuid;
|
private String uuid;
|
||||||
private File inputFile;
|
private ProgressTracker progressTracker = new ProgressTracker();
|
||||||
private File outputFile;
|
private JobState state = JobState.READY;
|
||||||
|
private String errorOutput;
|
||||||
|
|
||||||
// configs
|
private Path download;
|
||||||
private ClipOptions inputClipOptions;
|
private Instant createdAt = Instant.now();
|
||||||
private ClipOptions outputClipOptions = new ClipOptions();
|
|
||||||
|
|
||||||
// job status
|
public Job(String uuid) {
|
||||||
private JobStatus status = new JobStatus();
|
|
||||||
|
|
||||||
public Job(String uuid,
|
|
||||||
File inputFile,
|
|
||||||
File outputFile,
|
|
||||||
ClipOptions inputClipOptions) {
|
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.inputFile = inputFile;
|
|
||||||
this.outputFile = outputFile;
|
|
||||||
this.inputClipOptions = inputClipOptions;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
public enum JobState {
|
||||||
|
READY,
|
||||||
|
PROCESSING,
|
||||||
|
SUCCEEDED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class JobStatus {
|
|
||||||
private ProgressTracker process = new ProgressTracker();
|
|
||||||
private ProgressTracker conversion = new ProgressTracker();
|
|
||||||
private boolean failed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class TokenDTO {
|
|
||||||
private String token;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import jakarta.persistence.*;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "clips")
|
@Table(name = "clips")
|
||||||
@@ -28,7 +28,7 @@ public class Clip {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Column(name = "created_at")
|
@Column(name = "created_at")
|
||||||
private LocalDateTime createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@Column(name = "width", nullable = false)
|
@Column(name = "width", nullable = false)
|
||||||
private Integer width;
|
private Integer width;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user