diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..32c7cda
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Editor crud files
+*~
+\#*#
+.#*
+.vscode
+
+# Auto-generated files
+node_modules
\ No newline at end of file
diff --git a/api/youtube.js b/api/youtube.js
new file mode 100644
index 0000000..2dafdc2
--- /dev/null
+++ b/api/youtube.js
@@ -0,0 +1,596 @@
+const fetch = require("node-fetch")
+const {render} = require("pinski/plugins")
+const fs = require("fs").promises
+
+class TTLCache {
+ constructor(ttl) {
+ this.backing = new Map()
+ this.ttl = ttl
+ }
+
+ set(key, value) {
+ this.backing.set(key, {time: Date.now(), value})
+ return value
+ }
+
+ clean(key) {
+ if (this.backing.has(key) && Date.now() - this.backing.get(key).time > this.ttl) {
+ this.delete(key)
+ }
+ }
+
+ has(key) {
+ this.clean(key)
+ return this.backing.has(key)
+ }
+
+ get(key) {
+ this.clean(key)
+ if (this.has(key)) {
+ return this.backing.get(key).value
+ } else {
+ return null
+ }
+ }
+
+ async getAs(key, callback) {
+ return this.get(key) || callback().then(value => this.set(key, value))
+ }
+}
+
+const videoCache = new TTLCache(Infinity)
+
+const channelCacheTimeout = 4*60*60*1000;
+
+let shareWords = [];
+fs.readFile("util/words.txt", "utf8").then(words => {
+ shareWords = words.split("\n");
+})
+const IDLetterIndex = []
+ .concat(Array(26).fill().map((_, i) => String.fromCharCode(i+65)))
+ .concat(Array(26).fill().map((_, i) => String.fromCharCode(i+97)))
+ .concat(Array(10).fill().map((_, i) => i.toString()))
+ .join("")
+ +"-_"
+
+function getShareWords(id) {
+ if (shareWords.length == 0) {
+ console.error("Tried to get share words, but they aren't loaded yet!");
+ return "";
+ }
+ // Convert ID string to binary number string
+ let binaryString = "";
+ for (let letter of id) {
+ binaryString += IDLetterIndex.indexOf(letter).toString(2).padStart(6, "0");
+ }
+ binaryString = binaryString.slice(0, 64);
+ // Convert binary string to words
+ let words = [];
+ for (let i = 0; i < 6; i++) {
+ let bitFragment = binaryString.substr(i*11, 11).padEnd(11, "0");
+ let number = parseInt(bitFragment, 2);
+ let word = shareWords[number];
+ words.push(word);
+ }
+ return words;
+}
+function getIDFromWords(words) {
+ // Convert words to binary number string
+ let binaryString = "";
+ for (let word of words) {
+ binaryString += shareWords.indexOf(word).toString(2).padStart(11, "0")
+ }
+ binaryString = binaryString.slice(0, 64);
+ // Convert binary string to ID
+ let id = "";
+ for (let i = 0; i < 11; i++) {
+ let bitFragment = binaryString.substr(i*6, 6).padEnd(6, "0");
+ let number = parseInt(bitFragment, 2);
+ id += IDLetterIndex[number];
+ }
+ return id;
+}
+function validateShareWords(words) {
+ if (words.length != 6) throw new Error("Expected 6 words, got "+words.length);
+ for (let word of words) {
+ if (!shareWords.includes(word)) throw new Error(word+" is not a valid share word");
+ }
+}
+function findShareWords(string) {
+ if (string.includes(" ")) {
+ return string.toLowerCase().split(" ");
+ } else {
+ let words = [];
+ let currentWord = "";
+ for (let i = 0; i < string.length; i++) {
+ if (string[i] == string[i].toUpperCase()) {
+ if (currentWord) words.push(currentWord);
+ currentWord = string[i].toLowerCase();
+ } else {
+ currentWord += string[i];
+ }
+ }
+ words.push(currentWord);
+ return words;
+ }
+}
+
+let channelCache = new Map();
+
+function refreshCache() {
+ for (let e of channelCache.entries()) {
+ if (Date.now()-e[1].refreshed > channelCacheTimeout) channelCache.delete(e[0]);
+ }
+}
+
+function fetchChannel(channelID, ignoreCache) {
+ refreshCache();
+ let cache = channelCache.get(channelID);
+ if (cache && !ignoreCache) {
+ if (cache.constructor.name == "Promise") {
+ //cf.log("Waiting on promise for "+channelID, "info");
+ return cache;
+ } else {
+ //cf.log("Using cache for "+channelID+", expires in "+Math.floor((channelCacheTimeout-Date.now()+cache.refreshed)/1000/60)+" minutes", "spam");
+ return Promise.resolve(cache.data);
+ }
+ } else {
+ //cf.log("Setting new cache for "+channelID, "spam");
+ let promise = new Promise(resolve => {
+ let channelType = channelID.startsWith("UC") && channelID.length == 24 ? "channel_id" : "user";
+ Promise.all([
+ rp(`${getInvidiousHost("channel")}/api/v1/channels/${channelID}`),
+ rp(`https://www.youtube.com/feeds/videos.xml?${channelType}=${channelID}`)
+ ]).then(([body, xml]) => {
+ let data = JSON.parse(body);
+ if (data.error) throw new Error("Couldn't refresh "+channelID+": "+data.error);
+ let feedItems = fxp.parse(xml).feed.entry;
+ //console.log(feedItems.slice(0, 2))
+ data.latestVideos.forEach(v => {
+ v.author = data.author;
+ let gotDateFromFeed = false;
+ if (Array.isArray(feedItems)) {
+ let feedItem = feedItems.find(i => i["yt:videoId"] == v.videoId);
+ if (feedItem) {
+ const date = new Date(feedItem.published)
+ v.published = date.getTime();
+ v.publishedText = date.toUTCString().split(" ").slice(1, 4).join(" ")
+ gotDateFromFeed = true;
+ }
+ }
+ if (!gotDateFromFeed) v.published = v.published * 1000;
+ });
+ //console.log(data.latestVideos.slice(0, 2))
+ channelCache.set(channelID, {refreshed: Date.now(), data: data});
+ //cf.log("Set new cache for "+channelID, "spam");
+ resolve(data);
+ }).catch(error => {
+ cf.log("Error while refreshing "+channelID, "error");
+ cf.log(error, "error");
+ channelCache.delete(channelID);
+ resolve(null);
+ });
+ });
+ channelCache.set(channelID, promise);
+ return promise;
+ }
+}
+
+module.exports = [
+ {
+ route: "/watch", methods: ["GET"], code: async ({url}) => {
+ const id = url.searchParams.get("v")
+ const video = await videoCache.getAs(id, () => fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json()))
+ return render(200, "pug/video.pug", {video})
+ }
+ }
+ /*
+ {
+ route: "/v/(.*)", methods: ["GET"], code: async ({fill}) => {
+ let id;
+ let wordsString = fill[0];
+ wordsString = wordsString.replace(/%20/g, " ")
+ if (wordsString.length == 11) {
+ id = wordsString
+ } else {
+ let words = findShareWords(wordsString);
+ try {
+ validateShareWords(words);
+ } catch (e) {
+ return [400, e.message];
+ }
+ id = getIDFromWords(words);
+ }
+ return {
+ statusCode: 301,
+ contentType: "text/html",
+ content: "Redirecting...",
+ headers: {
+ "Location": "/cloudtube/video/"+id
+ }
+ }
+ }
+ },
+ {
+ route: "/cloudtube/video/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
+ rp(`${getInvidiousHost("video")}/api/v1/videos/${fill[0]}`).then(body => {
+ try {
+ let data = JSON.parse(body);
+ let page = pugCache.get("pug/old/cloudtube-video.pug").web()
+ page = page.replace('""', () => body);
+ let shareWords = getShareWords(fill[0]);
+ page = page.replace('""', () => JSON.stringify(shareWords));
+ page = page.replace("
", () => `${data.title} — CloudTube video`);
+ while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo");
+ let metaOGTags =
+ `\n`+
+ `\n`+
+ `\n`+
+ `\n`+
+ `\n`
+ page = page.replace("", () => metaOGTags);
+ resolve({
+ statusCode: 200,
+ contentType: "text/html",
+ content: page
+ });
+ } catch (e) {
+ resolve([400, "Error parsing data from Invidious"]);
+ }
+ }).catch(err => {
+ resolve([500, "Error requesting data from Invidious"]);
+ });
+ })
+ },
+ {
+ route: "/cloudtube/channel/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
+ fetchChannel(fill[0]).then(data => {
+ try {
+ let page = pugCache.get("pug/old/cloudtube-channel.pug").web()
+ page = page.replace('""', () => JSON.stringify(data));
+ page = page.replace("", () => `${data.author} — CloudTube channel`);
+ let metaOGTags =
+ `\n`+
+ `\n`+
+ // `\n`+
+ `\n`+
+ `\n`
+ page = page.replace("", () => metaOGTags);
+ resolve({
+ statusCode: 200,
+ contentType: "text/html",
+ content: page
+ });
+ } catch (e) {
+ resolve([400, "Error parsing data from Invidious"]);
+ }
+ }).catch(err => {
+ resolve([500, "Error requesting data from Invidious"]);
+ });
+ })
+ },
+ {
+ route: "/cloudtube/playlist/([\\w-]+)", methods: ["GET"], code: ({req, fill}) => new Promise(resolve => {
+ rp(`${getInvidiousHost("playlist")}/api/v1/playlists/${fill[0]}`).then(body => {
+ try {
+ let data = JSON.parse(body);
+ let page = pugCache.get("pug/old/cloudtube-playlist.pug").web()
+ page = page.replace('""', () => body);
+ page = page.replace("", () => `${data.title} — CloudTube playlist`);
+ while (page.includes("yt.www.watch.player.seekTo")) page = page.replace("yt.www.watch.player.seekTo", "seekTo");
+ let metaOGTags =
+ `\n`+
+ `\n`+
+ `\n`+
+ `\n`
+ if (data.videos[0]) metaOGTags += `\n`;
+ page = page.replace("", () => metaOGTags);
+ resolve({
+ statusCode: 200,
+ contentType: "text/html",
+ content: page
+ });
+ } catch (e) {
+ resolve([400, "Error parsing data from Invidious"]);
+ }
+ }).catch(err => {
+ resolve([500, "Error requesting data from Invidious"]);
+ });
+ })
+ },
+ {
+ route: "/cloudtube/search", methods: ["GET"], upload: "json", code: ({req, url}) => new Promise(resolve => {
+ const params = url.searchParams
+ console.log("URL:", req.url)
+ console.log("Headers:", req.headers)
+ let page = pugCache.get("pug/old/cloudtube-search.pug").web()
+ if (params.has("q")) { // search terms were entered
+ let sort_by = params.get("sort_by") || "relevance";
+ rp(`${getInvidiousHost("search")}/api/v1/search?q=${encodeURIComponent(decodeURIComponent(params.get("q")))}&sort_by=${sort_by}`).then(body => {
+ try {
+ // json.parse?
+ page = page.replace('""', () => body);
+ page = page.replace("", () => `${decodeURIComponent(params.get("q"))} — CloudTube search`);
+ let metaOGTags =
+ `\n`+
+ `\n`+
+ `\n`+
+ `\n`
+ page = page.replace("", () => metaOGTags);
+ resolve({
+ statusCode: 200,
+ contentType: "text/html",
+ content: page
+ });
+ } catch (e) {
+ resolve([400, "Error parsing data from Invidious"]);
+ }
+ }).catch(err => {
+ resolve([500, "Error requesting data from Invidious"]);
+ });
+ } else { // no search terms
+ page = page.replace("", "");
+ page = page.replace("", `CloudTube search`);
+ let metaOGTags =
+ `\n`+
+ `\n`+
+ `\n`+
+ `\n`
+ page = page.replace("", () => metaOGTags);
+ resolve({
+ statusCode: 200,
+ contentType: "text/html",
+ content: page
+ });
+ }
+ })
+ },
+ {
+ route: "/api/youtube/subscribe", methods: ["POST"], upload: "json", code: async ({data}) => {
+ if (!data.channelID) return [400, 1];
+ if (!data.token) return [400, 8];
+ let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
+ if (!userRow || userRow.expires <= Date.now()) return [401, 8];
+ let subscriptions = (await db.all("SELECT channelID FROM AccountSubscriptions WHERE userID = ?", userRow.userID)).map(r => r.channelID);
+ let nowSubscribed;
+ if (subscriptions.includes(data.channelID)) {
+ await db.run("DELETE FROM AccountSubscriptions WHERE userID = ? AND channelID = ?", [userRow.userID, data.channelID]);
+ nowSubscribed = false;
+ } else {
+ await db.run("INSERT INTO AccountSubscriptions VALUES (?, ?)", [userRow.userID, data.channelID]);
+ nowSubscribed = true;
+ }
+ return [200, {channelID: data.channelID, nowSubscribed}];
+ }
+ },
+ {
+ route: "/api/youtube/subscriptions", methods: ["POST"], upload: "json", code: async ({data}) => {
+ let subscriptions;
+ if (data.token) {
+ let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
+ if (!userRow || userRow.expires <= Date.now()) return [401, 8];
+ subscriptions = (await db.all("SELECT channelID FROM AccountSubscriptions WHERE userID = ?", userRow.userID)).map(r => r.channelID);
+ } else {
+ if (data.subscriptions && data.subscriptions.constructor.name == "Array" && data.subscriptions.every(i => typeof(i) == "string")) subscriptions = data.subscriptions;
+ else return [400, 4];
+ }
+ if (data.force) {
+ for (let channelID of subscriptions) channelCache.delete(channelID);
+ return [204, ""];
+ } else {
+ let videos = [];
+ let channels = [];
+ let failedCount = 0
+ await Promise.all(subscriptions.map(s => fetchChannel(s).then(data => {
+ if (data) {
+ videos = videos.concat(data.latestVideos);
+ channels.push({author: data.author, authorID: data.authorId, authorThumbnails: data.authorThumbnails});
+ } else {
+ failedCount++
+ }
+ })));
+ videos = videos.sort((a, b) => (b.published - a.published))
+ let limit = 60;
+ if (data.limit && !isNaN(+data.limit) && (+data.limit > 0)) limit = +data.limit;
+ videos = videos.slice(0, limit);
+ channels = channels.sort((a, b) => (a.author.toLowerCase() < b.author.toLowerCase() ? -1 : 1));
+ return [200, {videos, channels, failedCount}];
+ }
+ }
+ },
+ {
+ route: "/api/youtube/subscriptions/import", methods: ["POST"], upload: "json", code: async ({data}) => {
+ if (!data) return [400, 3];
+ if (!typeof(data) == "object") return [400, 5];
+ if (!data.token) return [401, 8];
+ let userRow = await db.get("SELECT userID FROM AccountTokens WHERE token = ?", data.token);
+ if (!userRow || userRow.expires <= Date.now()) return [401, 8];
+ if (!data.subscriptions) return [400, 4];
+ if (!data.subscriptions.every(v => typeof(v) == "string")) return [400, 5];
+ await db.run("BEGIN TRANSACTION");
+ await db.run("DELETE FROM AccountSubscriptions WHERE userID = ?", userRow.userID);
+ await Promise.all(data.subscriptions.map(v =>
+ db.run("INSERT OR IGNORE INTO AccountSubscriptions VALUES (?, ?)", [userRow.userID, v])
+ ))
+ await db.run("END TRANSACTION");
+ return [204, ""];
+ }
+ },
+ {
+ route: "/api/youtube/channels/([\\w-]+)/info", methods: ["GET"], code: ({fill}) => {
+ return rp(`${getInvidiousHost("channel")}/api/v1/channels/${fill[0]}`).then(body => {
+ return {
+ statusCode: 200,
+ contentType: "application/json",
+ content: body
+ }
+ }).catch(e => {
+ console.error(e);
+ return [500, "Unknown request error, check console"]
+ });
+ }
+ },
+ {
+ route: "/api/youtube/alternate/.*", methods: ["GET"], code: async ({req}) => {
+ return [404, "Please leave me alone. This endpoint has been removed and it's never coming back. Why not try youtube-dl instead? https://github.com/ytdl-org/youtube-dl/\nIf you own a bot that accesses this endpoint, please send me an email: https://cadence.moe/about/contact\nHave a nice day.\n"];
+ return null
+ return [400, {error: `/api/youtube/alternate has been removed. The page will be reloaded.
`}]
+ }
+ },
+ {
+ route: "/api/youtube/dash/([\\w-]+)", methods: ["GET"], code: ({fill}) => new Promise(resolve => {
+ let id = fill[0];
+ let sentReq = rp({
+ url: `http://localhost:3000/api/manifest/dash/id/${id}?local=true`,
+ timeout: 8000
+ });
+ sentReq.catch(err => {
+ if (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNRESET") resolve([502, "Request to Invidious timed out"]);
+ else {
+ console.log(err);
+ resolve([500, "Unknown request error, check console"]);
+ }
+ });
+ sentReq.then(body => {
+ let data = fxp.parse(body, {ignoreAttributes: false});
+ resolve([200, data]);
+ }).catch(err => {
+ if (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNRESET") resolve([502, "Request to Invidious timed out"]);
+ else {
+ console.log(err);
+ resolve([500, "Unknown parse error, check console"]);
+ }
+ });
+ })
+ },
+ {
+ route: "/api/youtube/get_endscreen", methods: ["GET"], code: async ({params}) => {
+ if (!params.v) return [400, 1];
+ let data = await rp("https://www.youtube.com/get_endscreen?v="+params.v);
+ data = data.toString();
+ try {
+ if (data == `""`) {
+ return {
+ statusCode: 204,
+ content: "",
+ contentType: "text/html",
+ headers: {"Access-Control-Allow-Origin": "*"}
+ }
+ } else {
+ let json = JSON.parse(data.slice(data.indexOf("\n")+1));
+ let promises = [];
+ for (let e of json.elements.filter(e => e.endscreenElementRenderer.style == "WEBSITE")) {
+ for (let thb of e.endscreenElementRenderer.image.thumbnails) {
+ let promise = rp(thb.url, {encoding: null});
+ promise.then(image => {
+ let base64 = image.toString("base64");
+ thb.url = "data:image/jpeg;base64,"+base64;
+ });
+ promises.push(promise);
+ }
+ }
+ await Promise.all(promises);
+ return {
+ statusCode: 200,
+ content: json,
+ contentType: "application/json",
+ headers: {"Access-Control-Allow-Origin": "*"}
+ }
+ }
+ } catch (e) {
+ return [500, "Couldn't parse endscreen data\n\n"+data];
+ }
+ }
+ },
+ {
+ route: "/api/youtube/video/([\\w-]+)", methods: ["GET"], code: ({fill}) => {
+ return new Promise(resolve => {
+ ytdl.getInfo(fill[0]).then(info => {
+ resolve([200, Object.assign(info, {constructor: new Object().constructor})]);
+ }).catch(err => {
+ resolve([400, err]);
+ });
+ });
+ }
+ },
+ {
+ route: "/api/youtube/channel/(\\S+)", methods: ["GET"], code: ({fill}) => {
+ return new Promise(resolve => {
+ rp(
+ "https://www.googleapis.com/youtube/v3/channels?part=contentDetails"+
+ `&id=${fill[0]}&key=${auth.yt_api_key}`
+ ).then(channelText => {
+ let channel = JSON.parse(channelText);
+ let playlistIDs = channel.items.map(i => i.contentDetails.relatedPlaylists.uploads);
+ Promise.all(playlistIDs.map(pid => rp(
+ "https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails"+
+ `&playlistId=${pid}&maxResults=50&key=${auth.yt_api_key}`
+ ))).then(playlistsText => {
+ let playlists = playlistsText.map(pt => JSON.parse(pt));;
+ let items = [].concat(...playlists.map(p => p.items))
+ .map(i => i.contentDetails)
+ .sort((a, b) => (a.videoPublishedAt > b.videoPublishedAt ? -1 : 1))
+ .slice(0, 50);
+ rp(
+ "https://www.googleapis.com/youtube/v3/videos?part=contentDetails,snippet"+
+ `&id=${items.map(i => i.videoId).join(",")}&key=${auth.yt_api_key}`
+ ).then(videosText => {
+ let videos = JSON.parse(videosText);
+ videos.items.forEach(v => {
+ let duration = v.contentDetails.duration.slice(2).replace(/\D/g, ":").slice(0, -1).split(":")
+ .map((t, i) => {
+ if (i) t = t.padStart(2, "0");
+ return t;
+ });
+ if (duration.length == 1) duration.splice(0, 0, "0");
+ v.duration = duration.join(":");
+ });
+ resolve([200, videos.items]);
+ });
+ });
+ }).catch(err => {
+ resolve([500, "Unexpected promise rejection error. This should not happen. Contact Cadence as soon as possible."]);
+ console.log("Unexpected promise rejection error!");
+ console.log(err);
+ });
+ });
+ }
+ },
+ {
+ route: "/api/youtube/search", methods: ["GET"], code: ({params}) => {
+ return new Promise(resolve => {
+ if (!params || !params.q) return resolve([400, "Missing ?q parameter"]);
+ let searchObject = {
+ maxResults: +params.maxResults || 20,
+ key: auth.yt_api_key,
+ type: "video"
+ };
+ if (params.order) searchObject.order = params.order;
+ yts(params.q, searchObject, (err, search) => {
+ if (err) {
+ resolve([500, "YouTube API error. This should not happen. Contact Cadence as soon as possible."]);
+ console.log("YouTube API error!");
+ console.log(search);
+ } else {
+ rp(
+ "https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id="+
+ search.map(r => r.id).join(",")+
+ "&key="+auth.yt_api_key
+ ).then(videos => {
+ JSON.parse(videos).items.forEach(v => {
+ let duration = v.contentDetails.duration.slice(2).replace(/\D/g, ":").slice(0, -1).split(":")
+ .map((t, i) => {
+ if (i) t = t.padStart(2, "0");
+ return t;
+ });
+ if (duration.length == 1) duration.splice(0, 0, "0");
+ search.find(r => r.id == v.id).duration = duration.join(":");
+ });
+ resolve([200, search]);
+ });
+ }
+ });
+ });
+ }
+ }*/
+]
diff --git a/html/static/fonts/bariol.woff b/html/static/fonts/bariol.woff
new file mode 100755
index 0000000..79981c2
Binary files /dev/null and b/html/static/fonts/bariol.woff differ
diff --git a/html/static/images/arrow-down-wide.svg b/html/static/images/arrow-down-wide.svg
new file mode 100644
index 0000000..495bd83
--- /dev/null
+++ b/html/static/images/arrow-down-wide.svg
@@ -0,0 +1,3 @@
+
diff --git a/html/static/images/search.svg b/html/static/images/search.svg
new file mode 100644
index 0000000..b00640b
--- /dev/null
+++ b/html/static/images/search.svg
@@ -0,0 +1,7 @@
+
diff --git a/html/static/js/elemjs/elemjs.js b/html/static/js/elemjs/elemjs.js
new file mode 100644
index 0000000..62be34e
--- /dev/null
+++ b/html/static/js/elemjs/elemjs.js
@@ -0,0 +1,137 @@
+/**
+ * Shortcut for querySelector.
+ * @template {HTMLElement} T
+ * @returns {T}
+ */
+const q = s => document.querySelector(s);
+/**
+ * Shortcut for querySelectorAll.
+ * @template {HTMLElement} T
+ * @returns {T[]}
+ */
+const qa = s => document.querySelectorAll(s);
+
+/**
+ * An easier, chainable, object-oriented way to create and update elements
+ * and children according to related data. Subclass ElemJS to create useful,
+ * advanced data managers, or just use it inline to quickly make a custom element.
+ */
+class ElemJS {
+ constructor(type) {
+ if (type instanceof HTMLElement) {
+ // If passed an existing element, bind to it
+ this.bind(type);
+ } else if (typeof type === "string") {
+ // Otherwise, create a new detached element to bind to
+ this.bind(document.createElement(type));
+ } else {
+ throw new Error("Cannot create an element of type ${type}")
+ }
+ this.children = [];
+ }
+
+ /** Bind this construct to an existing element on the page. */
+ bind(element) {
+ this.element = element;
+ this.element.js = this;
+ return this;
+ }
+
+ /** Add a class. */
+ class() {
+ for (let name of arguments) if (name) this.element.classList.add(name);
+ return this;
+ }
+
+ /** Remove a class. */
+ removeClass() {
+ for (let name of arguments) if (name) this.element.classList.remove(name);
+ return this;
+ }
+
+ /** Set a JS property on the element. */
+ direct(name, value) {
+ if (name) this.element[name] = value;
+ return this;
+ }
+
+ /** Set an attribute on the element. */
+ attribute(name, value) {
+ if (name) this.element.setAttribute(name, value);
+ return this;
+ }
+
+ /** Set a style on the element. */
+ style(name, value) {
+ if (name) this.element.style[name] = value;
+ return this;
+ }
+
+ /** Set the element's ID. */
+ id(name) {
+ if (name) this.element.id = name;
+ return this;
+ }
+
+ /** Attach a callback function to an event on the element. */
+ on(name, callback) {
+ this.element.addEventListener(name, callback);
+ return this;
+ }
+
+ /** Set the element's text. */
+ text(name) {
+ this.element.innerText = name;
+ return this;
+ }
+
+ /** Create a text node and add it to the element. */
+ addText(name) {
+ const node = document.createTextNode(name);
+ this.element.appendChild(node);
+ return this;
+ }
+
+ /** Set the element's HTML content. */
+ html(name) {
+ this.element.innerHTML = name;
+ return this;
+ }
+
+ /**
+ * Add children to the element.
+ * Children can either be an instance of ElemJS, in
+ * which case the element will be appended as a child,
+ * or a string, in which case the string will be added as a text node.
+ * Each child should be a parameter to this method.
+ */
+ child(...children) {
+ for (const toAdd of children) {
+ if (typeof toAdd === "object" && toAdd !== null) {
+ // Should be an instance of ElemJS, so append as child
+ toAdd.parent = this;
+ this.element.appendChild(toAdd.element);
+ this.children.push(toAdd);
+ } else if (typeof toAdd === "string") {
+ // Is a string, so add as text node
+ this.addText(toAdd);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Remove all children from the element.
+ */
+ clearChildren() {
+ this.children.length = 0;
+ while (this.element.lastChild) this.element.removeChild(this.element.lastChild);
+ }
+}
+
+/** Shortcut for `new ElemJS`. */
+function ejs(tag) {
+ return new ElemJS(tag);
+}
+
+export {q, qa, ElemJS, ejs};
diff --git a/html/static/js/elemjs/elemjs.mjs b/html/static/js/elemjs/elemjs.mjs
deleted file mode 100644
index 563d921..0000000
--- a/html/static/js/elemjs/elemjs.mjs
+++ /dev/null
@@ -1,78 +0,0 @@
-/** @returns {HTMLElement} */
-export function q(s) {
- return document.querySelector(s)
-}
-
-export class ElemJS {
- constructor(type) {
- if (type instanceof HTMLElement) this.bind(type)
- else this.bind(document.createElement(type))
- this.children = [];
- }
- bind(element) {
- /** @type {HTMLElement} */
- this.element = element
- // @ts-ignore
- this.element.js = this
- return this
- }
- class() {
- for (let name of arguments) if (name) this.element.classList.add(name);
- return this;
- }
- removeClass() {
- for (let name of arguments) if (name) this.element.classList.remove(name);
- return this;
- }
- direct(name, value) {
- if (name) this.element[name] = value;
- return this;
- }
- attribute(name, value) {
- if (name) this.element.setAttribute(name, value);
- return this;
- }
- style(name, value) {
- if (name) this.element.style[name] = value;
- return this;
- }
- id(name) {
- if (name) this.element.id = name;
- return this;
- }
- text(name) {
- this.element.innerText = name;
- return this;
- }
- addText(name) {
- const node = document.createTextNode(name)
- this.element.appendChild(node)
- return this
- }
- html(name) {
- this.element.innerHTML = name;
- return this;
- }
- event(name, callback) {
- this.element.addEventListener(name, event => callback(event))
- }
- child(toAdd, position) {
- if (typeof(toAdd) == "object") {
- toAdd.parent = this;
- if (typeof(position) == "number" && position >= 0) {
- this.element.insertBefore(toAdd.element, this.element.children[position]);
- this.children.splice(position, 0, toAdd);
- } else {
- this.element.appendChild(toAdd.element);
- this.children.push(toAdd);
- }
- } else if (typeof toAdd === "string") {
- this.text(toAdd)
- }
- return this;
- }
- clearChildren() {
- this.children.length = 0;
- while (this.element.lastChild) this.element.removeChild(this.element.lastChild);
- }
-}
diff --git a/html/static/js/focus.js b/html/static/js/focus.js
new file mode 100644
index 0000000..12b2dc3
--- /dev/null
+++ b/html/static/js/focus.js
@@ -0,0 +1,9 @@
+document.addEventListener("mousedown", () => {
+ document.body.classList.remove("show-focus")
+})
+
+document.addEventListener("keydown", event => {
+ if (event.key === "Tab") {
+ document.body.classList.add("show-focus")
+ }
+})
diff --git a/html/static/js/player.js b/html/static/js/player.js
new file mode 100644
index 0000000..69b016d
--- /dev/null
+++ b/html/static/js/player.js
@@ -0,0 +1,117 @@
+import {q, ElemJS} from "/static/js/elemjs/elemjs.js"
+
+const video = q("#video")
+const audio = q("#audio")
+
+const videoFormats = new Map()
+const audioFormats = new Map()
+for (const f of [].concat(
+ data.formatStreams.map(f => (f.isAdaptive = false, f)),
+ data.adaptiveFormats.map(f => (f.isAdaptive = true, f))
+)) {
+ if (f.type.startsWith("video")) {
+ videoFormats.set(f.itag, f)
+ } else {
+ audioFormats.set(f.itag, f)
+ }
+}
+
+function getBestAudioFormat() {
+ let best = null
+ for (const f of audioFormats.values()) {
+ if (best === null || f.bitrate > best.bitrate) {
+ best = f
+ }
+ }
+ return best
+}
+
+class FormatLoader {
+ constructor() {
+ this.npv = videoFormats.get(q("#video").getAttribute("data-itag"))
+ this.npa = null
+ }
+
+ play(itag) {
+ this.npv = videoFormats.get(itag)
+ if (this.npv.isAdaptive) {
+ this.npa = getBestAudioFormat()
+ } else {
+ this.npa = null
+ }
+ this.update()
+ }
+
+ update() {
+ const lastTime = video.currentTime
+ video.src = this.npv.url
+ video.currentTime = lastTime
+ if (this.npa) {
+ audio.src = this.npa.url
+ audio.currentTime = lastTime
+ }
+ }
+}
+
+const formatLoader = new FormatLoader()
+
+class QualitySelect extends ElemJS {
+ constructor() {
+ super(q("#quality-select"))
+ this.on("input", this.onInput.bind(this))
+ }
+
+ onInput() {
+ const itag = this.element.value
+ formatLoader.play(itag)
+ }
+}
+
+const qualitySelect = new QualitySelect()
+
+function playbackIntervention(event) {
+ console.log(event.target.tagName.toLowerCase(), event.type)
+ if (audio.src) {
+ let target = event.target
+ let targetName = target.tagName.toLowerCase()
+ let other = (event.target === video ? audio : video)
+ switch (event.type) {
+ case "durationchange":
+ target.ready = false;
+ break;
+ case "seeked":
+ target.ready = false;
+ target.pause();
+ other.currentTime = target.currentTime;
+ break;
+ case "play":
+ other.currentTime = target.currentTime;
+ other.play();
+ break;
+ case "pause":
+ other.currentTime = target.currentTime;
+ other.pause();
+ case "playing":
+ other.currentTime = target.currentTime;
+ break;
+ case "ratechange":
+ other.rate = target.rate;
+ break;
+ // case "stalled":
+ // case "waiting":
+ // target.pause();
+ // break;
+ }
+ } else {
+ // @ts-ignore this does exist
+ // if (event.type == "canplaythrough" && !video.manualPaused) video.play();
+ }
+}
+
+for (let eventName of ["pause", "play", "seeked"]) {
+ video.addEventListener(eventName, playbackIntervention)
+}
+for (let eventName of ["canplaythrough", "waiting", "stalled"]) {
+ video.addEventListener(eventName, playbackIntervention)
+ audio.addEventListener(eventName, playbackIntervention)
+}
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..834a9ea
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "esnext",
+ "checkJs": true,
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..d93bc22
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,622 @@
+{
+ "name": "cloudtube",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "node-fetch": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
+ "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
+ },
+ "pinski": {
+ "version": "file:../pinski",
+ "requires": {
+ "mime": "^2.4.4",
+ "pug": "^2.0.3",
+ "sass": "^1.26.5",
+ "ws": "^7.1.1"
+ },
+ "dependencies": {
+ "@types/babel-types": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/@types/babel-types/-/babel-types-7.0.7.tgz",
+ "integrity": "sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ=="
+ },
+ "@types/babylon": {
+ "version": "6.16.5",
+ "resolved": "https://registry.npmjs.org/@types/babylon/-/babylon-6.16.5.tgz",
+ "integrity": "sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==",
+ "requires": {
+ "@types/babel-types": "*"
+ }
+ },
+ "acorn": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
+ "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo="
+ },
+ "acorn-globals": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-3.1.0.tgz",
+ "integrity": "sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8=",
+ "requires": {
+ "acorn": "^4.0.4"
+ },
+ "dependencies": {
+ "acorn": {
+ "version": "4.0.13",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
+ "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
+ }
+ }
+ },
+ "align-text": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
+ "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
+ "requires": {
+ "kind-of": "^3.0.2",
+ "longest": "^1.0.1",
+ "repeat-string": "^1.5.2"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
+ "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
+ },
+ "async-limiter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
+ "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg=="
+ },
+ "babel-runtime": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+ "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+ "requires": {
+ "core-js": "^2.4.0",
+ "regenerator-runtime": "^0.11.0"
+ }
+ },
+ "babel-types": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
+ "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
+ "requires": {
+ "babel-runtime": "^6.26.0",
+ "esutils": "^2.0.2",
+ "lodash": "^4.17.4",
+ "to-fast-properties": "^1.0.3"
+ }
+ },
+ "babylon": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
+ "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
+ },
+ "binary-extensions": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
+ "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow=="
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "center-align": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
+ "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=",
+ "requires": {
+ "align-text": "^0.1.3",
+ "lazy-cache": "^1.0.3"
+ }
+ },
+ "character-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz",
+ "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=",
+ "requires": {
+ "is-regex": "^1.0.3"
+ }
+ },
+ "chokidar": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
+ "integrity": "sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==",
+ "requires": {
+ "anymatch": "~3.1.1",
+ "braces": "~3.0.2",
+ "fsevents": "~2.1.2",
+ "glob-parent": "~5.1.0",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.4.0"
+ }
+ },
+ "clean-css": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz",
+ "integrity": "sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==",
+ "requires": {
+ "source-map": "~0.6.0"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ }
+ }
+ },
+ "constantinople": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-3.1.2.tgz",
+ "integrity": "sha512-yePcBqEFhLOqSBtwYOGGS1exHo/s1xjekXiinh4itpNQGCu4KA1euPh1fg07N2wMITZXQkBz75Ntdt1ctGZouw==",
+ "requires": {
+ "@types/babel-types": "^7.0.0",
+ "@types/babylon": "^6.16.2",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0"
+ }
+ },
+ "core-js": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz",
+ "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A=="
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
+ },
+ "doctypes": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
+ "integrity": "sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk="
+ },
+ "esutils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
+ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "fsevents": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
+ "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
+ "optional": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ },
+ "glob-parent": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
+ "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+ },
+ "is-expression": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-3.0.0.tgz",
+ "integrity": "sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8=",
+ "requires": {
+ "acorn": "~4.0.2",
+ "object-assign": "^4.0.1"
+ },
+ "dependencies": {
+ "acorn": {
+ "version": "4.0.13",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
+ "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
+ }
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
+ },
+ "is-glob": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+ "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
+ },
+ "is-promise": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
+ "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o="
+ },
+ "is-regex": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
+ "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
+ "requires": {
+ "has": "^1.0.1"
+ }
+ },
+ "js-stringify": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
+ "integrity": "sha1-Fzb939lyTyijaCrcYjCufk6Weds="
+ },
+ "jstransformer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
+ "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=",
+ "requires": {
+ "is-promise": "^2.0.0",
+ "promise": "^7.0.1"
+ }
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ },
+ "lazy-cache": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
+ "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4="
+ },
+ "lodash": {
+ "version": "4.17.19",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+ "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
+ },
+ "longest": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
+ "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
+ },
+ "mime": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
+ "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA=="
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
+ "path-parse": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
+ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
+ },
+ "picomatch": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
+ "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
+ },
+ "promise": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
+ "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
+ "requires": {
+ "asap": "~2.0.3"
+ }
+ },
+ "pug": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pug/-/pug-2.0.4.tgz",
+ "integrity": "sha512-XhoaDlvi6NIzL49nu094R2NA6P37ijtgMDuWE+ofekDChvfKnzFal60bhSdiy8y2PBO6fmz3oMEIcfpBVRUdvw==",
+ "requires": {
+ "pug-code-gen": "^2.0.2",
+ "pug-filters": "^3.1.1",
+ "pug-lexer": "^4.1.0",
+ "pug-linker": "^3.0.6",
+ "pug-load": "^2.0.12",
+ "pug-parser": "^5.0.1",
+ "pug-runtime": "^2.0.5",
+ "pug-strip-comments": "^1.0.4"
+ }
+ },
+ "pug-attrs": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-2.0.4.tgz",
+ "integrity": "sha512-TaZ4Z2TWUPDJcV3wjU3RtUXMrd3kM4Wzjbe3EWnSsZPsJ3LDI0F3yCnf2/W7PPFF+edUFQ0HgDL1IoxSz5K8EQ==",
+ "requires": {
+ "constantinople": "^3.0.1",
+ "js-stringify": "^1.0.1",
+ "pug-runtime": "^2.0.5"
+ }
+ },
+ "pug-code-gen": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-2.0.2.tgz",
+ "integrity": "sha512-kROFWv/AHx/9CRgoGJeRSm+4mLWchbgpRzTEn8XCiwwOy6Vh0gAClS8Vh5TEJ9DBjaP8wCjS3J6HKsEsYdvaCw==",
+ "requires": {
+ "constantinople": "^3.1.2",
+ "doctypes": "^1.1.0",
+ "js-stringify": "^1.0.1",
+ "pug-attrs": "^2.0.4",
+ "pug-error": "^1.3.3",
+ "pug-runtime": "^2.0.5",
+ "void-elements": "^2.0.1",
+ "with": "^5.0.0"
+ }
+ },
+ "pug-error": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-1.3.3.tgz",
+ "integrity": "sha512-qE3YhESP2mRAWMFJgKdtT5D7ckThRScXRwkfo+Erqga7dyJdY3ZquspprMCj/9sJ2ijm5hXFWQE/A3l4poMWiQ=="
+ },
+ "pug-filters": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-3.1.1.tgz",
+ "integrity": "sha512-lFfjNyGEyVWC4BwX0WyvkoWLapI5xHSM3xZJFUhx4JM4XyyRdO8Aucc6pCygnqV2uSgJFaJWW3Ft1wCWSoQkQg==",
+ "requires": {
+ "clean-css": "^4.1.11",
+ "constantinople": "^3.0.1",
+ "jstransformer": "1.0.0",
+ "pug-error": "^1.3.3",
+ "pug-walk": "^1.1.8",
+ "resolve": "^1.1.6",
+ "uglify-js": "^2.6.1"
+ }
+ },
+ "pug-lexer": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-4.1.0.tgz",
+ "integrity": "sha512-i55yzEBtjm0mlplW4LoANq7k3S8gDdfC6+LThGEvsK4FuobcKfDAwt6V4jKPH9RtiE3a2Akfg5UpafZ1OksaPA==",
+ "requires": {
+ "character-parser": "^2.1.1",
+ "is-expression": "^3.0.0",
+ "pug-error": "^1.3.3"
+ }
+ },
+ "pug-linker": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-3.0.6.tgz",
+ "integrity": "sha512-bagfuHttfQOpANGy1Y6NJ+0mNb7dD2MswFG2ZKj22s8g0wVsojpRlqveEQHmgXXcfROB2RT6oqbPYr9EN2ZWzg==",
+ "requires": {
+ "pug-error": "^1.3.3",
+ "pug-walk": "^1.1.8"
+ }
+ },
+ "pug-load": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-2.0.12.tgz",
+ "integrity": "sha512-UqpgGpyyXRYgJs/X60sE6SIf8UBsmcHYKNaOccyVLEuT6OPBIMo6xMPhoJnqtB3Q3BbO4Z3Bjz5qDsUWh4rXsg==",
+ "requires": {
+ "object-assign": "^4.1.0",
+ "pug-walk": "^1.1.8"
+ }
+ },
+ "pug-parser": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-5.0.1.tgz",
+ "integrity": "sha512-nGHqK+w07p5/PsPIyzkTQfzlYfuqoiGjaoqHv1LjOv2ZLXmGX1O+4Vcvps+P4LhxZ3drYSljjq4b+Naid126wA==",
+ "requires": {
+ "pug-error": "^1.3.3",
+ "token-stream": "0.0.1"
+ }
+ },
+ "pug-runtime": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-2.0.5.tgz",
+ "integrity": "sha512-P+rXKn9un4fQY77wtpcuFyvFaBww7/91f3jHa154qU26qFAnOe6SW1CbIDcxiG5lLK9HazYrMCCuDvNgDQNptw=="
+ },
+ "pug-strip-comments": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-1.0.4.tgz",
+ "integrity": "sha512-i5j/9CS4yFhSxHp5iKPHwigaig/VV9g+FgReLJWWHEHbvKsbqL0oP/K5ubuLco6Wu3Kan5p7u7qk8A4oLLh6vw==",
+ "requires": {
+ "pug-error": "^1.3.3"
+ }
+ },
+ "pug-walk": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-1.1.8.tgz",
+ "integrity": "sha512-GMu3M5nUL3fju4/egXwZO0XLi6fW/K3T3VTgFQ14GxNi8btlxgT5qZL//JwZFm/2Fa64J/PNS8AZeys3wiMkVA=="
+ },
+ "readdirp": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
+ "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+ "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
+ },
+ "repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
+ },
+ "resolve": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.1.tgz",
+ "integrity": "sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==",
+ "requires": {
+ "path-parse": "^1.0.6"
+ }
+ },
+ "right-align": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz",
+ "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=",
+ "requires": {
+ "align-text": "^0.1.1"
+ }
+ },
+ "sass": {
+ "version": "1.26.5",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.5.tgz",
+ "integrity": "sha512-FG2swzaZUiX53YzZSjSakzvGtlds0lcbF+URuU9mxOv7WBh7NhXEVDa4kPKN4hN6fC2TkOTOKqiqp6d53N9X5Q==",
+ "requires": {
+ "chokidar": ">=2.0.0 <4.0.0"
+ }
+ },
+ "to-fast-properties": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
+ "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc="
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
+ "token-stream": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-0.0.1.tgz",
+ "integrity": "sha1-zu78cXp2xDFvEm0LnbqlXX598Bo="
+ },
+ "uglify-js": {
+ "version": "2.8.29",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz",
+ "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=",
+ "requires": {
+ "source-map": "~0.5.1",
+ "uglify-to-browserify": "~1.0.0",
+ "yargs": "~3.10.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz",
+ "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk="
+ },
+ "cliui": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
+ "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=",
+ "requires": {
+ "center-align": "^0.1.1",
+ "right-align": "^0.1.1",
+ "wordwrap": "0.0.2"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "yargs": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
+ "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=",
+ "requires": {
+ "camelcase": "^1.0.2",
+ "cliui": "^2.1.0",
+ "decamelize": "^1.0.0",
+ "window-size": "0.1.0"
+ }
+ }
+ }
+ },
+ "uglify-to-browserify": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz",
+ "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=",
+ "optional": true
+ },
+ "void-elements": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
+ "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w="
+ },
+ "window-size": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz",
+ "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0="
+ },
+ "with": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/with/-/with-5.1.1.tgz",
+ "integrity": "sha1-+k2qktrzLE6pTtRTyB8EaGtXXf4=",
+ "requires": {
+ "acorn": "^3.1.0",
+ "acorn-globals": "^3.0.0"
+ }
+ },
+ "wordwrap": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz",
+ "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8="
+ },
+ "ws": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.1.1.tgz",
+ "integrity": "sha512-o41D/WmDeca0BqYhsr3nJzQyg9NF5X8l/UdnFNux9cS3lwB+swm8qGWX5rn+aD6xfBU3rGmtHij7g7x6LxFU3A==",
+ "requires": {
+ "async-limiter": "^1.0.0"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dd38fd1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "cloudtube",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "AGPL-3.0",
+ "dependencies": {
+ "node-fetch": "^2.6.0",
+ "pinski": "file:../pinski"
+ }
+}
diff --git a/pug/includes/head.pug b/pug/includes/head.pug
new file mode 100644
index 0000000..bc0fb7a
--- /dev/null
+++ b/pug/includes/head.pug
@@ -0,0 +1 @@
+doctype html
diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug
new file mode 100644
index 0000000..438574a
--- /dev/null
+++ b/pug/includes/layout.pug
@@ -0,0 +1,15 @@
+doctype html
+html
+ head
+ meta(charset="utf-8")
+ meta(name="viewport" value="width=device-width, initial-scale=1")
+ link(rel="stylesheet" type="text/css" href=getStaticURL("sass", "/main.sass"))
+ script(type="module" src=getStaticURL("html", "/static/js/focus.js"))
+ block head
+
+ body.show-focus
+ nav.main-nav
+ a(href="/").link.home CloudTube
+ input(type="text" placeholder="Search" name="q" autocomplete="off").search
+
+ block content
diff --git a/pug/video.pug b/pug/video.pug
new file mode 100644
index 0000000..b16718f
--- /dev/null
+++ b/pug/video.pug
@@ -0,0 +1,68 @@
+extends includes/layout.pug
+
+block head
+ title= video.title
+ script(type="module" src=getStaticURL("html", "/static/js/player.js"))
+ script const data = !{JSON.stringify(video)}
+
+block content
+ - const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height)
+ - const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => b.second__height - a.second__height)
+ main.video-page
+ .main-video-section
+ .video-container
+ - const format = sortedFormatStreams[0]
+ video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video
+ source(src=format.url type=format.type)
+
+ #current-time-container
+ #end-cards-container
+ .info
+ header.info-main
+ h1.title= video.title
+ .author
+ a(href=`/channel/${video.authorId}`).author-link= `Uploaded by ${video.author}`
+ .info-secondary
+ - const date = new Date(video.published*1000)
+ - const month = new Intl.DateTimeFormat("en-US", {month: "short"}).format(date.getTime())
+ div= `Uploaded ${date.getUTCDate()} ${month} ${date.getUTCFullYear()}`
+ div= video.second__viewCountText
+ div(style=`--rating: ${video.rating*20}%`)#rating-bar.rating-bar
+
+ audio(preload="auto")#audio
+ #live-event-notice
+ #audio-loading-display
+
+ .video-button-container
+ button.border-look#subscribe Subscribe
+ button.border-look#theatre Theatre
+ select(autocomplete="off").border-look#quality-select
+ each f in sortedFormatStreams
+ option(value=f.itag)= `${f.qualityLabel} ${f.container}`
+ each f in sortedVideoAdaptiveFormats
+ option(value=f.itag)= `${f.qualityLabel} ${f.container} *`
+
+ .video-button-container
+ a(href="/subscriptions").border-look
+ img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon
+ | Search
+ button.border-look#share Share
+ a.border-look YouTube
+ a.border-look Iv: Snopyta
+
+ .description!= video.descriptionHtml
+
+ aside.related-videos
+ h2.related-header Related videos
+ each r in video.recommendedVideos
+ .related-video
+ - let link = `/watch?v=${r.videoId}`
+ a(href=link).thumbnail
+ img(src=`https://i.ytimg.com/vi/${r.videoId}/mqdefault.jpg` width=320 height=180 alt="").image
+ span.duration= r.second__lengthText
+ .info
+ div.title: a(href=link).title-link= r.title
+ div.author-line
+ a(href=`/channel/${authorId}`).author= r.author
+ = ` • `
+ span.views= r.viewCountText
diff --git a/sass/includes/base.sass b/sass/includes/base.sass
new file mode 100644
index 0000000..33eeef1
--- /dev/null
+++ b/sass/includes/base.sass
@@ -0,0 +1,34 @@
+@use "colors.sass" as c
+
+body
+ background-color: c.$bg-dark
+ color: c.$fg-main
+ font-family: "Bariol", sans-serif
+ font-size: 18px
+ margin: 0
+ padding: 0
+
+a
+ color: c.$link
+
+input, select, button
+ font-family: inherit
+ font-size: 16px
+
+button
+ cursor: pointer
+
+:-moz-focusring
+ outline: none
+
+::-moz-focus-inner
+ border: 0
+
+select:-moz-focusring
+ color: transparent
+ text-shadow: 0 0 0 c.$fg-bright
+
+body.show-focus
+ a, select, button, input, video
+ &:focus
+ outline: 2px dotted #ddd
diff --git a/sass/includes/colors.sass b/sass/includes/colors.sass
new file mode 100644
index 0000000..b0eec04
--- /dev/null
+++ b/sass/includes/colors.sass
@@ -0,0 +1,14 @@
+$bg-darkest: #202123
+$bg-darker: #303336
+$bg-dark: #36393f
+$bg-accent: #4f5359
+$bg-accent-x: #3f4247
+$bg-accent-area: #44474b
+
+$fg-bright: #fff
+$fg-main: #ddd
+$fg-dim: #bbb
+
+$edge-grey: #808080
+
+$link: #72b4f6
diff --git a/sass/includes/video-page.sass b/sass/includes/video-page.sass
new file mode 100644
index 0000000..51afc9b
--- /dev/null
+++ b/sass/includes/video-page.sass
@@ -0,0 +1,117 @@
+@use "colors.sass" as c
+
+.video-page
+ display: grid
+ grid-auto-flow: row
+ padding: 20px
+ grid-gap: 16px
+
+ @media screen and (min-width: 1000px)
+ grid-template-columns: 1fr 400px
+
+.main-video-section
+ .video-container
+ text-align: center
+
+ .video
+ display: inline-block
+ width: 100%
+ height: auto
+ max-height: 80vh
+
+ .info
+ display: flex
+ margin: 8px 4px 16px
+ font-size: 17px
+
+ .info-main
+ flex: 1
+
+ .title
+ margin: 0px 0px 4px
+ font-size: 30px
+ font-weight: normal
+ color: c.$fg-bright
+
+ .author-link
+ color: c.$fg-main
+ text-decoration: none
+
+ &:hover, &:active
+ color: c.$fg-bright
+ text-decoration: underline
+
+ .info-secondary
+ display: flex
+ flex-direction: column
+ align-items: end
+ margin-top: 6px
+ margin-left: 6px
+
+ .rating-bar
+ margin-top: 8px
+ width: 140px
+ height: 8px
+ border-radius: 3px
+ background: linear-gradient(to right, #1a1 var(--rating), #bbb var(--rating))
+
+ .description
+ font-size: 17px
+ margin: 16px 4px 4px 4px
+ background-color: c.$bg-accent-area
+ padding: 12px
+ border-radius: 4px
+
+.related-header
+ margin: 4px 0px 12px 2px
+ font-weight: normal
+ font-size: 26px
+
+.related-video
+ display: grid
+ grid-template-columns: 160px 1fr
+ grid-gap: 8px
+ align-items: start
+ align-content: start
+ margin-bottom: 16px
+
+ .thumbnail
+ position: relative
+ display: flex
+ background: c.$bg-darkest
+
+ .image
+ width: 160px
+ height: 90px
+
+ .duration
+ position: absolute
+ bottom: 3px
+ right: 3px
+ color: c.$fg-bright
+ font-size: 14px
+ background: rgba(20, 20, 20, 0.85)
+ line-height: 1
+ padding: 3px 5px 4px
+ border-radius: 4px
+
+ .title
+ font-size: 15px
+ line-height: 1.2
+
+ .title-link
+ color: c.$fg-main
+ text-decoration: none
+
+ .author-line
+ margin-top: 4px
+ font-size: 15px
+ color: c.$fg-dim
+
+ .author
+ color: c.$fg-dim
+ text-decoration: none
+
+ &:hover, &:active
+ color: c.$fg-bright
+ text-decoration: underline
diff --git a/sass/main.sass b/sass/main.sass
new file mode 100644
index 0000000..787d6b7
--- /dev/null
+++ b/sass/main.sass
@@ -0,0 +1,88 @@
+@use "includes/base.sass"
+@use "includes/colors.sass" as c
+@use "sass:selector"
+@use "includes/video-page.sass"
+
+@font-face
+ font-family: "Bariol"
+ src: url(/static/fonts/bariol.woff)
+
+@mixin button-base
+ appearance: none
+ -moz-appearance: none
+ color: c.$fg-bright
+ border: none
+ border-radius: 4px
+ padding: 8px
+ margin: 0
+ text-decoration: none
+ line-height: 1.25
+
+ @at-root #{selector.unify(&, "select")}
+ padding: 7px 27px 7px 8px
+ background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x
+
+ @at-root #{selector.unify(&, "a")}
+ padding: 7px 8px
+
+ .button-icon
+ position: relative
+ top: 3px
+ margin-right: 8px
+ margin-left: 2px
+
+@mixin button-bg
+ @include button-base
+
+ background-color: c.$bg-accent-x
+
+@mixin border-button
+ @include button-bg
+
+ border: 1px solid c.$edge-grey
+
+@mixin button-size
+ margin: 4px
+ font-size: 16px
+
+@mixin button-hover
+ &:hover
+ background-color: c.$bg-accent
+
+ &:active
+ background-color: c.$bg-dark
+
+.border-look
+ @include border-button
+ @include button-size
+ @include button-hover
+
+.main-nav
+ background-color: c.$bg-accent
+ display: flex
+ padding: 8px
+ box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
+
+ .link
+ @include button-base
+ text-decoration: none
+ margin: 1px 8px 1px 0px
+ font-size: 20px
+
+ &.home
+ font-weight: bold
+
+ &, &:visited
+ color: #fff
+
+ &:focus, &:hover
+ background-color: c.$bg-accent-x
+
+ .search
+ @include button-bg
+ flex: 1
+ margin: 1px
+
+ &:hover, &:focus
+ border: 1px solid c.$edge-grey
+ margin: 0px
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..01551a4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,26 @@
+const {Pinski} = require("pinski")
+const {setInstance} = require("pinski/plugins")
+
+const server = new Pinski({
+ port: 8080,
+ relativeRoot: __dirname,
+ filesDir: "html"
+})
+
+setInstance(server)
+
+server.addSassDir("sass", ["sass/includes"])
+server.addRoute("/static/css/main.css", "sass/main.sass", "sass")
+
+server.addPugDir("pug", ["pug/includes"])
+
+server.addStaticHashTableDir("html/static/js")
+server.addStaticHashTableDir("html/static/js/elemjs")
+
+server.addAPIDir("api")
+
+server.startServer()
+
+setTimeout(() => {
+ console.log(server.staticFileTable, server.pageHandlers)
+}, 2000)
diff --git a/util/words.txt b/util/words.txt
new file mode 100644
index 0000000..f78ccaf
--- /dev/null
+++ b/util/words.txt
@@ -0,0 +1,2048 @@
+abandon
+ability
+able
+about
+above
+absent
+absorb
+abstract
+absurd
+abuse
+access
+accident
+account
+accuse
+achieve
+acid
+acoustic
+acquire
+across
+act
+action
+actor
+actress
+actual
+adapt
+add
+addict
+address
+adjust
+admit
+adult
+advance
+advice
+aerobic
+affair
+afford
+afraid
+again
+age
+agent
+agree
+ahead
+aim
+air
+airport
+aisle
+alarm
+album
+alcohol
+alert
+alien
+all
+alley
+allow
+almost
+alone
+alpha
+already
+also
+alter
+always
+amateur
+amazing
+among
+amount
+amused
+analyst
+anchor
+ancient
+anger
+angle
+angry
+animal
+ankle
+announce
+annual
+another
+answer
+antenna
+antique
+anxiety
+any
+apart
+apology
+appear
+apple
+approve
+april
+arch
+arctic
+area
+arena
+argue
+arm
+armed
+armor
+army
+around
+arrange
+arrest
+arrive
+arrow
+art
+artefact
+artist
+artwork
+ask
+aspect
+assault
+asset
+assist
+assume
+asthma
+athlete
+atom
+attack
+attend
+attitude
+attract
+auction
+audit
+august
+aunt
+author
+auto
+autumn
+average
+avocado
+avoid
+awake
+aware
+away
+awesome
+awful
+awkward
+axis
+baby
+bachelor
+bacon
+badge
+bag
+balance
+balcony
+ball
+bamboo
+banana
+banner
+bar
+barely
+bargain
+barrel
+base
+basic
+basket
+battle
+beach
+bean
+beauty
+because
+become
+beef
+before
+begin
+behave
+behind
+believe
+below
+belt
+bench
+benefit
+best
+betray
+better
+between
+beyond
+bicycle
+bid
+bike
+bind
+biology
+bird
+birth
+bitter
+black
+blade
+blame
+blanket
+blast
+bleak
+bless
+blind
+blood
+blossom
+blouse
+blue
+blur
+blush
+board
+boat
+body
+boil
+bomb
+bone
+bonus
+book
+boost
+border
+boring
+borrow
+boss
+bottom
+bounce
+box
+boy
+bracket
+brain
+brand
+brass
+brave
+bread
+breeze
+brick
+bridge
+brief
+bright
+bring
+brisk
+broccoli
+broken
+bronze
+broom
+brother
+brown
+brush
+bubble
+buddy
+budget
+buffalo
+build
+bulb
+bulk
+bullet
+bundle
+bunker
+burden
+burger
+burst
+bus
+business
+busy
+butter
+buyer
+buzz
+cabbage
+cabin
+cable
+cactus
+cage
+cake
+call
+calm
+camera
+camp
+can
+canal
+cancel
+candy
+cannon
+canoe
+canvas
+canyon
+capable
+capital
+captain
+car
+carbon
+card
+cargo
+carpet
+carry
+cart
+case
+cash
+casino
+castle
+casual
+cat
+catalog
+catch
+category
+cattle
+caught
+cause
+caution
+cave
+ceiling
+celery
+cement
+census
+century
+cereal
+certain
+chair
+chalk
+champion
+change
+chaos
+chapter
+charge
+chase
+chat
+cheap
+check
+cheese
+chef
+cherry
+chest
+chicken
+chief
+child
+chimney
+choice
+choose
+chronic
+chuckle
+chunk
+churn
+cigar
+cinnamon
+circle
+citizen
+city
+civil
+claim
+clap
+clarify
+claw
+clay
+clean
+clerk
+clever
+click
+client
+cliff
+climb
+clinic
+clip
+clock
+clog
+close
+cloth
+cloud
+clown
+club
+clump
+cluster
+clutch
+coach
+coast
+coconut
+code
+coffee
+coil
+coin
+collect
+color
+column
+combine
+come
+comfort
+comic
+common
+company
+concert
+conduct
+confirm
+congress
+connect
+consider
+control
+convince
+cook
+cool
+copper
+copy
+coral
+core
+corn
+correct
+cost
+cotton
+couch
+country
+couple
+course
+cousin
+cover
+coyote
+crack
+cradle
+craft
+cram
+crane
+crash
+crater
+crawl
+crazy
+cream
+credit
+creek
+crew
+cricket
+crime
+crisp
+critic
+crop
+cross
+crouch
+crowd
+crucial
+cruel
+cruise
+crumble
+crunch
+crush
+cry
+crystal
+cube
+culture
+cup
+cupboard
+curious
+current
+curtain
+curve
+cushion
+custom
+cute
+cycle
+dad
+damage
+damp
+dance
+danger
+daring
+dash
+daughter
+dawn
+day
+deal
+debate
+debris
+decade
+december
+decide
+decline
+decorate
+decrease
+deer
+defense
+define
+defy
+degree
+delay
+deliver
+demand
+demise
+denial
+dentist
+deny
+depart
+depend
+deposit
+depth
+deputy
+derive
+describe
+desert
+design
+desk
+despair
+destroy
+detail
+detect
+develop
+device
+devote
+diagram
+dial
+diamond
+diary
+dice
+diesel
+diet
+differ
+digital
+dignity
+dilemma
+dinner
+dinosaur
+direct
+dirt
+disagree
+discover
+disease
+dish
+dismiss
+disorder
+display
+distance
+divert
+divide
+divorce
+dizzy
+doctor
+document
+dog
+doll
+dolphin
+domain
+donate
+donkey
+donor
+door
+dose
+double
+dove
+draft
+dragon
+drama
+drastic
+draw
+dream
+dress
+drift
+drill
+drink
+drip
+drive
+drop
+drum
+dry
+duck
+dumb
+dune
+during
+dust
+dutch
+duty
+dwarf
+dynamic
+eager
+eagle
+early
+earn
+earth
+easily
+east
+easy
+echo
+ecology
+economy
+edge
+edit
+educate
+effort
+egg
+eight
+either
+elbow
+elder
+electric
+elegant
+element
+elephant
+elevator
+elite
+else
+embark
+embody
+embrace
+emerge
+emotion
+employ
+empower
+empty
+enable
+enact
+end
+endless
+endorse
+enemy
+energy
+enforce
+engage
+engine
+enhance
+enjoy
+enlist
+enough
+enrich
+enroll
+ensure
+enter
+entire
+entry
+envelope
+episode
+equal
+equip
+era
+erase
+erode
+erosion
+error
+erupt
+escape
+essay
+essence
+estate
+eternal
+ethics
+evidence
+evil
+evoke
+evolve
+exact
+example
+excess
+exchange
+excite
+exclude
+excuse
+execute
+exercise
+exhaust
+exhibit
+exile
+exist
+exit
+exotic
+expand
+expect
+expire
+explain
+expose
+express
+extend
+extra
+eye
+eyebrow
+fabric
+face
+faculty
+fade
+faint
+faith
+fall
+false
+fame
+family
+famous
+fan
+fancy
+fantasy
+farm
+fashion
+fat
+fatal
+father
+fatigue
+fault
+favorite
+feature
+february
+federal
+fee
+feed
+feel
+female
+fence
+festival
+fetch
+fever
+few
+fiber
+fiction
+field
+figure
+file
+film
+filter
+final
+find
+fine
+finger
+finish
+fire
+firm
+first
+fiscal
+fish
+fit
+fitness
+fix
+flag
+flame
+flash
+flat
+flavor
+flee
+flight
+flip
+float
+flock
+floor
+flower
+fluid
+flush
+fly
+foam
+focus
+fog
+foil
+fold
+follow
+food
+foot
+force
+forest
+forget
+fork
+fortune
+forum
+forward
+fossil
+foster
+found
+fox
+fragile
+frame
+frequent
+fresh
+friend
+fringe
+frog
+front
+frost
+frown
+frozen
+fruit
+fuel
+fun
+funny
+furnace
+fury
+future
+gadget
+gain
+galaxy
+gallery
+game
+gap
+garage
+garbage
+garden
+garlic
+garment
+gas
+gasp
+gate
+gather
+gauge
+gaze
+general
+genius
+genre
+gentle
+genuine
+gesture
+ghost
+giant
+gift
+giggle
+ginger
+giraffe
+girl
+give
+glad
+glance
+glare
+glass
+glide
+glimpse
+globe
+gloom
+glory
+glove
+glow
+glue
+goat
+goddess
+gold
+good
+goose
+gorilla
+gospel
+gossip
+govern
+gown
+grab
+grace
+grain
+grant
+grape
+grass
+gravity
+great
+green
+grid
+grief
+grit
+grocery
+group
+grow
+grunt
+guard
+guess
+guide
+guilt
+guitar
+gun
+gym
+habit
+hair
+half
+hammer
+hamster
+hand
+happy
+harbor
+hard
+harsh
+harvest
+hat
+have
+hawk
+hazard
+head
+health
+heart
+heavy
+hedgehog
+height
+hello
+helmet
+help
+hen
+hero
+hidden
+high
+hill
+hint
+hip
+hire
+history
+hobby
+hockey
+hold
+hole
+holiday
+hollow
+home
+honey
+hood
+hope
+horn
+horror
+horse
+hospital
+host
+hotel
+hour
+hover
+hub
+huge
+human
+humble
+humor
+hundred
+hungry
+hunt
+hurdle
+hurry
+hurt
+husband
+hybrid
+ice
+icon
+idea
+identify
+idle
+ignore
+ill
+illegal
+illness
+image
+imitate
+immense
+immune
+impact
+impose
+improve
+impulse
+inch
+include
+income
+increase
+index
+indicate
+indoor
+industry
+infant
+inflict
+inform
+inhale
+inherit
+initial
+inject
+injury
+inmate
+inner
+innocent
+input
+inquiry
+insane
+insect
+inside
+inspire
+install
+intact
+interest
+into
+invest
+invite
+involve
+iron
+island
+isolate
+issue
+item
+ivory
+jacket
+jaguar
+jar
+jazz
+jealous
+jeans
+jelly
+jewel
+job
+join
+joke
+journey
+joy
+judge
+juice
+jump
+jungle
+junior
+junk
+just
+kangaroo
+keen
+keep
+ketchup
+key
+kick
+kid
+kidney
+kind
+kingdom
+kiss
+kit
+kitchen
+kite
+kitten
+kiwi
+knee
+knife
+knock
+know
+lab
+label
+labor
+ladder
+lady
+lake
+lamp
+language
+laptop
+large
+later
+latin
+laugh
+laundry
+lava
+law
+lawn
+lawsuit
+layer
+lazy
+leader
+leaf
+learn
+leave
+lecture
+left
+leg
+legal
+legend
+leisure
+lemon
+lend
+length
+lens
+leopard
+lesson
+letter
+level
+liar
+liberty
+library
+license
+life
+lift
+light
+like
+limb
+limit
+link
+lion
+liquid
+list
+little
+live
+lizard
+load
+loan
+lobster
+local
+lock
+logic
+lonely
+long
+loop
+lottery
+loud
+lounge
+love
+loyal
+lucky
+luggage
+lumber
+lunar
+lunch
+luxury
+lyrics
+machine
+mad
+magic
+magnet
+maid
+mail
+main
+major
+make
+mammal
+man
+manage
+mandate
+mango
+mansion
+manual
+maple
+marble
+march
+margin
+marine
+market
+marriage
+mask
+mass
+master
+match
+material
+math
+matrix
+matter
+maximum
+maze
+meadow
+mean
+measure
+meat
+mechanic
+medal
+media
+melody
+melt
+member
+memory
+mention
+menu
+mercy
+merge
+merit
+merry
+mesh
+message
+metal
+method
+middle
+midnight
+milk
+million
+mimic
+mind
+minimum
+minor
+minute
+miracle
+mirror
+misery
+miss
+mistake
+mix
+mixed
+mixture
+mobile
+model
+modify
+mom
+moment
+monitor
+monkey
+monster
+month
+moon
+moral
+more
+morning
+mosquito
+mother
+motion
+motor
+mountain
+mouse
+move
+movie
+much
+muffin
+mule
+multiply
+muscle
+museum
+mushroom
+music
+must
+mutual
+myself
+mystery
+myth
+naive
+name
+napkin
+narrow
+nasty
+nation
+nature
+near
+neck
+need
+negative
+neglect
+neither
+nephew
+nerve
+nest
+net
+network
+neutral
+never
+news
+next
+nice
+night
+noble
+noise
+nominee
+noodle
+normal
+north
+nose
+notable
+note
+nothing
+notice
+novel
+now
+nuclear
+number
+nurse
+nut
+oak
+obey
+object
+oblige
+obscure
+observe
+obtain
+obvious
+occur
+ocean
+october
+odor
+off
+offer
+office
+often
+oil
+okay
+old
+olive
+olympic
+omit
+once
+one
+onion
+online
+only
+open
+opera
+opinion
+oppose
+option
+orange
+orbit
+orchard
+order
+ordinary
+organ
+orient
+original
+orphan
+ostrich
+other
+outdoor
+outer
+output
+outside
+oval
+oven
+over
+own
+owner
+oxygen
+oyster
+ozone
+pact
+paddle
+page
+pair
+palace
+palm
+panda
+panel
+panic
+panther
+paper
+parade
+parent
+park
+parrot
+party
+pass
+patch
+path
+patient
+patrol
+pattern
+pause
+pave
+payment
+peace
+peanut
+pear
+peasant
+pelican
+pen
+penalty
+pencil
+people
+pepper
+perfect
+permit
+person
+pet
+phone
+photo
+phrase
+physical
+piano
+picnic
+picture
+piece
+pig
+pigeon
+pill
+pilot
+pink
+pioneer
+pipe
+pistol
+pitch
+pizza
+place
+planet
+plastic
+plate
+play
+please
+pledge
+pluck
+plug
+plunge
+poem
+poet
+point
+polar
+pole
+police
+pond
+pony
+pool
+popular
+portion
+position
+possible
+post
+potato
+pottery
+poverty
+powder
+power
+practice
+praise
+predict
+prefer
+prepare
+present
+pretty
+prevent
+price
+pride
+primary
+print
+priority
+prison
+private
+prize
+problem
+process
+produce
+profit
+program
+project
+promote
+proof
+property
+prosper
+protect
+proud
+provide
+public
+pudding
+pull
+pulp
+pulse
+pumpkin
+punch
+pupil
+puppy
+purchase
+purity
+purpose
+purse
+push
+put
+puzzle
+pyramid
+quality
+quantum
+quarter
+question
+quick
+quit
+quiz
+quote
+rabbit
+raccoon
+race
+rack
+radar
+radio
+rail
+rain
+raise
+rally
+ramp
+ranch
+random
+range
+rapid
+rare
+rate
+rather
+raven
+raw
+razor
+ready
+real
+reason
+rebel
+rebuild
+recall
+receive
+recipe
+record
+recycle
+reduce
+reflect
+reform
+refuse
+region
+regret
+regular
+reject
+relax
+release
+relief
+rely
+remain
+remember
+remind
+remove
+render
+renew
+rent
+reopen
+repair
+repeat
+replace
+report
+require
+rescue
+resemble
+resist
+resource
+response
+result
+retire
+retreat
+return
+reunion
+reveal
+review
+reward
+rhythm
+rib
+ribbon
+rice
+rich
+ride
+ridge
+rifle
+right
+rigid
+ring
+riot
+ripple
+risk
+ritual
+rival
+river
+road
+roast
+robot
+robust
+rocket
+romance
+roof
+rookie
+room
+rose
+rotate
+rough
+round
+route
+royal
+rubber
+rude
+rug
+rule
+run
+runway
+rural
+sad
+saddle
+sadness
+safe
+sail
+salad
+salmon
+salon
+salt
+salute
+same
+sample
+sand
+satisfy
+satoshi
+sauce
+sausage
+save
+say
+scale
+scan
+scare
+scatter
+scene
+scheme
+school
+science
+scissors
+scorpion
+scout
+scrap
+screen
+script
+scrub
+sea
+search
+season
+seat
+second
+secret
+section
+security
+seed
+seek
+segment
+select
+sell
+seminar
+senior
+sense
+sentence
+series
+service
+session
+settle
+setup
+seven
+shadow
+shaft
+shallow
+share
+shed
+shell
+sheriff
+shield
+shift
+shine
+ship
+shiver
+shock
+shoe
+shoot
+shop
+short
+shoulder
+shove
+shrimp
+shrug
+shuffle
+shy
+sibling
+sick
+side
+siege
+sight
+sign
+silent
+silk
+silly
+silver
+similar
+simple
+since
+sing
+siren
+sister
+situate
+six
+size
+skate
+sketch
+ski
+skill
+skin
+skirt
+skull
+slab
+slam
+sleep
+slender
+slice
+slide
+slight
+slim
+slogan
+slot
+slow
+slush
+small
+smart
+smile
+smoke
+smooth
+snack
+snake
+snap
+sniff
+snow
+soap
+soccer
+social
+sock
+soda
+soft
+solar
+soldier
+solid
+solution
+solve
+someone
+song
+soon
+sorry
+sort
+soul
+sound
+soup
+source
+south
+space
+spare
+spatial
+spawn
+speak
+special
+speed
+spell
+spend
+sphere
+spice
+spider
+spike
+spin
+spirit
+split
+spoil
+sponsor
+spoon
+sport
+spot
+spray
+spread
+spring
+spy
+square
+squeeze
+squirrel
+stable
+stadium
+staff
+stage
+stairs
+stamp
+stand
+start
+state
+stay
+steak
+steel
+stem
+step
+stereo
+stick
+still
+sting
+stock
+stomach
+stone
+stool
+story
+stove
+strategy
+street
+strike
+strong
+struggle
+student
+stuff
+stumble
+style
+subject
+submit
+subway
+success
+such
+sudden
+suffer
+sugar
+suggest
+suit
+summer
+sun
+sunny
+sunset
+super
+supply
+supreme
+sure
+surface
+surge
+surprise
+surround
+survey
+suspect
+sustain
+swallow
+swamp
+swap
+swarm
+swear
+sweet
+swift
+swim
+swing
+switch
+sword
+symbol
+symptom
+syrup
+system
+table
+tackle
+tag
+tail
+talent
+talk
+tank
+tape
+target
+task
+taste
+tattoo
+taxi
+teach
+team
+tell
+ten
+tenant
+tennis
+tent
+term
+test
+text
+thank
+that
+theme
+then
+theory
+there
+they
+thing
+this
+thought
+three
+thrive
+throw
+thumb
+thunder
+ticket
+tide
+tiger
+tilt
+timber
+time
+tiny
+tip
+tired
+tissue
+title
+toast
+tobacco
+today
+toddler
+toe
+together
+toilet
+token
+tomato
+tomorrow
+tone
+tongue
+tonight
+tool
+tooth
+top
+topic
+topple
+torch
+tornado
+tortoise
+toss
+total
+tourist
+toward
+tower
+town
+toy
+track
+trade
+traffic
+tragic
+train
+transfer
+trap
+trash
+travel
+tray
+treat
+tree
+trend
+trial
+tribe
+trick
+trigger
+trim
+trip
+trophy
+trouble
+truck
+true
+truly
+trumpet
+trust
+truth
+try
+tube
+tuition
+tumble
+tuna
+tunnel
+turkey
+turn
+turtle
+twelve
+twenty
+twice
+twin
+twist
+two
+type
+typical
+ugly
+umbrella
+unable
+unaware
+uncle
+uncover
+under
+undo
+unfair
+unfold
+unhappy
+uniform
+unique
+unit
+universe
+unknown
+unlock
+until
+unusual
+unveil
+update
+upgrade
+uphold
+upon
+upper
+upset
+urban
+urge
+usage
+use
+used
+useful
+useless
+usual
+utility
+vacant
+vacuum
+vague
+valid
+valley
+valve
+van
+vanish
+vapor
+various
+vast
+vault
+vehicle
+velvet
+vendor
+venture
+venue
+verb
+verify
+version
+very
+vessel
+veteran
+viable
+vibrant
+vicious
+victory
+video
+view
+village
+vintage
+violin
+virtual
+virus
+visa
+visit
+visual
+vital
+vivid
+vocal
+voice
+void
+volcano
+volume
+vote
+voyage
+wage
+wagon
+wait
+walk
+wall
+walnut
+want
+warfare
+warm
+warrior
+wash
+wasp
+waste
+water
+wave
+way
+wealth
+weapon
+wear
+weasel
+weather
+web
+wedding
+weekend
+weird
+welcome
+west
+wet
+whale
+what
+wheat
+wheel
+when
+where
+whip
+whisper
+wide
+width
+wife
+wild
+will
+win
+window
+wine
+wing
+wink
+winner
+winter
+wire
+wisdom
+wise
+wish
+witness
+wolf
+woman
+wonder
+wood
+wool
+word
+work
+world
+worry
+worth
+wrap
+wreck
+wrestle
+wrist
+write
+wrong
+yard
+year
+yellow
+you
+young
+youth
+zebra
+zero
+zone
+zoo
\ No newline at end of file