1
0
mirror of https://git.sr.ht/~cadence/cloudtube synced 2024-11-14 04:17:29 +00:00

Settings page and instance selection

This commit is contained in:
Cadence Ember 2020-09-01 01:22:16 +12:00
parent 59a7489545
commit c573a5ac3e
No known key found for this signature in database
GPG Key ID: 128B99B1B74A6412
22 changed files with 587 additions and 71 deletions

View File

@ -7,8 +7,9 @@ module.exports = [
{ {
route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill}) => { route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill}) => {
const id = fill[0] const id = fill[0]
const data = await fetchChannel(id)
const user = getUser(req) const user = getUser(req)
const settings = user.getSettingsOrDefaults()
const data = await fetchChannel(id, settings.instance)
const subscribed = user.isSubscribed(id) const subscribed = user.isSubscribed(id)
return render(200, "pug/channel.pug", {data, subscribed}) return render(200, "pug/channel.pug", {data, subscribed})
} }

67
api/settings.js Normal file
View File

@ -0,0 +1,67 @@
const {render, redirect} = require("pinski/plugins")
const db = require("./utils/db")
const {getToken, getUser} = require("./utils/getuser")
const constants = require("./utils/constants")
const validate = require("./utils/validate")
const V = validate.V
module.exports = [
{
route: "/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req)
const settings = user.getSettings()
return render(200, "pug/settings.pug", {constants, settings})
}
},
{
route: "/settings", methods: ["POST"], upload: true, code: async ({req, body}) => {
return new V()
.with(validate.presetLoad({body}))
.with(validate.presetURLParamsBody())
.last(async state => {
const {params} = state
const responseHeaders = {
Location: "/settings"
}
const token = getToken(req, responseHeaders)
const data = {}
for (const key of Object.keys(constants.user_settings)) {
const setting = constants.user_settings[key]
if (params.has(key)) {
const provided = params.get(key)
if (setting.type === "string") {
if (provided) data[key] = provided
else data[key] = null
} else if (setting.type === "integer") {
if (isNaN(provided)) data[key] = null
else data[key] = +provided
} else if (setting.type === "boolean") {
if (provided === "true") data[key] = true
else if (provided === "false") data[key] = false
else data[key] = null
} else {
throw new Error("Unsupported setting type: "+setting.type)
}
} else {
data[key] = null
}
}
db.prepare("DELETE FROM Settings WHERE token = ?").run(token)
const keys = ["token", ...Object.keys(constants.user_settings)]
const baseFields = keys.join(", ")
const atFields = keys.map(k => "@"+k).join(", ")
db.prepare(`INSERT INTO Settings (${baseFields}) VALUES (${atFields})`).run({token, ...data})
return {
statusCode: 303,
headers: responseHeaders,
contentType: "text/html",
content: "Redirecting..."
}
})
.go()
}
}
]

View File

@ -1,4 +1,15 @@
const constants = { const constants = {
user_settings: {
instance: {
type: "string",
default: "https://invidious.snopyta.org"
},
save_history: {
type: "boolean",
default: false
}
},
caching: { caching: {
csrf_time: 4*60*60*1000 csrf_time: 4*60*60*1000
}, },

View File

@ -23,6 +23,22 @@ class User {
this.token = token this.token = token
} }
getSettings() {
if (this.token) {
return db.prepare("SELECT * FROM Settings WHERE token = ?").get(this.token) || {}
} else {
return {}
}
}
getSettingsOrDefaults() {
const settings = this.getSettings()
for (const key of Object.keys(settings)) {
if (settings[key] === null) settings[key] = constants.user_settings[key].default
}
return settings
}
getSubscriptions() { getSubscriptions() {
if (this.token) { if (this.token) {
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token) return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token)

View File

@ -10,8 +10,14 @@ const deltas = [
.run() .run()
db.prepare("CREATE TABLE Channels (ucid TEXT NOT NULL, name TEXT NOT NULL, icon_url TEXT, PRIMARY KEY (ucid))") db.prepare("CREATE TABLE Channels (ucid TEXT NOT NULL, name TEXT NOT NULL, icon_url TEXT, PRIMARY KEY (ucid))")
.run() .run()
db.prepare("CREATE TABLE Videos (videoId TEXT NOT NULL, title TEXT NOT NULL, author TEXT, authorId TEXT NOT NULL, published INTEGER, publishedText TEXT, lengthText TEXT, viewCountText TEXT, descriptionHtml TEXT, PRIMARY KEY (videoId))")
.run()
db.prepare("CREATE TABLE CSRFTokens (token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (token))") db.prepare("CREATE TABLE CSRFTokens (token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (token))")
.run() .run()
db.prepare("CREATE TABLE SeenTokens (token TEXT NOT NULL, seen INTEGER NOT NULL, PRIMARY KEY (token))")
.run()
db.prepare("CREATE TABLE Settings (token TEXT NOT NULL, instance TEXT, save_history INTEGER, PRIMARY KEY (token))")
.run()
} }
] ]

View File

@ -1,9 +1,9 @@
const fetch = require("node-fetch") const fetch = require("node-fetch")
const db = require("./db") const db = require("./db")
async function fetchChannel(ucid) { async function fetchChannel(ucid, instance) {
// fetch // fetch
const channel = await fetch(`http://localhost:3000/api/v1/channels/${ucid}`).then(res => res.json()) const channel = await fetch(`${instance}/api/v1/channels/${ucid}`).then(res => res.json())
// update database // update database
const bestIcon = channel.authorThumbnails.slice(-1)[0] const bestIcon = channel.authorThumbnails.slice(-1)[0]
const iconURL = bestIcon ? bestIcon.url : null const iconURL = bestIcon ? bestIcon.url : null

View File

@ -1,19 +1,48 @@
const fetch = require("node-fetch") const fetch = require("node-fetch")
const {render} = require("pinski/plugins") const {render} = require("pinski/plugins")
const db = require("./utils/db") const db = require("./utils/db")
const {getToken} = require("./utils/getuser") const {getToken, getUser} = require("./utils/getuser")
const pug = require("pug")
class InstanceError extends Error {
constructor(error, identifier) {
super(error)
this.identifier = identifier
}
}
module.exports = [ module.exports = [
{ {
route: "/watch", methods: ["GET"], code: async ({req, url}) => { route: "/watch", methods: ["GET"], code: async ({req, url}) => {
const id = url.searchParams.get("v") const id = url.searchParams.get("v")
const video = await fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json()) const user = getUser(req)
let subscribed = false const settings = user.getSettingsOrDefaults()
const token = getToken(req) const outURL = `${settings.instance}/api/v1/videos/${id}`
if (token) { try {
subscribed = !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([token, video.authorId]) const video = await fetch(outURL).then(res => res.json())
if (!video) throw new Error("The instance returned null.")
if (video.error) throw new InstanceError(video.error, video.identifier)
const subscribed = user.isSubscribed(video.authorId)
return render(200, "pug/video.pug", {video, subscribed})
} catch (e) {
let message = pug.render("pre= error", {error: e.stack || e.toString()})
if (e instanceof fetch.FetchError) {
const template = `
p The selected instance, #[code= instance], did not respond correctly.
p Requested URL: #[a(href=url)= url]
`
message = pug.render(template, {instance: settings.instance, url: outURL})
} else if (e instanceof InstanceError) {
const template = `
p #[strong= error.message]
if error.identifier
p #[code= error.identifier]
p That error was generated by #[code= instance].
`
message = pug.render(template, {instance: settings.instance, error: e})
}
return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message})
} }
return render(200, "pug/video.pug", {video, subscribed})
} }
} }
] ]

View File

@ -0,0 +1,2 @@
const db = require("../api/utils/db")

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 6.6145832 6.6145837"
version="1.1"
id="svg27"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="settings.svg">
<defs
id="defs21">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath54">
<rect
style="opacity:1;fill:#c4c4c4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.0583334;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="rect56"
width="5.7141514"
height="5.8818746"
x="-344.6713"
y="-23.818602" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="30.493294"
inkscape:cy="11.333495"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1440"
inkscape:window-height="877"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="true"
inkscape:snap-center="true"
inkscape:snap-bbox="true"
inkscape:snap-bbox-midpoints="false"
showguides="false">
<inkscape:grid
type="xygrid"
id="grid40"
originx="-46.744516"
originy="-203.04119" />
</sodipodi:namedview>
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(344.08287,33.230171)">
<path
id="path100"
style="opacity:1;fill:#c4c4c4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.0583334;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
d="m -341.44225,-33.230169 c 0,0 -0.0312,0.939474 -0.42432,1.214551 -0.39309,0.275081 -1.33164,-0.320927 -1.33164,-0.320927 l -0.77299,1.338204 c -4e-5,-2.6e-5 -0.003,-0.0017 -0.003,-0.0017 l 0.003,0.0023 c 0.0111,0.0056 0.79687,0.498135 0.83826,0.973489 0.0416,0.477954 -0.94437,0.992482 -0.94437,0.992482 l 0.77131,1.339876 c 0,0 0.82948,-0.442244 1.26426,-0.239355 0.43479,0.202871 0.38726,1.313379 0.38726,1.313379 l 1.54597,0.0023 c 0,0 0.0312,-0.939459 0.42429,-1.21454 0.39309,-0.27508 1.33168,0.320916 1.33168,0.320916 l 0.77468,-1.337642 c 0,0 -0.79776,-0.496643 -0.8394,-0.974608 -0.0416,-0.477957 0.94383,-0.992483 0.94383,-0.992483 l -0.77131,-1.339874 c 0,0 -0.82893,0.442849 -1.26369,0.239971 -0.43478,-0.202878 -0.38784,-1.313998 -0.38784,-1.313998 z m 0.68755,2.250259 c 0.61082,-1.69e-4 1.10602,0.495039 1.10586,1.105858 -1.4e-4,0.610603 -0.49526,1.105472 -1.10586,1.105302 -0.61038,-1.38e-4 -1.10517,-0.494918 -1.1053,-1.105302 -1.7e-4,-0.6106 0.49469,-1.105718 1.1053,-1.105858 z"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -49,6 +49,7 @@ class FormatLoader {
video.currentTime = lastTime video.currentTime = lastTime
if (this.npa) { if (this.npa) {
audio.src = this.npa.url audio.src = this.npa.url
audio.pause()
audio.currentTime = lastTime audio.currentTime = lastTime
} else { } else {
audio.pause() audio.pause()
@ -59,6 +60,30 @@ class FormatLoader {
const formatLoader = new FormatLoader() const formatLoader = new FormatLoader()
class PlayManager {
constructor(media, isAudio) {
this.media = media
this.isAudio = isAudio
}
isActive() {
return !this.isAudio || formatLoader.npa
}
play() {
if (this.isActive()) this.media.play()
}
pause() {
if (this.isActive()) this.media.pause()
}
}
const playManagers = {
video: new PlayManager(video, false),
audio: new PlayManager(audio, true)
}
class QualitySelect extends ElemJS { class QualitySelect extends ElemJS {
constructor() { constructor() {
super(q("#quality-select")) super(q("#quality-select"))
@ -74,12 +99,21 @@ class QualitySelect extends ElemJS {
const qualitySelect = new QualitySelect() const qualitySelect = new QualitySelect()
const ignoreNext = {
play: 0
}
function playbackIntervention(event) { function playbackIntervention(event) {
console.log(event.target.tagName.toLowerCase(), event.type) console.log(event.target.tagName.toLowerCase(), event.type)
if (audio.src) { if (audio.src) {
let target = event.target let target = event.target
let targetName = target.tagName.toLowerCase()
let other = (event.target === video ? audio : video) let other = (event.target === video ? audio : video)
let targetPlayManager = playManagers[target.tagName.toLowerCase()]
let otherPlayManager = playManagers[other.tagName.toLowerCase()]
if (ignoreNext[event.type] > 0) {
ignoreNext[event.type]--
return
}
switch (event.type) { switch (event.type) {
case "durationchange": case "durationchange":
target.ready = false; target.ready = false;
@ -91,7 +125,7 @@ function playbackIntervention(event) {
break; break;
case "play": case "play":
other.currentTime = target.currentTime; other.currentTime = target.currentTime;
other.play(); otherPlayManager.play();
break; break;
case "pause": case "pause":
other.currentTime = target.currentTime; other.currentTime = target.currentTime;
@ -125,13 +159,44 @@ function relativeSeek(seconds) {
video.currentTime += seconds video.currentTime += seconds
} }
function playVideo() {
audio.currentTime = video.currentTime
let lastTime = video.currentTime
video.play().then(() => {
const interval = setInterval(() => {
console.log("checking video", video.currentTime, lastTime)
if (video.currentTime !== lastTime) {
clearInterval(interval)
playManagers.audio.play()
return
}
}, 15)
})
}
function togglePlaying() { function togglePlaying() {
if (video.paused) video.play() if (video.paused) playVideo()
else video.pause() else video.pause()
} }
function toggleFullScreen() {
if (document.fullscreen) document.exitFullscreen()
else video.requestFullscreen()
}
video.addEventListener("click", event => {
event.preventDefault()
togglePlaying()
})
video.addEventListener("dblclick", event => {
event.preventDefault()
toggleFullScreen()
})
document.addEventListener("keydown", event => { document.addEventListener("keydown", event => {
if (["INPUT", "SELECT", "BUTTON"].includes(event.target.tagName)) return if (["INPUT", "SELECT", "BUTTON"].includes(event.target.tagName)) return
if (event.ctrlKey || event.shiftKey) return
let caught = true let caught = true
if (event.key === "j" || event.key === "n") { if (event.key === "j" || event.key === "n") {
relativeSeek(-10) relativeSeek(-10)
@ -148,8 +213,7 @@ document.addEventListener("keydown", event => {
} else if (event.key >= "0" && event.key <= "9") { } else if (event.key >= "0" && event.key <= "9") {
video.currentTime = video.duration * (+event.key) / 10 video.currentTime = video.duration * (+event.key) / 10
} else if (event.key === "f") { } else if (event.key === "f") {
if (document.fullscreen) document.exitFullscreen() toggleFullScreen()
else video.requestFullscreen()
} else { } else {
caught = false caught = false
} }

View File

@ -10,8 +10,10 @@ html
body.show-focus body.show-focus
nav.main-nav nav.main-nav
a(href="/").link.home CloudTube a(href="/").link.home CloudTube
a(href="/subscriptions" title="Subscriptions").link.subscriptions-link a(href="/subscriptions" title="Subscriptions").link.icon-link
img(src=getStaticURL("html", "/static/images/subscriptions.svg") width=30 height=25 alt="Subscriptions.").subscriptions-icon img(src=getStaticURL("html", "/static/images/subscriptions.svg") width=30 height=25 alt="Subscriptions.").icon
a(href="/settings" title="Settings").link.icon-link
img(src=getStaticURL("html", "/static/images/settings.svg") width=25 height=25 alt="Settings.").icon
form(method="get" action="/search").search-form form(method="get" action="/search").search-form
input(type="text" placeholder="Search" name="q" autocomplete="off" value=query).search input(type="text" placeholder="Search" name="q" autocomplete="off" value=query).search

View File

@ -2,7 +2,8 @@ mixin video_list_item(video)
- let link = `/watch?v=${video.videoId}` - let link = `/watch?v=${video.videoId}`
a(href=link tabindex="-1").thumbnail a(href=link tabindex="-1").thumbnail
img(src=`https://i.ytimg.com/vi/${video.videoId}/mqdefault.jpg` width=320 height=180 alt="").image img(src=`https://i.ytimg.com/vi/${video.videoId}/mqdefault.jpg` width=320 height=180 alt="").image
span.duration= video.second__lengthText if video.second__lengthText !== undefined
span.duration= video.second__lengthText
.info .info
div.title: a(href=link).title-link= video.title div.title: a(href=link).title-link= video.title
div.author-line div.author-line

46
pug/settings.pug Normal file
View File

@ -0,0 +1,46 @@
extends includes/layout.pug
mixin fieldset(name)
fieldset
legend= name
.fieldset-contents
block
mixin input(id, description, placeholder, disabled, list)
.field-row
label.description(for=id)= description
input(type="text" id=id name=id value=settings[id] placeholder=placeholder disabled=disabled list=`${id}-list`).border-look
if list
datalist(id=`${id}-list`)
each item in list
option(value=item)
mixin select(id, description, disabled, options)
.field-row
label.description(for=id)= description
select(id=id name=id disabled=disabled).border-look
each option in options
option(value=option.value selected=(option.value === settings[id]))= option.text
block head
title Settings - CloudTube
block content
main.settings-page
form(method="post" action="/settings")
+fieldset("Settings")
+input("instance", "Instance", constants.user_settings.instance.default, false, [
"https://invidious.snopyta.org",
"https://invidious.13ad.de",
"https://watch.nettohikari.com",
"https://invidious.fdn.fr"
])
+select("save_history", "Watch history", false, [
{value: "", text: "Don't save"},
{value: "yes", text: "Save"}
])
.save-settings
button.border-look Save

View File

@ -7,6 +7,21 @@ block head
block content block content
main.subscriptions-page main.subscriptions-page
each video in videos if hasSubscriptions
.subscriptions-video section
+video_list_item(video) details.channels-details
summary #{channels.length} subscriptions
.channels-list
for channel in channels
a(href=`/channel/${channel.ucid}`).channel-item
img(src=channel.icon_url width=512 height=512 alt="").thumbnail
span.name= channel.name
each video in videos
.subscriptions-video
+video_list_item(video)
else
.no-subscriptions
h2 You have no subscriptions.
p Subscribing to a channel makes its videos appear here.
p You can find the subscribe button on channels and videos.

View File

@ -4,58 +4,70 @@ include includes/video-list-item
include includes/subscribe-button include includes/subscribe-button
block head block head
title= `${video.title} - CloudTube` unless error
title= `${video.title} - CloudTube`
else
title Error - CloudTube
script(type="module" src=getStaticURL("html", "/static/js/player.js")) script(type="module" src=getStaticURL("html", "/static/js/player.js"))
script const data = !{JSON.stringify(video)} script const data = !{JSON.stringify(video)}
block content block content
- const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) unless error
- const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => a.second__order - b.second__order) main.video-page
main.video-page - const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height)
.main-video-section - const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => a.second__order - b.second__order)
.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 .main-video-section
#end-cards-container .video-container
.info - const format = sortedFormatStreams[0]
header.info-main video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video
h1.title= video.title source(src=format.url type=format.type)
.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 #current-time-container
#live-event-notice #end-cards-container
#audio-loading-display .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
.video-button-container audio(preload="auto")#audio
+subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look #live-event-notice
//- button.border-look#theatre Theatre #audio-loading-display
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} *`
//-
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(href=`https://www.youtube.com/watch?v=${video.videoId}`).border-look YouTube
a(href=`https://invidio.us/watch?v=${video.videoId}`).border-look Invidious
.description!= video.descriptionHtml .button-container
+subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look
//- 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} *`
//-
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(href=`https://www.youtube.com/watch?v=${video.videoId}`).border-look YouTube
a(href=`https://invidio.us/watch?v=${video.videoId}`).border-look Invidious
aside.related-videos .description!= video.descriptionHtml
h2.related-header Related videos
each r in video.recommendedVideos aside.related-videos
.related-video h2.related-header Related videos
+video_list_item(r) each r in video.recommendedVideos
.related-video
+video_list_item(r)
else
//- error
main.video-error-page
h2 Error
!= message
p: a(href=`https://www.youtube.com/watch?v=${video.videoId}`) Watch on YouTube →

View File

@ -11,6 +11,14 @@ body
a a
color: c.$link color: c.$link
pre, code
font-size: 0.88em
code
background: c.$bg-darker
padding: 3px 5px
border-radius: 4px
input, select, button input, select, button
font-family: inherit font-family: inherit
font-size: 16px font-size: 16px
@ -38,3 +46,19 @@ body.show-focus
video video
background-color: black background-color: black
details
background-color: c.$bg-accent-x
padding: 12px
border-radius: 8px
summary
text-decoration: underline
cursor: pointer
line-height: 1
margin-bottom: 0
user-select: none
color: c.$fg-main
&[open] summary
margin-bottom: 16px

81
sass/includes/forms.sass Normal file
View File

@ -0,0 +1,81 @@
@use "colors.sass" as c
@mixin disabled
background-color: c.$bg-dark
color: #808080
fieldset
border: none
padding: 55px 0px 0px 0px
position: relative
@media screen and (max-width: 400px)
padding-top: 70px
legend
position: absolute
top: 5px
left: 0px
width: 100%
font-size: 28px
font-weight: bold
padding: 0
border-bottom: 1px solid #333
line-height: 1.56
@media screen and (max-width: 400px)
margin-top: 15px
.field-row
line-height: 1
display: flex
align-items: center
justify-content: space-between
position: relative
padding-bottom: 5px
margin-bottom: 5px
border-bottom: 1px solid #bbb
@media screen and (max-width: 400px)
flex-direction: column
align-items: start
padding-bottom: 15px
.description
padding: 8px 8px 8px 0px
//
.checkbox-row
.pill
display: flex
align-items: center
user-select: none
.fake-checkbox
-webkit-appearance: none
background-color: white
width: 16px
height: 16px
padding: 0px
border: 1px solid #666
border-radius: 3px
margin-left: 8px
position: relative
outline: none
.checkbox
display: none
&:checked + .pill .fake-checkbox
background: center center / contain url(/static/img/tick.svg)
&:disabled + .pill
@include disabled
.fake-checkbox
@include disabled
&.checkbox:not(:disabled) + .pill
@include acts-like-button
cursor: pointer

View File

@ -0,0 +1,11 @@
.settings-page
padding: 40px 20px 20px
max-width: 600px
margin: 0 auto
.save-settings
margin-top: 24px
.border-look
font-size: 22px
padding: 7px 16px 8px

View File

@ -1,3 +1,4 @@
@use "colors.sass" as c
@use "video-list-item.sass" as * @use "video-list-item.sass" as *
.subscriptions-page .subscriptions-page
@ -7,3 +8,28 @@
.subscriptions-video .subscriptions-video
@include subscriptions-video @include subscriptions-video
.no-subscriptions
text-align: center
.channels-details
margin-bottom: 24px
.channels-list
display: grid
grid-gap: 8px
.channel-item
display: flex
align-items: center
text-decoration: none
.thumbnail
width: 40px
height: 40px
border-radius: 50%
margin-right: 8px
.name
font-size: 22px
color: c.$fg-main

View File

@ -74,3 +74,8 @@
.related-video .related-video
@include video-list-item @include video-list-item
.video-error-page
padding: 40px 20px 20px
margin: 0 auto
max-width: 600px

View File

@ -6,13 +6,15 @@
@use "includes/home-page.sass" @use "includes/home-page.sass"
@use "includes/channel-page.sass" @use "includes/channel-page.sass"
@use "includes/subscriptions-page.sass" @use "includes/subscriptions-page.sass"
@use "includes/settings-page.sass"
@use "includes/forms.sass"
@font-face @font-face
font-family: "Bariol" font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1) src: url(/static/fonts/bariol.woff?statichash=1)
@mixin button-base @mixin button-base
appearance: none -webkit-appearance: none
-moz-appearance: none -moz-appearance: none
color: c.$fg-bright color: c.$fg-bright
border: none border: none
@ -23,12 +25,15 @@
line-height: 1.25 line-height: 1.25
@at-root #{selector.unify(&, "select")} @at-root #{selector.unify(&, "select")}
padding: 7px 27px 7px 8px padding: 8px 27px 8px 8px
background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x
@at-root #{selector.unify(&, "a")} @at-root #{selector.unify(&, "a")}
padding: 7px 8px padding: 7px 8px
@at-root #{selector.unify(&, "button")}
cursor: pointer
.button-icon .button-icon
position: relative position: relative
top: 3px top: 3px
@ -102,6 +107,9 @@
border: 1px solid c.$edge-grey border: 1px solid c.$edge-grey
margin: 0px margin: 0px
.subscriptions-link:hover, .subscriptions-link:focus .icon-link:hover, .icon-link:focus
.subscriptions-icon .icon
filter: brightness(2) filter: brightness(2)
.button-container
display: flex

View File

@ -26,4 +26,6 @@ const {setInstance} = require("pinski/plugins")
server.addAPIDir("api") server.addAPIDir("api")
server.startServer() server.startServer()
require("./background/feed-update")
})() })()