initial commit

This commit is contained in:
MrLetsplay 2025-02-11 23:04:28 +01:00
commit 8d8cd9aa2b
Signed by: mr
SSH Key Fingerprint: SHA256:92jBH80vpXyaZHjaIl47pjRq+Yt7XGTArqQg1V7hSqg
12 changed files with 427 additions and 0 deletions

1
icon/download.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#fff"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" /></svg>

After

Width:  |  Height:  |  Size: 133 B

27
index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>VideoBase</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/config.js" defer></script>
<script src="js/api.js" defer></script>
<script src="js/components.js" defer></script>
<script src="js/index.js" defer></script>
<link rel="stylesheet" href="style/base.css">
<link rel="stylesheet" href="style/components.css">
<link rel="stylesheet" href="style/index.css">
</head>
<body>
<noscript>Unfortunately, this page requires JavaScript to work correctly</noscript>
<div id="loading">Loading...</div>
<h1>Library</h1>
<div id="root"></div>
</body>
</html>

112
js/api.js Normal file
View File

@ -0,0 +1,112 @@
class API {
/**
* @type {!string}
*/
apiURL;
/**
* @param {!string} apiURL
*/
constructor(apiURL) {
this.apiURL = apiURL;
}
/**
* @param {"GET" | "POST" | "PUT"} method
* @param {!string} path
* @param {?Object.<string, string>} query
* @param {?any} body
* @returns {?any}
* @throws {APIError}
*/
async call(method, path, query, body) {
let params = new URLSearchParams();
if (query != null) {
for (let key in query) {
let value = query[key];
if (value == null) continue;
params.set(key, value);
}
}
let response = await fetch(this.apiURL + path + (params.size > 0 ? "?" + params : ""),
{
method,
headers: (body != null ? { "Content-Type": "application/json" } : undefined),
body
}
);
let json = null;
try {
json = await response.json();
} catch (e) {
throw "Failed to decode response";
}
if (!response.ok) {
throw json?.error || "Request failed: " + response.status + " " + response.statusText;
}
return json
}
/**
* @param {?("series" | "author")} groupBy
* @returns {Library}
* @throws {APIError}
*/
async getLibrary(groupBy) {
return await this.call("GET", "/library", { 'groupBy': groupBy }, null);
}
/**
* @param {!string} id
* @returns {Video}
* @throws {APIError}
*/
async getVideo(id) {
return await this.call("GET", "/library/video/" + encodeURIComponent(id), null, null);
}
}
class APIError {
/**
* @type {string}
*/
errorMessage;
/**
* @param {!string} errorMessage
*/
constructor(errorMessage) {
this.errorMessage = errorMessage
}
}
class Library {
/**
* @type {Object<string, Video[]>}
*/
videos;
}
class Video {
/**
* @type {string}
*/
id;
/**
* @type {VideoMetadata}
*/
metadata;
}
/**
* @typedef {{series:string, title: string, author: string, index: number, [key: string]: any}} VideoMetadata
*/

85
js/components.js Normal file
View File

@ -0,0 +1,85 @@
/**
* @param {string} type
* @param {?{classNames: ?string[], content: ?string, attributes: ?Object.<string, string>, onClick: ?(() => void)}} props
* @param {?HTMLElement[]} children
* @returns {HTMLElement}
*/
function element(type, props, children) {
let element = document.createElement(type);
if (props?.classNames != null && props.classNames.length > 0) {
element.classList.add(props.classNames);
}
if (props?.content != null) {
element.innerText = props.content;
}
if (props?.attributes != null) {
for (let attribute in props.attributes) {
element.setAttribute(attribute, props.attributes[attribute]);
}
}
if (props?.onClick != null) {
element.onclick = props.onClick;
}
if (children != null) {
for (let child of children) {
element.appendChild(child);
}
}
return element;
}
const components = {
/**
* @param {string} name
* @param {Video[]} videos
* @returns {HTMLElement}
*/
series(name, videos) {
return element("div", { classNames: ["series"] }, [
element("a", { content: name, attributes: { "href": "/series.html?name=" + encodeURIComponent(name) } }),
element("div", { content: [...new Set(videos.map(v => v.metadata.author))].join(", "), classNames: ["series-authors"] }),
element("div", { classNames: ["series-videos"] }, videos.map(v => components.video(v)))
]);
},
/**
* @param {Video} video
* @returns {HTMLElement}
*/
video(video) {
return element("a", { classNames: ["video"], attributes: { "href": "/watch.html?id=" + encodeURIComponent(video.id), "target": "_blank" } }, [
element("img", { attributes: { "src": API_URL + "/library/video/" + encodeURIComponent(video.id) + "/thumbnail" } }),
element("span", { content: video.metadata.title }),
]);
},
/**
* @param {Video} video
* @returns {HTMLElement}
*/
player(video) {
const streamURL = API_URL + "/library/video/" + encodeURIComponent(video.id) + "/stream";
const downloadVideo = () => {
window.location.href = streamURL;
};
return element("div", { classNames: ["player"] }, [
element("video", { attributes: { "width": "960", "height": "540", "poster": API_URL + "/library/video/" + encodeURIComponent(video.id) + "/thumbnail" } }, [
element("source", { attributes: { "src": streamURL } })
]),
element("button", { onClick: downloadVideo }, [
element("img", { attributes: { "src": "/icon/download.svg", "width": "24", "height": "24" } }),
element("span", { content: "Download" }),
])
]);
}
}

1
js/config.js Normal file
View File

@ -0,0 +1 @@
const API_URL = "http://localhost:6969/api"

15
js/index.js Normal file
View File

@ -0,0 +1,15 @@
const api = new API(API_URL);
const root = document.getElementById("root");
async function init() {
let library = await api.getLibrary("series");
for (let group in library.videos) {
root.appendChild(components.series(group, library.videos[group]));
}
document.getElementById("loading").style.display = 'none';
}
init();

14
js/watch.js Normal file
View File

@ -0,0 +1,14 @@
const api = new API(API_URL);
const root = document.getElementById("root");
async function init() {
let query = new URLSearchParams(document.location.search);
let video = await api.getVideo(query.get("id"));
root.appendChild(components.player(video));
document.getElementById("loading").style.display = 'none';
}
init();

88
style/base.css Normal file
View File

@ -0,0 +1,88 @@
html {
--background: #060f1a;
--foreground: #0d294a;
--accent: #3584e4;
--text: #fff;
--text-alternative: #bbb;
--link: var(--accent);
--info: var(--accent);
--success: green;
--warning: darkorange;
--error: orangered;
}
* {
font-family: system-ui;
color: var(--text);
}
body {
background-color: var(--background);
}
button {
background: var(--accent);
color: var(--text);
border: none;
border-radius: 5px;
padding: 8px;
cursor: pointer;
font-weight: bold;
}
button:has(img) {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input,
select {
background: #2c3444;
border: none;
color: var(--text);
border-radius: 5px;
padding: 8px;
}
select {
cursor: pointer;
}
input:focus,
button:focus,
a:focus,
select:focus {
outline: 2px solid var(--accent);
}
a {
background: unset;
color: var(--link);
text-decoration: none;
padding: 0;
font-weight: normal;
font-size: 1em;
border-radius: 0;
}
a:hover {
text-decoration: underline;
}
#loading {
background-color: var(--accent);
padding: 5px;
border-radius: 5px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

60
style/components.css Normal file
View File

@ -0,0 +1,60 @@
.series {
background-color: var(--foreground);
padding: 10px;
margin-top: 10px;
border-radius: 5px;
}
.series>a {
font-size: 1.2em;
text-decoration: none;
color: var(--text);
}
.series-authors {
color: var(--text-alternative);
}
.series-videos {
display: flex;
flex-direction: row;
overflow-x: scroll;
gap: 10px;
padding-top: 10px;
padding-bottom: 10px;
}
.video {
display: flex;
flex-direction: column;
gap: 10px;
text-decoration: none;
border: 1px solid var(gray);
border-radius: 5px;
max-width: min-content;
}
.video>img {
width: 300px;
/* height: 168.75px; */
aspect-ratio: 16/9;
background-color: var(--background);
height: auto;
border-radius: 5px;
}
.video>span {
color: var(--text-alternative);
}
.player {
display: flex;
flex-direction: column;
align-items: start;
gap: 5px;
background-color: var(--foreground);
max-width: min-content;
padding: 10px;
border-radius: 5px;
}

1
style/index.css Normal file
View File

@ -0,0 +1 @@

0
style/watch.css Normal file
View File

23
watch.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>VideoBase</title>
<script src="js/config.js" defer></script>
<script src="js/api.js" defer></script>
<script src="js/components.js" defer></script>
<script src="js/watch.js" defer></script>
<link rel="stylesheet" href="style/base.css">
<link rel="stylesheet" href="style/components.css">
<link rel="stylesheet" href="style/watch.css">
</head>
<body>
<noscript>Unfortunately, this page requires JavaScript to work correctly</noscript>
<div id="loading">Loading...</div>
<div id="root"></div>
</body>
</html>