78 Commits

Author SHA1 Message Date
d6c703457c Merge pull request 'Update prod to master' (#4) from master into prod
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/4
2026-06-25 23:28:51 +01:00
0b2c875889 fix(datetime): local datetime parsing 2026-06-25 23:17:03 +01:00
7db25194bc fix(datetime): incorrect datetime parsing 2026-06-25 23:10:32 +01:00
3d57007074 fix(maintainability): clean up reliability issues 2026-06-25 20:39:06 +01:00
5e00965baa Merge pull request 'Refactor job into being a simple UUID tracker' (#3) from refactor/generalize-job-abstraction into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/3
2026-06-25 20:26:08 +01:00
0e13e44feb fix(tailwind): top-level gitignore cause tailwind css issues
20:22
Scoped the bare video entry in the root .gitignore to its intended path. Tailwind V4 respects all .gitignore files in the project when scanning for utility classes, so the bare entry was silently preventing components/video/* from being scanned and its classes from appearing in the computed CSS output. The bug was absent in Docker as the root .gitignore was never copied into the container.
2026-06-25 20:22:44 +01:00
42294703ae chore(backend): remove LocalDateTime usage, replaced with Instant 2026-06-25 20:21:48 +01:00
755a18fc3a chore: update gitignore 2026-06-23 21:39:53 +01:00
8b299dc4fa fix(directories): inputs absolute paths into db 2026-06-23 21:31:45 +01:00
5651aa4ce9 refactor(directories): switch from File to Path objs 2026-06-23 20:53:24 +01:00
8b49a10a8c refactor(frontend): remove dead code 2026-06-22 22:50:07 +01:00
e36913d671 fix(frontend): update frontend to match new endpoints 2026-06-22 22:42:13 +01:00
7d8a62d331 docs(bruno): missing post response script 2026-06-22 22:34:18 +01:00
c571e12099 docs(bruno): remove old endpoints 2026-06-22 22:10:33 +01:00
e9abdfb44a docs(bruno): new bruno files 2026-06-22 22:09:10 +01:00
dc5af8f868 docs(bruno): overhaul bruno to match new API 2026-06-22 22:08:56 +01:00
da6bf16d07 refactor(media): consolidate upload/edit/download into MediaService
Merge UploadService, EditService, JobOrchestrationService, and
DownloadService into a single MediaService with a unified Job lifecycle
(READY → PROCESSING → SUCCEEDED/FAILED via JobState enum).

- Add MediaController (/api/v1/media) exposing compress, save, and clip
- Add JobController (/api/v1/job) for status polling and file download
- Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail)
- Move stream saveSection/clip from StreamService into MediaService
- Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file
- Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration
- Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner
- Rename GET /api/v1/auth/user → GET /api/v1/user/me
- Delete DownloadController, MetadataController, UploadController
2026-06-22 20:54:16 +01:00
b732c76aef Merge branch 'master' into refactor/generalize-job-abstraction 2026-06-22 18:32:45 +01:00
6c1f1112dc feat(stream): clipping stream now saves to db 2026-06-21 18:31:26 +01:00
9185a41147 feat(frontend): remove server-side upload process and switch to browser process 2026-06-20 22:04:42 +01:00
b05ae62230 refactor(dto): use record for Token DTO 2026-06-20 19:01:59 +01:00
a6850d3d65 refactor(jobs): split JobService into JobRegistryService and JobOrchestrationService 2026-06-20 18:50:14 +01:00
7cee64beb3 refactor(stream): centralize stream directory resolution in DirectoryService 2026-06-20 18:38:47 +01:00
a0efa3eb8c feat(stream): add streaming endpoint and indicator 2026-06-20 16:21:18 +01:00
2b1f8ce710 refactor(dto): move API DTOs 2026-06-20 16:04:19 +01:00
06f392a3ff refactor(dto): use Record instead of Lombok data 2026-06-20 14:54:36 +01:00
07305f0513 fix(frontend): add no auth screen for clips 2026-06-20 14:49:10 +01:00
b84950a2b8 feat(frontend): update home page 2026-06-20 14:38:35 +01:00
637d6c94ed feat(frontend): add edit clip page 2026-06-20 14:30:22 +01:00
bc5c50d025 docs(bruno): add edit endpoint 2026-06-20 14:24:17 +01:00
3797fe1feb feat(frontend): improve video player 2026-06-20 14:24:03 +01:00
fd1b0e19a1 feat(clips): add edit functionality for title & description 2026-06-20 14:23:50 +01:00
8059963210 feat(frontend): order clips by newest first 2026-06-20 14:04:00 +01:00
85d6f568db feat(frontend): add delete option for clips 2026-06-20 13:02:47 +01:00
91c3259331 feat(frontend): restyle and design frontend 2026-06-20 12:52:51 +01:00
1062f72661 refactor(directories): improve error handling 2026-06-20 12:38:40 +01:00
94269459b9 Merge pull request 'RTMP Streaming & User Streams' (#1) from feat/rtmp-streaming into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/1
2026-06-20 01:33:55 +01:00
7eb757d41a chore: remove .mvn 2026-06-20 01:29:48 +01:00
26a906c755 docs: update env & bruno 2026-06-20 01:28:15 +01:00
37e9b6c1e7 refactor(directory): propagate dir creation error to caller 2026-06-20 01:27:51 +01:00
8602f65db7 fix(stream history): add auth missing checks 2026-06-19 23:26:11 +01:00
85615ebc54 fix(schema): mismatch column names for Stream 2026-06-19 23:09:05 +01:00
e60b6e36b2 fix(stream clip): blank section at start of clip 2026-06-19 23:00:09 +01:00
736142e8b3 refactor(stream): clip section extends save section
Add javadocs.
2026-06-19 22:48:53 +01:00
f6061c25ca docs(bruno): add clip endpoint 2026-06-19 22:33:50 +01:00
88d83be50a feat(stream): implement clipping functionality 2026-06-19 22:30:30 +01:00
b55ba3ce51 feat(api): add error handling for save endpoint 2026-06-19 22:04:42 +01:00
5b997cb945 chore(bruno): add save stream endpoint 2026-06-16 21:08:44 +01:00
bf59cb087f feat(stream): implement section saving 2026-06-06 15:22:36 +01:00
480c21bc8c fix(profiles): missing profile page 2026-06-06 13:56:47 +01:00
8bc9044fab feat(frontend): add profile page to view stream key 2026-06-06 13:25:35 +01:00
a928bb390f fix: broken compose configuration 2026-06-04 22:19:45 +01:00
1dbc40d8e6 feat(stream): connect nginx with backend with controllers 2026-05-18 22:27:49 +01:00
3b188a3497 fix(nginx): envs not passed into nginx 2026-05-18 21:14:56 +01:00
c70cd3f939 fix(db): remove auto-drop 2026-05-17 22:30:35 +01:00
6230259582 refactor(nginx): streams are separated by key 2026-05-17 22:05:29 +01:00
781aecbc09 build: media stored in volume 2026-05-17 21:28:21 +01:00
32a016e212 build: update env example 2026-05-17 21:17:03 +01:00
a74e5d1453 build(properties): missing properties for db 2026-05-17 21:16:19 +01:00
a74ab9ee0a fix(frontend): missing URL prefixes 2026-05-17 21:06:13 +01:00
7eb4f1d34e build: move postgres credentials to env 2026-05-17 20:49:04 +01:00
09ec5cf09c fix: incorrect docker deployment 2026-05-14 20:27:27 +01:00
2e6679cb89 refactor(nginx): nginx only handles rtmp connections
Not longer handles frontend & backend. Partially for ease of development and also for separation of concenrs.
2026-05-14 20:20:37 +01:00
265a96acc1 feat: add Stream table & boilerplate 2026-05-03 12:51:32 +01:00
942f0d1a77 fix: broken frontend in docker compose 2026-05-02 19:19:25 +01:00
a20ea862b6 fix: missing dir in gitignore 2026-05-01 23:36:02 +01:00
a9e8b7fe78 feat: route Spring Boot & Vite through nginx 2026-05-01 23:35:04 +01:00
3418267829 feat: add nginx rtmp server & api and frontend passthroughs 2026-05-01 23:34:43 +01:00
fe097c8ee7 build: add comprehensive docker files for frontend & backend
Update docker-compose.yml to include new Dockerfiles
2026-02-22 21:38:43 +00:00
ab943be44e refactor(frontend): move media blob fetch function to endpoint file 2026-02-22 21:38:08 +00:00
0c5b93571b chore: add .env to gitignore 2026-02-22 21:37:07 +00:00
f2596e75f0 fix(api): incorrect local CORS url 2026-02-22 01:00:57 +00:00
7c95f87b40 chore: remove HTTPS module in frontend 2026-02-22 00:45:41 +00:00
Dylan De Faoite
83aa6e230d Update README to remove user management section
Removed user management plans from the README as these have been implemented
2026-01-08 11:09:58 +00:00
eed46cf266 Extract database clip saving into saveClip()
persistClip() had grown too large and handled multiple concerns.
This change moves the database save logic into saveClip(), making
persistClip() smaller and easier to refactor further.
2025-12-15 22:06:57 +00:00
65ec8cb29a MOVE clip creation method from ClipService to JobService
The original clip creation method in ClipService handled many different services, like metadata handling, callback methods and more. Moving it to a more suitable location in the JobService allows it to easily access the Job object and update it upon failure.
2025-12-15 21:23:01 +00:00
0f5fc76e55 ADD unified metadata validation to the MetadataService
Validation was happening in two places, in both EditService and in MetadataService doing different validations. This unifies them both into a singular method
2025-12-15 21:19:53 +00:00
2d0d168784 ADD information-level logging to EditService 2025-12-15 20:28:27 +00:00
131 changed files with 2941 additions and 1916 deletions

View File

@@ -1,3 +1,18 @@
# Secrets
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
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=

6
.gitignore vendored
View File

@@ -1,6 +1,7 @@
### Security ###
.env.local
.env.prod
.env
node_modules
HELP.md
@@ -38,6 +39,9 @@ build/
.vscode/
### Generated Files ###
videos/
/videos
/outputs
frontend/.flowbite-react
generated
.mvn

View File

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

View File

@@ -23,10 +23,6 @@ This VoD system began as a small project back in my 5th year of secondary school
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
# Future Plans
- **User Management**
- Database integration with authentication and login
- Clip saving and management for authenticated users
- **Format Handling**
- Backend conversion of non-MP4 files via FFMPEG for broader format support
- **Input Sources**

View 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
}

View 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
}

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 4
}
auth {
mode: inherit
}

View 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
}

View File

@@ -1,7 +1,7 @@
meta {
name: Get all clips
type: http
seq: 1
seq: 2
}
get {

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Clips
seq: 1
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Download
type: http
seq: 2
}
get {
url: {{base_url}}/download/output/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Download
type: http
seq: 1
}
get {
url: {{base_url}}/download/input/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Download
}

View File

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

View File

@@ -1,11 +0,0 @@
meta {
name: Process
type: http
seq: 2
}
get {
url: {{base_url}}/process/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Progress
type: http
seq: 3
}
get {
url: {{base_url}}/progress/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Edit
}

View 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
}

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Jobs
seq: 5
}
auth {
mode: inherit
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -0,0 +1,8 @@
meta {
name: Media
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Metadata
type: http
seq: 2
}
get {
url: {{base_url}}/metadata/converted/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Metadata
type: http
seq: 1
}
get {
url: {{base_url}}/metadata/original/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Metadata
}

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Streams
seq: 4
}
auth {
mode: inherit
}

View File

@@ -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);
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{base_url}}/auth/user
url: {{base_url}}/users/me
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
post {
url: {{base_url}}/auth/login
url: {{base_url}}/users/login
body: json
auth: none
}

View File

@@ -0,0 +1,8 @@
meta {
name: Users
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,7 @@
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}

32
docker-compose.dev.yml Normal file
View 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

View File

@@ -1,18 +1,56 @@
version: '3.8'
services:
postgres:
image: postgres:15
container_name: my_postgres
container_name: vod_postgres
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: vodSystem
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data
backend:
build:
context: .
dockerfile: src/main/Dockerfile
container_name: vod_backend
depends_on:
- postgres
env_file:
- .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- "8080:8080"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: vod_frontend
environment:
VITE_API_URL: ${BACKEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on:
- backend
ports:
- "5173:5173"
restart: unless-stopped
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes:
postgres_data:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped
depends_on:
- backend

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

9
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -7,6 +7,8 @@ import Home from './pages/Home';
import {useEffect} from "react";
import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx";
import EditClip from "./pages/EditClip.tsx";
import Profile from "./pages/Profile.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -25,6 +27,8 @@ function App() {
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/profile" element={<Profile />} />
</Route>
</Routes>
</Router>

View File

@@ -1,16 +1,14 @@
import clsx from "clsx";
type props = {
children: React.ReactNode,
className?: string
}
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({children, className}: props) => {
return (
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{children}
</div>
)
}
);
export default Box;

View File

@@ -1,60 +1,51 @@
import React, {useEffect, useRef} from "react";
import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx";
type DropdownItemProps = {
item: string;
onClick: (item: string) => void;
className?: string;
}
};
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
return (
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)}
>
{item}
</li>
);
}
type DropdownProps = {
label: string;
label: React.ReactNode;
children: React.ReactNode;
className?: string;
}
};
const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (ref.current && !ref.current.contains(event.target)) {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className={clsx(className, "relative inline-block text-left")}
ref={ref}>
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={toggleDropdown}
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"}
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
>
{label}
</button>
{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}
</ul>
)}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
);
};
export { Dropdown, DropdownItem };

View File

@@ -1,21 +1,16 @@
import React from "react";
type props = {
type Props = {
children: React.ReactNode;
label: String;
}
label: string;
};
const Selector = ({children, label}: props) => {
return (
<div className={"flex items-center gap-2"}>
<label
className={"w-full"}>
{ label }
</label>
<div className={"w-px h-6 bg-gray-400 mx-3"} />
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-gray-600">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" />
{children}
</div>
)
}
);
export default Selector;

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home } from 'lucide-react';
import Box from "./Box.tsx";
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
import type {User} from "../utils/types.ts";
type props = {
@@ -9,34 +8,43 @@ type props = {
className?: string
}
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const Sidebar = ({user, className}: props) => {
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
className={"w-45 mx-auto"}
src={"../../public/logo.png"}
alt={"VoD System Logo"}
/>
<SidebarButton
url={"/"}
logo={<Home size={20}/>}
label={"Home"}
/>
<SidebarButton
url={"/create"}
logo={<Plus size={20}/>}
label={"Create Clip"}
className="w-36 mx-auto"
src="../../public/logo.png"
alt="VoD System Logo"
/>
</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 && (
<SidebarButton
url={"/my-clips"}
logo={<Film size={20}/>}
label={"My Clips"}
/>
<>
<Divider />
<div className="flex flex-col gap-0.5">
<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>
);
};

View File

@@ -16,6 +16,7 @@ type props = {
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
@@ -24,6 +25,7 @@ const Topbar = ({
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
@@ -56,7 +58,7 @@ const Topbar = ({
}
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)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
@@ -69,7 +71,19 @@ const Topbar = ({
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"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />

View File

@@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<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}
/>
);

View File

@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<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}
/>
);

View File

@@ -1,19 +1,19 @@
import MenuButton from "./MenuButton.tsx";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
type props = {
type Props = {
url: string;
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 (
<Link
className={"w-full"}
to={url}
>
<MenuButton className={"flex items-center gap-2 w-full"}>
<Link className="w-full" to={url}>
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
{logo}{label}
</MenuButton>
</Link>

View File

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

View File

@@ -1,46 +1,36 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
type prop = {
setMetadata: Function;
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
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 vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({
...prevState,
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
};
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fps: parseInt(e.target.value)
}))
}
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fileSize: parseInt(e.target.value) * 1000
}))
}
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
};
return (
<div className={clsx("flex flex-col gap-2 p-10", className)}>
<h2 className={"text-xl font-bold"}>Export</h2>
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<Selector label={"Resolution"}>
<select id="resolution"
name="resolution"
defaultValue="1280,720"
onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option>
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
</select>
</Selector>
<Selector label={"FPS"}>
<select id="fps"
name="fps"
defaultValue="30"
onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
<option value="15">15</option>
</select>
</Selector>
<Selector label={"File Size Limit (MB)"}>
<input type="number"
min="1"
defaultValue="10"
onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
/>
</Selector>
</div>
)
);
}

View File

@@ -16,18 +16,20 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props)
Export
</BlueButton>
<div className="h-10 flex items-center">
{ downloadable ?
(<BlueButton
onClick={handleDownload}>
(<BlueButton className="w-full" onClick={handleDownload}>
Download
</BlueButton>)
:(
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress> )
className="w-full h-1.5 rounded bg-gray-200">
</progress>
)
}
</div>
</div>
)
}

View File

@@ -1,28 +1,22 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = {
setMetadata: Function
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
}
};
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<p className={"w-full font-bold text-xl "}>Title</p>
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
)
}
);
export default MetadataBox;

View File

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

View File

@@ -4,7 +4,6 @@ import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts";
type Props = {
video: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => {
if (!video) return;
if (video.paused) {
video.play();
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>) => {
if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
let volume = parseInt(e.target.value)
video.volume = volume / 100;
setVolume(volume);
}
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
useEffect(() => {
if (!video) return;
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
return (
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className={"flex gap-1"}>
<div className="flex gap-1">
<Icon size={24} />
<input
type='range'
type="range"
min={0}
max={100}
onChange={updateVolume}
value={volume}
className={"w-20"}
onChange={updateVolume}
className="w-20"
/>
</div>
<button
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} />}
</button>
<label>
<span>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</label>
</span>
</div>
);
}

View File

@@ -1,79 +1,138 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import {useEffect, useState} from "react";
import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = {
id: number,
title: string,
duration: number,
createdAt: string,
className?: string
onEdit?: () => void,
onDelete?: () => void,
className?: string,
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => {
isThumbnailAvailable(id)
.then((available) => {
setThumbnailAvailable(available);
})
.catch(() => {
setThumbnailAvailable(false);
});
.then(setThumbnailAvailable)
.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 (
<Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}>
<div className={"relative inline-block"}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<p className="
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
<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">
{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 className={"flex flex-col justify-between p-2"}>
<p>{title == "" ? "(No Title)" : title}</p>
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
<div className="flex flex-col p-2">
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title}
</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div>
</div>
</Link>

View File

@@ -2,20 +2,20 @@
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme {
--font-display: "Satoshi", "sans-serif";
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Breakpoints */
--breakpoint-3xl: 1920px;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
--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: oklch(0.55 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 */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
@@ -31,66 +31,15 @@
}
:root {
font-family: Helvetica;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*a {*/
/* font-weight: 500;*/
/* 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;*/
/* }*/
/*}*/
body {
background-color: var(--color-background);
}

View File

@@ -4,11 +4,12 @@ import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import type {User} from "../utils/types";
import { getUser } from "../utils/endpoints";
import { getUser, getStreamStatus } from "../utils/endpoints";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => {
try {
@@ -23,6 +24,14 @@ const MainLayout = () => {
fetchUser();
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
@@ -33,8 +42,9 @@ const MainLayout = () => {
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}/>
<div className="overflow-auto">
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />
</div>
</div>

View File

@@ -1,23 +1,28 @@
import { useParams } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.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 Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => {
const { id } = useParams();
const location = useLocation();
const localFile: File = location.state?.file;
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 [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "",
description: "",
startPoint: 0,
@@ -31,41 +36,66 @@ const ClipEdit = () => {
const [downloadable, setDownloadable] = useState(false);
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 () => {
if (!id) return;
if (!localFile) return;
editFile(id, outputMetadata)
.then(() => {
processFile(id)
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
})
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async() => await pollProgress(id, interval), 500);
let jobId: string;
try {
jobId = await compress(localFile, outputMetadata);
setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
return;
}
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.process.progress);
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
};
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);
setDownloadable(true);
} else {
setDownloadable(false)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
};
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) {
console.error('Download failed');
@@ -77,43 +107,27 @@ const ClipEdit = () => {
const a = document.createElement('a');
a.href = url;
a.download = `${id}.mp4`;
a.download = `${uploadedId}.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
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 (
<div className={"grid grid-cols-[7fr_3fr]"}>
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video
ref={videoRef}
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
<source src={videoUrl} type="video/ogg" />
Your browser does not support the video tag. Bzzzz.
</video>
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Box className={"w-4/5 h-full m-auto"}>
<MetadataBox
setMetadata={setOutputMetadata}
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Box>
{metadata &&
<Box className={"mt-4 p-5"}>
<Playbar
@@ -156,6 +170,6 @@ const ClipEdit = () => {
</Box>
</div>
);
}
};
export default ClipEdit;

View File

@@ -1,6 +1,5 @@
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx";
@@ -8,90 +7,35 @@ const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => {
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 = (() => {
const press = () => {
if (!file) {
setError("Please choose a file");
return;
}
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);
});
}
navigate('/create/new', { state: { file } });
};
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
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
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
onClick={press}>
Upload
</BlueButton>
<BlueButton onClick={press}>Upload</BlueButton>
<label className={"text-center text-red-500"}>{error}</label>
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Box>
</div>
)
};

View 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;

View File

@@ -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 (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
{/* Logo */}
<div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img
src="/logo.png"
alt="VoD System Logo"
className={"h-44 mb-8 object-contain"}
className="h-24 mb-6 object-contain"
/>
{/* Description Container */}
<div className={"max-w-xl text-center"}>
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
What is the VoD System?
</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.
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
A home for your clips.
</h1>
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
{/* File Support Note */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}>
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
Support for additional video formats will be added in future updates.
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{features.map(({ icon: Icon, title, description }) => (
<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>
<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>
);
};

View File

@@ -1,31 +1,55 @@
import VideoCard from "../components/video/VideoCard";
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";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
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));
}, []);
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 (
<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) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
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>
);
}

View 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;

View File

@@ -1,86 +1,326 @@
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
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 {getClipById} from "../utils/endpoints.ts";
import Box from "../components/Box.tsx"
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
const VideoPlayer = () => {
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 [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
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(() => {
// Fetch the video URL from the server
fetch(`/api/v1/download/clip/${id}`)
.then(response => {
if (!response.ok) {
throw new Error("Failed to load video");
}
return response.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
setVideoUrl(url);
})
.catch(err => {
console.error("Error fetching video:", err);
setError("Failed to load video. Please try again later.");
});
if (!id) { setError("Clip ID is required."); return; }
if (!id) {
setError("Clip ID is required.");
return;
}
getVideoBlob(id)
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)})
.catch((err) => {
console.error("Error fetching clip:", err);
setError("Failed to load clip details. Please try again later.");
.then(setClip)
.catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true);
else setError("Failed to load clip details.");
});
}, [id]);
// Update timeAgo live every second
useEffect(() => {
if (!clip || !clip.createdAt) return;
const update = () => {
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
if (!clip?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval); // cleanup
return () => clearInterval(interval);
}, [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 (
<div className={"w-9/10 m-auto"}>
<video
className={"w-full h-full rounded-lg m-auto"}
controls
autoPlay
src={videoUrl}
onError={(e) => {
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
}}
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
<Lock size={24} className="text-gray-400" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
</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.
</video>
<ArrowLeft size={16} />
Go back
</button>
</div>
);
}
{error && <div className="text-red-500 mt-2">{error}</div>}
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
return (
<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"}>
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
<p>{timeAgo}</p>
{/* Player */}
<div
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>
</div>
);
};

View File

@@ -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;
/**
* Login function
* @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 response = await fetch(API_URL + '/api/v1/auth/login', {
export class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const login = async (googleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: GoogleToken }),
credentials: 'include'
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: googleToken }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status}`);
}
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error(`Login failed: ${result.message}`);
}
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
}
};
const logout = async () => {
const response = await fetch(API_URL + '/api/v1/auth/logout', {
const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
credentials: 'include'
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Logout failed: ${response.status}`);
}
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
if (result.status === "error") {
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 compress = async (file: File, options: VideoMetadata): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('title', options.title);
if (options.description) formData.append('description', options.description);
formData.append('startPoint', options.startPoint.toString());
formData.append('duration', options.duration.toString());
if (options.fps) formData.append('fps', options.fps.toString());
if (options.width) formData.append('width', options.width.toString());
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/upload', {
const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
credentials: 'include',
});
const result: APIResponse = await response.json();
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
if (result.status == "error") {
throw new Error(`Failed to upload file: ${result.message}`);
}
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
/**
* Submits metadata changes to the backend.
* @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();
const getJob = async (uuid: string): Promise<JobResponse> => {
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
for (const [key, value] of Object.entries(videoMetadata)) {
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}`);
}
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error(`Failed to 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');
}
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
/**
* Fetches original metadata from the backend.
* @param uuid - The UUID of the video file.
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
const getUser = async (): Promise<User | null> => {
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
if (!response.ok) return null;
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.status}`);
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
const result: APIResponse = await response.json();
if (result.status === 'error') return null;
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 response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
@@ -211,54 +89,84 @@ const getClips = async (): Promise<Clip[]> => {
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
try {
const result: APIResponse = await response.json();
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 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.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`);
}
try{
const result: APIResponse = await response.json();
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 response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
}
return response.blob();
};
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
if (!response.ok) {
return false;
}
return true;
}
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
return response.ok;
};
export {
login,
logout,
uploadFile,
editFile,
processFile,
convertFile,
getProgress,
getMetadata,
compress,
getJob,
getUser,
getClips,
getClipById,
isThumbnailAvailable
getVideoBlob,
patchClip,
deleteClip,
getStreamStatus,
isThumbnailAvailable,
};

View File

@@ -17,8 +17,11 @@ type APIResponse = {
type User = {
name: string,
username: string,
email: string,
profilePictureUrl: string
profilePictureUrl: string,
streamKey: string,
createdAt: string,
}
type Clip = {
@@ -30,15 +33,13 @@ type Clip = {
createdAt: string,
}
type ProgressResult = {
process: {
type JobResponse = {
uuid: string,
progress: number,
complete: boolean
};
conversion: {
progress: number,
complete: boolean
};
isComplete: boolean,
state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED',
errorOutput: string | null,
createdAt: string,
};
export type {
@@ -46,5 +47,5 @@ export type {
VideoMetadata,
User,
Clip,
ProgressResult
JobResponse
}

View File

@@ -2,76 +2,42 @@ function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
const pad = (n: number) => n.toString().padStart(2, '0');
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(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 {
const validIso = dateString.substring(0, 23);
const date = new Date(validIso);
if (isNaN(date.getTime())) {
throw new Error("Invalid date string");
}
const normalized = dateString.replace(/(\.\d{3})\d+/, "$1");
const withTz = /[Zz]$|[+-]\d{2}:\d{2}$|[+-]\d{4}$/.test(normalized) ? normalized : `${normalized}Z`;
const date = new Date(withTz);
if (isNaN(date.getTime())) throw new Error("Invalid date string");
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 {
const now = new Date();
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000);
if (secondsAgo < 60) {
return `${secondsAgo} seconds ago`;
} else if (secondsAgo < 3600) {
const minutes = Math.floor(secondsAgo / 60);
if (minutes === 1) {
return "1 minute 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 secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [
[31536000, 'year'],
[2592000, 'month'],
[86400, 'day'],
[3600, 'hour'],
[60, 'minute'],
];
for (const [threshold, unit] of units) {
if (secondsAgo >= threshold) {
const count = Math.floor(secondsAgo / threshold);
return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
}
}
export {
formatTime,
stringToDate,
dateToTimeAgo
const s = Math.max(0, secondsAgo);
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };

View File

@@ -2,12 +2,11 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite";
import mkcert from 'vite-plugin-mkcert'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), flowbiteReact(), mkcert()],
plugins: [react(), tailwindcss(), flowbiteReact()],
preview: {
port: 5173,
strictPort: true,

9
nginx/Dockerfile Normal file
View 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"]

View 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
}
}
}

View 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;"

View File

@@ -81,6 +81,10 @@
<artifactId>google-api-client</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>

21
src/main/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Build stage
FROM maven:3.9.6-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# Run stage
FROM eclipse-temurin:21-jdk
# dependencies
RUN apt-get update && \
apt-get install -y ffmpeg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -1,13 +1,18 @@
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.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
public class AsyncConfig implements AsyncConfigurer {
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
@Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() {
@@ -19,4 +24,10 @@ public class AsyncConfig {
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
logger.error("Async task '{}' failed: {}", method.getName(), ex.getMessage(), ex);
}
}

View File

@@ -19,7 +19,7 @@ public class CorsConfig {
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/v1/**")
.allowedOrigins(frontendUrl)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
}

View File

@@ -30,11 +30,11 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll()
.requestMatchers("/api/v1/metadata/**").permitAll()
.requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

View File

@@ -1,9 +1,14 @@
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.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip;
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.web.bind.annotation.*;
@@ -15,15 +20,16 @@ import java.util.Optional;
public class ClipController {
private final ClipService clipService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) {
this.clipService = clipService;
}
@GetMapping("")
public ResponseEntity<APIResponse<List<ClipDTO>>> getClips() {
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser();
List<ClipDTO> clipDTOs = clips.stream()
List<ClipResponse> clipDTOs = clips.stream()
.map(this::convertToDTO)
.toList();
@@ -36,13 +42,13 @@ public class ClipController {
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipDTO>> getClipById(@PathVariable Long id) {
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Optional<Clip> clip = clipService.getClipById(id);
if (clip.isEmpty()) {
return ResponseEntity.notFound().build();
}
ClipDTO clipDTO = convertToDTO(clip.get());
ClipResponse clipDTO = convertToDTO(clip.get());
return ResponseEntity.ok(
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}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
if (!clipService.deleteClip(id)) {
@@ -67,14 +84,42 @@ public class ClipController {
);
}
private ClipDTO convertToDTO(Clip clip) {
ClipDTO dto = new ClipDTO();
dto.setId(clip.getId());
dto.setUserId(clip.getUser().getId());
dto.setTitle(clip.getTitle());
dto.setDescription(clip.getDescription());
dto.setDuration(clip.getDuration());
dto.setCreatedAt(clip.getCreatedAt());
return dto;
@GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = clipService.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("/{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()
);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -1,10 +1,7 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.exceptions.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -76,4 +73,25 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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())
));
}
}

View File

@@ -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));
}
}

View File

@@ -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))));
}
}

View File

@@ -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);
}
}

View File

@@ -1,7 +1,7 @@
package com.ddf.vodsystem.controllers;
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.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService;
@@ -14,18 +14,19 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/auth/")
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@Value("${jwt.expiration}")
private long jwtExpiration;
private static final String SUCCESS = "success";
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user")
@GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser();
@@ -34,14 +35,14 @@ public class UserController {
}
return ResponseEntity.ok(
new APIResponse<>("success", "User retrieved successfully", user.get())
new APIResponse<>(SUCCESS, "User retrieved successfully", user.get())
);
}
@PostMapping("/login")
public ResponseEntity<APIResponse<TokenDTO>> login(@RequestBody TokenDTO tokenDTO,
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletResponse response) {
String jwt = userService.login(tokenDTO.getToken());
String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true)
@@ -54,7 +55,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString());
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());
return ResponseEntity.ok(
new APIResponse<>("success", "Logged out successfully", null)
new APIResponse<>(SUCCESS, "Logged out successfully", null)
);
}
}

View File

@@ -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
) {}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull;
public record ClipSectionRequest(
@NotNull float duration
) {}

View File

@@ -0,0 +1,6 @@
package com.ddf.vodsystem.controllers.dto;
public record ClipUpdateRequest (
String title,
String description
) {}

View File

@@ -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
) {
}

View File

@@ -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) {}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
public record StreamResponse(
boolean isStreaming,
Long id
) {
}

View File

@@ -0,0 +1,5 @@
package com.ddf.vodsystem.controllers.dto;
public record Token(
String token
) {}

View File

@@ -0,0 +1,3 @@
package com.ddf.vodsystem.controllers.dto;
public record UUIDResponse(String uuid) {}

View File

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

View File

@@ -1,15 +1,31 @@
package com.ddf.vodsystem.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class ClipOptions {
@NotBlank
private String title;
private String description;
@NotNull
@Positive
private Float startPoint;
@NotNull @Positive
private Float duration;
@DecimalMin("1.0") @DecimalMax("120.0")
private Float fps;
@Positive
private Integer width;
@Positive
private Integer height;
@Positive
private Float fileSize;
}

View File

@@ -1,28 +1,21 @@
package com.ddf.vodsystem.dto;
import java.io.File;
import lombok.Data;
import java.nio.file.Path;
import java.time.Instant;
@Data
public class Job {
private String uuid;
private File inputFile;
private File outputFile;
private ProgressTracker progressTracker = new ProgressTracker();
private JobState state = JobState.READY;
private String errorOutput;
// configs
private ClipOptions inputClipOptions;
private ClipOptions outputClipOptions = new ClipOptions();
private Path download;
private Instant createdAt = Instant.now();
// job status
private JobStatus status = new JobStatus();
public Job(String uuid,
File inputFile,
File outputFile,
ClipOptions inputClipOptions) {
public Job(String uuid) {
this.uuid = uuid;
this.inputFile = inputFile;
this.outputFile = outputFile;
this.inputClipOptions = inputClipOptions;
}
}

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.dto;
public enum JobState {
READY,
PROCESSING,
SUCCEEDED,
FAILED
}

Some files were not shown because too many files have changed in this diff Show More