Compare commits

...

21 Commits

Author SHA1 Message Date
Olivier 'reivilibre' d08d6932e4 Merge branch 'main' into fork-master 2023-02-18 12:18:28 +00:00
Cadence Ember 9f7b1bbcae Update dependencies (now lockfile v3)
The new lockfile version appears to still be readable by old versions
of npm that only support version 1.
2023-01-15 22:07:29 +13:00
Cadence Ember 3d5927ab28 Optional no ssl flag using x-insecure header 2023-01-15 22:06:31 +13:00
jo! 8d292cc200 Add maskable icons for PWA manifest 2022-12-21 18:34:36 +13:00
jo! 17185c6b5e Add favicon and PWA manifest 2022-12-20 22:48:08 +13:00
Olivier 'reivilibre' 9970c5e92f Use YewTube as preferred fallback rather than YouTube 2022-09-28 19:00:20 +01:00
Olivier 'reivilibre' 6e87edad3b fixup merge: reinclude video embed sass 2022-09-28 19:00:20 +01:00
Olivier 'reivilibre' facb959cb8 Merge branch 'main' into rei/hacky_embed 2022-09-28 18:33:10 +01:00
Cadence Ember 61c8e205d8
Show subscribed properly on /user or /c pages 2022-09-16 17:18:51 +12:00
Cadence Ember a2dfeb2edc Fix calls to fetchChannel
Should fix subscribing to channels
2022-08-23 10:37:43 +12:00
Lomanic 6de9abd499 Partially fix #29: add routes for /c/ and /user/ paths for channels 2022-08-18 19:10:54 +12:00
Cadence Ember 5e6b2bf31c
Add /api/settings to get current settings as JSON 2022-07-27 18:54:45 +12:00
Cadence Ember f04be0d3f9
Add video durations to can't think page
Because why would I care when I instinctively click anyway?
2022-03-14 21:54:42 +13:00
Cadence Ember 25baf8c73b
Fix z-index on can't think page
Because why would I be able to test if I'm not able to think?
2022-03-14 21:48:33 +13:00
Cadence Ember 1333b990f6
Add recommended videos to the can't think page
this is probably some kind of sick irony yeah yeah
no, no thinking. content. consume the content. watch more videos.

https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think
watch more videos.
https://tube.cadence.moe/cant-think

...

can't think?
2022-03-14 21:32:34 +13:00
Cadence Ember 893684c311
Fix data deletion checkbox styles 2022-01-13 18:19:56 +13:00
Cadence Ember 109dcd22de
Rework subscribing to deleted channels 2022-01-10 14:18:45 +13:00
Cadence Ember 15e3f06ad6 Fix theme on can't think page 2021-12-28 20:36:54 +13:00
Cadence Ember 0d23d66700 Add theme support, light theme, and edgeless light 2021-12-28 16:32:11 +13:00
Cadence Ember 4e1f2b3607 Update dependencies 2021-12-22 00:17:57 +13:00
Olivier 'reivilibre' 6d1f91b2fa Add experimental, hacky and ugly video embed player 2021-12-17 09:20:52 +00:00
72 changed files with 1931 additions and 722 deletions

View File

@ -1,18 +1,28 @@
const {render} = require("pinski/plugins") const {render} = require("pinski/plugins")
const constants = require("../utils/constants")
const {fetchChannel} = require("../utils/youtube") const {fetchChannel} = require("../utils/youtube")
const {getUser} = require("../utils/getuser") const {getUser} = require("../utils/getuser")
const converters = require("../utils/converters") const converters = require("../utils/converters")
module.exports = [ module.exports = [
{ {
route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill, url}) => { route: `/(c|channel|user)/(.+)`, methods: ["GET"], code: async ({req, fill, url}) => {
const id = fill[0] const path = fill[0]
const id = fill[1]
const user = getUser(req) const user = getUser(req)
const settings = user.getSettingsOrDefaults() const settings = user.getSettingsOrDefaults()
const data = await fetchChannel(id, settings.instance) const data = await fetchChannel(path, id, settings.instance)
const subscribed = user.isSubscribed(id)
const instanceOrigin = settings.instance const instanceOrigin = settings.instance
// problem with the channel? fetchChannel has collected the necessary information for us.
// we can render a skeleton page, display the message, and provide the option to unsubscribe.
if (data.error) {
const statusCode = data.missing ? 410 : 500
const subscribed = user.isSubscribed(id)
return render(statusCode, "pug/channel-error.pug", {req, settings, data, subscribed, instanceOrigin})
}
// everything is fine
// normalise info, apply watched status // normalise info, apply watched status
if (!data.second__subCountText && data.subCount) { if (!data.second__subCountText && data.subCount) {
data.second__subCountText = converters.subscriberCountToText(data.subCount) data.second__subCountText = converters.subscriberCountToText(data.subCount)
@ -24,7 +34,8 @@ module.exports = [
video.watched = watchedVideos.includes(video.videoId) video.watched = watchedVideos.includes(video.videoId)
}) })
} }
return render(200, "pug/channel.pug", {url, data, subscribed, instanceOrigin}) const subscribed = user.isSubscribed(data.authorId)
return render(200, "pug/channel.pug", {req, settings, data, subscribed, instanceOrigin})
} }
} }
] ]

View File

@ -9,8 +9,7 @@ const {Matcher, PatternCompileError} = require("../utils/matcher")
const filterMaxLength = 160 const filterMaxLength = 160
const regexpEnabledText = constants.server_setup.allow_regexp_filters ? "" : "not" const regexpEnabledText = constants.server_setup.allow_regexp_filters ? "" : "not"
function getCategories(req) { function getCategories(user) {
const user = getUser(req)
const filters = user.getFilters() const filters = user.getFilters()
// Sort filters into categories for display. Titles are already sorted. // Sort filters into categories for display. Titles are already sorted.
@ -39,7 +38,9 @@ function getCategories(req) {
module.exports = [ module.exports = [
{ {
route: "/filters", methods: ["GET"], code: async ({req, url}) => { route: "/filters", methods: ["GET"], code: async ({req, url}) => {
const categories = getCategories(req) const user = getUser(req)
const categories = getCategories(user)
const settings = user.getSettingsOrDefaults()
let referrer = url.searchParams.get("referrer") || null let referrer = url.searchParams.get("referrer") || null
let type = null let type = null
@ -54,7 +55,7 @@ module.exports = [
label = url.searchParams.get("label") label = url.searchParams.get("label")
} }
return render(200, "pug/filters.pug", {categories, type, contents, label, referrer, filterMaxLength, regexpEnabledText}) return render(200, "pug/filters.pug", {req, settings, categories, type, contents, label, referrer, filterMaxLength, regexpEnabledText})
} }
}, },
{ {
@ -100,8 +101,10 @@ module.exports = [
return true return true
}, state => { }, state => {
const {type, contents, label, compileError} = state const {type, contents, label, compileError} = state
const categories = getCategories(req) const user = getUser(req)
return render(400, "pug/filters.pug", {categories, type, contents, label, compileError, filterMaxLength, regexpEnabledText}) const categories = getCategories(user)
const settings = user.getSettingsOrDefaults()
return render(400, "pug/filters.pug", {req, settings, categories, type, contents, label, compileError, filterMaxLength, regexpEnabledText})
}) })
.last(state => { .last(state => {
const {type, contents, label} = state const {type, contents, label} = state

View File

@ -23,10 +23,9 @@ module.exports = [
const token = user.token const token = user.token
if (add) { if (add) {
await fetchChannel(ucid, settings.instance) await fetchChannel("channel", ucid, settings.instance)
db.prepare( db.prepare(
"INSERT INTO Subscriptions (token, ucid) VALUES (?, ?)" "INSERT INTO Subscriptions (token, ucid) VALUES (?, ?)"
+ " ON CONFLICT (token, ucid) DO UPDATE SET channel_missing = 0"
).run(token, ucid) ).run(token, ucid)
} else { } else {
db.prepare("DELETE FROM Subscriptions WHERE token = ? AND ucid = ?").run(token, ucid) db.prepare("DELETE FROM Subscriptions WHERE token = ? AND ucid = ?").run(token, ucid)
@ -41,7 +40,6 @@ module.exports = [
}), }),
content: "Success, redirecting..." content: "Success, redirecting..."
} }
return redirect(params.get("referrer"), 303)
} else { } else {
return { return {
statusCode: 200, statusCode: 200,

View File

@ -1,16 +1,35 @@
const {render} = require("pinski/plugins") const {render} = require("pinski/plugins")
const {getUser} = require("../utils/getuser")
module.exports = [ module.exports = [
{ {
route: "/", methods: ["GET"], code: async ({req}) => { route: "/", methods: ["GET"], code: async ({req}) => {
const userAgent = req.headers["user-agent"] || "" const userAgent = req.headers["user-agent"] || ""
const mobile = userAgent.toLowerCase().includes("mobile") const mobile = userAgent.toLowerCase().includes("mobile")
return render(200, "pug/home.pug", {mobile}) const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/home.pug", {req, settings, mobile})
} }
}, },
{ {
route: "/js-licenses", methods: ["GET"], code: async () => { route: "/(?:js-)?licenses", methods: ["GET"], code: async ({req}) => {
return render(200, "pug/js-licenses.pug") const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/licenses.pug", {req, settings})
}
},
{
route: "/cant-think", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/cant-think.pug", {req, settings})
}
},
{
route: "/privacy", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return render(200, "pug/privacy.pug", {req, settings})
} }
} }
] ]

View File

@ -26,7 +26,7 @@ module.exports = [
const filters = user.getFilters() const filters = user.getFilters()
results = converters.applyVideoFilters(results, filters).videos results = converters.applyVideoFilters(results, filters).videos
return render(200, "pug/search.pug", {url, query, results, instanceOrigin}) return render(200, "pug/search.pug", {req, settings, url, query, results, instanceOrigin})
} }
} }
] ]

View File

@ -7,12 +7,23 @@ const validate = require("../utils/validate")
const V = validate.V const V = validate.V
module.exports = [ module.exports = [
{
route: "/api/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
return {
statusCode: 200,
contentType: "application/json",
content: settings
}
}
},
{ {
route: "/settings", methods: ["GET"], code: async ({req}) => { route: "/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req) const user = getUser(req)
const settings = user.getSettings() const settings = user.getSettings()
const instances = instancesList.get() const instances = instancesList.get()
return render(200, "pug/settings.pug", {constants, user, settings, instances}) return render(200, "pug/settings.pug", {req, constants, user, settings, instances})
} }
}, },
{ {

View File

@ -11,12 +11,14 @@ module.exports = [
let hasSubscriptions = false let hasSubscriptions = false
let videos = [] let videos = []
let channels = [] let channels = []
let missingChannelCount = 0
let refreshed = null let refreshed = null
if (user.token) { if (user.token) {
// trigger a background refresh, needed if they came back from being inactive // trigger a background refresh, needed if they came back from being inactive
refresher.skipWaiting() refresher.skipWaiting()
// get channels // get channels
channels = db.prepare(`SELECT Channels.* FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ? ORDER BY name`).all(user.token) channels = db.prepare(`SELECT Channels.* FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ? ORDER BY name`).all(user.token)
missingChannelCount = channels.reduce((a, c) => a + c.missing, 0)
// get refreshed status // get refreshed status
refreshed = db.prepare(`SELECT min(refreshed) as min, max(refreshed) as max, count(refreshed) as count FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ?`).get(user.token) refreshed = db.prepare(`SELECT min(refreshed) as min, max(refreshed) as max, count(refreshed) as count FROM Channels INNER JOIN Subscriptions ON Channels.ucid = Subscriptions.ucid WHERE token = ?`).get(user.token)
// get watched videos // get watched videos
@ -37,7 +39,7 @@ module.exports = [
} }
const settings = user.getSettingsOrDefaults() const settings = user.getSettingsOrDefaults()
const instanceOrigin = settings.instance const instanceOrigin = settings.instance
return render(200, "pug/subscriptions.pug", {url, settings, hasSubscriptions, videos, channels, refreshed, timeToPastText, instanceOrigin}) return render(200, "pug/subscriptions.pug", {req, url, settings, hasSubscriptions, videos, channels, missingChannelCount, refreshed, timeToPastText, instanceOrigin})
} }
} }
] ]

View File

@ -3,8 +3,8 @@ const {render} = require("pinski/plugins")
module.exports = [ module.exports = [
{ {
route: "/takedown", methods: ["GET"], code: async () => { route: "/takedown", methods: ["GET"], code: async ({req}) => {
return render(200, "pug/takedown.pug", {constants}) return render(200, "pug/takedown.pug", {req, constants})
} }
} }
] ]

View File

@ -111,7 +111,7 @@ module.exports = [
// Check if playback is allowed // Check if playback is allowed
const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id) const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id)
if (videoTakedownInfo) { if (videoTakedownInfo) {
return render(451, "pug/takedown-video.pug", videoTakedownInfo) return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, videoTakedownInfo))
} }
// Media fragment // Media fragment
@ -129,7 +129,7 @@ module.exports = [
// Work out how to fetch the video // Work out how to fetch the video
if (req.method === "GET") { if (req.method === "GET") {
if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment
return render(200, "pug/local-video.pug", {id}) return render(200, "pug/local-video.pug", {req, settings, id})
} }
var instanceOrigin = settings.instance var instanceOrigin = settings.instance
var outURL = `${instanceOrigin}/api/v1/videos/${id}` var outURL = `${instanceOrigin}/api/v1/videos/${id}`
@ -153,7 +153,7 @@ module.exports = [
// automatically add the entry to the videos list, so it won't be fetched again // automatically add the entry to the videos list, so it won't be fetched again
const args = {id, ...channelTakedownInfo} const args = {id, ...channelTakedownInfo}
db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args) db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args)
return render(451, "pug/takedown-video.pug", channelTakedownInfo) return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, channelTakedownInfo))
} }
// process stream list ordering // process stream list ordering
@ -199,7 +199,7 @@ module.exports = [
} }
return render(200, "pug/video.pug", { return render(200, "pug/video.pug", {
url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous, req, url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
sessionWatched, sessionWatchedNext, settings sessionWatched, sessionWatchedNext, settings
}) })
@ -225,7 +225,7 @@ module.exports = [
// Create appropriate formatted message // Create appropriate formatted message
const message = render(0, `pug/errors/${errorType}.pug`, locals).content const message = render(0, `pug/errors/${errorType}.pug`, locals).content
return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message}) return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message, req, settings})
} }
} }
} }

232
api/video_embed.js Normal file
View File

@ -0,0 +1,232 @@
const {request} = require("../utils/request")
/** @type {import("node-fetch").default} */
// @ts-ignore
const fetch = require("node-fetch")
const {render} = require("pinski/plugins")
const db = require("../utils/db")
const {getToken, getUser} = require("../utils/getuser")
const pug = require("pug")
const converters = require("../utils/converters")
const constants = require("../utils/constants")
class InstanceError extends Error {
constructor(error, identifier) {
super(error)
this.identifier = identifier
}
}
class MessageError extends Error {
}
function formatOrder(format) {
// most significant to least significant
// key, max, order, transform
// asc: lower number comes first, desc: higher number comes first
const spec = [
{key: "second__height", max: 8000, order: "desc", transform: x => x ? Math.floor(x/96) : 0},
{key: "fps", max: 100, order: "desc", transform: x => x ? Math.floor(x/10) : 0},
{key: "type", max: " ".repeat(60), order: "asc", transform: x => x.length}
]
let total = 0
for (let i = 0; i < spec.length; i++) {
const s = spec[i]
let diff = s.transform(format[s.key])
if (s.order === "asc") diff = s.transform(s.max) - diff
total += diff
if (i+1 < spec.length) { // not the last spec item?
const s2 = spec[i+1]
total *= s2.transform(s2.max)
}
}
return -total
}
function sortFormats(video, preference) {
// Add second__ extensions to format objects, required if Invidious was the extractor
let formats = video.formatStreams.concat(video.adaptiveFormats)
for (const format of formats) {
if (!format.second__height && format.resolution) format.second__height = +format.resolution.slice(0, -1)
if (!format.second__order) format.second__order = formatOrder(format)
format.cloudtube__label = `${format.qualityLabel} ${format.container}`
}
// Properly build and order format list
const standard = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height)
const adaptive = video.adaptiveFormats.filter(f => f.type.startsWith("video") && f.qualityLabel).sort((a, b) => a.second__order - b.second__order)
for (const format of adaptive) {
if (!format.cloudtube__label.endsWith("*")) format.cloudtube__label += " *"
}
formats = standard.concat(adaptive)
// Reorder fomats based on user preference
if (preference === 1) { // best dash
formats.sort((a, b) => {
const a1 = a.second__height + a.fps / 100
const b1 = b.second__height + b.fps / 100
return b1 - a1
})
} else if (preference === 2) { // best <=1080p
formats.sort((a, b) => {
const a1 = a.second__height + a.fps / 100
const b1 = b.second__height + b.fps / 100
if (b1 > 1081) {
if (a1 > 1081) return b1 - a1
return -1
}
if (a1 > 1081) return 1
return b1 - a1
})
} else if (preference === 3) { // best low-fps
formats.sort((a, b) => {
if (b.fps > 30) {
if (a.fps < 30) return b.second__height - a.second__height
return -1
}
if (a.fps > 30) return 1
return b.second__height - a.second__height
})
} else if (preference === 4) { // 360p only
formats.sort((a, b) => {
if (a.itag == 18) return -1
if (b.itag == 18) return 1
return 0
})
} else { // preference === 0, best combined
// should already be correct
}
return formats
}
module.exports = [
{
route: `/embed/(${constants.regex.video_id})`, methods: ["GET"], upload: false, code: async ({req, url, body, fill}) => {
// Prepare data needed to render video page
const user = getUser(req)
const settings = user.getSettingsOrDefaults()
const id = fill[0];
// Check if playback is allowed
const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id)
if (videoTakedownInfo) {
return render(451, "pug/takedown-video.pug", videoTakedownInfo)
}
// Media fragment
const t = url.searchParams.get("t")
let mediaFragment = converters.tToMediaFragment(t)
// Continuous mode
const continuous = url.searchParams.get("continuous") === "1"
const autoplay = url.searchParams.get("autoplay") === "1"
const swp = url.searchParams.get("session-watched")
const sessionWatched = swp ? swp.split(" ") : []
const sessionWatchedNext = sessionWatched.concat([id]).join("+")
if (continuous) settings.quality = 0 // autoplay with synced streams does not work
// Work out how to fetch the video
if (req.method === "GET") {
if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment
return render(200, "pug/local-video.pug", {id})
}
var instanceOrigin = settings.instance
var outURL = `${instanceOrigin}/api/v1/videos/${id}`
var videoFuture = request(outURL).then(res => res.json())
} else { // req.method === "POST"
var instanceOrigin = "http://localhost:3000"
var videoFuture = JSON.parse(new URLSearchParams(body.toString()).get("video"))
}
try {
// Fetch the video
const video = await videoFuture
// Error handling
if (!video) throw new MessageError("The instance returned null.")
if (video.error) throw new InstanceError(video.error, video.identifier)
// Check if channel playback is allowed
const channelTakedownInfo = db.prepare("SELECT ucid, org, url FROM TakedownChannels WHERE ucid = ?").get(video.authorId)
if (channelTakedownInfo) {
// automatically add the entry to the videos list, so it won't be fetched again
const args = {id, ...channelTakedownInfo}
db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args)
return render(451, "pug/takedown-video.pug", channelTakedownInfo)
}
// process stream list ordering
const formats = sortFormats(video, settings.quality)
// process length text and view count
for (const rec of video.recommendedVideos) {
converters.normaliseVideoInfo(rec)
}
// filter list
const {videos, filteredCount} = converters.applyVideoFilters(video.recommendedVideos, user.getFilters())
video.recommendedVideos = videos
// get subscription data
const subscribed = user.isSubscribed(video.authorId)
// process watched videos
user.addWatchedVideoMaybe(video.videoId)
const watchedVideos = user.getWatchedVideos()
if (watchedVideos.length) {
for (const rec of video.recommendedVideos) {
rec.watched = watchedVideos.includes(rec.videoId)
}
}
// normalise view count
if (!video.second__viewCountText && video.viewCount) {
video.second__viewCountText = converters.viewCountToText(video.viewCount)
}
// apply media fragment to all sources
for (const format of formats) {
format.url += mediaFragment
}
// rewrite description
video.descriptionHtml = converters.rewriteVideoDescription(video.descriptionHtml, id)
// rewrite captions urls so they are served on the same domain via the /proxy route
for (const caption of video.captions) {
caption.url = `/proxy?${new URLSearchParams({"url": caption.url})}`
}
return render(200, "pug/video_embed.pug", {
url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous,
sessionWatched, sessionWatchedNext, settings
})
} catch (error) {
// Something went wrong, somewhere! Find out where.
let errorType = "unrecognised-error"
const locals = {instanceOrigin, error}
// Sort error category
if (error instanceof fetch.FetchError) {
errorType = "fetch-error"
} else if (error instanceof MessageError) {
errorType = "message-error"
} else if (error instanceof InstanceError) {
if (error.identifier === "RATE_LIMITED_BY_YOUTUBE" || error.message === "Could not extract video info. Instance is likely blocked.") {
errorType = "rate-limited"
} else {
errorType = "instance-error"
}
}
// Create appropriate formatted message
const message = render(0, `pug/errors/${errorType}.pug`, locals).content
return render(500, "pug/video_embed.pug", {video: {videoId: id}, error: true, message})
}
}
}
]

View File

@ -15,8 +15,8 @@ const prepared = {
channel_refreshed_update: db.prepare( channel_refreshed_update: db.prepare(
"UPDATE Channels SET refreshed = ? WHERE ucid = ?" "UPDATE Channels SET refreshed = ? WHERE ucid = ?"
), ),
unsubscribe_all_from_channel: db.prepare( channel_mark_as_missing: db.prepare(
"UPDATE Subscriptions SET channel_missing = 1 WHERE ucid = ?" "UPDATE Channels SET missing = 1, missing_reason = ? WHERE ucid = ?"
) )
} }
@ -35,7 +35,7 @@ class RefreshQueue {
// get the next set of scheduled channels to refresh // get the next set of scheduled channels to refresh
const afterTime = Date.now() - constants.caching.seen_token_subscriptions_eligible const afterTime = Date.now() - constants.caching.seen_token_subscriptions_eligible
const channels = db.prepare( const channels = db.prepare(
"SELECT DISTINCT Subscriptions.ucid FROM SeenTokens INNER JOIN Subscriptions ON SeenTokens.token = Subscriptions.token AND SeenTokens.seen > ? WHERE Subscriptions.channel_missing = 0 ORDER BY SeenTokens.seen DESC" "SELECT DISTINCT Subscriptions.ucid FROM SeenTokens INNER JOIN Subscriptions ON SeenTokens.token = Subscriptions.token INNER JOIN Channels ON Channels.ucid = Subscriptions.ucid WHERE Channels.missing = 0 AND SeenTokens.seen > ? ORDER BY SeenTokens.seen DESC"
).pluck().all(afterTime) ).pluck().all(afterTime)
this.addLast(channels) this.addLast(channels)
this.lastLoadTime = Date.now() this.lastLoadTime = Date.now()
@ -72,11 +72,12 @@ class Refresher {
this.refreshQueue = new RefreshQueue() this.refreshQueue = new RefreshQueue()
this.state = this.sym.ACTIVE this.state = this.sym.ACTIVE
this.waitingTimeout = null this.waitingTimeout = null
this.lastFakeNotFoundTime = 0
this.next() this.next()
} }
refreshChannel(ucid) { refreshChannel(ucid) {
return fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}/latest`).then(res => res.json()).then(root => { return fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}/latest`).then(res => res.json()).then(/** @param {any} root */ root => {
if (Array.isArray(root)) { if (Array.isArray(root)) {
root.forEach(video => { root.forEach(video => {
// organise // organise
@ -89,11 +90,24 @@ class Refresher {
prepared.channel_refreshed_update.run(Date.now(), ucid) prepared.channel_refreshed_update.run(Date.now(), ucid)
// console.log(`updated ${root.length} videos for channel ${ucid}`) // console.log(`updated ${root.length} videos for channel ${ucid}`)
} else if (root.identifier === "PUBLISHED_DATES_NOT_PROVIDED") { } else if (root.identifier === "PUBLISHED_DATES_NOT_PROVIDED") {
return [] // nothing we can do. skip this iteration. // nothing we can do. skip this iteration.
} else if (root.identifier === "NOT_FOUND") { } else if (root.identifier === "NOT_FOUND") {
// the channel does not exist. we should unsubscribe all users so we don't try again. // YouTube sometimes returns not found for absolutely no reason.
// console.log(`channel ${ucid} does not exist, unsubscribing all users`) // There is no way to distinguish between a fake missing channel and a real missing channel without requesting the real endpoint.
prepared.unsubscribe_all_from_channel.run(ucid) // These fake missing channels often happen in bursts, which is why there is a cooldown.
const timeSinceLastFakeNotFound = Date.now() - this.lastFakeNotFoundTime
if (timeSinceLastFakeNotFound >= constants.caching.subscriptions_refesh_fake_not_found_cooldown) {
// We'll request the real endpoint to verify.
fetch(`${constants.server_setup.local_instance_origin}/api/v1/channels/${ucid}`).then(res => res.json()).then(/** @param {any} root */ root => {
if (root.error && (root.identifier === "NOT_FOUND" || root.identifier === "ACCOUNT_TERMINATED")) {
// The channel is really gone, and we should mark it as missing for everyone.
prepared.channel_mark_as_missing.run(root.error, ucid)
} else {
// The channel is not actually gone and YouTube is trolling us.
this.lastFakeNotFoundTime = Date.now()
}
})
} // else youtube is currently trolling us, skip this until later.
} else { } else {
throw new Error(root.error) throw new Error(root.error)
} }

9
html/browserconfig.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/static/images/mstile-150x150.png"/>
<TileColor>#2b5797</TileColor>
</tile>
</msapplication>
</browserconfig>

34
html/site.webmanifest Normal file
View File

@ -0,0 +1,34 @@
{
"name": "CloudTube",
"short_name": "CloudTube",
"icons": [
{
"src": "/static/images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/images/maskable-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/images/maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#36393f",
"background_color": "#36393f",
"start_url": "/",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

View File

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="8" viewBox="0 0 5.821 2.117"><path d="M1.269.53l.767.793h.161L2.964.53h.211v.265L2.117 1.852 1.058.794V.529z" fill="#202020" paint-order="markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

View File

@ -0,0 +1,33 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2667 4020 c-115 -29 -296 -105 -387 -162 -54 -34 -182 -133 -207
-159 -12 -14 -33 -49 -47 -79 -13 -30 -48 -80 -78 -110 -46 -47 -170 -221
-180 -252 -3 -7 -39 1 -96 21 -50 17 -147 43 -214 57 -111 23 -137 24 -258 18
-87 -4 -154 -13 -190 -24 -30 -10 -82 -24 -115 -31 -90 -17 -163 -59 -231
-131 -184 -197 -233 -305 -250 -554 -6 -90 -3 -115 31 -279 31 -152 44 -193
81 -265 24 -47 59 -120 78 -162 19 -43 56 -106 82 -140 66 -88 173 -196 214
-217 126 -65 482 -83 711 -35 68 14 156 36 194 49 95 32 317 43 420 22 39 -8
104 -18 145 -22 41 -3 109 -12 150 -20 166 -31 322 -37 825 -33 420 4 509 7
555 21 30 9 89 24 131 35 156 39 312 154 413 306 20 30 41 58 46 61 5 3 34 52
64 108 43 82 58 123 75 206 26 126 27 278 5 387 -18 86 -32 121 -74 174 -15
19 -34 49 -43 66 -34 67 -174 199 -262 248 -16 9 -52 32 -78 51 -27 19 -67 39
-90 45 -23 6 -71 24 -107 39 -64 28 -340 91 -397 91 -28 0 -30 4 -56 88 -15
48 -40 108 -55 134 -38 64 -224 269 -288 318 -63 48 -182 104 -250 118 -75 16
-224 22 -267 12z m-428 -984 c7 -8 33 -16 59 -19 183 -20 276 -46 447 -127 61
-29 128 -58 149 -66 22 -8 67 -32 100 -53 34 -21 84 -50 113 -65 29 -14 68
-40 88 -57 31 -27 35 -37 35 -78 0 -40 -5 -51 -32 -77 -53 -49 -130 -100 -171
-114 -21 -6 -57 -26 -80 -44 -49 -37 -70 -49 -192 -106 -49 -23 -99 -51 -111
-61 -11 -10 -45 -32 -75 -48 -30 -16 -88 -47 -129 -70 -95 -52 -134 -54 -177
-8 -74 76 -121 287 -133 592 -4 121 -13 236 -19 255 -19 60 -14 101 16 132 30
31 91 39 112 14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 6.615 6.615"><path d="M2.64 0s-.03.94-.424 1.215C1.823 1.49.885.894.885.894L.112 2.232.109 2.23l.003.002c.01.006.797.499.838.974.042.478-.944.992-.944.992l.77 1.34s.83-.442 1.265-.24c.435.204.387 1.314.387 1.314l1.546.003s.032-.94.425-1.215c.393-.275 1.331.321 1.331.321l.775-1.338s-.798-.496-.84-.974c-.041-.478.944-.993.944-.993l-.77-1.34s-.83.443-1.265.24C4.14 1.113 4.187.002 4.187.002zm.688 2.25a1.106 1.106 0 110 2.211 1.106 1.106 0 010-2.21z" fill="#c4c4c4" paint-order="fill markers stroke"/></svg> <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 6.615 6.615"><title>Settings</title><path d="M2.64 0s-.03.94-.424 1.215C1.823 1.49.885.894.885.894L.112 2.232.109 2.23l.003.002c.01.006.797.499.838.974.042.478-.944.992-.944.992l.77 1.34s.83-.442 1.265-.24c.435.204.387 1.314.387 1.314l1.546.003s.032-.94.425-1.215c.393-.275 1.331.321 1.331.321l.775-1.338s-.798-.496-.84-.974c-.041-.478.944-.993.944-.993l-.77-1.34s-.83.443-1.265.24C4.14 1.113 4.187.002 4.187.002zm.688 2.25a1.106 1.106 0 110 2.211 1.106 1.106 0 010-2.21z" paint-order="fill markers stroke"/></svg>

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 611 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="25" viewBox="0 0 7.937 6.615"><path d="M2.373 0h3.191a.52.52 0 01.521.521H1.852A.52.52 0 012.373 0zm-.91.794h5.011c.371 0 .67.298.67.67v.123l-6.35-.016v-.108c0-.37.298-.67.67-.67zm-.405 1.058C.472 1.852 0 2.184 0 2.77v2.868c0 .586.472.977 1.058.977H6.88c.586 0 1.059-.39 1.059-.977V2.77c0-.586-.473-.918-1.059-.918zM5.3 4.017c.167.099.19.276.012.366l-2.098.985c-.131.077-.304-.002-.302-.19V3.29c0-.203.18-.333.34-.245z" fill="#c4c4c4" paint-order="fill markers stroke"/></svg> <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="30" height="25" viewBox="0 0 7.937 6.615"><title>Subscriptions</title><path d="M2.373 0h3.191a.52.52 0 01.521.521H1.852A.52.52 0 012.373 0zm-.91.794h5.011c.371 0 .67.298.67.67v.123l-6.35-.016v-.108c0-.37.298-.67.67-.67zm-.405 1.058C.472 1.852 0 2.184 0 2.77v2.868c0 .586.472.977 1.058.977H6.88c.586 0 1.059-.39 1.059-.977V2.77c0-.586-.473-.918-1.059-.918zM5.3 4.017c.167.099.19.276.012.366l-2.098.985c-.131.077-.304-.002-.302-.19V3.29c0-.203.18-.333.34-.245z" paint-order="fill markers stroke"/></svg>

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 569 B

1327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,11 @@
"author": "", "author": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^7.1.0", "better-sqlite3": "^7.4.5",
"cookie": "^0.4.1", "cookie": "^0.4.1",
"denque": "^1.4.1", "denque": "^1.5.1",
"mixin-deep": "^2.0.1", "mixin-deep": "^2.0.1",
"node-fetch": "^2.6.0", "node-fetch": "^2.6.6",
"pinski": "git+https://git.sr.ht/~cadence/nodejs-pinski#210be3cfacbd93d44e104698a29abd39494a6271" "pinski": "git+https://git.sr.ht/~cadence/nodejs-pinski#9653807f309aee34c8c63ce4e6ee760cccbfdf0d"
} }
} }

View File

@ -18,6 +18,7 @@ mixin leave(index, prefix="No more?", final)
a(href="/search?q=cats+being+cute") [BLISS] a(href="/search?q=cats+being+cute") [BLISS]
block content block content
include includes/video-list-item
main.cant-think-page main.cant-think-page
- let src = constants.server_setup.cant_think_narration_url - let src = constants.server_setup.cant_think_narration_url
if src if src
@ -71,3 +72,8 @@ block content
p You know what you must do. p You know what you must do.
p.ultimatum: a(href="#i-understand").border-look I know what I must do. p.ultimatum: a(href="#i-understand").border-look I know what I must do.
a#i-understand a#i-understand
p.the-end Well, looks like you reached the end of the content.#[br]You know what happens now.
h3 Recommended videos
+video_list_item("related-video", {videoId: "jKKCF_Bqtw4", title: "dopamine review", author: "Jreg", authorId: "UCGSGPehp0RWfca-kENgBJ9Q", viewCountText: "84,651 views", second__lengthText: "3:10"})
+video_list_item("related-video", {videoId: "gLYWLobR248", title: "I Watch My YouTube Videos At 2x Speed", author: "Jreg", authorId: "UCGSGPehp0RWfca-kENgBJ9Q", viewCountText: "125,625 views", second__lengthText: "2:24"})

27
pug/channel-error.pug Normal file
View File

@ -0,0 +1,27 @@
extends includes/layout
include includes/video-list-item
include includes/subscribe-button
block head
title= `${data.row ? data.row.name : "Deleted channel"} - CloudTube`
script(type="module" src=getStaticURL("html", "/static/js/channel.js"))
block content
main.channel-page
if data.row
.channel-data
.info
- const iconURL = data.row.icon_url
if iconURL
.logo
img(src=iconURL alt="").thumbnail-image
.about
h1.name= data.row.name
+subscribe_button(data.ucid, subscribed, `/channel/${data.ucid}`).subscribe-button.base-border-look
.channel-error
div= data.message
if data.missing && subscribed
.you-should-unsubscribe To remove this channel from your subscriptions list, click Unsubscribe.

View File

@ -3,8 +3,21 @@ html
head head
meta(charset="utf-8") meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1") meta(name="viewport" content="width=device-width, initial-scale=1")
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", "/main.sass")) - const theme = settings && ["dark", "light", "edgeless-light"][settings.theme] || "dark"
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", `/${theme}.sass`))
script(type="module" src=getStaticURL("html", "/static/js/focus.js")) script(type="module" src=getStaticURL("html", "/static/js/focus.js"))
link(rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png")
link(rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png")
link(rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png")
link(rel="manifest" href="/site.webmanifest")
link(rel="mask-icon" href="/static/images/safari-pinned-tab.svg" color="#5bbad5")
link(rel="shortcut icon" href="/static/images/favicon.ico")
meta(name="apple-mobile-web-app-title" content="CloudTube")
meta(name="application-name" content="CloudTube")
meta(name="msapplication-TileColor" content="#2b5797")
meta(name="msapplication-config" content="/browserconfig.xml")
meta(name="theme-color" content="#36393f")
block head block head
body.show-focus body.show-focus
@ -13,15 +26,14 @@ html
if showNav if showNav
nav.main-nav nav.main-nav
.links .links
a(href="/").link.home CloudTube if req && req.headers && "x-insecure" in req.headers
a(href="/").link.home CloudTube - Insecure
else
a(href="/").link.home CloudTube
a(href="/subscriptions" title="Subscriptions").link.icon-link a(href="/subscriptions" title="Subscriptions").link.icon-link
svg(width=30 height=25) != icons.get("subscriptions")
image(href=getStaticURL("html", "/static/images/subscriptions.svg") alt="Subscriptions.").icon
title Subscriptions
a(href="/settings" title="Settings").link.icon-link a(href="/settings" title="Settings").link.icon-link
svg(width=25 height=25) != icons.get("settings")
image(href=getStaticURL("html", "/static/images/settings.svg") alt="Settings.").icon
title Settings
form(method="get" action="/search").search-form form(method="get" action="/search").search-form
input(type="text" placeholder="Search" aria-label="Search a video" name="q" autocomplete="off" value=query).search input(type="text" placeholder="Search" aria-label="Search a video" name="q" autocomplete="off" value=query).search

View File

@ -0,0 +1,12 @@
doctype html
html
head
meta(charset="utf-8")
meta(name="viewport" content="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
div
block content

View File

@ -36,6 +36,16 @@ block content
form(method="post" action="/settings") form(method="post" action="/settings")
+fieldset("Settings") +fieldset("Settings")
+select({
id: "theme",
label: "Theme",
options: [
{value: "0", text: "Standard dark"},
{value: "1", text: "Standard light"},
{value: "2", text: "Edgeless light"}
]
})
+input({ +input({
id: "instance", id: "instance",
label: "Instance", label: "Instance",

View File

@ -11,12 +11,24 @@ block content
if hasSubscriptions if hasSubscriptions
section section
details.channels-details details.channels-details
summary #{channels.length} subscriptions summary
| #{channels.length} subscriptions
if missingChannelCount === 1
= ` - ${missingChannelCount} channel is gone`
else if missingChannelCount > 1
= ` - ${missingChannelCount} channels are gone`
.channels-list .channels-list
for channel in channels for channel in channels
a(href=`/channel/${channel.ucid}`).channel-item a(href=`/channel/${channel.ucid}`).channel-item
img(src=channel.icon_url width=512 height=512 alt="").thumbnail img(src=channel.icon_url width=512 height=512 alt="").thumbnail
span.name= channel.name div
div.name= channel.name
if channel.missing
div.missing-reason
if channel.missing_reason
= channel.missing_reason
else
| This channel appears to be deleted or terminated. Click to check it.
if refreshed if refreshed
section section
@ -31,8 +43,8 @@ block content
if settings.save_history if settings.save_history
input(type="checkbox" id="watched-videos-display") input(type="checkbox" id="watched-videos-display")
.watched-videos-display-container .checkbox-hider__container
label(for="watched-videos-display").watched-videos-display-label Hide watched videos label(for="watched-videos-display").checkbox-hider__label Hide watched videos
each video in videos each video in videos
+video_list_item("subscriptions-video", video, instanceOrigin, {showMarkWatched: settings.save_history && !video.watched}) +video_list_item("subscriptions-video", video, instanceOrigin, {showMarkWatched: settings.save_history && !video.watched})

View File

@ -21,6 +21,7 @@ block content
noscript noscript
meta(http-equiv="refresh" content=`${video.lengthSeconds+5};url=/watch?v=${first.videoId}&continuous=1&session-watched=${sessionWatchedNext}`) meta(http-equiv="refresh" content=`${video.lengthSeconds+5};url=/watch?v=${first.videoId}&continuous=1&session-watched=${sessionWatchedNext}`)
.video-page(class={ .video-page(class={
"video-page--recommended-side": settings.recommended_mode === 0,
"video-page--recommended-below": settings.recommended_mode === 1, "video-page--recommended-below": settings.recommended_mode === 1,
"video-page--recommended-hidden": settings.recommended_mode === 2 "video-page--recommended-hidden": settings.recommended_mode === 2
}) })
@ -76,7 +77,7 @@ block content
img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon
| Search | Search
//- button.border-look#share Share //- button.border-look#share Share
a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`).border-look YouTube a(href=`https://yewtu.be/watch?v=${video.videoId}`).border-look YewTube
a(href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious a(href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
.description#description!= video.descriptionHtml .description#description!= video.descriptionHtml

58
pug/video_embed.pug Normal file
View File

@ -0,0 +1,58 @@
extends includes/layout_embed
include includes/video-list-item
include includes/subscribe-button
block head
unless error
title= `${video.title} (embedded) - CloudTube`
else
title Error - CloudTube
script(type="module" src=getStaticURL("html", "/static/js/player.js"))
script const data = !{JSON.stringify({...video, continuous})}
block content
unless error
.video-embed-page(class={
"video-page--recommended-below": settings.recommended_mode === 1,
"video-page--recommended-hidden": settings.recommended_mode === 2
})
main.embed-video-section
- const format = formats[0]
if format
video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag autoplay=continuous||autoplay)#video.video
source(src=format.url type=format.type)
each t in video.captions
track(label=t.label kind="subtitles" srclang=t.languageCode src=t.url)
// fallback: flash player
- let flashvars = new URLSearchParams({skin: "/static/flash/skin.swf", video: format.url})
embed(type="application/x-shockwave-flash" src="/static/flash/player.swf" id="f4Player" width=1280 height=720 flashvars=flashvars.toString() allowscriptaccess="always" allowfullscreen="true" bgcolor="#000000")
else
video(src="")#video.video
.stream-notice The server provided no playback streams.
#current-time-container
#end-cards-container
audio(preload="auto")#audio
#live-event-notice
#audio-loading-display
.button-container
select(aria-label="Quality" autocomplete="off").border-look#quality-select
each f in formats
option(value=f.itag)= f.cloudtube__label
//-
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(target="_blank", href=`/watch?v=${video.videoId}`).border-look CloudTube (not embedded)
a(target="_blank", href=`https://yewtu.be/watch?v=${video.videoId}`).border-look YewTube
a(target="_blank", href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
else
//- error
main.video-error-page
h2 Error
!= message
p: a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`) Watch on YouTube →

9
sass/dark.sass Normal file
View File

@ -0,0 +1,9 @@
@use "themes/dark" as *
@use "includes/main" with ($_theme: $theme)
@use "theme-modules/edgeless" with ($_theme: $theme)
// navigation shadow
.main-nav
position: relative // needed for box shadow to overlap related videos section
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)

4
sass/edgeless-light.sass Normal file
View File

@ -0,0 +1,4 @@
@use "themes/edgeless-light" as *
@use "includes/main" with ($_theme: $theme)
@use "theme-modules/edgeless" with ($_theme: $theme)

View File

@ -1,8 +1,10 @@
@use "colors.sass" as c $_theme: () !default
@use "sass:map"
body body
background-color: c.$bg-dark background-color: map.get($_theme, "bg-2")
color: c.$fg-main color: map.get($_theme, "fg-main")
font-family: "Bariol", sans-serif font-family: "Bariol", sans-serif
font-size: 18px font-size: 18px
margin: 0 margin: 0
@ -13,13 +15,13 @@ body
flex-direction: column flex-direction: column
a a
color: c.$link color: map.get($_theme, "link")
pre, code pre, code
font-size: 0.88em font-size: 0.88em
code code
background: c.$bg-darker background: map.get($_theme, "bg-1")
padding: 3px 5px padding: 3px 5px
border-radius: 4px border-radius: 4px
@ -32,7 +34,7 @@ button
cursor: pointer cursor: pointer
::placeholder ::placeholder
color: #c4c4c4 color: map.get($_theme, "placeholder")
opacity: 1 opacity: 1
// focus section // focus section
@ -48,19 +50,20 @@ button
select:-moz-focusring select:-moz-focusring
color: transparent color: transparent
text-shadow: 0 0 0 c.$fg-bright text-shadow: 0 0 0 map.get($_theme, "fg-bright")
body.show-focus body.show-focus
a, select, button, input, video, summary a, select, button, input, video, summary
&:focus &:focus
outline: 2px dotted #ddd outline: 2px dotted map.get($_theme, "fg-main")
video video
background-color: black background-color: black
details details
background-color: c.$bg-accent-x background-color: map.get($_theme, "bg-3")
padding: 12px padding: 12px
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px border-radius: 8px
summary summary
@ -68,20 +71,22 @@ details
line-height: 1 line-height: 1
margin-bottom: 0 margin-bottom: 0
user-select: none user-select: none
color: c.$fg-main color: map.get($_theme, "fg-main")
&[open] summary &[open] summary
margin-bottom: 16px padding-bottom: 12px
border-bottom: 1px solid map.get($_theme, "edge-grey")
margin-bottom: 8px
table table
background-color: c.$bg-darker background-color: map.get($_theme, "bg-1")
table, td, th table, td, th
border: 1px solid c.$edge-grey border: 1px solid map.get($_theme, "edge-grey")
border-collapse: collapse border-collapse: collapse
td, th td, th
padding: 4px 8px padding: 4px 8px
thead, tr:nth-child(even) thead, tr:nth-child(even)
background-color: c.$bg-darkest background-color: map.get($_theme, "bg-0")

View File

@ -1,10 +1,12 @@
$_theme: () !default
@use "sass:selector" @use "sass:selector"
@use "colors.sass" as c @use "sass:map"
@mixin button-base @mixin button-base
-webkit-appearance: none -webkit-appearance: none
-moz-appearance: none -moz-appearance: none
color: c.$fg-bright color: map.get($_theme, "fg-bright")
border: none border: none
border-radius: 4px border-radius: 4px
padding: 8px padding: 8px
@ -14,7 +16,7 @@
@at-root #{selector.unify(&, "select")} @at-root #{selector.unify(&, "select")}
padding: 8px 27px 8px 8px padding: 8px 27px 8px 8px
background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x background: map.get($_theme, "image-dropdown") right 53% no-repeat map.get($_theme, "bg-4")
@at-root #{selector.unify(&, "a")} @at-root #{selector.unify(&, "a")}
padding: 7px 8px padding: 7px 8px
@ -31,12 +33,12 @@
@mixin button-bg @mixin button-bg
@include button-base @include button-base
background-color: c.$bg-accent-x background-color: map.get($_theme, "bg-4")
@mixin border-button @mixin border-button
@include button-bg @include button-bg
border: 1px solid c.$edge-grey border: 1px solid map.get($_theme, "edge-grey")
@mixin button-size @mixin button-size
margin: 4px margin: 4px
@ -44,10 +46,10 @@
@mixin button-hover @mixin button-hover
&:hover &:hover
background-color: c.$bg-accent background-color: map.get($_theme, "bg-3")
&:active &:active
background-color: c.$bg-dark background-color: map.get($_theme, "bg-2")
.base-border-look .base-border-look
@include border-button @include border-button
@ -62,13 +64,13 @@
@include button-size @include button-size
-webkit-appearance: none -webkit-appearance: none
-moz-appearance: none -moz-appearance: none
color: c.$fg-bright color: map.get($_theme, "fg-bright")
text-decoration: none text-decoration: none
line-height: 1.25 line-height: 1.25
margin: 0 margin: 0
padding: 8px 20px padding: 8px 20px
background: c.$bg-accent background: map.get($_theme, "bg-3")
border: solid c.$bg-darker border: solid map.get($_theme, "edge-grey")
border-width: 1px 0px 0px border-width: 1px 0px 0px
text-align: left text-align: left
@ -76,7 +78,7 @@
border-width: 1px 0px 1px border-width: 1px 0px 1px
&:hover &:hover
background: c.$bg-accent-x background: map.get($_theme, "bg-4")
&:active &:active
background: c.$bg-darker background: map.get($_theme, "bg-1")

View File

@ -1,5 +1,7 @@
$_theme: () !default
@use "sass:list" @use "sass:list"
@use "colors.sass" as c @use "sass:map"
.cant-think-page .cant-think-page
.main-nav .main-nav
@ -9,13 +11,14 @@
text-align: left text-align: left
max-width: 572px max-width: 572px
padding-top: 40px padding-top: 40px
margin-bottom: 12vh
border-radius: 0px 0px 16px 16px border-radius: 0px 0px 16px 16px
box-sizing: border-box box-sizing: border-box
.page-narration .page-narration
background-color: c.$bg-accent background-color: map.get($_theme, "bg-3")
border: 1px solid #aaa border: 1px solid map.get($_theme, "edge-grey")
color: #fff color: map.get($_theme, "fg-bright")
border-radius: 0 border-radius: 0
padding: 16px padding: 16px
margin: 40px auto 60px margin: 40px auto 60px
@ -28,7 +31,7 @@
.leave .leave
margin: 26px 32px !important margin: 26px 32px !important
color: #aaa color: map.get($_theme, "fg-dim")
$sizes: 14px 16px 21px 30px 72px $sizes: 14px 16px 21px 30px 72px
@each $size in $sizes @each $size in $sizes
@ -37,7 +40,7 @@
&.leave__final &.leave__final
font-weight: bold font-weight: bold
color: #f2f2f2 color: map.get($_theme, "fg-bright")
text-align: center text-align: center
.leave__actions .leave__actions
@ -54,6 +57,12 @@
.ultimatum .ultimatum
margin-top: 32px !important margin-top: 32px !important
.the-end
margin: 120px 0px 1em !important
.thumbnail__more
display: none
#i-understand #i-understand
display: flex display: flex
height: 20px height: 20px
@ -65,6 +74,7 @@
&:target &:target
position: fixed position: fixed
z-index: 1
top: 0 top: 0
bottom: 0 bottom: 0
left: 0 left: 0

View File

@ -1,6 +1,8 @@
@use "colors.sass" as c $_theme: () !default
@use "video-list-item.sass" as *
@use "_dimensions.sass" as dimensions @use "sass:map"
@use "_dimensions" as dimensions
@use "video-list-item" as *
.channel-page .channel-page
padding: 40px 20px 20px padding: 40px 20px 20px
@ -17,7 +19,7 @@
align-self: flex-start align-self: flex-start
.channel-data .channel-data
background-color: c.$bg-darker background-color: map.get($_theme, "bg-1")
padding: 24px padding: 24px
margin: 12px 0px 24px margin: 12px 0px 24px
border-radius: 8px border-radius: 8px
@ -44,11 +46,11 @@
.name .name
font-size: 30px font-size: 30px
font-weight: normal font-weight: normal
color: c.$fg-bright color: map.get($_theme, "fg-bright")
margin: 0 margin: 0
.subscribers .subscribers
color: c.$fg-main color: map.get($_theme, "fg-main")
font-size: 18px font-size: 18px
.subscribe-form .subscribe-form
@ -61,7 +63,8 @@
line-height: 1 line-height: 1
border-radius: 8px border-radius: 8px
font-size: 22px font-size: 22px
background-color: c.$power-deep background-color: map.get($_theme, "power-deep")
color: map.get($_theme, "power-fg")
border: none border: none
.description .description
@ -71,6 +74,19 @@
.channel-video .channel-video
@include channel-video @include channel-video
.channel-error
background-color: map.get($_theme, "bg-1")
padding: 24px
margin: 12px 0px 24px
border-radius: 8px
border: 1px solid map.get($_theme, "edge-grey")
font-size: 20px
color: map.get($_theme, "fg-warning")
.you-should-unsubscribe
margin-top: 20px
color: map.get($_theme, "fg-main")
.about-description // class provided by youtube .about-description // class provided by youtube
pre pre
font-size: inherit font-size: inherit

View File

@ -1,10 +1,12 @@
@use "colors.sass" as c $_theme: () !default
@use "sass:map"
@mixin filter-notice @mixin filter-notice
margin-top: 24px margin-top: 24px
padding: 12px padding: 12px
border-radius: 8px border-radius: 8px
background-color: c.$bg-darker background-color: map.get($_theme, "bg-1")
white-space: pre-line white-space: pre-line
.filters-page .filters-page
@ -20,23 +22,23 @@
.filter-confirmation-notice .filter-confirmation-notice
@include filter-notice @include filter-notice
color: c.$fg-warning color: map.get($_theme, "fg-warning")
.filter-compile-error .filter-compile-error
@include filter-notice @include filter-notice
&__header &__header
color: c.$fg-warning color: map.get($_theme, "fg-warning")
&__trace &__trace
background-color: c.$bg-darkest background-color: map.get($_theme, "bg-0")
padding: 6px padding: 6px
.save-filter .save-filter
margin-top: 12px margin-top: 12px
.border-look .border-look
background-color: c.$bg-darker background-color: map.get($_theme, "bg-1")
font-size: 22px font-size: 22px
padding: 7px 16px 8px padding: 7px 16px 8px
font-size: 24px font-size: 24px
@ -48,17 +50,17 @@
.filter .filter
display: flex display: flex
padding: 5px 0 padding: 5px 0
border-top: 1px solid c.$edge-grey border-top: 1px solid map.get($_theme, "edge-grey")
&:last-child &:last-child
border-bottom: 1px solid c.$edge-grey border-bottom: 1px solid map.get($_theme, "edge-grey")
&__details &__details
flex: 1 flex: 1
&__type &__type
font-size: 15px font-size: 15px
color: c.$fg-dim color: map.get($_theme, "fg-dim")
&__remove &__remove
flex-shrink: 0 flex-shrink: 0

View File

@ -1,4 +1,6 @@
@use "./colors.sass" as c $_theme: () !default
@use "sass:map"
.footer__container .footer__container
flex: 1 flex: 1
@ -10,9 +12,10 @@
display: flex display: flex
flex-direction: column flex-direction: column
align-items: center align-items: center
background-color: c.$bg-darkest background-color: map.get($_theme, "bg-dim")
margin: 40px 0 0 margin: 40px 0 0
padding: 10px 10px 30px padding: 10px 10px 30px
border-top: 1px solid map.get($_theme, "edge-grey")
.footer__cols .footer__cols
display: flex display: flex

View File

@ -1,7 +1,9 @@
@use "colors.sass" as c $_theme: () !default
@use "sass:map"
@mixin disabled @mixin disabled
background-color: c.$bg-dark background-color: map.get($_theme, "bg-2")
color: #808080 color: #808080
fieldset fieldset
@ -20,7 +22,7 @@ fieldset
font-size: 28px font-size: 28px
font-weight: bold font-weight: bold
padding: 0 padding: 0
border-bottom: 1px solid #333 border-bottom: 1px solid map.get($_theme, "edge-grey") // TODO: originally contrasted more
line-height: 1.56 line-height: 1.56
@media screen and (max-width: 400px) @media screen and (max-width: 400px)
@ -36,7 +38,7 @@ fieldset
position: relative position: relative
padding-bottom: 5px padding-bottom: 5px
margin-bottom: 5px margin-bottom: 5px
border-bottom: 1px solid #999 border-bottom: 1px solid map.get($_theme, "edge-grey")
@media screen and (max-width: 400px) @media screen and (max-width: 400px)
flex-direction: column flex-direction: column
@ -52,7 +54,7 @@ fieldset
&__label &__label
grid-area: label grid-area: label
padding: 8px 8px 8px 0px padding: 8px 8px 8px 0px
color: #fff color: map.get($_theme, "fg-main")
&__input &__input
grid-area: input grid-area: input
@ -63,7 +65,7 @@ fieldset
white-space: pre-line white-space: pre-line
margin: 12px 0px 18px margin: 12px 0px 18px
font-size: 16px font-size: 16px
color: #ccc color: map.get($_theme, "fg-dim")
line-height: 1.2 line-height: 1.2
// //
@ -79,7 +81,7 @@ fieldset
width: 16px width: 16px
height: 16px height: 16px
padding: 0px padding: 0px
border: 1px solid #666 border: 1px solid map.get($_theme, "edge-grey")
border-radius: 3px border-radius: 3px
margin-left: 8px margin-left: 8px
position: relative position: relative
@ -101,6 +103,20 @@ fieldset
@include acts-like-button @include acts-like-button
cursor: pointer cursor: pointer
.checkbox-hider__container
position: relative
display: grid // why does the default not work???
top: -42px
background: map.get($_theme, "bg-3")
line-height: 1
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px
margin-bottom: -18px
.checkbox-hider__label
padding: 12px 0px 12px 32px
cursor: pointer
@mixin checkbox-hider($base) @mixin checkbox-hider($base)
##{$base} ##{$base}
position: relative position: relative
@ -110,18 +126,17 @@ fieldset
height: 42px height: 42px
margin: 0 margin: 0
.#{$base}-container /*
position: relative automatically add these styles too
display: grid // why does the default not work??? this means that components based off this can either add the .checkbox-hider__container class, or they can add the .base-name-container class,
top: -42px depending on which one is more reasonable in the moment
background: c.$bg-accent-x for example, .delete-confirm-container takes advantage of the @extend here.
line-height: 1
border-radius: 8px
margin-bottom: -18px
.#{$base}-label .#{$base}-container
padding: 12px 0px 12px 32px @extend .checkbox-hider__container
cursor: pointer
.#{$base}-label
@extend .checkbox-hider__label
@mixin single-button-form @mixin single-button-form
display: inline-block display: inline-block

View File

@ -1,4 +1,6 @@
@use "colors.sass" as c $_theme: () !default
@use "sass:map"
.home-page .home-page
padding: 40px padding: 40px
@ -18,8 +20,8 @@
padding: 16px padding: 16px
border-radius: 4px border-radius: 4px
font-size: 20px font-size: 20px
background-color: c.$bg-darker background-color: map.get($_theme, "bg-1")
color: c.$fg-main color: map.get($_theme, "fg-main")
p p
margin: 0 32px margin: 0 32px

View File

@ -1,3 +1,5 @@
$_theme: () !default
.js-licenses-page .js-licenses-page
max-width: 800px max-width: 800px
margin: 0 auto margin: 0 auto

33
sass/includes/_main.sass Normal file
View File

@ -0,0 +1,33 @@
$_theme: () !default
@use "sass:selector"
// preload second-level includes with the theme (there will be conflicts due to reconfiguration they are loaded individually)
// this isn't _exactly_ what @forward is supposed to be used for, but it's the best option here
@forward "video-list-item" show _ with ($_theme: $_theme)
@forward "forms" show _ with ($_theme: $_theme)
@forward "buttons" show _ with ($_theme: $_theme)
@use "base" with ($_theme: $_theme)
@use "video-page" with ($_theme: $_theme)
@use "video-embed" with ($_theme: $_theme)
@use "search-page" with ($_theme: $_theme)
@use "home-page" with ($_theme: $_theme)
@use "channel-page" with ($_theme: $_theme)
@use "subscriptions-page" with ($_theme: $_theme)
@use "settings-page" with ($_theme: $_theme)
@use "cant-think-page" with ($_theme: $_theme)
@use "privacy-page" with ($_theme: $_theme)
@use "licenses-page" with ($_theme: $_theme)
@use "filters-page" with ($_theme: $_theme)
@use "takedown-page" with ($_theme: $_theme)
@use "nav" with ($_theme: $_theme)
@use "footer" with ($_theme: $_theme)
@font-face
font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1)
.button-container
display: flex
flex-wrap: wrap

View File

@ -1,12 +1,14 @@
@use "colors.sass" as c $_theme: () !default
@use "buttons.sass" as *
@use "_dimensions.sass" as dimensions @use "sass:map"
@use "buttons" as *
@use "_dimensions" as dimensions
.main-nav .main-nav
background-color: c.$bg-accent background-color: map.get($_theme, "bg-nav")
display: flex display: flex
padding: 8px padding: 8px
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1) border-bottom: 1px solid map.get($_theme, "edge-grey")
+dimensions.thin +dimensions.thin
display: block display: block
@ -30,10 +32,16 @@
font-weight: bold font-weight: bold
&, &:visited &, &:visited
color: #fff color: map.get($_theme, "fg-bright")
&:focus, &:hover &:focus, &:hover
background-color: c.$bg-accent-x background-color: map.get($_theme, "bg-4")
&.icon-link
color: map.get($_theme, "fg-dim")
&:hover, &:focus
color: map.get($_theme, "fg-bright")
.search-form .search-form
display: flex display: flex
@ -44,8 +52,7 @@
@include button-bg @include button-bg
padding: 10px padding: 10px
flex: 1 flex: 1
margin: 1px border: 1px solid map.get($_theme, "bg-nav")
&:hover, &:focus &:hover, &:focus
border: 1px solid c.$edge-grey border-color: map.get($_theme, "edge-grey")
margin: 0px

View File

@ -1,3 +1,5 @@
$_theme: () !default
.privacy-page .privacy-page
max-width: 600px max-width: 600px
margin: 0 auto margin: 0 auto

View File

@ -1,5 +1,7 @@
@use "video-list-item.sass" as * $_theme: () !default
@use "colors.sass" as c
@use "sass:map"
@use "video-list-item" as *
.search-page .search-page
padding: 40px 20px 20px padding: 40px 20px 20px

View File

@ -1,5 +1,7 @@
@use "forms.sass" as forms $_theme: () !default
@use "colors.sass" as c
@use "sass:map"
@use "forms" as forms
.settings-page .settings-page
padding: 40px 20px 20px padding: 40px 20px 20px
@ -19,8 +21,9 @@
.more-settings .more-settings
margin-top: 24px margin-top: 24px
padding: 12px padding: 12px
border: 1px solid map.get($_theme, "edge-grey")
border-radius: 8px border-radius: 8px
background-color: c.$bg-accent-x background-color: map.get($_theme, "bg-3")
&__list &__list
margin: 0 margin: 0
@ -34,7 +37,7 @@
margin-top: 24px margin-top: 24px
.delete-confirm-container .delete-confirm-container
background: c.$bg-darker background: map.get($_theme, "bg-1")
margin-bottom: -36px margin-bottom: -36px
@include forms.checkbox-hider("delete-confirm") @include forms.checkbox-hider("delete-confirm")

View File

@ -1,6 +1,8 @@
@use "colors.sass" as c $_theme: () !default
@use "video-list-item.sass" as *
@use "forms.sass" as forms @use "sass:map"
@use "forms" as forms
@use "video-list-item" as *
.subscriptions-page .subscriptions-page
padding: 40px 20px 20px padding: 40px 20px 20px
@ -33,7 +35,11 @@
.name .name
font-size: 22px font-size: 22px
color: c.$fg-main color: map.get($_theme, "fg-main")
.missing-reason
font-size: 16px
color: map.get($_theme, "fg-warning")
@include forms.checkbox-hider("watched-videos-display") @include forms.checkbox-hider("watched-videos-display")

View File

@ -0,0 +1,16 @@
$_theme: () !default
@use "sass:map"
.takedown-page
max-width: 700px
margin: 0 auto
.new-section
margin-top: 200px
.important-section
padding: 4px 20px
border: 1px solid map.get($_theme, "edge-grey")
color: map.get($_theme, "fg-bright")
background-color: map.get($_theme, "bg-1")

View File

@ -0,0 +1,22 @@
$_theme: () !default
@use "sass:map"
@use "video-list-item" as *
.video-embed-page
display: block
.embed-video-section
width: 100%
height: 100vh
display: flex
flex-direction: column
.video
flex-grow: 1
display: block
width: 100%
.stream-notice
background: map.get($_theme, "bg-0")
padding: 4px

View File

@ -1,5 +1,7 @@
@use "colors.sass" as c $_theme: () !default
@use "_dimensions.sass" as dimensions
@use "sass:map"
@use "_dimensions" as dimensions
// navigator hacks // navigator hacks
.thumbnail > .thumbnail__options-container .thumbnail > .thumbnail__options-container
@ -30,6 +32,7 @@
&__show-more &__show-more
display: block display: block
height: $more-size height: $more-size
color: #fff
line-height: 16px line-height: 16px
font-size: 25px font-size: 25px
text-align: center text-align: center
@ -52,7 +55,7 @@
&__options-list &__options-list
pointer-events: auto pointer-events: auto
display: grid display: grid
background-color: c.$bg-accent background-color: map.get($_theme, "bg-3")
padding: 8px 0px padding: 8px 0px
border-radius: 8px border-radius: 8px
box-shadow: 0 2px 6px 2px #000 box-shadow: 0 2px 6px 2px #000
@ -67,7 +70,7 @@
right: 0 right: 0
transform: translate(-6px, -1px) rotate(-45deg) transform: translate(-6px, -1px) rotate(-45deg)
clip-path: polygon(-5% -20%, 120% -20%, 120% 125%) clip-path: polygon(-5% -20%, 120% -20%, 120% 125%)
background-color: c.$bg-accent background-color: map.get($_theme, "bg-3")
box-shadow: 0px 0px 4px 0px #000 box-shadow: 0px 0px 4px 0px #000
pointer-events: none pointer-events: none
@ -80,7 +83,7 @@
margin-bottom: 12px margin-bottom: 12px
@at-root .video-list-item--watched#{&} @at-root .video-list-item--watched#{&}
background: c.$bg-darker background: map.get($_theme, "bg-dim")
padding: 4px 4px 0px padding: 4px 4px 0px
margin: -4px -4px 8px margin: -4px -4px 8px
@ -93,7 +96,7 @@
.thumbnail .thumbnail
position: relative position: relative
display: flex display: flex
background: c.$bg-darkest background: map.get($_theme, "bg-0")
&__link &__link
font-size: 0 // remove whitespace around the image font-size: 0 // remove whitespace around the image
@ -106,7 +109,7 @@
position: absolute position: absolute
bottom: 3px bottom: 3px
right: 3px right: 3px
color: c.$fg-bright color: #fff
font-size: 14px font-size: 14px
background: rgba(20, 20, 20, 0.85) background: rgba(20, 20, 20, 0.85)
line-height: 1 line-height: 1
@ -119,20 +122,20 @@
line-height: 1.2 line-height: 1.2
.title-link .title-link
color: c.$fg-main color: map.get($_theme, "fg-main")
text-decoration: none text-decoration: none
.author-line .author-line
margin-top: 4px margin-top: 4px
font-size: 15px font-size: 15px
color: c.$fg-dim color: map.get($_theme, "fg-dim")
.author .author
color: c.$fg-dim color: map.get($_theme, "fg-dim")
text-decoration: none text-decoration: none
&:hover, &:active &:hover, &:active
color: c.$fg-bright color: map.get($_theme, "fg-bright")
text-decoration: underline text-decoration: underline
@mixin recommendation-item @mixin recommendation-item
@ -176,15 +179,15 @@
.author-line .author-line
font-size: 15px font-size: 15px
color: c.$fg-main color: map.get($_theme, "fg-main")
.author .author
color: c.$fg-main color: map.get($_theme, "fg-main")
.description .description
margin-top: 16px margin-top: 16px
font-size: 15px font-size: 15px
color: c.$fg-dim color: map.get($_theme, "fg-dim")
+dimensions.thin +dimensions.thin
.description .description
@ -195,7 +198,7 @@
.description b .description b
font-weight: normal font-weight: normal
color: c.$fg-main color: map.get($_theme, "fg-main")
@mixin channel-video @mixin channel-video
@include large-item @include large-item

View File

@ -1,11 +1,11 @@
@use "colors.sass" as c $_theme: () !default
@use "video-list-item.sass" as *
@use "sass:map"
@use "video-list-item" as *
.video-page .video-page
display: grid display: grid
grid-auto-flow: row grid-auto-flow: row
padding: 20px
grid-gap: 16px
@media screen and (min-width: 1000px) @media screen and (min-width: 1000px)
grid-template-columns: 1fr 400px grid-template-columns: 1fr 400px
@ -13,10 +13,25 @@
&--recommended-below, &--recommended-hidden &--recommended-below, &--recommended-hidden
grid-template-columns: none grid-template-columns: none
&--recommended-hidden .related-videos &--recommended-side
display: none .related-videos
border-left: 1px solid map.get($_theme, "edge-grey")
padding-left: 12px
padding-right: 20px
background-color: map.get($_theme, "bg-4")
padding-top: 12px
&--recommended-below
.related-videos
padding: 20px
&--recommended-hidden
.related-videos
display: none
.main-video-section .main-video-section
padding: 20px
.video-container .video-container
text-align: center text-align: center
@ -27,7 +42,7 @@
max-height: 80vh max-height: 80vh
.stream-notice .stream-notice
background: c.$bg-darkest background: map.get($_theme, "bg-0")
padding: 4px padding: 4px
.info .info
@ -45,15 +60,15 @@
margin: 0px 0px 4px margin: 0px 0px 4px
font-size: 30px font-size: 30px
font-weight: normal font-weight: normal
color: c.$fg-bright color: map.get($_theme, "fg-bright")
word-break: break-word word-break: break-word
.author-link .author-link
color: c.$fg-main color: map.get($_theme, "fg-main")
text-decoration: none text-decoration: none
&:hover, &:active &:hover, &:active
color: c.$fg-bright color: map.get($_theme, "fg-bright")
text-decoration: underline text-decoration: underline
.info-secondary .info-secondary
@ -75,7 +90,7 @@
margin: 16px 4px margin: 16px 4px
padding: 12px padding: 12px
border-radius: 4px border-radius: 4px
background-color: c.$bg-darkest background-color: map.get($_theme, "bg-0")
&__description &__description
margin-left: 12px margin-left: 12px
@ -85,7 +100,7 @@
&__script-warning &__script-warning
font-size: 15px font-size: 15px
color: c.$fg-warning color: map.get($_theme, "fg-warning")
&__buttons &__buttons
display: flex display: flex
@ -97,12 +112,12 @@
line-height: 1.4 line-height: 1.4
word-break: break-word word-break: break-word
margin: 16px 4px 4px 4px margin: 16px 4px 4px 4px
background-color: c.$bg-accent-area background-color: map.get($_theme, "bg-5")
padding: 12px padding: 12px
border-radius: 4px border-radius: 4px
--regular-background: #{c.$bg-accent-area} --regular-background: #{map.get($_theme, "bg-5")}
--highlight-background: #{c.$bg-darker} --highlight-background: #{map.get($_theme, "bg-1")}
.subscribe-form .subscribe-form
display: inline-block display: inline-block

View File

@ -1,17 +0,0 @@
$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
$fg-warning: #fdca6d
$edge-grey: #a0a0a0
$link: #8ac2f9
$power-deep: #c62727

View File

@ -1,14 +0,0 @@
@use "colors.sass" as c
.takedown-page
max-width: 700px
margin: 0 auto
.new-section
margin-top: 200px
.important-section
padding: 4px 20px
border: 1px solid c.$edge-grey
color: c.$fg-bright
background-color: c.$bg-darker

2
sass/light.sass Normal file
View File

@ -0,0 +1,2 @@
@use "themes/light" as *
@use "includes/main" with ($_theme: $theme)

View File

@ -1,30 +0,0 @@
@use "includes/colors.sass" as c
@use "includes/base.sass"
@use "sass:selector"
@use "includes/video-page.sass"
@use "includes/search-page.sass"
@use "includes/home-page.sass"
@use "includes/channel-page.sass"
@use "includes/subscriptions-page.sass"
@use "includes/settings-page.sass"
@use "includes/cant-think-page.sass"
@use "includes/privacy-page.sass"
@use "includes/licenses-page.sass"
@use "includes/filters-page.sass"
@use "includes/takedown-page.sass"
@use "includes/forms.sass"
@use "includes/nav.sass"
@use "includes/footer.sass"
@font-face
font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1)
.icon-link:hover, .icon-link:focus
.icon
filter: brightness(2)
.button-container
display: flex
flex-wrap: wrap

View File

@ -0,0 +1,27 @@
$_theme: () !default
@use "sass:map"
// remove separating edges
.main-nav, .footer__center, .video-page--recommended-side .related-videos
border: none
// no background change to recommended videos sidebar
.video-page--recommended-side .related-videos
background-color: map.get($_theme, "bg-2")
// navigation shadow
.main-nav
position: relative // needed for box shadow to overlap related videos section
box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
// thumbnail dropdown menu dividers
.menu-look
border-color: map.get($_theme, "bg-0")
// details areas
details, .checkbox-hider__container, .more-settings
border: none
details[open] summary
border: none
margin-bottom: 4px

38
sass/themes/_dark.scss Normal file
View File

@ -0,0 +1,38 @@
// Defined in scss file instead of sass because indented syntax does not have multiline maps
// https://github.com/sass/sass/issues/216
@use "sass:map";
// This section is for colour shades
$theme: (
// darker
"bg-0": #252628,
"bg-1": #303336,
// regular
"bg-2": #36393f,
// lighter
"bg-3": #3f4247, // slightly
"bg-4": #44474b, // noticably
"bg-5": #4f5359, // brightly
"fg-bright": #fff,
"fg-main": #ddd,
"fg-dim": #bbb,
"fg-warning": #fdca6d,
"edge-grey": #a0a0a0,
"placeholder": #c4c4c4,
"link": #8ac2f9,
"power-deep": #c62727,
"power-fg": "#fff",
"image-dropdown": url(/static/images/arrow-down-wide-dark.svg)
);
// This section is for colour meanings
$theme: map.merge($theme, (
"bg-dim": map.get($theme, "bg-0"),
"bg-nav": map.get($theme, "bg-5"),
));

View File

@ -0,0 +1,8 @@
// extend regular light theme to change a couple of shades
@use "light";
@use "sass:map";
// this section is for colour meanings
$theme: map.merge(light.$theme, (
"edge-grey": #c0c0c0,
));

38
sass/themes/_light.scss Normal file
View File

@ -0,0 +1,38 @@
// Defined in scss file instead of sass because indented syntax does not have multiline maps
// https://github.com/sass/sass/issues/216
@use "sass:map";
// this section is for colour shades
$theme: (
// lighter
"bg-0": #fff,
"bg-1": #fff,
// regular
"bg-2": #f2f2f2,
// darker
"bg-3": #e8e8e8, // slightly
"bg-4": #dadada, // noticably
"bg-5": #d0d0d0, // brightly
"fg-bright": #000,
"fg-main": #202020,
"fg-dim": #454545,
"fg-warning": #ce8600,
"edge-grey": #909090,
"placeholder": #636363,
"link": #0b51d4,
"power-deep": #c62727,
"power-fg": #fff,
"image-dropdown": url(/static/images/arrow-down-wide-light.svg)
);
// this section is for colour meanings
$theme: map.merge($theme, (
"bg-dim": map.get($theme, "bg-4"),
"bg-nav": map.get($theme, "bg-0")
));

View File

@ -1,9 +1,11 @@
const {Pinski} = require("pinski") const {Pinski} = require("pinski")
const {setInstance} = require("pinski/plugins") const {setInstance} = require("pinski/plugins")
const constants = require("./utils/constants") const constants = require("./utils/constants")
const iconLoader = require("./utils/icon-loader").icons
;(async () => { ;(async () => {
await require("./utils/upgradedb")() await require("./utils/upgradedb")()
const icons = await iconLoader
const server = new Pinski({ const server = new Pinski({
port: 10412, port: 10412,
@ -13,19 +15,19 @@ const constants = require("./utils/constants")
setInstance(server) setInstance(server)
server.pugDefaultLocals.constants = constants server.pugDefaultLocals.constants = constants
server.pugDefaultLocals.icons = icons
server.muteLogsStartingWith("/vi/") server.muteLogsStartingWith("/vi/")
server.muteLogsStartingWith("/favicon") server.muteLogsStartingWith("/favicon")
server.muteLogsStartingWith("/static") server.muteLogsStartingWith("/static")
server.addSassDir("sass", ["sass/includes"]) server.addSassDir("sass", ["sass/includes", "sass/themes", "sass/theme-modules"])
server.addRoute("/static/css/main.css", "sass/main.sass", "sass") server.addRoute("/static/css/dark.css", "sass/dark.sass", "sass")
server.addRoute("/static/css/light.css", "sass/light.sass", "sass")
server.addRoute("/static/css/edgeless-light.css", "sass/edgeless-light.sass", "sass")
server.addPugDir("pug", ["pug/includes"]) server.addPugDir("pug", ["pug/includes"])
server.addPugDir("pug/errors") server.addPugDir("pug/errors")
server.addRoute("/cant-think", "pug/cant-think.pug", "pug")
server.addRoute("/privacy", "pug/privacy.pug", "pug")
server.addRoute("/licenses", "pug/licenses.pug", "pug")
server.addStaticHashTableDir("html/static/js") server.addStaticHashTableDir("html/static/js")
server.addStaticHashTableDir("html/static/js/elemjs") server.addStaticHashTableDir("html/static/js/elemjs")

View File

@ -9,6 +9,10 @@ let constants = {
type: "string", type: "string",
default: "http://localhost:3000" default: "http://localhost:3000"
}, },
theme: {
type: "integer",
default: 0
},
save_history: { save_history: {
type: "boolean", type: "boolean",
default: false default: false
@ -46,6 +50,7 @@ let constants = {
csrf_time: 4*60*60*1000, csrf_time: 4*60*60*1000,
seen_token_subscriptions_eligible: 40*60*60*1000, seen_token_subscriptions_eligible: 40*60*60*1000,
subscriptions_refresh_loop_min: 5*60*1000, subscriptions_refresh_loop_min: 5*60*1000,
subscriptions_refesh_fake_not_found_cooldown: 10*60*1000,
}, },
// Pattern matching. // Pattern matching.

View File

@ -54,7 +54,7 @@ class User {
getSubscriptions() { getSubscriptions() {
if (this.token) { if (this.token) {
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ? AND channel_missing = 0").pluck().all(this.token) return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token)
} else { } else {
return [] return []
} }
@ -62,7 +62,7 @@ class User {
isSubscribed(ucid) { isSubscribed(ucid) {
if (this.token) { if (this.token) {
return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ? AND channel_missing = 0").get([this.token, ucid]) return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([this.token, ucid])
} else { } else {
return false return false
} }

8
utils/icon-loader.js Normal file
View File

@ -0,0 +1,8 @@
const fs = require("fs").promises
const names = ["subscriptions", "settings"]
const icons = names.map(name => fs.readFile(`html/static/images/${name}.svg`, "utf8"))
module.exports.icons = Promise.all(icons).then(resolvedIcons => {
return new Map(names.map((name, index) => [name, resolvedIcons[index]]))
})

View File

@ -70,6 +70,31 @@ const deltas = [
.run() .run()
db.prepare("CREATE TABLE TakedownChannels (ucid TEXT NOT NULL, org TEXT, url TEXT, PRIMARY KEY (ucid))") db.prepare("CREATE TABLE TakedownChannels (ucid TEXT NOT NULL, org TEXT, url TEXT, PRIMARY KEY (ucid))")
.run() .run()
},
// 11: Settings +theme
function() {
db.prepare("ALTER TABLE Settings ADD COLUMN theme INTEGER DEFAULT 0")
.run()
},
// 12: Channels +missing +missing_reason, Subscriptions -
// Better management for missing channels
// We totally discard the existing Subscriptions.channel_missing since it is unreliable.
function() {
db.prepare("ALTER TABLE Channels ADD COLUMN missing INTEGER NOT NULL DEFAULT 0")
.run()
db.prepare("ALTER TABLE Channels ADD COLUMN missing_reason TEXT")
.run()
// https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
db.transaction(() => {
db.prepare("CREATE TABLE NEW_Subscriptions (token TEXT NOT NULL, ucid TEXT NOT NULL, PRIMARY KEY (token, ucid))")
.run()
db.prepare("INSERT INTO NEW_Subscriptions (token, ucid) SELECT token, ucid FROM Subscriptions")
.run()
db.prepare("DROP TABLE Subscriptions")
.run()
db.prepare("ALTER TABLE NEW_Subscriptions RENAME TO Subscriptions")
.run()
})()
} }
] ]
@ -82,7 +107,7 @@ async function createBackup(entry) {
/** /**
* @param {number} entry * @param {number} entry
* @param {boolean} log * @param {boolean} [log]
*/ */
function runDelta(entry, log) { function runDelta(entry, log) {
process.stdout.write(`Upgrading database to version ${entry}... `) process.stdout.write(`Upgrading database to version ${entry}... `)

View File

@ -1,15 +1,59 @@
const {request} = require("./request") const {request} = require("./request")
const db = require("./db") const db = require("./db")
async function fetchChannel(ucid, instance) { async function fetchChannel(path, ucid, instance) {
function updateGoodData(channel) {
const bestIcon = channel.authorThumbnails.slice(-1)[0]
const iconURL = bestIcon ? bestIcon.url : null
db.prepare("REPLACE INTO Channels (ucid, name, icon_url, missing, missing_reason) VALUES (?, ?, ?, 0, NULL)").run(channel.authorId, channel.author, iconURL)
}
function updateBadData(channel) {
if (channel.identifier === "NOT_FOUND" || channel.identifier === "ACCOUNT_TERMINATED") {
db.prepare("UPDATE Channels SET missing = 1, missing_reason = ? WHERE ucid = ?").run(channel.error, channel.authorId)
return {
missing: true,
message: channel.error
}
} else {
return {
missing: false,
message: channel.error
}
}
}
if (!instance) throw new Error("No instance parameter provided") if (!instance) throw new Error("No instance parameter provided")
// fetch
const channel = await request(`${instance}/api/v1/channels/${ucid}`).then(res => res.json()) const row = db.prepare("SELECT * FROM Channels WHERE ucid = ?").get(ucid)
// update database
const bestIcon = channel.authorThumbnails.slice(-1)[0] // handle the case where the channel has a known error
const iconURL = bestIcon ? bestIcon.url : null if (row && row.missing_reason) {
db.prepare("REPLACE INTO Channels (ucid, name, icon_url) VALUES (?, ?, ?)").run([channel.authorId, channel.author, iconURL]) return {
// return error: true,
ucid,
row,
missing: true,
message: row.missing_reason
}
}
/** @type {any} */
const channel = await request(`${instance}/api/v1/channels/${ucid}?second__path=${path}`).then(res => res.json())
// handle the case where the channel has a newly discovered error
if (channel.error) {
const missingData = updateBadData(channel)
return {
error: true,
ucid,
row,
...missingData
}
}
// handle the case where the channel returns good data (this is the only remaining scenario)
updateGoodData(channel)
return channel return channel
} }