Merge pull request 'Update prod to master' (#4) from master into prod

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
2026-06-25 23:28:51 +01:00
128 changed files with 2858 additions and 1894 deletions

View File

@@ -1,3 +1,18 @@
# Secrets
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
JWT_SECRET_KEY= JWT_SECRET_KEY=
# Volumes
POSTGRES_DATA=
STREAM_DATA=
MEDIA_DATA=
# DB Credentials
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=vodSystem
FRONTEND_URL=
BACKEND_URL=

5
.gitignore vendored
View File

@@ -39,6 +39,9 @@ build/
.vscode/ .vscode/
### Generated Files ### ### Generated Files ###
videos/ /videos
/outputs
frontend/.flowbite-react
generated 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

@@ -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 { meta {
name: Get all clips name: Get all clips
type: http type: http
seq: 1 seq: 2
} }
get { 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 { get {
url: {{base_url}}/auth/user url: {{base_url}}/users/me
body: none body: none
auth: inherit auth: inherit
} }

View File

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

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,16 +1,13 @@
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: vod_postgres container_name: vod_postgres
environment: environment:
POSTGRES_USER: myuser POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: mypassword POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: vodSystem POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - ${POSTGRES_DATA}:/var/lib/postgresql/data
backend: backend:
build: build:
@@ -21,6 +18,9 @@ services:
- postgres - postgres
env_file: env_file:
- .env - .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped
@@ -30,13 +30,27 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vod_frontend container_name: vod_frontend
env_file: environment:
- .env VITE_API_URL: ${BACKEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on: depends_on:
- backend - backend
ports: ports:
- "5173:5173" - "5173:5173"
restart: unless-stopped restart: unless-stopped
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes: volumes:
postgres_data: - ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped
depends_on:
- backend

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
EXPOSE 5173 EXPOSE 5173

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
import Box from "./Box.tsx";
import type {User} from "../utils/types.ts"; import type {User} from "../utils/types.ts";
type props = { type props = {
@@ -9,34 +8,43 @@ type props = {
className?: string className?: string
} }
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const Sidebar = ({user, className}: props) => { const Sidebar = ({user, className}: props) => {
return ( return (
<Box className={clsx("h-screen flex flex-col mr-5", className)}> <aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
<div className="px-4 pt-5 pb-3">
<img <img
className={"w-45 mx-auto"} className="w-36 mx-auto"
src={"../../public/logo.png"} src="../../public/logo.png"
alt={"VoD System Logo"} alt="VoD System Logo"
/>
<SidebarButton
url={"/"}
logo={<Home size={20}/>}
label={"Home"}
/>
<SidebarButton
url={"/create"}
logo={<Plus size={20}/>}
label={"Create Clip"}
/> />
</div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div>
{user && ( {user && (
<SidebarButton <>
url={"/my-clips"} <Divider />
logo={<Film size={20}/>} <div className="flex flex-col gap-0.5">
label={"My Clips"} <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
/> </div>
</>
)} )}
</Box>
<div className="flex-1" />
{user && (
<>
<Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</nav>
</aside>
); );
}; };

View File

@@ -16,6 +16,7 @@ type props = {
setSidebarToggled: (toggled: boolean) => void; setSidebarToggled: (toggled: boolean) => void;
user: User | null; user: User | null;
fetchUser: () => void; fetchUser: () => void;
isStreaming: boolean;
className?: string; className?: string;
} }
@@ -24,6 +25,7 @@ const Topbar = ({
setSidebarToggled, setSidebarToggled,
user, user,
fetchUser, fetchUser,
isStreaming,
className}: props) => { className}: props) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,7 +58,7 @@ const Topbar = ({
} }
return ( return (
<div className={clsx(className, "flex justify-between")}> <div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}> <MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>} {sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton> </MenuButton>
@@ -69,7 +71,19 @@ const Topbar = ({
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<Dropdown label={user.name}> <Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout" <DropdownItem item="Logout"
onClick={() => handleLogout()} onClick={() => handleLogout()}
className={"text-red-500 font-medium"} /> className={"text-red-500 font-medium"} />

View File

@@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => { const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
return ( return (
<button <button
className={`bg-primary text-text rounded-lg hover:bg-primary-pressed transition-colors duration-100 h-10 ${className}`} className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
{...props} {...props}
/> />
); );

View File

@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => { const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return ( return (
<button <button
className={clsx("p-2 rounded-lg hover:bg-gray-300 hover:text-black transition-colors duration-200", className)} className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props} {...props}
/> />
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,79 +1,138 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import {useEffect, useState} from "react"; import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts"; import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
className?: string onEdit?: () => void,
onDelete?: () => void,
className?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
setTimeout(() => { useEffect(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))) const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000); }, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) isThumbnailAvailable(id)
.then((available) => { .then(setThumbnailAvailable)
setThumbnailAvailable(available); .catch(() => setThumbnailAvailable(false));
})
.catch(() => {
setThumbnailAvailable(false);
});
}, []); }, []);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
setConfirmDelete(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
e.preventDefault();
e.stopPropagation();
fn();
};
return ( return (
<Link to={"/video/" + id}> <Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}> <div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className={"relative inline-block"}> <div className="relative overflow-hidden rounded-lg">
<img <img
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail} src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />
<p className=" <span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
{formatTime(duration)} {formatTime(duration)}
</p> </span>
{(onEdit || onDelete) && (
<div
ref={menuRef}
className="absolute top-1.5 right-1.5"
onClick={(e) => e.preventDefault()}
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil size={13} /> Edit
</button>
)}
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 size={13} /> Delete
</button>
)}
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div> </div>
<div className="flex flex-col p-2">
<div className={"flex flex-col justify-between p-2"}> <p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
<p>{title == "" ? "(No Title)" : title}</p> {title === "" ? "(No Title)" : title}
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
</p> </p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -2,20 +2,20 @@
@plugin "flowbite-react/plugin/tailwindcss"; @plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json"; @source "../.flowbite-react/class-list.json";
@theme { @theme {
--font-display: "Satoshi", "sans-serif"; --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Breakpoints */ /* Breakpoints */
--breakpoint-3xl: 1920px; --breakpoint-3xl: 1920px;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */ --color-primary: oklch(0.55 0.21 254);
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
--color-primary-pressed: oklch(0.45 0.21 254); --color-primary-pressed: oklch(0.45 0.21 254);
--color-secondary: oklch(0.94 0.01 250);
--color-accent: oklch(0.68 0.20 288);
--color-text: oklch(0.17 0.01 270);
--color-background: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* Easing */ /* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
@@ -31,66 +31,15 @@
} }
:root { :root {
font-family: Helvetica; font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/*a {*/ body {
/* font-weight: 500;*/ background-color: var(--color-background);
/* color: #646cff;*/ }
/* text-decoration: inherit;*/
/*}*/
/*a:hover {*/
/* color: #535bf2;*/
/*}*/
/*body {*/
/* margin: 0;*/
/* display: flex;*/
/* place-items: center;*/
/* min-width: 320px;*/
/* min-height: 100vh;*/
/*}*/
/*h1 {*/
/* font-size: 3.2em;*/
/* line-height: 1.1;*/
/*}*/
/*button {*/
/* border-radius: 8px;*/
/* border: 1px solid transparent;*/
/* padding: 0.6em 1.2em;*/
/* font-size: 1em;*/
/* font-weight: 500;*/
/* font-family: inherit;*/
/* background-color: #1a1a1a;*/
/* cursor: pointer;*/
/* transition: border-color 0.25s;*/
/*}*/
/*button:hover {*/
/* border-color: #646cff;*/
/*}*/
/*button:focus,*/
/*button:focus-visible {*/
/* outline: 4px auto -webkit-focus-ring-color;*/
/*}*/
/*@media (prefers-color-scheme: light) {*/
/* :root {*/
/* color: #213547;*/
/* background-color: #ffffff;*/
/* }*/
/* a:hover {*/
/* color: #747bff;*/
/* }*/
/* button {*/
/* background-color: #f9f9f9;*/
/* }*/
/*}*/

View File

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

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 { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar"; import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider"; import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" import { compress, getJob } from "../utils/endpoints"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => { const ClipEdit = () => {
const { id } = useParams(); const location = useLocation();
const localFile: File = location.state?.file;
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const videoUrl = `/api/v1/download/input/${id}` const [videoUrl, setVideoUrl] = useState<string>('');
const [uploadedId, setUploadedId] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null); const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "", title: "",
description: "", description: "",
startPoint: 0, startPoint: 0,
@@ -31,41 +36,66 @@ const ClipEdit = () => {
const [downloadable, setDownloadable] = useState(false); const [downloadable, setDownloadable] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
const handleVideoMetadataLoaded = () => {
const video = videoRef.current;
if (!video) return;
setMetadata({
title: '',
description: '',
startPoint: 0,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
fps: 30,
fileSize: localFile?.size ?? 0,
});
};
const sendData = async () => { const sendData = async () => {
if (!id) return; if (!localFile) return;
editFile(id, outputMetadata) let jobId: string;
.then(() => { try {
jobId = await compress(localFile, outputMetadata);
processFile(id) setUploadedId(jobId);
.catch((err: Error) => setError(`Failed to process file: ${err.message}`)); } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
}) return;
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async() => await pollProgress(id, interval), 500);
} }
const pollProgress = async (id: string, intervalId: number) => { const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
getProgress(id) };
.then((progress) => {
setProgress(progress.process.progress);
if (progress.process.complete) { const pollProgress = async (jobId: string, intervalId: number) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
} else if (job.isComplete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
} else {
setDownloadable(false)
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`); setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId); clearInterval(intervalId);
}); });
} };
const handleDownload = async () => { const handleDownload = async () => {
const response = await fetch(`/api/v1/download/output/${id}`); if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
console.error('Download failed'); console.error('Download failed');
@@ -77,43 +107,27 @@ const ClipEdit = () => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${id}.mp4`; a.download = `${uploadedId}.mp4`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
useEffect(() => {
if (!id) return;
getMetadata(id)
.then((data) => setMetadata(data ?? null))
.catch((err) => console.error("Metadata fetch failed:", err));
}, [id]);
return ( return (
<div className={"grid grid-cols-[7fr_3fr]"}> <div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video <video
ref={videoRef} ref={videoRef}
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}> src={videoUrl}
<source src={videoUrl} type="video/mp4" /> className={"w-full rounded-lg shadow-sm bg-black"}
<source src={videoUrl} type="video/webm" /> onLoadedMetadata={handleVideoMetadataLoaded}
<source src={videoUrl} type="video/ogg" /> />
Your browser does not support the video tag. Bzzzz.
</video>
<Box className={"w-4/5 h-full m-auto"}> <Box className={"w-4/5 h-full m-auto"}>
<MetadataBox <MetadataBox setMetadata={setOutputMetadata} />
setMetadata={setOutputMetadata} <ConfigBox setMetadata={setOutputMetadata} />
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
</Box> </Box>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Box className={"mt-4 p-5"}>
<Playbar <Playbar
@@ -156,6 +170,6 @@ const ClipEdit = () => {
</Box> </Box>
</div> </div>
); );
} };
export default ClipEdit; export default ClipEdit;

View File

@@ -1,6 +1,5 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
@@ -8,90 +7,35 @@ const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => { const press = () => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Please choose a file");
return; return;
} }
navigate('/create/new', { state: { file } });
};
uploadFile(file)
.then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
return ( return (
<Box className={"flex flex-col justify-between gap-3 p-5"}> <div className="flex items-start justify-center p-8 min-h-full">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<input <input
type="file" type="file"
onChange={(e) => { onChange={(e) => {
const selected = e.target.files?.[0] ?? null; const selected = e.target.files?.[0] ?? null;
setFile(selected); setFile(selected);
setError(null);
}} }}
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"} className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/> />
<BlueButton <BlueButton onClick={press}>Upload</BlueButton>
onClick={press}>
Upload
</BlueButton>
<label className={"text-center text-red-500"}>{error}</label> {error && <p className="text-sm text-red-500 text-center">{error}</p>}
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
</Box> </Box>
</div>
) )
}; };

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 ( return (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}> <div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Logo */} {/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img <img
src="/logo.png" src="/logo.png"
alt="VoD System Logo" alt="VoD System Logo"
className={"h-44 mb-8 object-contain"} className="h-24 mb-6 object-contain"
/> />
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
{/* Description Container */} A home for your clips.
<div className={"max-w-xl text-center"}> </h1>
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}> <p className="text-base text-gray-500 leading-relaxed">
What is the VoD System? Upload, trim, and export video clips. Stream live if you need to.
</h2>
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
The VoD System is a powerful clip management platform designed to streamline how you
handle your video content. Whether you're a content creator, streamer, or educator,
VoD System lets you:
</p>
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
<li>Upload clips effortlessly and securely.</li>
<li>Edit and trim videos with intuitive controls.</li>
<li>Compress files to specific file sizes.</li>
<li>Organize your clips for quick access and sharing.</li>
</ul>
<p className={"text-lg leading-relaxed text-gray-600"}>
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
making video clip management faster and more enjoyable than ever.
</p> </p>
</div> </div>
{/* File Support Note */} {/* Feature grid */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing. {features.map(({ icon: Icon, title, description }) => (
Support for additional video formats will be added in future updates. <div
key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div> </div>
<div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -1,31 +1,55 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getClips } from "../utils/endpoints"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/endpoints";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() getClips()
.then((data) => setClips(data)) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)); .catch((err) => setError(err));
}, []); }, []);
const handleDelete = async (id: number) => {
try {
await deleteClip(id);
setClips(prev => prev.filter(c => c.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete clip");
}
};
return ( return (
<div className={"flex flex-row"}> <div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
<VideoCard <VideoCard
key={clip.id}
id={clip.id} id={clip.id}
title={clip.title} title={clip.title}
duration={clip.duration} duration={clip.duration}
createdAt={clip.createdAt} createdAt={clip.createdAt}
className={"w-40 m-5"} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/> />
))} ))}
</div>
{error} {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
</div> </div>
); );
} }

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,76 +1,326 @@
import {useEffect, useState} from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import {useParams} from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
import {getClipById, getVideoBlob } from "../utils/endpoints.ts"; import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx" import Box from "../components/Box.tsx";
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
const VideoPlayer = () => { const VideoPlayer = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined); const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
const [clip, setClip] = useState<Clip | null>(null); const [clip, setClip] = useState<Clip | null>(null);
const [timeAgo, setTimeAgo] = useState<String>(""); const [timeAgo, setTimeAgo] = useState("");
// Playback
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
// Volume
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// UI
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
// ── Data loading ────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!id) { if (!id) { setError("Clip ID is required."); return; }
setError("Clip ID is required.");
return;
}
getVideoBlob(id) getVideoBlob(id)
.then((blob) => { .then((blob) => setVideoUrl(URL.createObjectURL(blob)))
const url = URL.createObjectURL(blob); .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
setVideoUrl(url);
})
getClipById(id) getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)}) .then(setClip)
.catch((err) => { .catch((e) => {
console.error("Error fetching clip:", err); if (e instanceof AuthError) setNotAuthenticated(true);
setError("Failed to load clip details. Please try again later."); else setError("Failed to load clip details.");
}); });
}, [id]); }, [id]);
// Update timeAgo live every second
useEffect(() => { useEffect(() => {
if (!clip || !clip.createdAt) return; if (!clip?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
const update = () => { update();
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
return () => clearInterval(interval);
return () => clearInterval(interval); // cleanup
}, [clip]); }, [clip]);
// ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
}, []);
const showControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
document.fullscreenElement
? document.exitFullscreen()
: containerRef.current.requestFullscreen();
}, []);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const video = videoRef.current;
if (!video) return;
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
video.paused ? video.play() : video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showControls();
break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
}, [duration]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
const onUp = () => setIsDragging(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isDragging, seekTo]);
// ── Video element event handlers ────────────────────────────────────────
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime);
if (video.buffered.length > 0) {
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
if (notAuthenticated) {
return ( return (
<div className={"w-9/10 m-auto"}> <div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<video <div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
className={"w-full h-full rounded-lg m-auto"} <Lock size={24} className="text-gray-400" />
controls </div>
autoPlay <div>
src={videoUrl} <p className="text-lg font-semibold text-gray-900">Not authenticated</p>
onError={(e) => { <p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
setError(e.currentTarget.error?.message || "An error occurred while playing the video."); </div>
}} <button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
> >
Your browser does not support the video tag. <ArrowLeft size={16} />
</video> Go back
</button>
</div>
);
}
{error && <div className="text-red-500 mt-2">{error}</div>} return (
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>} <div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<Box className={"p-2 m-2 flex flex-col"}> {/* Player */}
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p> <div
<p>{timeAgo}</p> ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
src={videoUrl}
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => {
setIsPlaying(false);
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/>
{/* Buffering spinner */}
{isWaiting && videoUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
{/* Controls bar — pointer-events-auto only here */}
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-white/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
</div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!videoUrl && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box> </Box>
</div> </div>
); );
}; };

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; const API_URL = import.meta.env.VITE_API_URL;
/** export class AuthError extends Error {
* Login function constructor() { super("Not authenticated"); this.name = "AuthError"; }
* @param GoogleToken - The Google token received from the frontend. }
* @return A promise that resolves to a JWT
*/ const login = async (googleToken: string): Promise<string> => {
const login = async (GoogleToken: string): Promise<string> => { const response = await fetch(API_URL + '/api/v1/users/login', {
const response = await fetch(API_URL + '/api/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ token: googleToken }),
}, credentials: 'include',
body: JSON.stringify({ token: GoogleToken }),
credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Login failed: ${response.status}`);
throw new Error(`Login failed: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
if (result.status === "error") {
throw new Error(`Login failed: ${result.message}`);
}
return result.data.token; return result.data.token;
} };
const logout = async () => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/auth/logout', { const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
throw new Error(`Logout failed: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
if (result.status === "error") { const compress = async (file: File, options: VideoMetadata): Promise<string> => {
throw new Error(`Logout failed: ${result.message}`);
}
}
/**
* Uploads a file to the backend.
* @param file - The file to upload.
*/
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('title', options.title);
if (options.description) formData.append('description', options.description);
formData.append('startPoint', options.startPoint.toString());
formData.append('duration', options.duration.toString());
if (options.fps) formData.append('fps', options.fps.toString());
if (options.width) formData.append('width', options.width.toString());
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/upload', { const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST', method: 'POST',
body: formData, body: formData,
credentials: 'include',
}); });
const result: APIResponse = await response.json(); if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
if (result.status == "error") { const result: APIResponse = await response.json();
throw new Error(`Failed to upload file: ${result.message}`); if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
}
return result.data.uuid; return result.data.uuid;
}; };
/** const getJob = async (uuid: string): Promise<JobResponse> => {
* Submits metadata changes to the backend. const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
* @param uuid - The UUID of the video file to edit.
* @param videoMetadata - The metadata to update.
*/
const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(videoMetadata)) { if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
if (value !== undefined && value !== null) {
formData.append(key, value.toString());
}
}
const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`Failed to edit file: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
if (result.status === "error") {
throw new Error(`Failed to edit file: ${result.message}`);
}
};
/**
* Triggers file processing.
* @param uuid - The UUID of the video file to process.
*/
const processFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"});
if (!response.ok) {
throw new Error(`Failed to process file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to process file: " + result.message);
}
};
const convertFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/convert/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to convert file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to convert file: " + result.message);
}
};
/**
* Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/
const getProgress = async (uuid: string): Promise<ProgressResult> => {
const response = await fetch(API_URL + `/api/v1/progress/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to fetch progress: ${response.status}`);
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data) {
throw new Error('Invalid progress data received');
}
return result.data; return result.data;
}; };
/** const getUser = async (): Promise<User | null> => {
* Fetches original metadata from the backend. const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
* @param uuid - The UUID of the video file. if (!response.ok) return null;
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
if (!response.ok) { const result: APIResponse = await response.json();
throw new Error(`Failed to fetch metadata: ${response.status}`); if (result.status === 'error') return null;
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
return result.data; return result.data;
}; };
/**
* Fetches the current user information. Returns null if not authenticated.
*/
const getUser = async (): Promise<null | User > => {
const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"});
if (!response.ok) {
return null;
}
const result = await response.json();
if (result.status === "error") {
return null;
}
console.log(result.data);
return result.data;
}
/**
* Fetches all clips for the current user.
*/
const getClips = async (): Promise<Clip[]> => { const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' }); const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
@@ -211,69 +89,84 @@ const getClips = async (): Promise<Clip[]> => {
throw new Error(`Failed to fetch clips: ${errorResult.message}`); throw new Error(`Failed to fetch clips: ${errorResult.message}`);
} }
try {
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch { };
throw new Error('Failed to parse response');
}
}
/**
* Fetches a clip by its ID.
* @param id
*/
const getClipById = async (id: string): Promise<Clip | null> => { const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",}); const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`); throw new Error(`Failed to fetch clip: ${response.status}`);
} }
try{
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
}; };
const getVideoBlob = async (id: string): Promise<Blob> => { const getVideoBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",}); const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch video: ${id}: ${response.status}`) if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
} }
try {
return response.blob(); return response.blob();
} catch { };
throw new Error(`Failed to convert Clip Return Object to blob`);
} const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
} const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): Promise<boolean> => { const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
if (!response.ok) { return response.ok;
return false; };
}
return true;
}
export { export {
login, login,
logout, logout,
uploadFile, compress,
editFile, getJob,
processFile,
convertFile,
getProgress,
getMetadata,
getUser, getUser,
getClips, getClips,
getClipById, getClipById,
getVideoBlob, getVideoBlob,
isThumbnailAvailable patchClip,
deleteClip,
getStreamStatus,
isThumbnailAvailable,
}; };

View File

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

View File

@@ -2,76 +2,42 @@ function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
const pad = (n: number) => n.toString().padStart(2, '0');
const padded = (n: number) => n.toString().padStart(2, '0'); if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
} }
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date { function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23); const normalized = dateString.replace(/(\.\d{3})\d+/, "$1");
const date = new Date(validIso); const withTz = /[Zz]$|[+-]\d{2}:\d{2}$|[+-]\d{4}$/.test(normalized) ? normalized : `${normalized}Z`;
if (isNaN(date.getTime())) { const date = new Date(withTz);
throw new Error("Invalid date string"); if (isNaN(date.getTime())) throw new Error("Invalid date string");
}
return date; return date;
} }
function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOptions): string {
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
function dateToTimeAgo(date: Date): string { function dateToTimeAgo(date: Date): string {
const now = new Date(); const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000); const units: [number, string][] = [
[31536000, 'year'],
if (secondsAgo < 60) { [2592000, 'month'],
return `${secondsAgo} seconds ago`; [86400, 'day'],
} else if (secondsAgo < 3600) { [3600, 'hour'],
const minutes = Math.floor(secondsAgo / 60); [60, 'minute'],
];
if (minutes === 1) { for (const [threshold, unit] of units) {
return "1 minute ago"; if (secondsAgo >= threshold) {
} const count = Math.floor(secondsAgo / threshold);
return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
return `${minutes} minutes ago`;
} else if (secondsAgo < 86400) {
const hours = Math.floor(secondsAgo / 3600);
if (hours === 1) {
return "1 hour ago";
}
return `${hours} hours ago`;
} else if (secondsAgo < 2592000) {
const days = Math.floor(secondsAgo / 86400);
if (days === 1) {
return "1 day ago";
}
return `${days} days ago`;
} else if (secondsAgo < 31536000) {
const months = Math.floor(secondsAgo / 2592000);
if (months === 1) {
return "1 month ago";
}
return `${months} months ago`;
} else {
const years = Math.floor(secondsAgo / 31536000);
if (years === 1) {
return "1 year ago";
}
return `${years} years ago`;
} }
} }
const s = Math.max(0, secondsAgo);
export { return `${s} second${s !== 1 ? 's' : ''} ago`;
formatTime,
stringToDate,
dateToTimeAgo
} }
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };

9
nginx/Dockerfile Normal file
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> <artifactId>google-api-client</artifactId>
<version>2.8.0</version> <version>2.8.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipDTO; import com.ddf.vodsystem.controllers.dto.ClipResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,15 +20,16 @@ import java.util.Optional;
public class ClipController { public class ClipController {
private final ClipService clipService; private final ClipService clipService;
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) { public ClipController(ClipService clipService) {
this.clipService = clipService; this.clipService = clipService;
} }
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<ClipDTO>>> getClips() { public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser(); List<Clip> clips = clipService.getClipsByUser();
List<ClipDTO> clipDTOs = clips.stream() List<ClipResponse> clipDTOs = clips.stream()
.map(this::convertToDTO) .map(this::convertToDTO)
.toList(); .toList();
@@ -36,13 +42,13 @@ public class ClipController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipDTO>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Optional<Clip> clip = clipService.getClipById(id); Optional<Clip> clip = clipService.getClipById(id);
if (clip.isEmpty()) { if (clip.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
ClipDTO clipDTO = convertToDTO(clip.get()); ClipResponse clipDTO = convertToDTO(clip.get());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, new APIResponse<>(SUCCESS,
@@ -52,6 +58,17 @@ public class ClipController {
); );
} }
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) {
Clip clip = clipService.updateClip(id, updateFields);
ClipResponse clipDTO = convertToDTO(clip);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Clip updated successfully", clipDTO)
);
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
if (!clipService.deleteClip(id)) { if (!clipService.deleteClip(id)) {
@@ -67,14 +84,42 @@ public class ClipController {
); );
} }
private ClipDTO convertToDTO(Clip clip) { @GetMapping("/{id}/media")
ClipDTO dto = new ClipDTO(); public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
dto.setId(clip.getId()); Resource resource = clipService.downloadClip(id);
dto.setUserId(clip.getUser().getId());
dto.setTitle(clip.getTitle()); if (resource == null || !resource.exists()) {
dto.setDescription(clip.getDescription()); return ResponseEntity.notFound().build();
dto.setDuration(clip.getDuration()); }
dto.setCreatedAt(clip.getCreatedAt());
return dto; return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private ClipResponse convertToDTO(Clip clip) {
return new ClipResponse(
clip.getId(),
clip.getUser().getId(),
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
clip.getCreatedAt()
);
} }
} }

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; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -76,4 +73,25 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
} }
@ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
@ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e);
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
} }

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

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; package com.ddf.vodsystem.dto;
import jakarta.validation.constraints.*;
import lombok.Data; import lombok.Data;
@Data @Data
public class ClipOptions { public class ClipOptions {
@NotBlank
private String title; private String title;
private String description; private String description;
@NotNull
@Positive
private Float startPoint; private Float startPoint;
@NotNull @Positive
private Float duration; private Float duration;
@DecimalMin("1.0") @DecimalMax("120.0")
private Float fps; private Float fps;
@Positive
private Integer width; private Integer width;
@Positive
private Integer height; private Integer height;
@Positive
private Float fileSize; private Float fileSize;
} }

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
@Data
public class JobStatus {
private ProgressTracker process = new ProgressTracker();
private ProgressTracker conversion = new ProgressTracker();
private boolean failed = false;
}

View File

@@ -1,10 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenDTO {
private String token;
}

View File

@@ -5,7 +5,7 @@ import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "clips") @Table(name = "clips")
@@ -28,7 +28,7 @@ public class Clip {
private String description; private String description;
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private Instant createdAt;
@Column(name = "width", nullable = false) @Column(name = "width", nullable = false)
private Integer width; private Integer width;

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