From 8d8cd9aa2b341840419ecd4098cf9212451a82a5 Mon Sep 17 00:00:00 2001 From: MrLetsplay Date: Tue, 11 Feb 2025 23:04:28 +0100 Subject: [PATCH] initial commit --- icon/download.svg | 1 + index.html | 27 +++++++++++ js/api.js | 112 +++++++++++++++++++++++++++++++++++++++++++ js/components.js | 85 ++++++++++++++++++++++++++++++++ js/config.js | 1 + js/index.js | 15 ++++++ js/watch.js | 14 ++++++ style/base.css | 88 ++++++++++++++++++++++++++++++++++ style/components.css | 60 +++++++++++++++++++++++ style/index.css | 1 + style/watch.css | 0 watch.html | 23 +++++++++ 12 files changed, 427 insertions(+) create mode 100644 icon/download.svg create mode 100644 index.html create mode 100644 js/api.js create mode 100644 js/components.js create mode 100644 js/config.js create mode 100644 js/index.js create mode 100644 js/watch.js create mode 100644 style/base.css create mode 100644 style/components.css create mode 100644 style/index.css create mode 100644 style/watch.css create mode 100644 watch.html diff --git a/icon/download.svg b/icon/download.svg new file mode 100644 index 0000000..6d1a779 --- /dev/null +++ b/icon/download.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..8449626 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + VideoBase + + + + + + + + + + + + + + +
Loading...
+ +

Library

+ +
+ + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..0f154e5 --- /dev/null +++ b/js/api.js @@ -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.} 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} + */ + videos; +} + +class Video { + /** + * @type {string} + */ + id; + + /** + * @type {VideoMetadata} + */ + metadata; +} + +/** + * @typedef {{series:string, title: string, author: string, index: number, [key: string]: any}} VideoMetadata + */ diff --git a/js/components.js b/js/components.js new file mode 100644 index 0000000..1add218 --- /dev/null +++ b/js/components.js @@ -0,0 +1,85 @@ +/** + * @param {string} type + * @param {?{classNames: ?string[], content: ?string, attributes: ?Object., 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" }), + ]) + ]); + } + +} diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..0a503f7 --- /dev/null +++ b/js/config.js @@ -0,0 +1 @@ +const API_URL = "http://localhost:6969/api" diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..4675a96 --- /dev/null +++ b/js/index.js @@ -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(); diff --git a/js/watch.js b/js/watch.js new file mode 100644 index 0000000..ebe6182 --- /dev/null +++ b/js/watch.js @@ -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(); diff --git a/style/base.css b/style/base.css new file mode 100644 index 0000000..a4b3952 --- /dev/null +++ b/style/base.css @@ -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%); +} diff --git a/style/components.css b/style/components.css new file mode 100644 index 0000000..4057f72 --- /dev/null +++ b/style/components.css @@ -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; +} diff --git a/style/index.css b/style/index.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/style/index.css @@ -0,0 +1 @@ + diff --git a/style/watch.css b/style/watch.css new file mode 100644 index 0000000..e69de29 diff --git a/watch.html b/watch.html new file mode 100644 index 0000000..9f14612 --- /dev/null +++ b/watch.html @@ -0,0 +1,23 @@ + + + + + VideoBase + + + + + + + + + + + + +
Loading...
+ +
+ + +