initial commit
This commit is contained in:
commit
8d8cd9aa2b
1
icon/download.svg
Normal file
1
icon/download.svg
Normal 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
27
index.html
Normal 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
112
js/api.js
Normal 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
85
js/components.js
Normal 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
1
js/config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
const API_URL = "http://localhost:6969/api"
|
15
js/index.js
Normal file
15
js/index.js
Normal 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
14
js/watch.js
Normal 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
88
style/base.css
Normal 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
60
style/components.css
Normal 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
1
style/index.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
0
style/watch.css
Normal file
0
style/watch.css
Normal file
23
watch.html
Normal file
23
watch.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user