Updated styling, formatting, structure of frontend

This commit is contained in:
rockerBOO 2025-04-30 20:04:21 -04:00
parent 18ce902aa0
commit 71a30512c9
No known key found for this signature in database
GPG Key ID: 0D4EAF00DCABC97B
60 changed files with 3683 additions and 2451 deletions

View File

@ -53,7 +53,6 @@ get-libs:
invidious: get-libs
crystal build src/invidious.cr $(FLAGS) --progress --stats --error-trace
run: invidious
./invidious
@ -73,6 +72,12 @@ verify:
crystal build src/invidious.cr -Dskip_videojs_download \
--no-codegen --progress --stats --error-trace
dev:
crystal build src/invidious.cr -Dskip_videojs_download \
--no-codegen --progress --stats --error-trace -- --disable-static-cache
dev-reload:
tree -fiA --prune --noreport src | entr -rd make dev
# -----------------------
# (Un)Install
@ -125,4 +130,4 @@ help:
# No targets generates an output named after themselves
.PHONY: all get-libs build amd64 run
.PHONY: format test verify clean distclean help
.PHONY: format test verify dev dev-reload clean distclean help

9
assets/css/animation.css Normal file
View File

@ -0,0 +1,9 @@
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,18 +10,18 @@
}
.watch-on-invidious {
font-size: 1.3em !important;
font-size: 1.3em;
font-weight: bold;
white-space: nowrap;
margin: 0 1em 0 1em !important;
margin: 0 1em;
order: 3;
}
/**/
/* .watch-on-invidious > a { */
/* color: white; */
/* } */
.watch-on-invidious > a {
color: white;
}
.watch-on-invidious > a:hover,
.watch-on-invidious > a:focus {
color: rgba(0, 182, 240, 1);;
}
/* .watch-on-invidious > a:hover, */
/* .watch-on-invidious > a:focus { */
/* color: ; */
/* } */

View File

@ -1,16 +1,32 @@
/* Widget covers the whole page */
#search-widget {
text-align: center;
margin: 20vh 0 50px 0;
position: absolute;
height: 100%;
width: 100%;
display: grid;
justify-content: center;
grid-gap: var(--gap);
top: 0;
left: 0;
pointer-events: none;
}
#logo > h1 {
font-size: 3.5em;
.search-homepage {
display: grid;
grid-template-rows: 1fr 1fr;
}
#search-widget > h1 {
align-self: flex-end;
font-size: 3em;
text-transform: uppercase;
margin: 0;
padding: 0;
text-align: center;
}
@media screen and (max-width: 1500px) and (max-height: 1000px) {
#logo > h1 {
font-size: 10vmin;
}
.searchbar {
/* reset pointer events for interactive components */
pointer-events: initial;
}

View File

@ -1,92 +1,106 @@
.video-js {
font-family: inherit;
font-size: inherit;
}
/* Youtube player style */
.video-js.player-style-youtube .vjs-progress-control {
height: 0;
}
/* .video-js.player-style-youtube .vjs-progress-control { */
/* height: 0; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, */
/* .video-js.player-style-youtube .vjs-progress-control { */
/* position: absolute; */
/* right: 0; */
/* left: 0; */
/* width: 100%; */
/* margin: 0; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-control-bar { */
/* background: linear-gradient(rgb(0 0 0 / 10%), rgba(0 0 0 / 50%)); */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-slider { */
/* background-color: rgb(255 255 255 / 20%); */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-load-progress > div { */
/* background-color: rgb(255 255 255 / 20%); */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-play-progress { */
/* background-color: var(--accent-bg-color); */
/* } */
/**/
/* .video-js.player-style-youtube */
/* .vjs-progress-control:hover */
/* .vjs-progress-holder { */
/* font-size: 1em; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-control-bar > .vjs-spacer { */
/* flex: 1; */
/* order: 2; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-play-progress .vjs-time-tooltip { */
/* display: none; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-play-progress::before { */
/* color: var(--accent-bg-color); */
/* font-size: 0.85em; */
/* display: none; */
/* } */
/**/
/* .video-js.player-style-youtube */
/* .vjs-progress-holder:hover */
/* .vjs-play-progress::before { */
/* display: unset; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-control-bar { */
/* display: flex; */
/* flex-direction: row; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-big-play-button { */
/* /* */
/* Styles copied from video-js.min.css, definition of */
/* .vjs-big-play-centered .vjs-big-play-button */
/* */
*/
/* top: 50%; */
/* left: 50%; */
/* margin-top: -0.8167em; */
/* margin-left: -1.5em; */
/* } */
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {
position: absolute;
right: 0;
left: 0;
width: 100%;
margin: 0;
}
/* .video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu { */
/* margin-bottom: 2em; */
/* padding-top: 2em; */
/* } */
/**/
/* .video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, */
/* .video-js.player-style-youtube .vjs-progress-control { */
/* height: 0.3em; */
/* margin-bottom: 0.6em; */
/* } */
/**/
/* ul.vjs-menu-content::-webkit-scrollbar { */
/* display: none; */
/* } */
/**/
/* .vjs-user-inactive { */
/* cursor: none; */
/* } */
.video-js.player-style-youtube .vjs-control-bar {
background: linear-gradient(rgba(0,0,0,0.1), rgba(0, 0, 0,0.5));
}
.video-js.player-style-youtube .vjs-slider {
background-color: rgba(255,255,255,0.2);
}
.video-js.player-style-youtube .vjs-load-progress > div {
background-color: rgba(255,255,255,0.5);
}
.video-js.player-style-youtube .vjs-play-progress {
background-color: red;
}
.video-js.player-style-youtube .vjs-progress-control:hover .vjs-progress-holder {
font-size: 15px;
}
.video-js.player-style-youtube .vjs-control-bar > .vjs-spacer {
flex: 1;
order: 2;
}
.video-js.player-style-youtube .vjs-play-progress .vjs-time-tooltip {
display: none;
}
.video-js.player-style-youtube .vjs-play-progress::before {
color: red;
font-size: 0.85em;
display: none;
}
.video-js.player-style-youtube .vjs-progress-holder:hover .vjs-play-progress::before {
display: unset;
}
.video-js.player-style-youtube .vjs-control-bar {
display: flex;
flex-direction: row;
}
.video-js.player-style-youtube .vjs-big-play-button {
/*
Styles copied from video-js.min.css, definition of
.vjs-big-play-centered .vjs-big-play-button
*/
top: 50%;
left: 50%;
margin-top: -0.81666em;
margin-left: -1.5em;
}
.video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu {
margin-bottom: 2em;
padding-top: 2em
}
.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px;
margin-bottom: 10px;}
ul.vjs-menu-content::-webkit-scrollbar {
display: none;
}
.vjs-user-inactive {
cursor: none;
}
.video-js .vjs-text-track-display > div > div > div {
background-color: rgba(0, 0, 0, 0.75) !important;
border-radius: 9px !important;
padding: 5px !important;
}
/* .video-js .vjs-text-track-display > div > div > div { */
/* background-color: rgb(0 0 0 / 75%); */
/* border-radius: 0.5em; */
/* padding: 0.3em; */
/* } */
.vjs-play-control,
.vjs-volume-panel,
@ -123,143 +137,252 @@ ul.vjs-menu-content::-webkit-scrollbar {
order: 7;
}
.vjs-playback-rate > .vjs-menu {
width: 50px;
/* .vjs-playback-rate > .vjs-menu { */
/* width: 3.2em; */
/* } */
/**/
/* Make the video relative, instead of absolute, so that
the parent container will size based on the video. Also,
note the max-height rule. Note the line-height 0 is to prevent
a small artifact on the bottom of the video.
https://stackoverflow.com/questions/46747320/limit-the-height-in-videojs-in-fluid-mode/47039499#47039499
*/
.video-js.vjs-fluid,
.video-js.vjs-16-9,
.video-js.vjs-4-3,
video.video-js,
video.vjs-tech {
max-height: 85vh;
position: relative !important;
width: 100%;
height: auto;
max-width: 100% !important;
padding-top: 0 !important;
line-height: 0;
}
.vjs-control-bar {
.video-js.vjs-16-9 {
/* Keep the video spaced to fit */
aspect-ratio: 16 / 9;
}
.video-js.vjs-4-3 {
/* Keep the video spaced to fit */
aspect-ratio: 4 / 3;
}
#player {
/* Default to 16/9 video spacing */
aspect-ratio: 16 / 9;
max-width: 100%;
}
.vjs-error .vjs-error-display:before,
.video-js .vjs-volume-tooltip,
.video-js .vjs-time-tooltip,
.vjs-menu .vjs-menu-content {
font-family: inherit;
}
.vjs-menu-button-popup .vjs-menu .vjs-menu-content {
background-color: var(--secondary-bg-color-dark);
}
.vjs-menu li.vjs-menu-item:focus,
.vjs-menu li.vjs-menu-item:hover,
.js-focus-visible .vjs-menu li.vjs-menu-item:hover {
background-color: var(--accent-bg-color-dark);
}
.video-js .vjs-slider {
background-color: rgb(166 166 166 / 50%);
}
.video-js .vjs-load-progress {
background-color: rgb(227 227 227 / 75%);
}
@media (max-width: 480px) {
#player {
/* Default to 16/9 video spacing */
aspect-ratio: unset;
}
}
.vjs-fullscreen video {
max-height: 100vh !important;
}
.video-js .vjs-control-bar {
display: flex;
flex-direction: row;
scrollbar-width: none;
/* Fix the control bar due to us resetting the line-height on the video-js */
line-height: 1;
background: linear-gradient(
to bottom,
transparent 0%,
var(--secondary-bg-color-dark) 50%
);
}
.vjs-control-bar button:hover,
.vjs-control-bar button:focus {
outline-width: 0;
}
.vjs-control-bar::-webkit-scrollbar {
display: none;
}
.video-js .vjs-icon-cog {
font-size: 18px;
.vjs-playback-rate .vjs-playback-rate-value {
font-size: 1em;
line-height: 3rem;
}
.video-js .vjs-control-bar,
.vjs-menu-button-popup .vjs-menu .vjs-menu-content {
background-color: rgba(35, 35, 35, 0.75);
.vjs-button > .vjs-icon-placeholder::before {
font-size: 1.25em;
line-height: 3rem;
}
.vjs-menu li.vjs-menu-item:focus,
.vjs-menu li.vjs-menu-item:hover {
background-color: rgba(255, 255, 255, 0.75);
color: rgba(49, 49, 51, 0.75);
.vjs-menu li {
font-size: 1em;
}
.vjs-menu li.vjs-selected,
.vjs-menu li.vjs-selected:focus,
.vjs-menu li.vjs-selected:hover {
background-color: rgba(0, 182, 240, 0.75);
.video-js .vjs-control {
width: 3em;
}
/* Progress Bar */
.video-js .vjs-slider {
background-color: rgba(15, 15, 15, 0.5);
.video-js .vjs-time-control {
width: auto;
}
.video-js .vjs-load-progress,
.video-js .vjs-load-progress div {
background: rgba(87, 87, 88, 1);
.vjs-poster {
background-size: cover;
}
.video-js .vjs-share__short-link-wrapper {
color: var(--fg-color-dark);
background-color: var(--secondary-bg-color-dark);
height: 2em;
margin: 0 auto;
margin-bottom: var(--secondary-gap);
border-radius: var(--radius);
}
.video-js .vjs-share__short-link {
padding: var(--secondary-gap);
background-color: var(--secondary-bg-color-dark);
color: var(--fg-color-dark);
font-family: var(--monospace);
min-width: 15em;
}
.video-js .vjs-share__btn {
background-color: var(--secondary-bg-color-dark);
color: var(--fg-color-dark);
height: 2em;
width: 2.25em;
padding: var(--secondary-gap);
}
.video-js .vjs-share__title {
font-size: 1.25em;
color: var(--fg-color-dark);
}
.video-js .vjs-share__subtitle {
font-size: 1em;
color: var(--fg-color-dark);
margin: 0 auto var(--secondary-gap);
}
.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button {
height: var(--gap);
}
.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button::before {
content: "";
}
.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder::before {
line-height: 1.67;
}
.video-js .vjs-share__middle {
padding: 0 var(--secondary-gap);
}
.vjs-modal-dialog .vjs-modal-dialog-content {
font-size: 1em;
line-height: 1.1;
}
/**/
/* .video-js .vjs-icon-cog { */
/* font-size: 1em; */
/* } */
/**/
/* .video-js .vjs-control-bar, */
/* .vjs-menu-button-popup .vjs-menu .vjs-menu-content { */
/* background-color: rgb(35 35 35 / 75%); */
/* } */
/**/
/* .vjs-menu li.vjs-menu-item:focus, */
/* .vjs-menu li.vjs-menu-item:hover { */
/* background-color: rgb(255 255 255 / 75%); */
/* color: rgb(49 49 51 / 75%); */
/* } */
/**/
/* .vjs-menu li.vjs-selected, */
/* .vjs-menu li.vjs-selected:focus, */
/* .vjs-menu li.vjs-selected:hover { */
/* background-color: rgb(0 182 240 / 75%); */
/* } */
/**/
/* /* Progress Bar */
*/
/* .video-js .vjs-slider { */
/* background-color: rgb(15 15 15 / 50%); */
/* } */
/**/
/* .video-js .vjs-load-progress, */
/* .video-js .vjs-load-progress div { */
/* background: rgb(87 87 88); */
/* } */
/**/
.video-js .vjs-slider:hover,
.video-js button:hover {
color: rgba(0, 182, 240, 1);
background-color: var(--accent-bg-color);
}
.video-js.player-style-invidious .vjs-play-progress {
background-color: rgba(0, 182, 240, 1);
background-color: var(--accent-bg-color);
}
/* Overlay */
.video-js .vjs-overlay {
background-color: rgba(35, 35, 35, 0.75) !important;
}
.video-js .vjs-overlay * {
color: rgba(255, 255, 255, 1) !important;
text-align: center;
.vjs-modal-dialog .vjs-modal-dialog-content {
padding: var(--gap);
}
/* ProgressBar marker */
.vjs-marker {
background-color: rgba(255, 255, 255, 1);
z-index: 0;
}
/* Big "Play" Button */
.video-js .vjs-big-play-button {
background-color: rgba(35, 35, 35, 0.5);
font-size: var(--gap);
line-height: var(--gap);
height: var(--gap);
width: calc(var(--gap) * 2);
top: var(--secondary-gap);
left: var(--secondary-gap);
background-color: var(--secondary-bg-color);
border: none;
opacity: 0.7;
transition: opacity 240ms;
}
.video-js:hover .vjs-big-play-button {
background-color: rgba(35, 35, 35, 0.75);
.video-js .vjs-big-play-button:hover {
opacity: 0.9;
}
.video-js .vjs-current-time,
.video-js .vjs-time-divider,
.video-js .vjs-duration {
display: block;
}
.video-js .vjs-time-divider {
min-width: 0px;
padding-left: 0px;
padding-right: 0px;
}
.video-js .vjs-poster {
background-size: cover;
object-fit: cover;
}
.player-dimensions.vjs-fluid {
padding-top: 82vh;
}
video.video-js {
position: absolute;
height: 100%;
}
#player-container {
position: relative;
padding-left: 0;
padding-right: 0;
margin-left: 1em;
margin-right: 1em;
padding-bottom: 82vh;
height: 0;
}
.mobile-operations-bar {
display: flex;
position: absolute;
top: 0;
right: 1px !important;
left: initial !important;
width: initial !important;
}
.mobile-operations-bar ul {
position: absolute !important;
bottom: unset !important;
top: 1.5em;
}
@media screen and (max-width: 700px) {
.video-js .vjs-share {
justify-content: unset;
}
}
@media screen and (max-width: 650px) {
.vjs-modal-dialog-content {
overflow-x: hidden;
}
.vjs-control-bar {
background-color: var(--secondary-bg-color-dark);
}

73
assets/css/pure-fix.css Normal file
View File

@ -0,0 +1,73 @@
/** fixes for pure to support our colors */
.pure-form input[type="color"],
.pure-form input[type="date"],
.pure-form input[type="datetime-local"],
.pure-form input[type="datetime"],
.pure-form input[type="email"],
.pure-form input[type="month"],
.pure-form input[type="number"],
.pure-form input[type="password"],
.pure-form input[type="search"],
.pure-form input[type="tel"],
.pure-form input[type="text"],
.pure-form input[type="time"],
.pure-form input[type="url"],
.pure-form input[type="week"],
.pure-form select,
.pure-form textarea {
font-size: inherit;
padding: var(--secondary-gap);
color: var(--fg-color);
background-color: var(--secondary-bg-color);
border: 1px solid var(--secondary-bg-color);
box-shadow: unset;
border-radius: var(--radius);
}
.pure-menu-heading,
.pure-g,
.pure-g [class*="pure-u"] {
font-family: inherit;
letter-spacing: initial;
}
.pure-form legend {
color: var(--fg-color);
border-bottom: 1px solid var(--accent-color);
}
legend {
border: initial;
padding: initial;
}
.pure-button {
font-family: inherit;
font-size: inherit;
padding: 0;
color: currentcolor;
border: none;
border: none transparent;
background-color: transparent;
text-decoration: none;
border-radius: inherit;
}
.pure-button-hover,
.pure-button:focus,
.pure-button:hover {
background-image: none;
}
/* Wider settings name to less word wrap */
.pure-form-aligned .pure-control-group label {
width: 19em;
}
.pure-menu-heading {
color: var(--accent-color);
}
a:active, a:hover {
outline: inherit;
}

View File

@ -1,121 +1,111 @@
summary {
/* This should hide the marker */
display: block;
font-size: 1.17em;
font-weight: bold;
margin: 0 auto 10px auto;
margin: 0 auto 0.625em;
cursor: pointer;
}
summary::-webkit-details-marker,
summary::marker { display: none; }
summary::marker {
display: none;
}
summary:before {
border-radius: 5px;
content: "[ + ]";
margin: -2px 10px 0 10px;
padding: 1px 0 3px 0;
summary::before {
content: "+ ";
text-align: center;
width: 40px;
}
details[open] > summary:before { content: "[ ]"; }
#filters-box {
padding: 10px 20px 20px 10px;
margin: 10px 15px;
details[open] > summary::before {
content: " ";
}
#filters-flex {
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12em, 1fr));
grid-gap: var(--secondary-gap);
margin: var(--gap) 0;
}
.filters fieldset {
display: grid;
grid-gap: var(--secondary-gap);
align-content: baseline;
}
.filters fieldset div {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: flex-start;
align-content: flex-start;
justify-content: flex-start;
gap: var(--secondary-gap);
}
fieldset, legend {
display: contents !important;
border: none !important;
margin: 0 !important;
padding: 0 !important;
}
.filter-column {
display: inline-block;
display: inline-flex;
width: max-content;
min-width: max-content;
max-width: 16em;
margin: 15px;
flex-grow: 2;
flex-basis: auto;
flex-direction: column;
}
.filter-name, .filter-options {
display: block;
padding: 5px 10px;
margin: 0;
text-align: start;
}
.filter-options div { margin: 6px 0; }
.filter-options div * { vertical-align: middle; }
.filter-options label { margin: 0 10px; }
#filters-apply {
text-align: right; /* IE11 only */
text-align: end; /* Override for compatible browsers */
}
/* #filters-box { */
/* background-color: var(--secondary-bg-color); */
/* } */
/**/
/* #filters-flex { */
/* display: flex; */
/* flex-flow: row wrap; */
/* align-items: flex-start; */
/* place-content: flex-start flex-start; */
/* } */
/**/
/* .filter-column { */
/* display: inline-block; */
/* display: inline-flex; */
/* width: max-content; */
/* min-width: max-content; */
/* max-width: 16em; */
/* margin: 0.9375rem; */
/* flex-grow: 2; */
/* flex-basis: auto; */
/* flex-direction: column; */
/* } */
/**/
/* .filter-name, */
/* .filter-options { */
/* display: block; */
/* padding: 0.3125rem 0.625rem; */
/* margin: 0; */
/* text-align: start; */
/* } */
/**/
/* .filter-options div { */
/* margin: 0.375rem 0; */
/* } */
/**/
/* .filter-options div * { */
/* vertical-align: middle; */
/* } */
/**/
/* .filter-options label { */
/* margin: 0 0.625rem; */
/* } */
/**/
/* #filters-apply { */
/* text-align: right; /* IE11 only */ */
/* text-align: end; /* Override for compatible browsers */ */
/* } */
/* Error message */
.no-results-error {
text-align: center;
line-height: 180%;
font-size: 110%;
padding: 15px 15px 125px 15px;
font-size: 1.1em;
padding: 1em 1em 8em;
}
/* Responsive rules */
@media only screen and (max-width: 800px) {
summary { font-size: 1.30em; }
#filters-box {
margin: 10px 0 0 0;
padding: 0;
}
#filters-apply {
text-align: center;
padding: 15px;
}
}
/* Light theme */
.light-theme #filters-box {
background: #dfdfdf;
}
@media (prefers-color-scheme: light) {
.no-theme #filters-box {
background: #dfdfdf;
}
}
/* Dark theme */
.dark-theme #filters-box {
background: #373737;
}
@media (prefers-color-scheme: dark) {
.no-theme #filters-box {
background: #373737;
}
}
/* @media only screen and (max-width: 50px) { */
/* summary { */
/* font-size: 1.3em; */
/* } */
/**/
/* #filters-box { */
/* margin: 0.6em 0 0; */
/* padding: 0; */
/* } */
/**/
/* #filters-apply { */
/* text-align: center; */
/* padding: 1em; */
/* } */
/* } */
/**/

View File

@ -0,0 +1,16 @@
:root {
--fg-color-dark: #f8f8f2;
--bg-color-dark: #eff1f5;
--accent-color-dark: #fff;
--accent-bg-color-dark: #181926;
--secondary-color-dark: #e3e3e3;
--secondary-bg-color-dark: #494d64;
/* light theme colors */
--fg-color-light: #4c4f69;
--bg-color-light: #eff1f5;
--accent-color-light: #bcc0cc;
--accent-bg-color-light: #7287fd;
--secondary-color-light: #5c5f77;
--secondary-bg-color-light: #e6e9ef;
}

View File

@ -0,0 +1,16 @@
:root {
--fg-color-dark: #f8f8f2;
--bg-color-dark: #24273a;
--accent-color-dark: #fff;
--accent-bg-color-dark: #181926;
--secondary-color-dark: #e3e3e3;
--secondary-bg-color-dark: #494d64;
/* light theme colors */
--fg-color-light: black;
--bg-color-light: #eee;
--accent-color-light: #3a3a3a;
--accent-bg-color-light: #008bec;
--secondary-color-light: #424242;
--secondary-bg-color-light: #d9d9d9;
}

View File

@ -0,0 +1,16 @@
:root {
--fg-color-dark: #f8f8f2;
--bg-color-dark: #282a36;
--accent-color-dark: #fff;
--accent-bg-color-dark: #44475a;
--secondary-color-dark: #e3e3e3;
--secondary-bg-color-dark: #21222C;
/* light theme colors */
--fg-color-light: black;
--bg-color-light: #eee;
--accent-color-light: #3a3a3a;
--accent-bg-color-light: #008bec;
--secondary-color-light: #424242;
--secondary-bg-color-light: #d9d9d9;
}

View File

@ -0,0 +1,16 @@
:root {
--fg-color-dark: #f8f8f2;
--bg-color-dark: #1a1b26;
--accent-color-dark: #fff;
--accent-bg-color-dark: #3e4f80;
--secondary-color-dark: #e3e3e3;
--secondary-bg-color-dark: #2e2e3e;
/* light theme colors */
--fg-color-light: black;
--bg-color-light: #eee;
--accent-color-light: #3a3a3a;
--accent-bg-color-light: #008bec;
--secondary-color-light: #424242;
--secondary-bg-color-light: #d9d9d9;
}

59
assets/css/theme.css Normal file
View File

@ -0,0 +1,59 @@
:root {
--fg-color-dark: #f0f0f0;
--bg-color-dark: #131313;
--accent-color-dark: #27a6ff;
--accent-bg-color-dark: #004a7e;
--secondary-color-dark: #e3e3e3;
--secondary-bg-color-dark: #313131;
--watched-overlay-color-dark: rgb(0 0 0 / 40%);
/* light theme colors */
--fg-color-light: black;
--bg-color-light: #eee;
--accent-color-light: #044c99;
--accent-bg-color-light: #3eaefd;
--secondary-color-light: #404040;
--secondary-bg-color-light: #dbdbdb;
--watched-overlay-color-light: rgb(255 255 255 / 40%);
/** apply default colors to dark */
--fg-color: var(--fg-color-dark);
--bg-color: var(--bg-color-dark);
--accent-color: var(--accent-color-dark);
--accent-bg-color: var(--accent-bg-color-dark);
--secondary-color: var(--secondary-color-dark);
--secondary-bg-color: var(--secondary-bg-color-dark);
--watched-overlay-color: var(--watched-overlay-color-dark);
}
@media (prefers-color-scheme: light) {
:root {
--fg-color: var(--fg-color-light);
--bg-color: var(--bg-color-light);
--accent-color: var(--accent-color-light);
--accent-bg-color: var(--accent-bg-color-light);
--secondary-color: var(--secondary-color-light);
--secondary-bg-color: var(--secondary-bg-color-light);
--watched-overlay-color: var(--watched-overlay-color-dark);
}
}
.light-theme {
--fg-color: var(--fg-color-light);
--bg-color: var(--bg-color-light);
--accent-color: var(--accent-color-light);
--accent-bg-color: var(--accent-bg-color-light);
--secondary-color: var(--secondary-color-light);
--secondary-bg-color: var(--secondary-bg-color-light);
--watched-overlay-color: var(--watched-overlay-color-light);
}
.dark-theme {
--fg-color: var(--fg-color-dark);
--bg-color: var(--bg-color-dark);
--accent-color: var(--accent-color-dark);
--accent-bg-color: var(--accent-bg-color-dark);
--secondary-color: var(--secondary-color-dark);
--secondary-bg-color: var(--secondary-bg-color-dark);
--watched-overlay-color: var(--watched-overlay-color-dark);
}

View File

@ -1,6 +1,6 @@
var video_data = JSON.parse(document.getElementById('video_data').textContent);
var spinnerHTML = '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
var spinnerHTML = '<div class="loading"><i class="icon ion-ios-refresh"></i></div>';
var spinnerHTMLwithHR = spinnerHTML + '<hr>';
String.prototype.supplant = function (o) {
@ -11,14 +11,14 @@ String.prototype.supplant = function (o) {
};
function toggle_comments(event) {
var target = event.target;
var body = target.parentNode.parentNode.parentNode.children[1];
if (body.style.display === 'none') {
target.textContent = '[ ]';
body.style.display = '';
const target = event.target;
const comments = document.querySelector(".comments");
if (comments.style.display === 'none') {
target.textContent = '';
comments.style.display = '';
} else {
target.textContent = '[ + ]';
body.style.display = 'none';
target.textContent = '+';
comments.style.display = 'none';
}
}
@ -39,6 +39,7 @@ function hide_youtube_replies(event) {
function show_youtube_replies(event) {
var target = event.target;
console.log(target);
var sub_text = target.getAttribute('data-inner-text');
var inner_text = target.getAttribute('data-sub-text');
@ -75,23 +76,24 @@ function get_youtube_comments() {
helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, {
on200: function (response) {
var commentInnerHtml = ' \
<div> \
<h3> \
<a href="javascript:void(0)">[ ]</a> \
<nav class="comments-header"> \
<ul> \
<li> \
<button class="secondary" id="toggle-comments"></button> \
{commentsText} \
</h3> \
<b> \
'
</li> \
\
<li>'
if (video_data.support_reddit) {
commentInnerHtml += ' <a href="javascript:void(0)" data-comments="reddit"> \
commentInnerHtml += ' <button data-comments="reddit"> \
{redditComments} \
</a> \
</button> \
'
}
commentInnerHtml += ' </b> \
</div> \
<div>{contentHtml}</div> \
<hr>'
commentInnerHtml += ' </li> \
</ul> \
</nav> \
<div class="comments">{contentHtml}</div>'
commentInnerHtml = commentInnerHtml.supplant({
contentHtml: response.contentHtml,
redditComments: video_data.reddit_comments_text,
@ -104,9 +106,9 @@ function get_youtube_comments() {
})
});
comments.innerHTML = commentInnerHtml;
comments.children[0].children[0].children[0].onclick = toggle_comments;
document.getElementById("toggle-comments").onclick = toggle_comments;
if (video_data.support_reddit) {
comments.children[0].children[1].children[0].onclick = swap_comments;
comments.children[1].children[1].onclick = swap_comments;
}
},
onNon200: onNon200, // declared above
@ -122,7 +124,7 @@ function get_youtube_comments() {
function get_youtube_replies(target, load_more, load_replies) {
var continuation = target.getAttribute('data-continuation');
var body = target.parentNode.parentNode;
var body = target.parentNode;
var fallback = body.innerHTML;
body.innerHTML = spinnerHTML;
var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id
@ -140,26 +142,24 @@ function get_youtube_replies(target, load_more, load_replies) {
helpers.xhr('GET', url, {}, {
on200: function (response) {
if (load_more) {
body = body.parentNode.parentNode;
body = body.parentNode;
body.removeChild(body.lastElementChild);
body.insertAdjacentHTML('beforeend', response.contentHtml);
} else {
body.removeChild(body.lastElementChild);
var p = document.createElement('p');
var a = document.createElement('a');
p.appendChild(a);
var div = document.createElement('div');
var button = document.createElement('button');
div.appendChild(button);
a.href = 'javascript:void(0)';
a.onclick = hide_youtube_replies;
a.setAttribute('data-sub-text', video_data.hide_replies_text);
a.setAttribute('data-inner-text', video_data.show_replies_text);
a.textContent = video_data.hide_replies_text;
button.onclick = hide_youtube_replies;
button.setAttribute('data-sub-text', video_data.hide_replies_text);
button.setAttribute('data-inner-text', video_data.show_replies_text);
button.textContent = video_data.hide_replies_text;
var div = document.createElement('div');
div.innerHTML = response.contentHtml;
body.appendChild(p);
body.appendChild(div);
}
},

View File

@ -58,13 +58,13 @@
el.onclick = function () { mark_unwatched(el); };
});
document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) {
el.onclick = function () { add_playlist_video(el); };
el.onclick = function (e) { add_playlist_video(e); };
});
document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) {
el.onclick = function () { add_playlist_item(el); };
el.onclick = function (e) { add_playlist_item(e); };
});
document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) {
el.onclick = function () { remove_playlist_item(el); };
el.onclick = function (e) { remove_playlist_item(e); };
});
document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) {
el.onclick = function () { revoke_token(el); };

View File

@ -1,31 +1,51 @@
'use strict';
var notification_data = JSON.parse(document.getElementById('notification_data').textContent);
"use strict";
var notification_data = JSON.parse(
document.getElementById("notification_data").textContent,
);
/** Boolean meaning 'some tab have stream' */
const STORAGE_KEY_STREAM = 'stream';
const STORAGE_KEY_STREAM = "stream";
/** Number of notifications. May be increased or reset */
const STORAGE_KEY_NOTIF_COUNT = 'notification_count';
const STORAGE_KEY_NOTIF_COUNT = "notification_count";
var notifications, delivered;
var notifications_mock = { close: function () { } };
var notifications_mock = { close: function () {} };
function get_subscriptions() {
helpers.xhr('GET', '/api/v1/auth/subscriptions', {
async function get_subscriptions_call() {
return new Promise((resolve) => {
helpers.xhr(
"GET",
"/api/v1/auth/subscriptions",
{
retries: 5,
entity_name: 'subscriptions'
}, {
on200: create_notification_stream
entity_name: "subscriptions",
},
{
on200: function (subscriptions) {
create_notification_stream(subscriptions);
resolve(subscriptions);
},
},
);
});
}
// Start the retry mechanism
const get_subscriptions = exponential_backoff(get_subscriptions_call, 100, 1000);
function create_notification_stream(subscriptions) {
// sse.js can't be replaced to EventSource in place as it lack support of payload and headers
// see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource
notifications = new SSE(
'/api/v1/auth/notifications', {
notifications = new SSE("/api/v1/auth/notifications", {
withCredentials: true,
payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId; }).join(','),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
payload:
"topics=" +
subscriptions
.map(function (subscription) {
return subscription.authorId;
})
.join(","),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
delivered = [];
@ -35,10 +55,14 @@ function create_notification_stream(subscriptions) {
if (!event.id) return;
var notification = JSON.parse(event.data);
console.info('Got notification:', notification);
console.info("Got notification:", notification);
// Ignore not actual and delivered notifications
if (start_time > notification.published || delivered.includes(notification.videoId)) return;
if (
start_time > notification.published ||
delivered.includes(notification.videoId)
)
return;
delivered.push(notification.videoId);
@ -49,38 +73,44 @@ function create_notification_stream(subscriptions) {
update_ticker_count();
// permission for notifications handled on settings page. JS handler is in handlers.js
if (window.Notification && Notification.permission === 'granted') {
var notification_text = notification.liveNow ? notification_data.live_now_text : notification_data.upload_text;
notification_text = notification_text.replace('`x`', notification.author);
if (window.Notification && Notification.permission === "granted") {
var notification_text = notification.liveNow
? notification_data.live_now_text
: notification_data.upload_text;
notification_text = notification_text.replace("`x`", notification.author);
var system_notification = new Notification(notification_text, {
body: notification.title,
icon: '/ggpht' + new URL(notification.authorThumbnails[2].url).pathname,
img: '/ggpht' + new URL(notification.authorThumbnails[4].url).pathname
icon: "/ggpht" + new URL(notification.authorThumbnails[2].url).pathname,
img: "/ggpht" + new URL(notification.authorThumbnails[4].url).pathname,
});
system_notification.onclick = function (e) {
open('/watch?v=' + notification.videoId, '_blank');
open("/watch?v=" + notification.videoId, "_blank");
};
}
};
notifications.addEventListener('error', function (e) {
console.warn('Something went wrong with notifications, trying to reconnect...');
notifications.addEventListener("error", function (e) {
console.warn(
"Something went wrong with notifications, trying to reconnect...",
);
notifications = notifications_mock;
setTimeout(get_subscriptions, 1000);
});
notifications.stream();
}
function update_ticker_count() {
var notification_ticker = document.getElementById('notification_ticker');
var notification_ticker = document.getElementById("notification_ticker");
const notification_count = helpers.storage.get(STORAGE_KEY_STREAM);
if (notification_count > 0) {
notification_ticker.innerHTML =
'<span id="notification_count">' + notification_count + '</span> <i class="icon ion-ios-notifications"></i>';
'<span id="notification_count">' +
notification_count +
'</span> <i class="icon ion-ios-notifications"></i>';
} else {
notification_ticker.innerHTML =
'<i class="icon ion-ios-notifications-outline"></i>';
@ -89,23 +119,27 @@ function update_ticker_count() {
function start_stream_if_needed() {
// random wait for other tabs set 'stream' flag
setTimeout(function () {
setTimeout(
function () {
if (!helpers.storage.get(STORAGE_KEY_STREAM)) {
// if no one set 'stream', set it by yourself and start stream
helpers.storage.set(STORAGE_KEY_STREAM, true);
notifications = notifications_mock;
get_subscriptions();
}
}, Math.random() * 1000 + 50); // [0.050 .. 1.050) second
},
Math.random() * 1000 + 50,
); // [0.050 .. 1.050) second
}
addEventListener('storage', function (e) {
if (e.key === STORAGE_KEY_NOTIF_COUNT)
update_ticker_count();
addEventListener("storage", function (e) {
if (e.key === STORAGE_KEY_NOTIF_COUNT) update_ticker_count();
// if 'stream' key was removed
if (e.key === STORAGE_KEY_STREAM && !helpers.storage.get(STORAGE_KEY_STREAM)) {
if (
e.key === STORAGE_KEY_STREAM &&
!helpers.storage.get(STORAGE_KEY_STREAM)
) {
if (notifications) {
// restore it if we have active stream
helpers.storage.set(STORAGE_KEY_STREAM, true);
@ -115,9 +149,11 @@ addEventListener('storage', function (e) {
}
});
addEventListener('load', function () {
var notification_count_el = document.getElementById('notification_count');
var notification_count = notification_count_el ? parseInt(notification_count_el.textContent) : 0;
addEventListener("load", function () {
var notification_count_el = document.getElementById("notification_count");
var notification_count = notification_count_el
? parseInt(notification_count_el.textContent)
: 0;
helpers.storage.set(STORAGE_KEY_NOTIF_COUNT, notification_count);
if (helpers.storage.get(STORAGE_KEY_STREAM))
@ -125,7 +161,37 @@ addEventListener('load', function () {
start_stream_if_needed();
});
addEventListener('unload', function () {
addEventListener("unload", function () {
// let chance to other tabs to be a streamer via firing 'storage' event
if (notifications) helpers.storage.remove(STORAGE_KEY_STREAM);
});
function exponential_backoff(
fn,
maxRetries = 5,
initialDelay = 1000,
randomnessFactor = 0.5,
) {
let attempt = 0;
return function tryFunction() {
fn()
.then((response) => {
attempt = 0;
})
.catch((error) => {
if (attempt < maxRetries) {
attempt++;
let delay = initialDelay * Math.pow(2, attempt); // Exponential backoff
let randomMultiplier = 1 + Math.random() * randomnessFactor;
delay = delay * randomMultiplier;
console.log(
`Attempt ${attempt} failed. Retrying in ${(delay / 1000).toPrecision(2)} seconds...`,
);
setTimeout(tryFunction, delay); // Retry after delay
} else {
console.log("Max retries reached. Operation failed:", error);
}
});
}
}

View File

@ -2,8 +2,9 @@
var playlist_data = JSON.parse(document.getElementById('playlist_data').textContent);
var payload = 'csrf_token=' + playlist_data.csrf_token;
function add_playlist_video(target) {
var select = target.parentNode.children[0].children[1];
function add_playlist_video(event) {
const target = event.target;
var select = document.querySelector("#playlists");
var option = select.children[select.selectedIndex];
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
@ -12,37 +13,43 @@ function add_playlist_video(target) {
helpers.xhr('POST', url, {payload: payload}, {
on200: function (response) {
option.textContent = '✓' + option.textContent;
option.textContent = '✓ ' + option.textContent;
}
});
}
function add_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
function add_playlist_item(event) {
event.preventDefault();
const target = event.target;
const video_id = target.getAttribute('data-id');
var card = document.querySelector(`#video-card-${video_id}`);
card.classList.add("hide");
var url = '/playlist_ajax?action_add_video=1&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&video_id=' + video_id +
'&playlist_id=' + target.getAttribute('data-plid');
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
tile.style.display = '';
card.classList.remove("hide");
}
});
}
function remove_playlist_item(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
function remove_playlist_item(event) {
event.preventDefault();
const target = event.target;
const video_index = target.getAttribute('data-index');
const card = document.querySelector(`.video-card [data-index="${video_index}"]`)
card.classList.add("hide");
var url = '/playlist_ajax?action_remove_video=1&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') +
'&set_video_id=' + video_index +
'&playlist_id=' + target.getAttribute('data-plid');
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
tile.style.display = '';
card.classList.remove("hide");
}
});
}

View File

@ -3,7 +3,6 @@ var subscribe_data = JSON.parse(document.getElementById('subscribe_data').textCo
var payload = 'csrf_token=' + subscribe_data.csrf_token;
var subscribe_button = document.getElementById('subscribe');
subscribe_button.parentNode.action = 'javascript:void(0)';
if (subscribe_button.getAttribute('data-type') === 'subscribe') {
subscribe_button.onclick = subscribe;
@ -11,10 +10,29 @@ if (subscribe_button.getAttribute('data-type') === 'subscribe') {
subscribe_button.onclick = unsubscribe;
}
function subscribe() {
var fallback = subscribe_button.innerHTML;
function toggleSubscribeButton() {
subscribe_button.classList.remove("primary");
subscribe_button.classList.remove("secondary");
subscribe_button.classList.remove("unsubscribe");
subscribe_button.classList.remove("subscribe");
if (subscribe_button.getAttribute('data-type') === 'subscribe') {
subscribe_button.textContent = subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text;
subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
subscribe_button.classList.add("secondary");
subscribe_button.classList.add("unsubscribe");
} else {
subscribe_button.textContent = subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text;
subscribe_button.onclick = subscribe;
subscribe_button.classList.add("primary");
subscribe_button.classList.add("subscribe");
}
}
function subscribe(e) {
e.preventDefault();
var fallback = subscribe_button.textContent;
toggleSubscribeButton();
var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' +
'&c=' + subscribe_data.ucid;
@ -22,15 +40,15 @@ function subscribe() {
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
onNon200: function (xhr) {
subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = fallback;
subscribe_button.textContent = fallback;
}
});
}
function unsubscribe() {
var fallback = subscribe_button.innerHTML;
subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
function unsubscribe(e) {
e.preventDefault();
var fallback = subscribe_button.textContent;
toggleSubscribeButton();
var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' +
'&c=' + subscribe_data.ucid;
@ -38,7 +56,7 @@ function unsubscribe() {
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, {
onNon200: function (xhr) {
subscribe_button.onclick = unsubscribe;
subscribe_button.innerHTML = fallback;
subscribe_button.textContent = fallback;
}
});
}

44
assets/js/theme.js Normal file
View File

@ -0,0 +1,44 @@
const themeSelector = document.querySelector("#theme-selector");
themeSelector.addEventListener("change", (event) => {
const select = event.target;
const selected = select.options[select.selectedIndex].text;
applyTheme(selected);
});
const colorSchemeSelector = document.querySelector("#color-scheme");
colorSchemeSelector.addEventListener("change", (event) => {
const select = event.target;
const selected = select.options[select.selectedIndex].text;
applyColorScheme(selected);
});
function applyTheme(theme) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `/css/theme-${theme}.css`;
link.id = "theme-css";
const themeCss = document.querySelector("#theme-css");
if (themeCss) {
themeCss.parentNode.removeChild(themeCss);
}
const head = document.getElementsByTagName("head")[0];
head.appendChild(link);
}
function applyColorScheme(colorScheme) {
document.body.classList.remove("dark-theme");
document.body.classList.remove("light-theme");
if (colorScheme === "dark" || colorScheme === "light") {
document.body.classList.add(`${colorScheme}-theme`);
}
}
applyTheme(themeSelector.options[themeSelector.selectedIndex].text);
applyColorScheme("dark");
// <link rel="stylesheet" href="/css/theme-dracula.css" />
// <link rel="stylesheet" href="/css/theme-catppuccin-latte.css" />
// <link rel="stylesheet" href="/css/ionicons.min.css" />

View File

@ -1,13 +1,13 @@
'use strict';
var toggle_theme = document.getElementById('toggle_theme');
toggle_theme.href = 'javascript:void(0)';
const STORAGE_KEY_THEME = 'dark_mode';
const THEME_DARK = 'dark';
const THEME_LIGHT = 'light';
// TODO: theme state controlled by system
toggle_theme.addEventListener('click', function () {
toggle_theme.addEventListener('click', function (e) {
e.preventDefault();
const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK;
const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK;
setTheme(newTheme);

View File

@ -69,6 +69,8 @@ function get_playlist(plid) {
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) {
if (response === null) return;
playlist.innerHTML = response.playlistHtml;
if (!response.nextVideo) return;

View File

@ -1,22 +1,13 @@
# Warning: This docker-compose file is made for development purposes.
# Using it will build an image from the locally cloned repository.
#
# If you want to use Invidious in production, see the docker-compose.yml file provided
# in the installation documentation: https://docs.invidious.io/installation/
---
version: "3"
services:
invidious:
build:
context: .
dockerfile: docker/Dockerfile
image: rockerboo/invidious
image: quay.io/invidious/invidious:master
# image: quay.io/invidious/invidious:master-arm64 # ARM64/AArch64 devices
restart: unless-stopped
# Remove "127.0.0.1:" if used from an external IP
ports:
- "127.0.0.1:9999:3000"
- "127.0.0.1:3000:3000"
environment:
# Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax:
@ -29,22 +20,63 @@ services:
host: invidious-db
port: 5432
check_tables: true
invidious_companion:
# URL used for the internal communication between invidious and invidious companion
# There is no need to change that except if Invidious companion does not run on the same docker compose file.
- private_url: "http://companion:8282"
# (public) URL used for the communication between your browser and invidious companion
# IF you are using a reverse proxy OR accessing invidious from an external IP then you NEED to change this value
# Please consult for more doc: https://github.com/unixfox/invidious/blob/invidious-companion/config/config.example.yml#L57-L88
# And make sure to add the routes from the post install when using a reverse proxy!
public_url: "http://localhost:8282"
# IT is NOT recommended to use the same key as HMAC KEY. Generate a new key!
# Use the key generated in the 2nd step
invidious_companion_key: "foo0te5naijooTho"
# external_port:
# domain:
# https_only: false
# statistics_enabled: false
hmac_key: "CHANGE_ME!!"
# Use the key generated in the 2nd step
hmac_key: "foo0te5naijooTho"
healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/trending || exit 1
interval: 30s
timeout: 5s
retries: 2
logging:
options:
max-size: "1G"
max-file: "4"
depends_on:
- invidious-db
companion:
image: quay.io/invidious/invidious-companion:latest
environment:
# Use the key generated in the 2nd step
- SERVER_SECRET_KEY=foo0te5naijooTho
restart: unless-stopped
# Remove "127.0.0.1:" if used from an external IP
ports:
- "127.0.0.1:8282:8282"
logging:
options:
max-size: "1G"
max-file: "4"
cap_drop:
- ALL
read_only: true
# cache for youtube library
volumes:
- companioncache:/var/tmp/youtubei.js:rw
security_opt:
- no-new-privileges:true
invidious-db:
image: docker.io/library/postgres:14
restart: unless-stopped
volumes:
- postgresdata:/var/lib/postgresql/data
- postgresdata-companion:/var/lib/postgresql/data
- ./config/sql:/config/sql
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
environment:
@ -55,4 +87,5 @@ services:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
volumes:
postgresdata:
postgresdata-companion:
companioncache:

View File

@ -463,6 +463,7 @@
"next_steps_error_message": "After which you should try to: ",
"next_steps_error_message_refresh": "Refresh",
"next_steps_error_message_go_to_youtube": "Go to YouTube",
"invidious_footer_description": "A free and open source frontend for Youtube that that respects your privacy! Now you can watch videos (ad-free), subscribe to channels, create playlist and much more all without the prying eyes of Google!",
"footer_donate_page": "Donate",
"footer_documentation": "Documentation",
"footer_source_code": "Source code",

View File

@ -133,6 +133,9 @@ Kemal.config.extra_options do |parser|
Invidious::Database::Migrator.new(PG_DB).migrate
exit
end
parser.on("--disable-static-cache", "Disable static file handler cache") do
CONFIG.disable_static_cache = true
end
end
Kemal::CLI.new ARGV

View File

@ -158,6 +158,9 @@ class Config
# Playlist length limit
property playlist_length_limit : Int32 = 500
# Disable static file handle cache. Prefer only for development. --disable_static_cache also available on build.
property disable_static_cache : Bool = false
def disabled?(option)
case disabled = CONFIG.disable_proxy
when Bool

View File

@ -23,22 +23,15 @@ module Invidious::Frontend::ChannelPage
tab_name = tab.to_s.downcase
if channel.tabs.includes? tab_name
str << %(<div class="pure-u-1 pure-md-1-3">\n)
if tab == selected_tab
str << "\t<b>"
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</b>\n"
else
# Video tab doesn't have the last path component
url = tab.videos? ? base_url : "#{base_url}/#{tab_name}"
selected_class = tab == selected_tab ? "selected" : ""
str << %(<li class=") << selected_class << %(">\n)
str << %(\t<a href=") << url << %(">)
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</a>\n"
end
str << "</div>"
str << "</li>"
end
end
end

View File

@ -13,14 +13,9 @@ module Invidious::Frontend::Comments
)
replies_html = <<-END_HTML
<div id="replies" class="pure-g">
<div class="pure-u-1-24"></div>
<div class="pure-u-23-24">
<p>
<a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}"
data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</a>
</p>
</div>
<div class="replies">
<button data-continuation="#{child["replies"]["continuation"]}"
data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</button>
</div>
END_HTML
elsif comments["authorId"]? && !comments["singlePost"]?
@ -32,21 +27,20 @@ module Invidious::Frontend::Comments
)
replies_html = <<-END_HTML
<div class="pure-g">
<div class="pure-u-1-24"></div>
<div class="pure-u-23-24">
<p>
<a href="/post/#{child["commentId"]}?ucid=#{comments["authorId"]}">#{replies_count_text}</a>
</p>
</div>
<div class="reply-count">
<a role="button" href="/post/#{child["commentId"]}?ucid=#{comments["authorId"]}">#{replies_count_text}</a>
</div>
END_HTML
end
if !thin_mode
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}"
else
if thin_mode
author_thumbnail = ""
else
author_thumbnail_url = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}"
author_thumbnail = <<-THUMBNAIL
<img loading="lazy" src="#{author_thumbnail_url}" class="profile-pic" alt="" />
THUMBNAIL
end
author_name = HTML.escape(child["author"].as_s)
@ -65,18 +59,76 @@ module Invidious::Frontend::Comments
str << %(width="16" height="16" />)
end
end
html << <<-END_HTML
<div class="pure-g" style="width:100%">
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
<img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}" alt="" />
</div>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
<b>
<div class="comment" id="comment_#{child["commentId"]}">
<div class="comment-header">
<div class="comment-temporal">
<div class="channel-profile">
#{author_thumbnail}
<h4>
<a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a>
</b>
#{sponsor_icon}
<p style="white-space:pre-wrap">#{child["contentHtml"]}</p>
</h4>
</div>
<time datetime="#{Time.unix(child["published"].as_i64).to_s("%Y-%m-%d")}" title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</time>
</div>
END_HTML
html << <<-END_HTML
<ul class="comment-meta-sub">
END_HTML
if child["likeCount"]?
html << <<-END_HTML
<li>
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
</li>
END_HTML
end
if child["creatorHeart"]?
if !thin_mode
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}"
else
creator_thumbnail = ""
end
html << <<-END_HTML
<li>
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<span class="creator-heart">
<img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
<span class="creator-heart-small-hearted">
<span class="icon ion-ios-heart creator-heart-small-container"></span>
</span>
</span>
</span>
</li>
END_HTML
end
if comments["videoId"]?
html << <<-END_HTML
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}" title="View on YouTube">YT</a>
</li>
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}" title="View on YouTube">YT</a>
</li>
END_HTML
end
html << <<-END_HTML
</ul>
</div>
<div class="_comment">
<p class="raw-text">#{child["contentHtml"]}</p>
END_HTML
if child["attachment"]?
@ -87,10 +139,8 @@ module Invidious::Frontend::Comments
attachment = attachment["imageThumbnails"][1]
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-2">
<img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}" alt="" />
</div>
<div>
<img loading="lazy" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}" alt="" />
</div>
END_HTML
when "video"
@ -141,50 +191,8 @@ module Invidious::Frontend::Comments
end
end
html << <<-END_HTML
<p>
<span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span>
|
END_HTML
if comments["videoId"]?
html << <<-END_HTML
<a rel="noreferrer noopener" href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
elsif comments["authorId"]?
html << <<-END_HTML
<a rel="noreferrer noopener" href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a>
|
END_HTML
end
html << <<-END_HTML
<i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])}
END_HTML
if child["creatorHeart"]?
if !thin_mode
creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}"
else
creator_thumbnail = ""
end
html << <<-END_HTML
&nbsp;
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
<span class="creator-heart">
<img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" />
<span class="creator-heart-small-hearted">
<span class="icon ion-ios-heart creator-heart-small-container"></span>
</span>
</span>
</span>
END_HTML
end
html << <<-END_HTML
</p>
#{replies_html}
</div>
</div>
@ -193,16 +201,17 @@ module Invidious::Frontend::Comments
if comments["continuation"]?
html << <<-END_HTML
<div class="pure-g">
<div class="pure-u-1">
<p>
<a href="javascript:void(0)" data-continuation="#{comments["continuation"]}"
data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{translate(locale, "Load more")}</a>
</p>
<div>
<button data-continuation="#{comments["continuation"]}"
data-onclick="get_youtube_replies" data-load-more #{"data-load-replies" if is_replies}>#{translate(locale, "Load more")}</button>
</div>
END_HTML
end
# Closes comment box
html << <<-END_HTML
</div>
END_HTML
end
end
end
end

View File

@ -5,7 +5,7 @@ module Invidious::Frontend::Pagination
private def previous_page(str : String::Builder, locale : String?, url : String)
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
str << %(<a href=") << url << %(" class="secondary" role="button">)
if locale_is_rtl?(locale)
# Inverted arrow ("previous" points to the right)
@ -24,12 +24,12 @@ module Invidious::Frontend::Pagination
private def next_page(str : String::Builder, locale : String?, url : String)
# Link
str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
str << %(<a href=") << url << %(" class="secondary" role="button">)
if locale_is_rtl?(locale)
# Inverted arrow ("next" points to the left)
str << %(<i class="icon ion-ios-arrow-back"></i>)
str << "&nbsp;&nbsp;"
str << "&nbsp;"
str << translate(locale, "Next page")
else
# Regular arrow ("next" points to the right)
@ -43,55 +43,49 @@ module Invidious::Frontend::Pagination
def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true)
return String.build do |str|
str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n)
str << %(<div class="page-prev-container flex-left">)
str << %(<nav class="pagination">\n<ul>\n)
if current_page > 1
str << %(<li>)
params_prev = URI::Params{"page" => (current_page - 1).to_s}
url_prev = HttpServer::Utils.add_params_to_url(base_url, params_prev)
self.previous_page(str, locale, url_prev.to_s)
str << %(</li>\n)
end
str << %(</div>\n)
str << %(<div class="page-next-container flex-right">)
if show_next
str << %(<li>)
params_next = URI::Params{"page" => (current_page + 1).to_s}
url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
self.next_page(str, locale, url_next.to_s)
str << %(</li>\n)
end
str << %(</div>\n)
str << %(</div>\n)
str << %(</div>\n\n)
str << %(</ul>\n</nav>\n)
end
end
def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
puts ctoken
return String.build do |str|
str << %(<div class="h-box">\n)
str << %(<div class="page-nav-container flexible">\n)
str << %(<div class="page-prev-container flex-left"></div>\n)
str << %(<div class="page-next-container flex-right">)
if !ctoken.nil?
str << %(<nav class="pagination">\n<ul>\n)
str << %(<li>)
params_next = URI::Params{"continuation" => ctoken}
url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
self.next_page(str, locale, url_next.to_s)
str << %(</li>\n)
str << %(</ul>\n</nav>\n)
end
str << %(</div>\n)
str << %(</div>\n)
str << %(</div>\n\n)
end
end
end

View File

@ -5,15 +5,15 @@ module Invidious::Frontend::SearchFilters
def generate(filters : Search::Filters, query : String, page : Int, locale : String) : String
return String.build(8000) do |str|
str << "<div id='filters'>\n"
str << "\t<details id='filters-collapse'>"
str << "\t<details id='filters'>"
str << "\t\t<summary>" << translate(locale, "search_filters_title") << "</summary>\n"
str << "\t\t<div id='filters-box'><form action='/search' method='get'>\n"
str << "\t\t<form action='/search' method='get'>\n"
str << "\t\t\t<input type='hidden' name='q' value='" << HTML.escape(query) << "'>\n"
str << "\t\t\t<input type='hidden' name='page' value='" << page << "'>\n"
str << "\t\t\t<div id='filters-flex'>"
str << "\t\t\t<div class='filters'>"
filter_wrapper(date)
filter_wrapper(type)
@ -23,32 +23,32 @@ module Invidious::Frontend::SearchFilters
str << "\t\t\t</div>\n"
str << "\t\t\t<div id='filters-apply'>"
str << "<button type='submit' class=\"pure-button pure-button-primary\">"
str << "\t\t\t<div class='action-controls'>"
str << "<button type='submit' class=\"primary\">"
str << translate(locale, "search_filters_apply_button")
str << "</button></div>\n"
str << "\t\t</form></div>\n"
str << "\t</details>\n"
str << "</div>\n"
# str << "</div>\n"
end
end
# Generate wrapper HTML (`<div>`, filter name, etc...) around the
# `<input>` elements of a search filter
macro filter_wrapper(name)
str << "\t\t\t\t<div class=\"filter-column\"><fieldset>\n"
str << "\t\t\t\t<fieldset class='form'>\n"
str << "\t\t\t\t\t<legend><div class=\"filter-name underlined\">"
str << "\t\t\t\t\t<legend>"
str << translate(locale, "search_filters_{{name}}_label")
str << "</div></legend>\n"
str << "</legend>\n"
str << "\t\t\t\t\t<div class=\"filter-options\">\n"
str << "\t\t\t\t\t\n"
make_{{name}}_filter_options(str, filters.{{name}}, locale)
str << "\t\t\t\t\t</div>"
str << "\t\t\t\t\t"
str << "\t\t\t\t</fieldset></div>\n"
str << "\t\t\t\t</fieldset>\n"
end
# Generates the HTML for the list of radio buttons of the "date" search filter

View File

@ -25,7 +25,7 @@ module Invidious::Frontend::WatchPage
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " class=\"watch-action-group\""
str << " action='/download'"
str << " method='post'"
str << " rel='noopener'"
@ -36,7 +36,7 @@ module Invidious::Frontend::WatchPage
str << "<input type='hidden' name='id' value='" << video.id << "'/>\n"
str << "<input type='hidden' name='title' value='" << HTML.escape(video.title) << "'/>\n"
str << "\t<div class=\"pure-control-group\">\n"
# str << "\t<div class=\"control-group\">\n"
str << "\t\t<label for='download_widget'>"
str << translate(locale, "Download as: ")
@ -92,14 +92,14 @@ module Invidious::Frontend::WatchPage
str << "</option>\n"
end
# End of form
str << "\t\t</select>\n"
str << "\t</div>\n"
# str << "\t</div>\n"
str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n"
str << "\t\t<b>" << translate(locale, "Download") << "</b>\n"
# str << "\t<div class=\"control-actions\">"
str << "\t<button type=\"submit\" class=\"secondary\">\n"
str << "\t\t" << translate(locale, "Download") << "\n"
str << "\t</button>\n"
# str << "\t</div>"
str << "</form>\n"
end

View File

@ -98,11 +98,11 @@ def template_mix(mix)
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
<div class="thumbnail">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
<div class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</div>
</div>
<p style="width:100%">#{video["title"]}</p>
<p>#{video["title"]}</p>
<p>
<b style="width:100%">#{video["author"]}</b>
#{video["author"]}
</p>
</a>
</li>

View File

@ -450,6 +450,8 @@ end
def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
videos = [] of PlaylistVideo
puts initial_data.inspect
if initial_data["contents"]?
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
@ -480,6 +482,9 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any))
title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || ""
# TODO: Possibly add author_thumbnail support
# author_thumbnail = i["playlistSidebarSecondaryInfoRenderer"]?.try &.["videoOwner"]["videoOwnerRenderer"]["thumbnail"]["thumbnails"][0]["url"].as_s || ""
length_seconds = i["lengthSeconds"]?.try &.as_s.to_i
live = false
@ -512,23 +517,29 @@ def template_playlist(playlist)
#{playlist["title"]}
</a>
</h3>
<div class="pure-menu pure-menu-scrollable playlist-restricted">
<ol class="pure-menu-list">
<div class="playlist-restricted">
<ol>
END_HTML
playlist["videos"].as_a.each do |video|
html += <<-END_HTML
<li class="pure-menu-item" id="#{video["videoId"]}">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<li id="#{video["videoId"]}">
<div class="thumbnail">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
</div>
<p style="width:100%">#{video["title"]}</p>
<p>
<b style="width:100%">#{video["author"]}</b>
</p>
<div class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</div>
</a>
</div>
<h3>
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
#{video["title"]}
</a>
</h3>
<h4>
<a href="/channel/#{video["ucid"]}">
#{video["author"]}
</a>
</h4>
</li>
END_HTML
end
@ -536,7 +547,6 @@ def template_playlist(playlist)
html += <<-END_HTML
</ol>
</div>
<hr>
END_HTML
html

View File

@ -267,6 +267,8 @@ module Invidious::Routes::Playlists
show_next: (items.size >= 20)
)
puts items
env.set "add_playlist_items", plid
templated "add_playlist_items"
end

View File

@ -67,6 +67,10 @@ module Invidious::Routes::Search
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
puts items.size
puts (items.size >= 20)
puts query.page
# Pagination
page_nav_html = Frontend::Pagination.nav_numeric(locale,
base_url: "/search?#{query.to_http_params}",

View File

@ -3,24 +3,37 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" />
<% end %>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/add_playlist_items" method="get">
<legend><a href="/playlist?list=<%= playlist.id %>"><%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend>
<div class="playlist-header">
<h1><%= translate(locale, "Editing playlist") %></h1>
<nav>
<ul>
<li><a href="/playlist?list=<%= playlist.id %>"><%= translate(locale, "View playlist") %></a></li>
</ul>
</nav>
</div>
<h2><%= HTML.escape(playlist.title) %></h2>
<form action="/add_playlist_items" method="get">
<fieldset>
<input class="pure-input-1" type="search" name="q"
<legend><%= translate(locale, "Search for videos") %></legend>
<div class="search-box">
<input type="search" class="search" autocorrect="off"
autocapitalize="none" spellcheck="false" autofocus
name="q"
id="playlist-video-search"
<% if query %>value="<%= HTML.escape(query.text) %>"<% end %>
placeholder="<%= translate(locale, "Search for videos") %>">
placeholder="<%= translate(locale, "Search for videos") %>"
title="<%= translate(locale, "search") %>">
<button type="submit" class="search-action" aria-label="<%= translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>
</button>
</div>
<input type="hidden" name="list" value="<%= plid %>">
</fieldset>
</form>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
</div>
</form>
<script id="playlist_data" type="application/json">
<%=
@ -31,5 +44,4 @@
</script>
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<%= rendered "components/items_paginated" %>

View File

@ -46,9 +46,4 @@
<%= rendered "components/channel_info" %>
<div class="h-box">
<hr>
</div>
<%= rendered "components/items_paginated" %>

View File

@ -17,16 +17,10 @@
<%= rendered "components/channel_info" %>
<div class="h-box">
<hr>
</div>
<% if error_message %>
<div class="h-box">
<p><%= error_message %></p>
</div>
<% else %>
<div class="h-box pure-g comments" id="comments">
<div class="comments community" id="comments">
<%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
</div>
<% end %>

View File

@ -1,61 +1,49 @@
<% if channel.banner %>
<div class="h-box">
<img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" />
</div>
<div class="h-box">
<hr>
</div>
<img src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" />
<% end %>
<div class="pure-g h-box flexible title">
<div class="pure-u-1-2 flex-left flexible">
<div class="title" dir="auto">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div>
<h1><%= author %><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></h1>
</div>
<div class="pure-u-1-2 flex-right flexible button-container">
<div class="pure-u">
<div class="button-container">
<% sub_count_text = number_to_short_text(channel.sub_count) %>
<%= rendered "components/subscribe_widget" %>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>">
<a role="button" class="secondary" href="/feed/channel/<%= ucid %>">
<i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
</a>
</div>
</div>
<div class="watch-context">
<div id="description" dir="auto">
<p class="raw-text"><%= channel.description_html %></p>
</div>
</div>
<div class="h-box">
<div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div>
</div>
<div class="pure-g h-box">
<div class="pure-u-1-2">
<div class="pure-u-1 pure-md-1-3">
<nav dir="auto">
<ul>
<li>
<a href="<%= youtube_url %>"><%= translate(locale, "View channel on YouTube") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
</li>
<li>
<a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</div>
<%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
</div>
<div class="pure-u-1-2">
<div class="pure-g" style="text-align:end">
<% sort_options.each do |sort| %>
<div class="pure-u-1 pure-md-1-3">
<% if sort_by == sort %>
<b><%= translate(locale, sort) %></b>
<% else %>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a>
<% end %>
</div>
<% end %>
</div>
</div>
</li>
</ul>
</nav>
</div>
<nav class="menu" dir="auto">
<ul>
<%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
</ul>
<ul>
<% sort_options.each do |sort| %>
<li <% if sort_by == sort %>class="selected"<% end %>>
<a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a>
</li>
<% end %>
</ul>
</nav>

View File

@ -1,11 +1,15 @@
<div class="feed-menu">
<nav class="feed-menu">
<ul>
<% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %>
<% if !env.get?("user") %>
<% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
<% end %>
<% feed_menu.each do |feed| %>
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
<li>
<a href="/feed/<%= feed.downcase %>">
<%= translate(locale, feed) %>
</a>
</li>
<% end %>
</div>
</ul>
</nav>

View File

@ -3,58 +3,53 @@
item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
author_verified = item.responds_to?(:author_verified) && item.author_verified
-%>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% case item when %>
<% when SearchChannel %>
<% if !thin_mode %>
<a tabindex="-1" href="/channel/<%= item.ucid %>">
<center>
<img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</center>
</a>
<article class="video-card" dir="auto">
<% if thin_mode %>
<div class="thumbnail-placeholder"></div>
<%- else -%>
<div class="thumbnail-placeholder" style="width:56.25%"></div>
<a tabindex="-1" href="/channel/<%= item.ucid %>" title="<%= HTML.escape(item.author) %> channel">
<img loading="lazy" class="profile-pic" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="<%= HTML.escape(item.author) %> profile picture" />
</a>
<% end %>
<div class="video-card-row flexible">
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<h3 class="channel-name">
<a href="/channel/<%= item.ucid %>" title="<%= HTML.escape(item.author) %> channel">
<%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a></div>
</div>
<% if !item.channel_handle.nil? %><p class="channel-name" dir="auto"><%= item.channel_handle %></p><% end %>
<p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
<% if !item.auto_generated && item.channel_handle.nil? %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchHashtag %>
<% if !thin_mode %>
<a tabindex="-1" href="<%= item.url %>">
<center><img style="width:56.25%" src="/hashtag.svg" alt="" /></center>
</a>
</h3>
<% if !item.channel_handle.nil? %><h3 class="channel-name"><%= item.channel_handle %></h3><% end %>
<div><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></div>
<% if !item.auto_generated && item.channel_handle.nil? %><div><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></div><% end %>
<p><%= item.description_html %></p>
<% when SearchHashtag %>
<article class="video-card" dir="auto">
<% if thin_mode %>
<div class="thumbnail"></div>
<%- else -%>
<div class="thumbnail-placeholder" style="width:56.25%"></div>
<a tabindex="-1" href="<%= item.url %>">
<img src="/hashtag.svg" alt="" />
</a>
<% end %>
<div class="video-card-row">
<div class="flex-left"><a href="<%= item.url %>"><%= HTML.escape(item.title) %></a></div>
</div>
<h3><a href="<%= item.url %>" title="<%= HTML.escape(item.title) %>"><%= HTML.escape(item.title) %></a></h3>
<div class="video-card-row">
<%- if item.video_count != 0 -%>
<p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
<%- end -%>
<div>
<%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>
</div>
<%- end -%>
<div class="video-card-row">
<div>
<%- if item.channel_count != 0 -%>
<p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p>
<div><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></div>
<%- end -%>
</div>
<% when SearchPlaylist, InvidiousPlaylist %>
<article class="video-card" dir="auto">
<%-
if item.id.starts_with? "RD"
link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}"
@ -65,39 +60,37 @@
<div class="thumbnail">
<%- if !thin_mode %>
<a tabindex="-1" href="<%= link_url %>">
<a tabindex="-1" href="<%= link_url %>" title="<%= HTML.escape(item.title) %>">
<img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
</a>
<%- else -%>
<div class="thumbnail-placeholder"></div>
<%- end -%>
<div class="bottom-right-overlay">
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
<div class="length">
<%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>
</div>
</div>
<div class="video-card-row">
<a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
</div>
<h3><a href="<%= link_url %>" title="<%= HTML.escape(item.title) %>"><%= HTML.escape(item.title) %></a></h3>
<div class="video-card-row flexible">
<div class="flex-left">
<div class="video-meta-sub">
<h3 class="channel-name">
<% if !item.ucid.to_s.empty? %>
<a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a>
<% else %>
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
<a href="/channel/<%= item.ucid %>" title="<%= HTML.escape(item.author) %> channel">
<% end %>
<%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<% if !item.ucid.to_s.empty? %>
</a>
<% end %>
</div>
</div>
<% when Category %>
<% else %>
<%- if item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items"))
index_attribute = "data-index='#{item.index}'"
else
index_attribute = ""
end
-%>
<article class="video-card" dir="auto" id="video-card-<%= item.id %>" data-id="<%= item.id %>" <%= index_attribute %>>
<%-
# `endpoint_params` is used for the "video-context-buttons" component
if item.is_a?(PlaylistVideo)
@ -111,10 +104,13 @@
endpoint_params = "?v=#{item.id}"
end
-%>
<div class="thumbnail">
<%- if !thin_mode -%>
<a tabindex="-1" href="<%= link_url %>">
<%- if thin_mode -%>
<a tabindex="-1" href="<%= link_url %>" title="<%= HTML.escape(item.title) %>">
<div class="thumbnail-placeholder"></div>
</a>
<%- else -%>
<a tabindex="-1" href="<%= link_url %>" title="<%= HTML.escape(item.title) %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if item_watched %>
@ -122,15 +118,13 @@
<div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
<% end %>
</a>
<%- else -%>
<div class="thumbnail-placeholder"></div>
<%- end -%>
<div class="top-left-overlay">
<%- if env.get? "show_watched" -%>
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
<button type="submit" class="secondary"
data-onclick="mark_watched" data-id="<%= item.id %>">
<i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
</button>
@ -141,65 +135,60 @@
<%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
<button type="submit" class="secondary"
data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
</form>
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
<%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
<form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
<button type="submit" class="secondary"
data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
</form>
<%- end -%>
</div>
<div class="bottom-right-overlay">
<%- if item.responds_to?(:live_now) && item.live_now -%>
<p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></p>
<div class="length"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></div>
<%- elsif item.length_seconds != 0 -%>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<div class="length"><%= recode_length_seconds(item.length_seconds) %></div>
<%- end -%>
</div>
</div>
<div class="video-card-row">
<a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
</div>
<h3><a href="<%= link_url %>" title="<%= HTML.escape(item.title) %>"><%= HTML.escape(item.title) %></a></h3>
<div class="video-card-row flexible">
<div class="flex-left">
<div class="video-meta-sub">
<h3 class="channel-name">
<% if !item.ucid.to_s.empty? %>
<a href="/channel/<%= item.ucid %>">
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a>
<% else %>
<p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
<a href="/channel/<%= item.ucid %>" title="<%= HTML.escape(item.author) %>">
<% end %>
</div>
<%= HTML.escape(item.author) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<% if !item.ucid.to_s.empty? %>
</a>
<% end %>
</h3>
<%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
<div class="flex-left">
<div class="video-meta-sub">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
<% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
<div class="video-data">
<time datetime="<%= item.premiere_timestamp.try { |t| t.to_s("%Y-%m-%d") } %>" title="<%= item.premiere_timestamp.try { |t| t.to_s(translate(locale, "%A %B %-d, %Y")) } %>">
<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>
</time>
</div>
<% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<div class="video-data">
<time datetime="<%= item.published.try { |t| t.to_s("%Y-%m-%d") } %>" title="<%= item.published.try { |t| t.to_s(translate(locale, "%A %B %-d, %Y")) } %>">
<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>
</time>
</div>
<% end %>
<% if item.responds_to?(:views) && item.views %>
<div class="flex-right">
<p class="video-data" dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
</div>
<div class="video-data"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></div>
<% end %>
</div>
<% end %>
</div>
</div>
</article>

View File

@ -1,6 +1,6 @@
<%= page_nav_html %>
<div class="pure-g">
<div class="videos">
<%- items.each do |item| -%>
<%= rendered "components/item" %>
<%- end -%>

View File

@ -1,12 +1,12 @@
<form class="pure-form" action="/search" method="get">
<fieldset>
<input type="search" id="searchbox" autocorrect="off"
<form action="/search" method="get" id="site-search">
<div class="search-box">
<input type="search" class="search" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
name="q" placeholder="<%= translate(locale, "search") %>"
title="<%= translate(locale, "search") %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset>
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>">
<button type="submit" class="search-action" aria-label="<%= translate(locale, "search") %>">
<i class="icon ion-ios-search"></i>
</button>
</div>
</form>

View File

@ -2,16 +2,12 @@
<% if subscriptions.includes? ucid %>
<form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
<input data-type="unsubscribe" id="subscribe" class="secondary" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>">
</form>
<% else %>
<form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button>
<input class="primary" data-type="subscribe" id="subscribe" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>">
</form>
<% end %>
@ -29,8 +25,8 @@
</script>
<script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% else %>
<a id="subscribe" class="pure-button pure-button-primary"
<a id="subscribe" class="primary" role="button"
href="/login?referer=<%= env.get("current_page") %>">
<b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>
</a>
<% end %>

View File

@ -1,21 +1,29 @@
<div class="flex-right flexible">
<div class="icon-buttons">
<nav>
<ul>
<li>
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>">
<i class="icon ion-logo-youtube"></i>
</a>
</li>
<li>
<a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
</li>
<li>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>" rel="noreferrer noopener">
<i class="icon ion-md-jet"></i>
</a>
<% else %>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>">
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="https://redirect.invidious.io/watch<%=endpoint_params%>" rel="noreferrer noopener" >
<i class="icon ion-md-jet"></i>
</a>
<% end %>
</li>
</ul>
</nav>
</div>
</div>

View File

@ -2,20 +2,16 @@
<title><%= translate(locale, "Create playlist") %> - Invidious</title>
<% end %>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/create_playlist?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<form action="/create_playlist?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset class="form">
<legend><%= translate(locale, "Create playlist") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="title"><%= translate(locale, "Title") %> :</label>
<input required name="title" type="text" placeholder="<%= translate(locale, "Title") %>">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label>
<select name="privacy" id="privacy">
<% PlaylistPrivacy.names.each do |option| %>
@ -24,16 +20,13 @@
</select>
</div>
<div class="pure-controls">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</fieldset>
<div class="action-controls">
<button type="submit" name="action" value="create_playlist" class="pure-button pure-button-primary">
<%= translate(locale, "Create playlist") %>
</button>
</div>
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</fieldset>
</form>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
</div>

View File

@ -5,56 +5,62 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" />
<% end %>
<form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post">
<div class="h-box flexible">
<div class="flex-right button-container">
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>">
<form action="/edit_playlist?list=<%= plid %>" method="post">
<div class="header-group">
<nav class="menu">
<ul>
<li>
<a class="secondary" role="button" dir="auto" href="/playlist?list=<%= plid %>">
<i class="icon ion-md-close"></i>&nbsp;<%= translate(locale, "generic_button_cancel") %>
</a>
</div>
<div class="pure-u">
<button class="pure-button pure-button-secondary low-profile" dir="auto" type="submit">
<i class="icon ion-md-save"></i>&nbsp;<%= translate(locale, "generic_button_save") %>
</button>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
</li>
<li>
<a class="secondary" role="button" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
</a>
</div>
</div>
</div>
</li>
</ul>
</nav>
</div>
<div class="h-box flexible title">
<div>
<h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3>
</div>
</div>
<div class="h-box">
<div class="pure-u-1-1">
<b>
<%= HTML.escape(playlist.author) %> |
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
</b>
<ul>
<li>
<%= HTML.escape(playlist.author) %>
</li>
<li>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %>
</li>
<li>
<select name="privacy">
<%- {"Public", "Unlisted", "Private"}.each do |option| -%>
<option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
<%- end -%>
</select>
</div>
</li>
</ul>
<fieldset class="form">
<legend>Edit playlist</legend>
<div class="control-group">
<label for="title">Title</label>
<input id="title" maxlength="150" name="title" type="text" value="<%= title %>">
</div>
<div class="h-box">
<textarea maxlength="5000" name="description" style="margin-top:10px;max-width:100%;height:20vh" class="pure-input-1"><%= playlist.description %></textarea>
<div class="control-group">
<label for="description">Description</label>
<textarea maxlength="5000" name="description"><%= playlist.description %></textarea>
</div>
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
<div class="action-controls">
<button class="primary" dir="auto" type="submit">
<i class="icon ion-md-save"></i>&nbsp;<%= translate(locale, "generic_button_save") %>
</button>
</div>
</fieldset>
</form>
<div class="h-box">
<hr>
</div>
<%= rendered "components/items_paginated" %>

View File

@ -2,20 +2,18 @@
<title><%= translate(locale, "History") %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<div class="history-header">
<h3><%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %></h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<nav>
<ul>
<li>
<a href="/feed/subscriptions"><%= translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, NumberFormatting::HtmlSpan) %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
</li>
<li>
<a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a>
</h3>
</div>
</li>
</ul>
</nav>
</div>
<script id="watched_data" type="application/json">
@ -27,12 +25,11 @@
</script>
<script src="/js/watched_widget.js"></script>
<div class="pure-g">
<div class="videos">
<% watched.each do |item| %>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<div class="video-card">
<div class="thumbnail">
<a style="width:100%" href="/watch?v=<%= item %>">
<a href="/watch?v=<%= item %>">
<img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
</a>
@ -44,8 +41,6 @@
</form>
</div></div>
</div>
<p></p>
</div>
</div>
<% end %>
</div>

View File

@ -4,38 +4,33 @@
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<div class="playlist-header">
<h3><%= translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<nav>
<ul>
<li>
<a href="/create_playlist?referer=<%= URI.encode_www_form("/feed/playlists") %>"><%= translate(locale, "Create playlist") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
</li>
<li>
<a href="/data_control?referer=<%= URI.encode_www_form("/feed/playlists") %>">
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
</li>
</ul>
</nav>
</div>
<div class="pure-g">
<div class="videos">
<% items_created.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<div class="pure-g h-box">
<div class="pure-u-1">
<h3><%= translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
</div>
</div>
<h3><%= translate(locale, "user_saved_playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3>
<div class="pure-g">
<div class="videos">
<% items_saved.each do |item| %>
<% puts item %>
<%= rendered "components/item" %>
<% end %>
</div>

View File

@ -11,10 +11,10 @@
<%= rendered "components/feed_menu" %>
<div class="pure-g">
<main class="videos">
<% popular_videos.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
</main>
<script src="/js/watched_indicator.js"></script>

View File

@ -5,63 +5,59 @@
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i></a>
</h3>
</div>
</div>
<main>
<header class="subscription-menu">
<% if CONFIG.enable_user_notifications %>
<% if CONFIG.enable_user_notifications %>
<center>
<div>
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
<% if !notifications.empty? %>
<div class="h-box">
<hr>
</div>
<% end %>
<% else %>
<div></div>
<% end %>
<div class="pure-g">
<% notifications.each do |item| %>
<nav>
<ul>
<li>
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</li>
<li>
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</li>
<li>
<a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "Subscription RSS") %></a>
</li>
</ul>
</nav>
</header>
<% if CONFIG.enable_user_notifications %>
<% if !notifications.empty? %>
<div class="notifications videos">
<% notifications.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
<div class="h-box">
<hr>
</div>
<script id="watched_data" type="application/json">
<%=
{
<script id="watched_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/watched_widget.js"></script>
}.to_pretty_json
%>
</script>
<script src="/js/watched_widget.js"></script>
<div class="pure-g">
<% videos.each do |item| %>
<div class="videos">
<% videos.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>
</div>
</main>
<script src="/js/watched_indicator.js"></script>

View File

@ -11,39 +11,31 @@
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box">
<div style="align-self:flex-end" class="pure-u-2-3">
<nav class="menu filters">
<ul>
<% if plid %>
<li>
<a href="/playlist?list=<%= plid %>">
<%= translate(locale, "View as playlist") %>
</a>
</li>
<% end %>
</div>
<div class="pure-u-1-3">
<div class="pure-g" style="text-align:right">
</ul>
<ul>
<% {"Default", "Music", "Gaming", "Movies"}.each do |option| %>
<div class="pure-u-1 pure-md-1-3">
<% if trending_type == option %>
<b><%= translate(locale, option) %></b>
<% else %>
<li <% if trending_type == option %>class="selected"<% end %>>
<a href="/feed/trending?type=<%= option %>&region=<%= region %>">
<%= translate(locale, option) %>
<%= translate(locale, option) %></b>
</a>
</li>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</ul>
</nav>
<div class="h-box">
<hr>
</div>
<div class="pure-g">
<main class="videos">
<% trending.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
</main>
<script src="/js/watched_indicator.js"></script>

View File

@ -6,59 +6,96 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" />
<% end %>
<div class="h-box flexible title">
<div class="flex-left"><h3><%= title %></h3></div>
<div class="playlist-header">
<h1><%= title %></h1>
<div class="flex-right button-container">
<nav class="menu">
<ul>
<%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>">
<li>
<a class="secondary" role="button" dir="auto" href="/add_playlist_items?list=<%= plid %>">
<i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "playlist_button_add_items") %>
</a>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/edit_playlist?list=<%= plid %>">
</li>
<li>
<a class="secondary" role="button" dir="auto" href="/edit_playlist?list=<%= plid %>">
<i class="icon ion-md-create"></i>&nbsp;<%= translate(locale, "generic_button_edit") %>
</a>
</div>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
</li>
<li>
<a class="secondary" role="button" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
</a>
</div>
</li>
<%- else -%>
<div class="pure-u">
<li>
<%- if IV::Database::Playlists.exists?(playlist.id) -%>
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/subscribe_playlist?list=<%= plid %>">
<a class="secondary" role="button" dir="auto" href="/subscribe_playlist?list=<%= plid %>">
<i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "Subscribe") %>
</a>
<%- else -%>
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
<a class="secondary" role="button" dir="auto" href="/delete_playlist?list=<%= plid %>">
<i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "Unsubscribe") %>
</a>
<%- end -%>
</div>
</li>
<%- end -%>
<div class="pure-u">
<a class="pure-button pure-button-secondary low-profile" dir="auto" href="/feed/playlist/<%= plid %>">
<li>
<a class="secondary" role="button" dir="auto" href="/feed/playlist/<%= plid %>">
<i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
</a>
</div>
</div>
</li>
</ul>
</nav>
</div>
<div class="h-box">
<div class="pure-u-1-1">
<nav>
<ul>
<% if playlist.is_a? InvidiousPlaylist %>
<b>
<li>
<% if playlist.author == user.try &.email %>
<a href="/feed/playlists"><%= author %></a> |
<a href="/feed/playlists"><%= author %></a>
<% else %>
<%= author %> |
<%= author %>
<% end %>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
</li>
<% else %>
<li>
<% if !author.empty? %>
<% if playlist.author_thumbnail %>
<% author_profile_pic = URI.parse(playlist.author_thumbnail).request_target %>
<div class="channel-profile">
<img src="/ggpht<%= author_profile_pic %>" alt="<%= author %> Profile Picture" class="profile-pic" />
<a href="/channel/<%= playlist.ucid %>"><%= author %></a>
</div>
<% else %>
<a href="/channel/<%= playlist.ucid %>"><%= author %></a>
<% end %>
<% elsif !playlist.subtitle.nil? %>
<% subtitle = playlist.subtitle || "" %>
<span><%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %></span>
<% end %>
</li>
<li>
<a href="/channel/<%= playlist.ucid %>/playlists"><%= translate(locale, "Playlists") %></a>
</li>
<% end %>
</ul>
</nav>
<div class="playlist-meta">
<ul>
<% if playlist.is_a? InvidiousPlaylist %>
<li>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %></li>
</li>
<li>
<time datetime="<%= playlist.updated.try { |t| t.to_s("%Y-%m-%d") } %>" title="<%= playlist.updated.try { |t| t.to_s(translate(locale, "%A %B %-d, %Y")) } %>">
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
</time>
</li>
<li>
<% case playlist.as(InvidiousPlaylist).privacy when %>
<% when PlaylistPrivacy::Public %>
<i class="icon ion-md-globe"></i> <%= translate(locale, "Public") %>
@ -67,27 +104,27 @@
<% when PlaylistPrivacy::Private %>
<i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %>
<% end %>
</b>
</li>
<% else %>
<b>
<% if !author.empty? %>
<a href="/channel/<%= playlist.ucid %>"><%= author %></a> |
<% elsif !playlist.subtitle.nil? %>
<% subtitle = playlist.subtitle || "" %>
<span><%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %></span> |
<% end %>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %>
</b>
<li>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %>
</li>
<li>
<time datetime="<%= playlist.updated.try { |t| t.to_s("%Y-%m-%d") } %>" title="<%= playlist.updated.to_s(translate(locale, "%A %B %-d, %Y")) %>"><%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %></time>
</li>
<% end %>
</ul>
<nav>
<ul>
<% if !playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-2-3">
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>
</li>
<li>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a href="/redirect?referer=<%= env.get?("current_page") %>">
<%= translate(locale, "Switch Invidious Instance") %>
@ -97,18 +134,13 @@
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% end %>
</div>
</li>
<% end %>
</div>
</ul>
</nav>
</div>
<div class="h-box">
<div id="descriptionWrapper"><%= playlist.description_html %></div>
</div>
<div class="h-box">
<hr>
</div>
<div id="description" class="raw-text"><%= playlist.description_html %></div>
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
<script id="playlist_data" type="application/json">
@ -121,5 +153,5 @@
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
<!-- pagination -->
<%= rendered "components/items_paginated" %>

View File

@ -5,14 +5,16 @@
<!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
<%- if items.empty? -%>
<div class="h-box no-results-error">
<div class="no-results-error">
<div>
<%= translate(locale, "search_message_no_results") %>
</div>
<div>
<%= translate(locale, "search_message_change_filters_or_query") %>
</div>
<div>
<%= translate(locale, "search_message_no_results") %><br/><br/>
<%= translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %>
</div>
</div>

View File

@ -6,15 +6,11 @@
<link rel="stylesheet" href="/css/empty.css?v=<%= ASSET_COMMIT %>">
<% end %>
<%= rendered "components/feed_menu" %>
<div class="search-homepage">
<%= rendered "components/feed_menu" %>
<div class="pure-g h-box" id="search-widget">
<div class="pure-u-1" id="logo">
<h1 href="/" class="pure-menu-heading">Invidious</h1>
</div>
<div class="pure-u-1-4"></div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<div id="search-widget">
<h1>Invidious</h1>
<% autofocus = true %><%= rendered "components/search_box" %>
</div>
<div class="pure-u-1-4"></div>
</div>

View File

@ -18,8 +18,11 @@
<meta name="theme-color" content="#575757">
<link title="Invidious" type="application/opensearchdescription+xml" rel="search" href="/opensearch.xml">
<link rel="stylesheet" href="/css/pure-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/pure-fix.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/theme.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/animation.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/carousel.css?v=<%= ASSET_COMMIT %>">
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
@ -27,31 +30,29 @@
<body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme">
<span style="display:none" id="dark_mode_pref"><%= dark_mode %></span>
<div class="pure-g">
<div class="pure-u-1 pure-u-xl-20-24" id="contents">
<div class="pure-g navbar h-box">
<header class="container">
<nav class="navbar">
<% if navbar_search %>
<div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
<div id="index-link">
<a href="/">Invidious</a>
</div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<% autofocus = false %><%= rendered "components/search_box" %>
</div>
<% end %>
<div class="pure-u-1 pure-u-md-8-24 user-field">
<ul id="user-nav">
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
<li>
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" title="<%= translate(locale, "toggle_theme") %>">
<% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
<% end %>
</a>
</div>
<div class="pure-u-1-4">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
</li>
<li>
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions">
<% notification_count = env.get("user").as(Invidious::User).notifications.size %>
<% if CONFIG.enable_user_notifications && notification_count > 0 %>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
@ -59,103 +60,179 @@
<i class="icon ion-ios-notifications-outline"></i>
<% end %>
</a>
</div>
<div class="pure-u-1-4">
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
</li>
<li>
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>">
<i class="icon ion-ios-cog"></i>
</a>
</div>
</li>
<% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;">
<li class="nick">
<span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
</div>
</li>
<% end %>
<div class="pure-u-1-4">
<li>
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>">
</a>
<input class="logout" type="submit" value="<%= translate(locale, "Log out") %>">
</form>
</div>
</li>
<% else %>
<div class="pure-u-1-3">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>">
<li>
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" title="<%= translate(locale, "toggle_theme") %>">
<% if dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
<% end %>
</a>
</div>
<div class="pure-u-1-3">
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
</li>
<li>
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>">
<i class="icon ion-ios-cog"></i>
</a>
</div>
</li>
<% if CONFIG.login_enabled %>
<div class="pure-u-1-3">
<a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<li>
<a href="/login?referer=<%= env.get?("current_page") %>">
<%= translate(locale, "Log in") %>
</a>
</div>
</li>
<% end %>
<% end %>
</div>
</div>
</ul>
</nav>
</header>
<% if CONFIG.banner %>
<div class="h-box">
<h3><%= CONFIG.banner %></h3>
</div>
<h3 class="container"><%= CONFIG.banner %></h3>
<% end %>
<div id="contents" class="container">
<%= content %>
<footer>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<span>
<i class="icon ion-logo-github"></i>
<% if CONFIG.modified_source_code_url %>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a>&nbsp;/
<a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a>
<% else %>
<a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a>
<% end %>
</span>
<span>
<i class="icon ion-ios-paper"></i>
<a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a>
</span>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<span>
<a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a>
</span>
<span>
<i class="icon ion-logo-javascript"></i>
<a rel="jslicense" href="/licenses"><%= translate(locale, "View JavaScript license information.") %></a>
</span>
<span>
<i class="icon ion-ios-paper"></i>
<a href="/privacy"><%= translate(locale, "View privacy policy.") %></a>
</span>
<footer class="container">
<div>
<h3>Invidious</h3>
<p><%=translate(locale, "invidious_footer_description")%></p>
</div>
<div>
<h3><%= translate(locale, "Navigation")%></h3>
<nav>
<ul>
<li>
<a href="/" title="<%= translate(locale, "Home")%>">
<%= translate(locale, "Home") %>
</a>
</li>
<li>
<a href="/feed/popular" title="<%= translate(locale, "Popular")%>">
<%= translate(locale, "Popular") %>
</a>
</li>
<li>
<a href="/feed/trending" title="<%= translate(locale, "Trending")%>" style="">
<%= translate(locale, "Trending") %>
</a>
</li>
<li>
<a href="/search" title="<%= translate(locale, "Search")%>">
<%= translate(locale, "Search") %>
</a>
</li>
<li>
<a href="/preferences?referer=<%= env.get?("current_page") %>" title="<%= translate(locale, "Preferences")%>">
<%= translate(locale, "Preferences") %>
</a>
</li>
</ul>
</nav>
</div>
<div>
<h3 class="footer-section-header">Invidious</h3>
<nav>
<ul>
<li>
<a href="https://invidious.io" title="<%= translate(locale, "Project Homepage")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Project homepage") %>
</a>
</li>
<li>
<a href="https://github.com/iv-org/invidious" title="<%= translate(locale, "Source Code")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Source code") %>
</a>
</li>
<li>
<a href="https://github.com/iv-org/invidious/issues" title="<%= translate(locale, "Issue tracker")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Issue tracker") %>
</a>
</li>
<li>
<a href="https://instances.invidious.io/" title="<%= translate(locale, "Public instances")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Public instances") %>
</a>
</li>
<li>
<a href="https://invidious.io/donate" title="<%= translate(locale, "Donate")%>" target="_blank">
<%= translate(locale, "Donate") %>
</a>
</li>
<li>
<a href="https://matrix.to/#/#invidious:matrix.org" title="<%= translate(locale, "Matrix")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Matrix") %>
</a>
</li>
<li>
<a href="https://social.tchncs.de/@invidious" title="<%= translate(locale, "Matrix")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Fediverse") %>
</a>
<div class="pure-u-1 pure-u-md-1-3">
<span>
<i class="icon ion-ios-wallet"></i>
<a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a>
</span>
<span><%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %></span>
</li>
</ul>
</nav>
</div>
<div>
<h3><%= translate(locale, "Support")%></h3>
<nav>
<ul>
<li>
<a href="https://github.com/iv-org/invidious/issues/new" title="<%= translate(locale, "Report a bug")%>" target="_blank" rel="nofollow">
<%= translate(locale, "Report a bug") %>
</a>
</li>
<li>
<a href="https://docs.invidious.io/faq/" title="<%= translate(locale, "FAQs")%>" target="_blank" rel="nofollow">
<%= translate(locale, "FAQs") %>
</a>
</li>
</ul>
</nav>
</div>
<div>
<h3><%= translate(locale, "Legal")%></h3>
<nav>
<ul>
<li>
<a href="/licenses" title="<%= translate(locale, "Licenses")%>">
<%= translate(locale, "Licences") %>
</a>
</li>
<!-- TODO -->
<li>
<a href="/privacy" title="<%= translate(locale, "Privacy")%>">
<%= translate(locale, "Privacy") %>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<!-- <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %> -->
</footer>
</div>
</div>
<script src="/js/handlers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script>
<% if env.get? "user" %>

View File

@ -2,17 +2,18 @@
<title><%= translate(locale, "Import and Export Data") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" enctype="multipart/form-data" action="/data_control?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<h1>Import/export</h1>
<form enctype="multipart/form-data" action="/data_control?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset class="form">
<legend><%= translate(locale, "Import") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="import_invidious"><%= translate(locale, "Import Invidious data") %></label>
<input type="file" id="import_invidious" name="import_invidious">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_youtube">
<a rel="noopener" target="_blank" href="https://github.com/iv-org/documentation/blob/master/docs/export-youtube-subscriptions.md">
<%= translate(locale, "Import YouTube subscriptions") %>
@ -21,46 +22,49 @@
<input type="file" id="import_youtube" name="import_youtube">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_youtube_pl"><%= translate(locale, "Import YouTube playlist (.csv)") %></label>
<input type="file" id="import_youtube_pl" name="import_youtube_pl">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_youtube_wh"><%= translate(locale, "Import YouTube watch history (.json)") %></label>
<input type="file" id="import_youtube_wh" name="import_youtube_wh">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_freetube"><%= translate(locale, "Import FreeTube subscriptions (.db)") %></label>
<input type="file" id="import_freetube" name="import_freetube">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_newpipe_subscriptions"><%= translate(locale, "Import NewPipe subscriptions (.json)") %></label>
<input type="file" id="import_newpipe_subscriptions" name="import_newpipe_subscriptions">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="import_newpipe"><%= translate(locale, "Import NewPipe data (.zip)") %></label>
<input type="file" id="import_newpipe" name="import_newpipe">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Import") %></button>
<div class="action-controls">
<button type="submit" class="primary"><%= translate(locale, "Import") %></button>
</div>
</fieldset>
<fieldset class="form">
<legend><%= translate(locale, "Export") %></legend>
<div class="pure-control-group">
<div class="control-group">
<a href="/subscription_manager?action_takeout=1"><%= translate(locale, "Export subscriptions as OPML") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/subscription_manager?action_takeout=1&format=newpipe"><%= translate(locale, "Export subscriptions as OPML (for NewPipe & FreeTube)") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/subscription_manager?action_takeout=1&format=json"><%= translate(locale, "Export data as JSON") %></a>
</div>
</fieldset>

View File

@ -2,76 +2,84 @@
<title><%= translate(locale, "Log in") %> - Invidious</title>
<% end %>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5"></div>
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<% case account_type when %>
<% else # "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset>
<form action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset class="form">
<legend>Log in</legend>
<% if email %>
<div class="control-group">
<input name="email" type="hidden" value="<%= HTML.escape(email) %>">
</div>
<% else %>
<div class="control-group">
<label for="email"><%= translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
</div>
<% end %>
<% if password %>
<div class="control-group">
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
</div>
<% else %>
<div class="control-group">
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
</div>
<% end %>
</fieldset>
<% if captcha %>
<% case captcha_type when %>
<% when "image" %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
<img class="captcha" src='<%= captcha[:question] %>'/>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="image">
<div class="control-group">
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
</div>
<% else # "text" %>
<% captcha = captcha.not_nil! %>
<% captcha[:tokens].each_with_index do |token, i| %>
<input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>">
<% end %>
<input type="hidden" name="captcha_type" value="text">
<div class="control-group">
<label for="answer"><%= captcha[:question] %></label>
<input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
</div>
<% end %>
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<button type="submit" name="action" value="signin" class="primary">
<%= translate(locale, "Register") %>
</button>
<% case captcha_type when %>
<% when "image" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="text">
<button type="submit" name="change_type" class="primary" value="text">
<%= translate(locale, "Text CAPTCHA") %>
</button>
</label>
<% else # "text" %>
<label>
<button type="submit" name="change_type" class="pure-button pure-button-primary" value="image">
<button type="submit" name="change_type" class="primary" value="image">
<%= translate(locale, "Image CAPTCHA") %>
</button>
</label>
<% end %>
<% else %>
<div class="action-controls">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">
<%= translate(locale, "Sign In") %>/<%= translate(locale, "Register") %>
</button>
</div>
<% end %>
</fieldset>
</form>
<% end %>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
</div>

View File

@ -2,47 +2,48 @@
<title><%= translate(locale, "Preferences") %> - Invidious</title>
<% end %>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset>
<h1>Preferences</h1>
<form action="/preferences?referer=<%= URI.encode_www_form(referer) %>" method="post">
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_player") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="video_loop"><%= translate(locale, "preferences_video_loop_label") %></label>
<input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
<input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if preferences.continue %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="continue_autoplay"><%= translate(locale, "preferences_continue_autoplay_label") %></label>
<input name="continue_autoplay" id="continue_autoplay" type="checkbox" <% if preferences.continue_autoplay %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="local"><%= translate(locale, "preferences_local_label") %></label>
<input name="local" id="local" type="checkbox" <% if preferences.local && !CONFIG.disabled?("local") %>checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="listen"><%= translate(locale, "preferences_listen_label") %></label>
<input name="listen" id="listen" type="checkbox" <% if preferences.listen %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="speed"><%= translate(locale, "preferences_speed_label") %></label>
<select name="speed" id="speed">
<% {2.0, 1.75, 1.5, 1.25, 1.0, 0.75, 0.5, 0.25}.each do |option| %>
@ -51,7 +52,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
<select name="quality" id="quality">
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
@ -63,7 +64,7 @@
</div>
<% if !CONFIG.disabled?("dash") %>
<div class="pure-control-group">
<div class="control-group">
<label for="quality_dash"><%= translate(locale, "preferences_quality_dash_label") %></label>
<select name="quality_dash" id="quality_dash">
<% {"auto", "best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "worst"}.each do |option| %>
@ -73,14 +74,17 @@
</div>
<% end %>
<div class="pure-control-group">
<div class="control-group">
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
<div class="control-message">
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
<span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span>
<span id="volume-value"><%= preferences.volume %></span>
</div>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="comments[0]"><%= translate(locale, "preferences_comments_label") %></label>
<div>
<% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %>
@ -89,9 +93,11 @@
</select>
<% end %>
</div>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label>
<div>
<% preferences.captions.each_with_index do |caption, index| %>
<select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]">
<% Invidious::Videos::Captions::LANGUAGES.each do |option| %>
@ -100,35 +106,37 @@
</select>
<% end %>
</div>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="related_videos"><%= translate(locale, "preferences_related_videos_label") %></label>
<input name="related_videos" id="related_videos" type="checkbox" <% if preferences.related_videos %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="annotations"><%= translate(locale, "preferences_annotations_label") %></label>
<input name="annotations" id="annotations" type="checkbox" <% if preferences.annotations %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="extend_desc"><%= translate(locale, "preferences_extend_desc_label") %></label>
<input name="extend_desc" id="extend_desc" type="checkbox" <% if preferences.extend_desc %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="vr_mode"><%= translate(locale, "preferences_vr_mode_label") %></label>
<input name="vr_mode" id="vr_mode" type="checkbox" <% if preferences.vr_mode %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="save_player_pos"><%= translate(locale, "preferences_save_player_pos_label") %></label>
<input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>>
</div>
</fieldset>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_visual") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="locale"><%= translate(locale, "preferences_locale_label") %></label>
<select name="locale" id="locale">
<% LOCALES_LIST.each do |iso_name, full_name| %>
@ -137,7 +145,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="region"><%= translate(locale, "preferences_region_label") %></label>
<select name="region" id="region">
<% CONTENT_REGIONS.each do |option| %>
@ -146,7 +154,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="player_style"><%= translate(locale, "preferences_player_style_label") %></label>
<select name="player_style" id="player_style">
<% {"invidious", "youtube"}.each do |option| %>
@ -155,7 +163,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="dark_mode"><%= translate(locale, "preferences_dark_mode_label") %></label>
<select name="dark_mode" id="dark_mode">
<% {"", "light", "dark"}.each do |option| %>
@ -164,7 +172,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="thin_mode"><%= translate(locale, "preferences_thin_mode_label") %></label>
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
</div>
@ -175,7 +183,7 @@
<% feed_options = {"", "Popular", "Trending"} %>
<% end %>
<div class="pure-control-group">
<div class="control-group">
<label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="default_home" id="default_home">
<% feed_options.each do |option| %>
@ -184,8 +192,9 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<div>
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
@ -194,39 +203,46 @@
</select>
<% end %>
</div>
</div>
<% if env.get? "user" %>
<div class="pure-control-group">
<div class="control-group">
<label for="show_nick"><%= translate(locale, "preferences_show_nick_label") %></label>
<input name="show_nick" id="show_nick" type="checkbox" <% if preferences.show_nick %>checked<% end %>>
</div>
<% end %>
</fieldset>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_misc") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="automatic_instance_redirect"><%= translate(locale, "preferences_automatic_instance_redirect_label") %></label>
<input name="automatic_instance_redirect" id="automatic_instance_redirect" type="checkbox" <% if preferences.automatic_instance_redirect %>checked<% end %>>
</div>
</fieldset>
<% if env.get? "user" %>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_subscription") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="watch_history"><%= translate(locale, "preferences_watch_history_label") %></label>
<input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="annotations_subscribed"><%= translate(locale, "preferences_annotations_subscribed_label") %></label>
<input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="max_results"><%= translate(locale, "preferences_max_results_label") %></label>
<input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>">
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="sort"><%= translate(locale, "preferences_sort_label") %></label>
<select name="sort" id="sort">
<% {"published", "published - reverse", "alphabetically", "alphabetically - reverse", "channel name", "channel name - reverse"}.each do |option| %>
@ -235,7 +251,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<% if preferences.unseen_only %>
<label for="latest_only"><%= translate(locale, "Only show latest unwatched video from channel: ") %></label>
<% else %>
@ -244,30 +260,32 @@
<input name="latest_only" id="latest_only" type="checkbox" <% if preferences.latest_only %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="unseen_only"><%= translate(locale, "preferences_unseen_only_label") %></label>
<input name="unseen_only" id="unseen_only" type="checkbox" <% if preferences.unseen_only %>checked<% end %>>
</div>
<% if CONFIG.enable_user_notifications %>
<div class="pure-control-group">
<div class="control-group">
<label for="notifications_only"><%= translate(locale, "preferences_notifications_only_label") %></label>
<input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>>
</div>
<% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || CONFIG.https_only %>
<div class="pure-control-group">
<div class="control-group">
<a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
<% end %>
</fieldset>
<% end %>
<% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_admin") %></legend>
<div class="pure-control-group">
<div class="control-group">
<label for="admin_default_home"><%= translate(locale, "preferences_default_home_label") %></label>
<select name="admin_default_home" id="admin_default_home">
<% feed_options.each do |option| %>
@ -276,7 +294,7 @@
</select>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="admin_feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]">
@ -287,77 +305,79 @@
<% end %>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label>
<input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="captcha_enabled"><%= translate(locale, "CAPTCHA enabled: ") %></label>
<input name="captcha_enabled" id="captcha_enabled" type="checkbox" <% if CONFIG.captcha_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="login_enabled"><%= translate(locale, "Login enabled: ") %></label>
<input name="login_enabled" id="login_enabled" type="checkbox" <% if CONFIG.login_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="registration_enabled"><%= translate(locale, "Registration enabled: ") %></label>
<input name="registration_enabled" id="registration_enabled" type="checkbox" <% if CONFIG.registration_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="statistics_enabled"><%= translate(locale, "Report statistics: ") %></label>
<input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<div class="control-group">
<label for="modified_source_code_url"><%= translate(locale, "adminprefs_modified_source_code_url_label") %></label>
<input name="modified_source_code_url" id="modified_source_code_url" type="url" value="<%= CONFIG.modified_source_code_url %>">
</div>
</fieldset>
<% end %>
<% if env.get? "user" %>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_data") %></legend>
<div class="pure-control-group">
<div class="control-group">
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Clear watch history") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Change password") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Import/export data") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div>
<div class="pure-control-group">
<div class="control-group">
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Delete account") %></a>
</div>
</fieldset>
<% end %>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Save preferences") %></button>
<div class="action-controls">
<button type="submit" class="primary"><%= translate(locale, "Save preferences") %></button>
</div>
</fieldset>
</form>
</div>

View File

@ -2,51 +2,40 @@
<title><%= translate(locale, "Subscription manager") %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1-3">
<h3>
<div class="subscription-header">
<h1><%= translate(locale, "Subscription manager") %></h1>
<nav>
<ul>
<li>
<a href="/feed/subscriptions">
<%= translate_count(locale, "generic_subscriptions_count", subscriptions.size, NumberFormatting::HtmlSpan) %>
</a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:center">
</li>
<li>
<a href="/feed/history">
<%= translate(locale, "Watch history") %>
</a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
</li>
<li>
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>">
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
</li>
<ul>
</nav>
</div>
<div class="subscriptions">
<% subscriptions.each do |channel| %>
<div class="h-box">
<div class="pure-g<% if channel.deleted %> deleted <% end %>">
<div class="pure-u-2-5">
<h3 style="padding-left:0.5em">
<div class="<% if channel.deleted %> deleted <% end %>">
<h3>
<a href="/channel/<%= channel.id %>"><%= HTML.escape(channel.author) %></a>
</h3>
</div>
<div class="pure-u-2-5"></div>
<div class="pure-u-1-5" style="text-align:right">
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
<input class="secondary-button" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
</h3>
</div>
</div>
<% if subscriptions[-1].author != channel.author %>
<hr>
<% end %>
</div>
<% end %>
</div>

View File

@ -34,11 +34,11 @@
we're going to need to do it here in order to allow for translations.
-->
<style>
#descexpansionbutton ~ label > a::after {
#description-expansion ~ label::after {
content: "<%= translate(locale, "Show more") %>"
}
#descexpansionbutton:checked ~ label > a::after {
#description-expansion:checked ~ label::after {
content: "<%= translate(locale, "Show less") %>"
}
</style>
@ -70,13 +70,17 @@ we're going to need to do it here in order to allow for translations.
%>
</script>
<div id="player-container" class="h-box">
<main>
<article>
<div id="player-container">
<%= rendered "components/player" %>
</div>
</div>
<div class="h-box">
<header class="video-header">
<h1>
<%= title %>
</h1>
<div class="listen">
<% if params.listen %>
<a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam"></i>
@ -86,33 +90,33 @@ we're going to need to do it here in order to allow for translations.
<i class="icon ion-md-headset"></i>
</a>
<% end %>
</h1>
</div>
</header>
<div class="watch">
<aside class="watch-meta">
<% if !video.is_listed %>
<h3>
<h4>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
</h3>
</h4>
<% end %>
<% if video.reason %>
<h3>
<h4>
<%= video.reason %>
</h3>
</h4>
<% elsif video.premiere_timestamp.try &.> Time.utc %>
<h3>
<%= video.premiere_timestamp.try { |t| translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>
</h3>
<h4>
<time datetime="<%= video.premiere_timestamp.try { |t| t.to_s("%Y-%m-%d") } %>"><%= video.premiere_timestamp.try { |t| translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %></time>
</h4>
<% elsif video.live_now %>
<h3>
<%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %>
</h3>
<h4>
<time datetime="<%= video.premiere_timestamp.try { |t| t.to_s("%Y-%m-%d") } %>"><%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %></time>
</h4>
<% end %>
</div>
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5">
<div class="h-box">
<span id="watch-on-youtube">
<nav>
<ul>
<li id="watch-on-youtube">
<%-
link_yt_watch = URI.new(scheme: "https", host: "www.youtube.com", path: "/watch", query: "v=#{video.id}")
link_yt_embed = URI.new(scheme: "https", host: "www.youtube.com", path: "/embed/#{video.id}")
@ -124,15 +128,15 @@ we're going to need to do it here in order to allow for translations.
end
-%>
<a id="link-yt-watch" rel="noreferrer noopener" data-base-url="<%= link_yt_watch %>" href="<%= link_yt_watch %>"><%= translate(locale, "videoinfo_watch_on_youTube") %></a>
(<a id="link-yt-embed" rel="noreferrer noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>)
</span>
<a id="link-yt-embed" rel="noreferrer noopener" data-base-url="<%= link_yt_embed %>" href="<%= link_yt_embed %>"><%= translate(locale, "videoinfo_youTube_embed_link") %></a>
</li>
<p id="watch-on-another-invidious-instance">
<li id="watch-on-another-invidious-instance">
<%- link_iv_other = IV::Frontend::Misc.redirect_url(env) -%>
<a id="link-iv-other" data-base-url="<%= link_iv_other %>" href="<%= link_iv_other %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</p>
</li>
<p id="embed-link">
<li id="embed-link">
<%-
params_iv_embed = env.params.query.dup
params_iv_embed.delete_all("v")
@ -141,9 +145,9 @@ we're going to need to do it here in order to allow for translations.
link_iv_embed = IV::HttpServer::Utils.add_params_to_url(link_iv_embed, params_iv_embed)
-%>
<a id="link-iv-embed" data-base-url="<%= link_iv_embed %>" href="<%= link_iv_embed %>"><%= translate(locale, "videoinfo_invidious_embed_link") %></a>
</p>
</li>
<p id="annotations">
<li id="annotations">
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
<%= translate(locale, "Hide annotations") %>
@ -153,26 +157,26 @@ we're going to need to do it here in order to allow for translations.
<%=translate(locale, "Show annotations")%>
</a>
<% end %>
</p>
</li>
</ul>
</nav>
<% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %>
<form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank">
<div class="pure-control-group">
<label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label>
<select style="width:100%" name="playlist_id" id="playlist_id">
<form data-onsubmit="return_false" action="/playlist_ajax" method="post" target="_blank" class="watch-action-group">
<label for="playlists"><%= translate(locale, "Add to playlist") %></label>
<select name="playlist_id" id="playlists">
<% playlists.each do |plid, playlist_title| %>
<option data-plid="<%= plid %>" value="<%= plid %>"><%= HTML.escape(playlist_title) %></option>
<% end %>
</select>
</div>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<input type="hidden" name="action_add_video" value="1">
<input type="hidden" name="video_id" value="<%= video.id %>">
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="pure-button pure-button-primary">
<b><%= translate(locale, "Add to playlist") %></b>
<button data-onclick="add_playlist_video" data-id="<%= video.id %>" type="submit" class="secondary">
<%= translate(locale, "Add to playlist") %>
</button>
</form>
<script id="playlist_data" type="application/json">
@ -188,83 +192,87 @@ we're going to need to do it here in order to allow for translations.
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
<p id="dislikes" style="display: none; visibility: hidden;"></p>
<p id="genre"><%= translate(locale, "Genre: ") %>
<ul class="content-meta">
<li id="genre"><%= translate(locale, "Genre: ") %>
<% if !video.genre_url %>
<%= video.genre %>
<% else %>
<a href="<%= video.genre_url %>"><%= video.genre %></a>
<% end %>
</p>
</li>
<% if video.license %>
<li id="license">
<% if video.license.empty? %>
<p id="license"><%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %></p>
<%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %>
<% else %>
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
<%= translate(locale, "License: ") %><%= video.license %>
<% end %>
</li>
<% end %>
<p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p>
<p id="wilson" style="display: none; visibility: hidden;"></p>
<p id="rating" style="display: none; visibility: hidden;"></p>
<p id="engagement" style="display: none; visibility: hidden;"></p>
<li id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></li>
<% if video.allowed_regions.size != REGIONS.size %>
<p id="allowed_regions">
<li id="allowed_regions">
<% if video.allowed_regions.size < REGIONS.size // 2 %>
<%= translate(locale, "Whitelisted regions: ") %><%= video.allowed_regions.join(", ") %>
<% else %>
<%= translate(locale, "Blacklisted regions: ") %><%= (REGIONS.to_a - video.allowed_regions).join(", ") %>
<% end %>
</p>
</li>
<% end %>
</div>
</div>
</ul>
</aside>
<div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
<div class="pure-g h-box flexible title">
<div class="pure-u-1-2 flex-left flexible">
<a href="/channel/<%= video.ucid %>">
<div class="video">
<div class="channel">
<div class="title">
<div class="channel-profile">
<% if !video.author_thumbnail.empty? %>
<img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
<% end %>
<span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
</div>
<a href="/channel/<%= video.ucid %>">
<img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" class="profile-pic" alt="<%= author %>" />
</a>
<% end %>
<h2 id="channel-name">
<a href="/channel/<%= video.ucid %>">
<%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</a>
</h2>
</div>
<div class="pure-u-1-2 flex-right flexible button-container">
<div class="pure-u">
<div class="button-container">
<% sub_count_text = video.sub_count_text %>
<%= rendered "components/subscribe_widget" %>
</div>
</div>
</div>
<div class="h-box">
<p id="published-date">
<div class="video-meta">
<div>
<% if video.premiere_timestamp.try &.> Time.utc %>
<b><%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %></b>
<time datetime="<%= video.premiere_timestamp.try { |t| t.to_s("%Y-%m-%d") } %>">
<%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %>
</time>
<% else %>
<b><%= translate(locale, "Shared `x`", video.published.to_s("%B %-d, %Y")) %></b>
<% end %>
</p>
<div id="description-box"> <!-- Description -->
<% if video.description.size < 200 || params.extend_desc %>
<div id="descriptionWrapper"><%= video.description_html %></div>
<% else %>
<input id="descexpansionbutton" type="checkbox"/>
<div id="descriptionWrapper"><%= video.description_html %></div>
<label for="descexpansionbutton">
<a></a>
</label>
<time datetime="<%= video.published.to_s("%Y-%m-%d") %>">
<%= translate(locale, "Shared `x`", video.published.to_s("%B %-d, %Y")) %>
</time>
<% end %>
</div>
<div class="video-interaction-meta">
<div id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></div>
<div id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></div>
</div>
</div>
<hr>
<div id="description-box">
<% if video.description.size < 200 || params.extend_desc %>
<p id="description" class="raw-text"><%= video.description_html %></p>
<% else %>
<input id="description-expansion" type="checkbox"/>
<p id="description" class="raw-text"><%= video.description_html %></p>
<label for="description-expansion" role="button"></label>
<% end %>
</div>
</div>
<% if !video.music.empty? %>
<input id="music-desc-expansion" type="checkbox"/>
@ -279,16 +287,15 @@ we're going to need to do it here in order to allow for translations.
<div id="music-description-box">
<% video.music.each do |music| %>
<div class="music-item">
<p class="music-song"><%= translate(locale, "Song: ") %><%= music.song %></p>
<p class="music-artist"><%= translate(locale, "Artist: ") %><%= music.artist %></p>
<p class="music-album"><%= translate(locale, "Album: ") %><%= music.album %></p>
<div class="music-song"><%= translate(locale, "Song: ") %><%= music.song %></div>
<div class="music-artist"><%= translate(locale, "Artist: ") %><%= music.artist %></div>
<div class="music-album"><%= translate(locale, "Album: ") %><%= music.album %></div>
</div>
<% end %>
</div>
<hr>
<% end %>
<% end %>
<div id="comments" class="comments">
<aside id="comments">
<% if nojs %>
<%= comment_html %>
<% else %>
@ -298,77 +305,82 @@ we're going to need to do it here in order to allow for translations.
</a>
</noscript>
<% end %>
</div>
</div>
</aside>
</div>
<% if params.related_videos || plid %>
<div class="pure-u-1 pure-u-lg-1-5">
<aside class="more-videos">
<% if plid %>
<div id="playlist" class="h-box"></div>
<div id="playlist"></div>
<% end %>
<% if params.related_videos %>
<div class="h-box">
<div class="related-videos">
<% if !video.related_videos.empty? %>
<div <% if plid %>style="display:none"<% end %>>
<div class="pure-control-group">
<% if plid %>
<div class="control-group">
<label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if params.continue %>checked<% end %>>
</div>
<hr>
</div>
<% end %>
<% end %>
<% video.related_videos.each do |rv| %>
<% if rv["id"]? %>
<div class="pure-u-1">
<article class="video-card" dir="auto">
<div class="thumbnail">
<%- if !env.get("preferences").as(Preferences).thin_mode -%>
<a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" />
<%- if env.get("preferences").as(Preferences).thin_mode -%>
<a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>" title="<%= HTML.escape(rv["title"]) %>">
<div class="thumbnail-placeholder"></div>
</a>
<%- else -%>
<div class="thumbnail-placeholder"></div>
<a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>" title="<%= HTML.escape(rv["title"]) %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="<%= HTML.escape(rv["title"]) %>" />
</a>
<%- end -%>
<div class="bottom-right-overlay">
<%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%>
<p class="length"><%= recode_length_seconds(length_seconds) %></p>
<div class="length"><%= recode_length_seconds(length_seconds) %></div>
<%- end -%>
</div>
</div>
<div class="video-card-row">
<a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"]) %></p></a>
</div>
<h3>
<a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>" title="<%= HTML.escape(rv["title"]) %>">
<%= HTML.escape(rv["title"]) %>
</a>
</h3>
<h5 class="pure-g">
<div class="pure-u-14-24">
<div class="video-meta-sub">
<h4>
<% if !rv["ucid"].empty? %>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<a href="/channel/<%= rv["ucid"] %>" title="<%= HTML.escape(rv["author"]) %>">
<%= HTML.escape(rv["author"]) %>
<% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</a>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% end %>
</div>
</h4>
<div class="pure-u-10-24" style="text-align:right">
<b class="width:100%"><%=
<div>
<%=
views = rv["view_count"]?.try &.to_i?
views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) }
translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short)
%></b>
%>
</div>
</h5>
</div>
<% end %>
<% end %>
</article>
<% end %> <% # if rv["id"]? %>
<% end %> <% # video.related_videos.each do |rv| %>
</div> <% # <div class="related-videos"> %>
<% end %> <% # if params.related_videos %>
</aside>
<% end %> <% # if params.related_videos || plid %>
</div>
<% end %>
</div>
<% end %>
</div>
</article>
</main>
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>