This commit is contained in:
Dave Lage 2025-05-29 00:16:47 +00:00 committed by GitHub
commit e8bb81e29e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 7982 additions and 3952 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;
margin: 0;
padding: 0;
.search-homepage {
display: grid;
grid-template-rows: 1fr 1fr;
}
@media screen and (max-width: 1500px) and (max-height: 1000px) {
#logo > h1 {
font-size: 10vmin;
}
#search-widget > h1 {
align-self: flex-end;
font-size: 3em;
text-transform: uppercase;
margin: 0;
padding: 0;
text-align: center;
}
.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 @@
#filters-collapse summary {
/* This should hide the marker */
display: block;
font-size: 1.17em;
font-weight: bold;
margin: 0 auto 10px auto;
cursor: pointer;
summary {
display: block;
font-size: 1.17em;
margin: 0 auto 0.625em;
cursor: pointer;
}
#filters-collapse summary::-webkit-details-marker,
#filters-collapse summary::marker { display: none; }
#filters-collapse summary:before {
border-radius: 5px;
content: "[ + ]";
margin: -2px 10px 0 10px;
padding: 1px 0 3px 0;
text-align: center;
width: 40px;
summary::-webkit-details-marker,
summary::marker {
display: none;
}
#filters-collapse details[open] > summary:before { content: "[ ]"; }
#filters-box {
padding: 10px 20px 20px 10px;
margin: 10px 15px;
summary::before {
content: "+ ";
text-align: center;
}
#filters-flex {
details[open] > summary::before {
content: " ";
}
.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;
text-align: center;
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,254 +1,273 @@
'use strict';
"use strict";
// Contains only auxiliary methods
// May be included and executed unlimited number of times without any consequences
// Polyfills for IE11
Array.prototype.find = Array.prototype.find || function (condition) {
Array.prototype.find =
Array.prototype.find ||
function (condition) {
return this.filter(condition)[0];
};
};
Array.from = Array.from || function (source) {
Array.from =
Array.from ||
function (source) {
return Array.prototype.slice.call(source);
};
NodeList.prototype.forEach = NodeList.prototype.forEach || function (callback) {
};
NodeList.prototype.forEach =
NodeList.prototype.forEach ||
function (callback) {
Array.from(this).forEach(callback);
};
String.prototype.includes = String.prototype.includes || function (searchString) {
};
String.prototype.includes =
String.prototype.includes ||
function (searchString) {
return this.indexOf(searchString) >= 0;
};
String.prototype.startsWith = String.prototype.startsWith || function (prefix) {
};
String.prototype.startsWith =
String.prototype.startsWith ||
function (prefix) {
return this.substr(0, prefix.length) === prefix;
};
Math.sign = Math.sign || function(x) {
};
Math.sign =
Math.sign ||
function (x) {
x = +x;
if (!x) return x; // 0 and NaN
return x > 0 ? 1 : -1;
};
if (!window.hasOwnProperty('HTMLDetailsElement') && !window.hasOwnProperty('mockHTMLDetailsElement')) {
window.mockHTMLDetailsElement = true;
const style = 'details:not([open]) > :not(summary) {display: none}';
document.head.appendChild(document.createElement('style')).textContent = style;
};
if (
!window.hasOwnProperty("HTMLDetailsElement") &&
!window.hasOwnProperty("mockHTMLDetailsElement")
) {
window.mockHTMLDetailsElement = true;
const style = "details:not([open]) > :not(summary) {display: none}";
document.head.appendChild(document.createElement("style")).textContent =
style;
addEventListener('click', function (e) {
if (e.target.nodeName !== 'SUMMARY') return;
const details = e.target.parentElement;
if (details.hasAttribute('open'))
details.removeAttribute('open');
else
details.setAttribute('open', '');
});
addEventListener("click", function (e) {
if (e.target.nodeName !== "SUMMARY") return;
const details = e.target.parentElement;
if (details.hasAttribute("open")) details.removeAttribute("open");
else details.setAttribute("open", "");
});
}
// Monstrous global variable for handy code
// Includes: clamp, xhr, storage.{get,set,remove}
window.helpers = window.helpers || {
/**
* https://en.wikipedia.org/wiki/Clamping_(graphics)
* @param {Number} num Source number
* @param {Number} min Low border
* @param {Number} max High border
* @returns {Number} Clamped value
*/
clamp: function (num, min, max) {
if (max < min) {
var t = max; max = min; min = t; // swap max and min
/**
* https://en.wikipedia.org/wiki/Clamping_(graphics)
* @param {Number} num Source number
* @param {Number} min Low border
* @param {Number} max High border
* @returns {Number} Clamped value
*/
clamp: function (num, min, max) {
if (max < min) {
var t = max;
max = min;
min = t; // swap max and min
}
if (max < num) return max;
if (min > num) return min;
return num;
},
/** @private */
_xhr: function (method, url, options, callbacks) {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
// Default options
xhr.responseType = "json";
xhr.timeout = 10000;
// Default options redefining
if (options.responseType) xhr.responseType = options.responseType;
if (options.timeout) xhr.timeout = options.timeout;
if (method === "POST")
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
// better than onreadystatechange because of 404 codes https://stackoverflow.com/a/36182963
xhr.onloadend = function () {
if (xhr.status === 200) {
if (callbacks.on200) {
// fix for IE11. It doesn't convert response to JSON
if (xhr.responseType === "" && typeof xhr.response === "string")
callbacks.on200(JSON.parse(xhr.response));
else callbacks.on200(xhr.response);
}
} else {
// handled by onerror
if (xhr.status === 0) return;
if (max < num)
return max;
if (min > num)
return min;
return num;
},
if (callbacks.onNon200) callbacks.onNon200(xhr);
}
};
/** @private */
_xhr: function (method, url, options, callbacks) {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.ontimeout = function () {
if (callbacks.onTimeout) callbacks.onTimeout(xhr);
};
// Default options
xhr.responseType = 'json';
xhr.timeout = 10000;
// Default options redefining
if (options.responseType)
xhr.responseType = options.responseType;
if (options.timeout)
xhr.timeout = options.timeout;
xhr.onerror = function () {
if (callbacks.onError) callbacks.onError(xhr);
};
if (method === 'POST')
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
if (options.payload) xhr.send(options.payload);
else xhr.send();
},
/** @private */
_xhrRetry: function (method, url, options, callbacks) {
if (options.retries <= 0) {
console.warn("Failed to pull", options.entity_name);
if (callbacks.onTotalFail) callbacks.onTotalFail();
return;
}
helpers._xhr(method, url, options, callbacks);
},
/**
* @callback callbackXhrOn200
* @param {Object} response - xhr.response
*/
/**
* @callback callbackXhrError
* @param {XMLHttpRequest} xhr
*/
/**
* @param {'GET'|'POST'} method - 'GET' or 'POST'
* @param {String} url - URL to send request to
* @param {Object} options - other XHR options
* @param {XMLHttpRequestBodyInit} [options.payload=null] - payload for POST-requests
* @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [options.responseType=json]
* @param {Number} [options.timeout=10000]
* @param {Number} [options.retries=1]
* @param {String} [options.entity_name='unknown'] - string to log
* @param {Number} [options.retry_timeout=1000]
* @param {Object} callbacks - functions to execute on events fired
* @param {callbackXhrOn200} [callbacks.on200]
* @param {callbackXhrError} [callbacks.onNon200]
* @param {callbackXhrError} [callbacks.onTimeout]
* @param {callbackXhrError} [callbacks.onError]
* @param {callbackXhrError} [callbacks.onTotalFail] - if failed after all retries
*/
xhr: function (method, url, options, callbacks) {
if (!options.retries || options.retries <= 1) {
helpers._xhr(method, url, options, callbacks);
return;
}
// better than onreadystatechange because of 404 codes https://stackoverflow.com/a/36182963
xhr.onloadend = function () {
if (xhr.status === 200) {
if (callbacks.on200) {
// fix for IE11. It doesn't convert response to JSON
if (xhr.responseType === '' && typeof(xhr.response) === 'string')
callbacks.on200(JSON.parse(xhr.response));
else
callbacks.on200(xhr.response);
}
} else {
// handled by onerror
if (xhr.status === 0) return;
if (callbacks.onNon200)
callbacks.onNon200(xhr);
}
};
xhr.ontimeout = function () {
if (callbacks.onTimeout)
callbacks.onTimeout(xhr);
};
xhr.onerror = function () {
if (callbacks.onError)
callbacks.onError(xhr);
};
if (options.payload)
xhr.send(options.payload);
else
xhr.send();
},
/** @private */
_xhrRetry: function(method, url, options, callbacks) {
if (options.retries <= 0) {
console.warn('Failed to pull', options.entity_name);
if (callbacks.onTotalFail)
callbacks.onTotalFail();
return;
}
helpers._xhr(method, url, options, callbacks);
},
/**
* @callback callbackXhrOn200
* @param {Object} response - xhr.response
*/
/**
* @callback callbackXhrError
* @param {XMLHttpRequest} xhr
*/
/**
* @param {'GET'|'POST'} method - 'GET' or 'POST'
* @param {String} url - URL to send request to
* @param {Object} options - other XHR options
* @param {XMLHttpRequestBodyInit} [options.payload=null] - payload for POST-requests
* @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [options.responseType=json]
* @param {Number} [options.timeout=10000]
* @param {Number} [options.retries=1]
* @param {String} [options.entity_name='unknown'] - string to log
* @param {Number} [options.retry_timeout=1000]
* @param {Object} callbacks - functions to execute on events fired
* @param {callbackXhrOn200} [callbacks.on200]
* @param {callbackXhrError} [callbacks.onNon200]
* @param {callbackXhrError} [callbacks.onTimeout]
* @param {callbackXhrError} [callbacks.onError]
* @param {callbackXhrError} [callbacks.onTotalFail] - if failed after all retries
*/
xhr: function(method, url, options, callbacks) {
if (!options.retries || options.retries <= 1) {
helpers._xhr(method, url, options, callbacks);
return;
}
if (!options.entity_name) options.entity_name = 'unknown';
if (!options.retry_timeout) options.retry_timeout = 1000;
const retries_total = options.retries;
let currentTry = 1;
const retry = function () {
console.warn('Pulling ' + options.entity_name + ' failed... ' + (currentTry++) + '/' + retries_total);
setTimeout(function () {
options.retries--;
helpers._xhrRetry(method, url, options, callbacks);
}, options.retry_timeout);
};
// Pack retry() call into error handlers
callbacks._onError = callbacks.onError;
callbacks.onError = function (xhr) {
if (callbacks._onError)
callbacks._onError(xhr);
retry();
};
callbacks._onTimeout = callbacks.onTimeout;
callbacks.onTimeout = function (xhr) {
if (callbacks._onTimeout)
callbacks._onTimeout(xhr);
retry();
};
if (!options.entity_name) options.entity_name = "unknown";
if (!options.retry_timeout) options.retry_timeout = 1000;
const retries_total = options.retries;
let currentTry = 1;
const retry = function () {
console.warn(
"Pulling " +
options.entity_name +
" failed... " +
currentTry++ +
"/" +
retries_total,
);
setTimeout(function () {
options.retries--;
helpers._xhrRetry(method, url, options, callbacks);
},
}, options.retry_timeout);
};
/**
* @typedef {Object} invidiousStorage
* @property {(key:String) => Object} get
* @property {(key:String, value:Object)} set
* @property {(key:String)} remove
*/
// Pack retry() call into error handlers
callbacks._onError = callbacks.onError;
callbacks.onError = function (xhr) {
if (callbacks._onError) callbacks._onError(xhr);
retry();
};
callbacks._onTimeout = callbacks.onTimeout;
callbacks.onTimeout = function (xhr) {
if (callbacks._onTimeout) callbacks._onTimeout(xhr);
retry();
};
/**
* Universal storage, stores and returns JS objects. Uses inside localStorage or cookies
* @type {invidiousStorage}
*/
storage: (function () {
// access to localStorage throws exception in Tor Browser, so try is needed
let localStorageIsUsable = false;
try{localStorageIsUsable = !!localStorage.setItem;}catch(e){}
helpers._xhrRetry(method, url, options, callbacks);
},
if (localStorageIsUsable) {
return {
get: function (key) {
let storageItem = localStorage.getItem(key)
if (!storageItem) return;
try {
return JSON.parse(decodeURIComponent(storageItem));
} catch(e) {
// Erase non parsable value
helpers.storage.remove(key);
}
},
set: function (key, value) {
let encoded_value = encodeURIComponent(JSON.stringify(value))
localStorage.setItem(key, encoded_value);
},
remove: function (key) { localStorage.removeItem(key); }
};
/**
* @typedef {Object} invidiousStorage
* @property {(key:String) => Object} get
* @property {(key:String, value:Object)} set
* @property {(key:String)} remove
*/
/**
* Universal storage, stores and returns JS objects. Uses inside localStorage or cookies
* @type {invidiousStorage}
*/
storage: (function () {
// access to localStorage throws exception in Tor Browser, so try is needed
let localStorageIsUsable = false;
try {
localStorageIsUsable = !!localStorage.setItem;
} catch (e) {}
if (localStorageIsUsable) {
return {
get: function (key) {
let storageItem = localStorage.getItem(key);
if (!storageItem) return;
try {
return JSON.parse(decodeURIComponent(storageItem));
} catch (e) {
// Erase non parsable value
helpers.storage.remove(key);
}
},
set: function (key, value) {
let encoded_value = encodeURIComponent(JSON.stringify(value));
localStorage.setItem(key, encoded_value);
},
remove: function (key) {
localStorage.removeItem(key);
},
};
}
// TODO: fire 'storage' event for cookies
console.info(
"Storage: localStorage is disabled or unaccessible. Cookies used as fallback",
);
return {
get: function (key) {
const cookiePrefix = key + "=";
function findCallback(cookie) {
return cookie.startsWith(cookiePrefix);
}
const matchedCookie = document.cookie.split("; ").find(findCallback);
if (matchedCookie) {
const cookieBody = matchedCookie.replace(cookiePrefix, "");
if (cookieBody.length === 0) return;
try {
return JSON.parse(decodeURIComponent(cookieBody));
} catch (e) {
// Erase non parsable value
helpers.storage.remove(key);
}
}
},
set: function (key, value) {
const cookie_data = encodeURIComponent(JSON.stringify(value));
// TODO: fire 'storage' event for cookies
console.info('Storage: localStorage is disabled or unaccessible. Cookies used as fallback');
return {
get: function (key) {
const cookiePrefix = key + '=';
function findCallback(cookie) {return cookie.startsWith(cookiePrefix);}
const matchedCookie = document.cookie.split('; ').find(findCallback);
if (matchedCookie) {
const cookieBody = matchedCookie.replace(cookiePrefix, '');
if (cookieBody.length === 0) return;
try {
return JSON.parse(decodeURIComponent(cookieBody));
} catch(e) {
// Erase non parsable value
helpers.storage.remove(key);
}
}
},
set: function (key, value) {
const cookie_data = encodeURIComponent(JSON.stringify(value));
// Set expiration in 2 year
const date = new Date();
date.setFullYear(date.getFullYear() + 2);
// Set expiration in 2 year
const date = new Date();
date.setFullYear(date.getFullYear()+2);
document.cookie = key + '=' + cookie_data + '; expires=' + date.toGMTString();
},
remove: function (key) {
document.cookie = key + '=; Max-Age=0';
}
};
})()
document.cookie =
key + "=" + cookie_data + "; expires=" + date.toGMTString();
},
remove: function (key) {
document.cookie = key + "=; Max-Age=0";
},
};
})(),
};

View File

@ -1,174 +1,196 @@
var video_data = JSON.parse(document.getElementById('video_data').textContent);
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 spinnerHTMLwithHR = spinnerHTML + '<hr>';
var spinnerHTML =
'<div class="loading"><i class="icon ion-ios-refresh"></i></div>';
var spinnerHTMLwithHR = spinnerHTML + "<hr>";
String.prototype.supplant = function (o) {
return this.replace(/{([^{}]*)}/g, function (a, b) {
var r = o[b];
return typeof r === 'string' || typeof r === 'number' ? r : a;
});
return this.replace(/{([^{}]*)}/g, function (a, b) {
var r = o[b];
return typeof r === "string" || typeof r === "number" ? r : a;
});
};
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 = '';
} else {
target.textContent = '[ + ]';
body.style.display = 'none';
}
const target = event.target;
const comments = document.querySelector(".comments");
if (comments.style.display === "none") {
target.textContent = "";
comments.style.display = "";
} else {
target.textContent = "+";
comments.style.display = "none";
}
}
function hide_youtube_replies(event) {
var target = event.target;
var target = event.target;
var sub_text = target.getAttribute('data-inner-text');
var inner_text = target.getAttribute('data-sub-text');
var sub_text = target.getAttribute("data-inner-text");
var inner_text = target.getAttribute("data-sub-text");
var body = target.parentNode.parentNode.children[1];
body.style.display = 'none';
var body = target.parentNode.parentNode.children[1];
body.style.display = "none";
target.textContent = sub_text;
target.onclick = show_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
target.textContent = sub_text;
target.onclick = show_youtube_replies;
target.setAttribute("data-inner-text", inner_text);
target.setAttribute("data-sub-text", sub_text);
}
function show_youtube_replies(event) {
var target = event.target;
var target = event.target;
console.log(target);
var sub_text = target.getAttribute('data-inner-text');
var inner_text = target.getAttribute('data-sub-text');
var sub_text = target.getAttribute("data-inner-text");
var inner_text = target.getAttribute("data-sub-text");
var body = target.parentNode.parentNode.children[1];
body.style.display = '';
var body = target.parentNode.parentNode.children[1];
body.style.display = "";
target.textContent = sub_text;
target.onclick = hide_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
target.textContent = sub_text;
target.onclick = hide_youtube_replies;
target.setAttribute("data-inner-text", inner_text);
target.setAttribute("data-sub-text", sub_text);
}
function get_youtube_comments() {
var comments = document.getElementById('comments');
var comments = document.getElementById("comments");
var fallback = comments.innerHTML;
comments.innerHTML = spinnerHTML;
var fallback = comments.innerHTML;
comments.innerHTML = spinnerHTML;
var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id
var url = baseUrl +
'?format=html' +
'&hl=' + video_data.preferences.locale +
'&thin_mode=' + video_data.preferences.thin_mode;
var baseUrl = video_data.base_url || "/api/v1/comments/" + video_data.id;
var url =
baseUrl +
"?format=html" +
"&hl=" +
video_data.preferences.locale +
"&thin_mode=" +
video_data.preferences.thin_mode;
if (video_data.ucid) {
url += '&ucid=' + video_data.ucid
}
if (video_data.ucid) {
url += "&ucid=" + video_data.ucid;
}
var onNon200 = function (xhr) { comments.innerHTML = fallback; };
if (video_data.params.comments[1] === 'youtube')
onNon200 = function (xhr) {};
var onNon200 = function (xhr) {
comments.innerHTML = fallback;
};
if (video_data.params.comments[1] === "youtube") onNon200 = function (xhr) {};
helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, {
on200: function (response) {
var commentInnerHtml = ' \
<div> \
<h3> \
<a href="javascript:void(0)">[ ]</a> \
helpers.xhr(
"GET",
url,
{ retries: 5, entity_name: "comments" },
{
on200: function (response) {
var commentInnerHtml =
' \
<nav class="comments-header"> \
<ul> \
<li> \
<button class="secondary" id="toggle-comments"></button> \
{commentsText} \
</h3> \
<b> \
'
if (video_data.support_reddit) {
commentInnerHtml += ' <a href="javascript:void(0)" data-comments="reddit"> \
</li> \
\
<li>';
if (video_data.support_reddit) {
commentInnerHtml +=
' <button data-comments="reddit"> \
{redditComments} \
</a> \
'
}
commentInnerHtml += ' </b> \
</div> \
<div>{contentHtml}</div> \
<hr>'
commentInnerHtml = commentInnerHtml.supplant({
contentHtml: response.contentHtml,
redditComments: video_data.reddit_comments_text,
commentsText: video_data.comments_text.supplant({
// toLocaleString correctly splits number with local thousands separator. e.g.:
// '1,234,567.89' for user with English locale
// '1 234 567,89' for user with Russian locale
// '1.234.567,89' for user with Portuguese locale
commentCount: response.commentCount.toLocaleString()
})
});
comments.innerHTML = commentInnerHtml;
comments.children[0].children[0].children[0].onclick = toggle_comments;
if (video_data.support_reddit) {
comments.children[0].children[1].children[0].onclick = swap_comments;
}
},
onNon200: onNon200, // declared above
onError: function (xhr) {
comments.innerHTML = spinnerHTML;
},
onTimeout: function (xhr) {
comments.innerHTML = spinnerHTML;
</button> \
';
}
});
commentInnerHtml +=
' </li> \
</ul> \
</nav> \
<div class="comments">{contentHtml}</div>';
commentInnerHtml = commentInnerHtml.supplant({
contentHtml: response.contentHtml,
redditComments: video_data.reddit_comments_text,
commentsText: video_data.comments_text.supplant({
// toLocaleString correctly splits number with local thousands separator. e.g.:
// '1,234,567.89' for user with English locale
// '1 234 567,89' for user with Russian locale
// '1.234.567,89' for user with Portuguese locale
commentCount: response.commentCount.toLocaleString(),
}),
});
comments.innerHTML = commentInnerHtml;
document.getElementById("toggle-comments").onclick = toggle_comments;
if (video_data.support_reddit) {
comments.children[1].children[1].onclick = swap_comments;
}
},
onNon200: onNon200, // declared above
onError: function (xhr) {
comments.innerHTML = spinnerHTML;
},
onTimeout: function (xhr) {
comments.innerHTML = spinnerHTML;
},
},
);
}
function get_youtube_replies(target, load_more, load_replies) {
var continuation = target.getAttribute('data-continuation');
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML = spinnerHTML;
var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id
var url = baseUrl +
'?format=html' +
'&hl=' + video_data.preferences.locale +
'&thin_mode=' + video_data.preferences.thin_mode +
'&continuation=' + continuation;
var body = target.parentNode;
var fallback = body.innerHTML;
body.innerHTML = spinnerHTML;
var baseUrl = video_data.base_url || "/api/v1/comments/" + video_data.id;
var url =
baseUrl +
"?format=html" +
"&hl=" +
video_data.preferences.locale +
"&thin_mode=" +
video_data.preferences.thin_mode +
"&continuation=" +
continuation;
if (video_data.ucid) {
url += '&ucid=' + video_data.ucid
}
if (load_replies) url += '&action=action_get_comment_replies';
if (video_data.ucid) {
url += "&ucid=" + video_data.ucid;
}
if (load_replies) url += "&action=action_get_comment_replies";
helpers.xhr('GET', url, {}, {
on200: function (response) {
if (load_more) {
body = body.parentNode.parentNode;
body.removeChild(body.lastElementChild);
body.insertAdjacentHTML('beforeend', response.contentHtml);
} else {
body.removeChild(body.lastElementChild);
helpers.xhr(
"GET",
url,
{},
{
on200: function (response) {
if (load_more) {
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;
var div = document.createElement("div");
div.innerHTML = response.contentHtml;
body.appendChild(p);
body.appendChild(div);
}
},
onNon200: function (xhr) {
body.innerHTML = fallback;
},
onTimeout: function (xhr) {
console.warn('Pulling comments failed');
body.innerHTML = fallback;
body.appendChild(div);
}
});
}
},
onNon200: function (xhr) {
body.innerHTML = fallback;
},
onTimeout: function (xhr) {
console.warn("Pulling comments failed");
body.innerHTML = fallback;
},
},
);
}

View File

@ -1,82 +1,94 @@
'use strict';
var community_data = JSON.parse(document.getElementById('community_data').textContent);
"use strict";
var community_data = JSON.parse(
document.getElementById("community_data").textContent,
);
function hide_youtube_replies(event) {
var target = event.target;
var target = event.target;
var sub_text = target.getAttribute('data-inner-text');
var inner_text = target.getAttribute('data-sub-text');
var sub_text = target.getAttribute("data-inner-text");
var inner_text = target.getAttribute("data-sub-text");
var body = target.parentNode.parentNode.children[1];
body.style.display = 'none';
var body = target.parentNode.parentNode.children[1];
body.style.display = "none";
target.innerHTML = sub_text;
target.onclick = show_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
target.innerHTML = sub_text;
target.onclick = show_youtube_replies;
target.setAttribute("data-inner-text", inner_text);
target.setAttribute("data-sub-text", sub_text);
}
function show_youtube_replies(event) {
var target = event.target;
var target = event.target;
var sub_text = target.getAttribute('data-inner-text');
var inner_text = target.getAttribute('data-sub-text');
var sub_text = target.getAttribute("data-inner-text");
var inner_text = target.getAttribute("data-sub-text");
var body = target.parentNode.parentNode.children[1];
body.style.display = '';
var body = target.parentNode.parentNode.children[1];
body.style.display = "";
target.innerHTML = sub_text;
target.onclick = hide_youtube_replies;
target.setAttribute('data-inner-text', inner_text);
target.setAttribute('data-sub-text', sub_text);
target.innerHTML = sub_text;
target.onclick = hide_youtube_replies;
target.setAttribute("data-inner-text", inner_text);
target.setAttribute("data-sub-text", sub_text);
}
function get_youtube_replies(target, load_more) {
var continuation = target.getAttribute('data-continuation');
var continuation = target.getAttribute("data-continuation");
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
var body = target.parentNode.parentNode;
var fallback = body.innerHTML;
body.innerHTML =
'<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>';
var url = '/api/v1/channels/comments/' + community_data.ucid +
'?format=html' +
'&hl=' + community_data.preferences.locale +
'&thin_mode=' + community_data.preferences.thin_mode +
'&continuation=' + continuation;
var url =
"/api/v1/channels/comments/" +
community_data.ucid +
"?format=html" +
"&hl=" +
community_data.preferences.locale +
"&thin_mode=" +
community_data.preferences.thin_mode +
"&continuation=" +
continuation;
helpers.xhr('GET', url, {}, {
on200: function (response) {
if (load_more) {
body = body.parentNode.parentNode;
body.removeChild(body.lastElementChild);
body.innerHTML += response.contentHtml;
} else {
body.removeChild(body.lastElementChild);
helpers.xhr(
"GET",
url,
{},
{
on200: function (response) {
if (load_more) {
body = body.parentNode.parentNode;
body.removeChild(body.lastElementChild);
body.innerHTML += response.contentHtml;
} else {
body.removeChild(body.lastElementChild);
var p = document.createElement('p');
var a = document.createElement('a');
p.appendChild(a);
var p = document.createElement("p");
var a = document.createElement("a");
p.appendChild(a);
a.href = 'javascript:void(0)';
a.onclick = hide_youtube_replies;
a.setAttribute('data-sub-text', community_data.hide_replies_text);
a.setAttribute('data-inner-text', community_data.show_replies_text);
a.textContent = community_data.hide_replies_text;
a.href = "javascript:void(0)";
a.onclick = hide_youtube_replies;
a.setAttribute("data-sub-text", community_data.hide_replies_text);
a.setAttribute("data-inner-text", community_data.show_replies_text);
a.textContent = community_data.hide_replies_text;
var div = document.createElement('div');
div.innerHTML = response.contentHtml;
var div = document.createElement("div");
div.innerHTML = response.contentHtml;
body.appendChild(p);
body.appendChild(div);
}
},
onNon200: function (xhr) {
body.innerHTML = fallback;
},
onTimeout: function (xhr) {
console.warn('Pulling comments failed');
body.innerHTML = fallback;
body.appendChild(p);
body.appendChild(div);
}
});
},
onNon200: function (xhr) {
body.innerHTML = fallback;
},
onTimeout: function (xhr) {
console.warn("Pulling comments failed");
body.innerHTML = fallback;
},
},
);
}

View File

@ -1,64 +1,79 @@
'use strict';
var video_data = JSON.parse(document.getElementById('video_data').textContent);
"use strict";
var video_data = JSON.parse(document.getElementById("video_data").textContent);
function get_playlist(plid) {
var plid_url;
if (plid.startsWith('RD')) {
plid_url = '/api/v1/mixes/' + plid +
'?continuation=' + video_data.id +
'&format=html&hl=' + video_data.preferences.locale;
} else {
plid_url = '/api/v1/playlists/' + plid +
'?index=' + video_data.index +
'&continuation' + video_data.id +
'&format=html&hl=' + video_data.preferences.locale;
}
var plid_url;
if (plid.startsWith("RD")) {
plid_url =
"/api/v1/mixes/" +
plid +
"?continuation=" +
video_data.id +
"&format=html&hl=" +
video_data.preferences.locale;
} else {
plid_url =
"/api/v1/playlists/" +
plid +
"?index=" +
video_data.index +
"&continuation" +
video_data.id +
"&format=html&hl=" +
video_data.preferences.locale;
}
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) {
if (!response.nextVideo)
return;
helpers.xhr(
"GET",
plid_url,
{ retries: 5, entity_name: "playlist" },
{
on200: function (response) {
if (!response.nextVideo) return;
player.on('ended', function () {
var url = new URL('https://example.com/embed/' + response.nextVideo);
player.on("ended", function () {
var url = new URL("https://example.com/embed/" + response.nextVideo);
url.searchParams.set('list', plid);
if (!plid.startsWith('RD'))
url.searchParams.set('index', response.index);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set('autoplay', '1');
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set('listen', video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set('speed', video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set('local', video_data.params.local);
url.searchParams.set("list", plid);
if (!plid.startsWith("RD"))
url.searchParams.set("index", response.index);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set("autoplay", "1");
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set("listen", video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set("speed", video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set("local", video_data.params.local);
location.assign(url.pathname + url.search);
});
}
});
location.assign(url.pathname + url.search);
});
},
},
);
}
addEventListener('load', function (e) {
if (video_data.plid) {
get_playlist(video_data.plid);
} else if (video_data.video_series) {
player.on('ended', function () {
var url = new URL('https://example.com/embed/' + video_data.video_series.shift());
addEventListener("load", function (e) {
if (video_data.plid) {
get_playlist(video_data.plid);
} else if (video_data.video_series) {
player.on("ended", function () {
var url = new URL(
"https://example.com/embed/" + video_data.video_series.shift(),
);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set('autoplay', '1');
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set('listen', video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set('speed', video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set('local', video_data.params.local);
if (video_data.video_series.length !== 0)
url.searchParams.set('playlist', video_data.video_series.join(','));
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set("autoplay", "1");
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set("listen", video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set("speed", video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set("local", video_data.params.local);
if (video_data.video_series.length !== 0)
url.searchParams.set("playlist", video_data.video_series.join(","));
location.assign(url.pathname + url.search);
});
}
location.assign(url.pathname + url.search);
});
}
});

View File

@ -1,149 +1,220 @@
'use strict';
"use strict";
(function () {
var video_player = document.getElementById('player_html5_api');
if (video_player) {
video_player.onmouseenter = function () { video_player['data-title'] = video_player['title']; video_player['title'] = ''; };
video_player.onmouseleave = function () { video_player['title'] = video_player['data-title']; video_player['data-title'] = ''; };
video_player.oncontextmenu = function () { video_player['title'] = video_player['data-title']; };
var video_player = document.getElementById("player_html5_api");
if (video_player) {
video_player.onmouseenter = function () {
video_player["data-title"] = video_player["title"];
video_player["title"] = "";
};
video_player.onmouseleave = function () {
video_player["title"] = video_player["data-title"];
video_player["data-title"] = "";
};
video_player.oncontextmenu = function () {
video_player["title"] = video_player["data-title"];
};
}
// For dynamically inserted elements
addEventListener("click", function (e) {
if (!e || !e.target) return;
var t = e.target;
var handler_name = t.getAttribute("data-onclick");
switch (handler_name) {
case "jump_to_time":
e.preventDefault();
var time = t.getAttribute("data-jump-time");
player.currentTime(time);
break;
case "get_youtube_replies":
var load_more = t.getAttribute("data-load-more") !== null;
var load_replies = t.getAttribute("data-load-replies") !== null;
get_youtube_replies(t, load_more, load_replies);
break;
case "toggle_parent":
e.preventDefault();
toggle_parent(t);
break;
default:
break;
}
});
document
.querySelectorAll('[data-mouse="switch_classes"]')
.forEach(function (el) {
var classes = el.getAttribute("data-switch-classes").split(",");
var classOnEnter = classes[0];
var classOnLeave = classes[1];
function toggle_classes(toAdd, toRemove) {
el.classList.add(toAdd);
el.classList.remove(toRemove);
}
el.onmouseenter = function () {
toggle_classes(classOnEnter, classOnLeave);
};
el.onmouseleave = function () {
toggle_classes(classOnLeave, classOnEnter);
};
});
document
.querySelectorAll('[data-onsubmit="return_false"]')
.forEach(function (el) {
el.onsubmit = function () {
return false;
};
});
document
.querySelectorAll('[data-onclick="mark_watched"]')
.forEach(function (el) {
el.onclick = function () {
mark_watched(el);
};
});
document
.querySelectorAll('[data-onclick="mark_unwatched"]')
.forEach(function (el) {
el.onclick = function () {
mark_unwatched(el);
};
});
document
.querySelectorAll('[data-onclick="add_playlist_video"]')
.forEach(function (el) {
el.onclick = function (e) {
add_playlist_video(e);
};
});
document
.querySelectorAll('[data-onclick="add_playlist_item"]')
.forEach(function (el) {
el.onclick = function (e) {
add_playlist_item(e);
};
});
document
.querySelectorAll('[data-onclick="remove_playlist_item"]')
.forEach(function (el) {
el.onclick = function (e) {
remove_playlist_item(e);
};
});
document
.querySelectorAll('[data-onclick="revoke_token"]')
.forEach(function (el) {
el.onclick = function () {
revoke_token(el);
};
});
document
.querySelectorAll('[data-onclick="remove_subscription"]')
.forEach(function (el) {
el.onclick = function () {
remove_subscription(el);
};
});
document
.querySelectorAll('[data-onclick="notification_requestPermission"]')
.forEach(function (el) {
el.onclick = function () {
Notification.requestPermission();
};
});
document
.querySelectorAll('[data-onrange="update_volume_value"]')
.forEach(function (el) {
function update_volume_value() {
document.getElementById("volume-value").textContent = el.value;
}
el.oninput = update_volume_value;
el.onchange = update_volume_value;
});
function revoke_token(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = "none";
var count = document.getElementById("count");
count.textContent--;
var url =
"/token_ajax?action=revoke_token&redirect=false" +
"&referer=" +
encodeURIComponent(location.href) +
"&session=" +
target.getAttribute("data-session");
var payload =
"csrf_token=" +
target.parentNode.querySelector('input[name="csrf_token"]').value;
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
count.textContent++;
row.style.display = "";
},
},
);
}
function remove_subscription(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = "none";
var count = document.getElementById("count");
count.textContent--;
var url =
"/subscription_ajax?action=remove_subscriptions&redirect=false" +
"&referer=" +
encodeURIComponent(location.href) +
"&c=" +
target.getAttribute("data-ucid");
var payload =
"csrf_token=" +
target.parentNode.querySelector('input[name="csrf_token"]').value;
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
count.textContent++;
row.style.display = "";
},
},
);
}
// Handle keypresses
addEventListener("keydown", function (event) {
// Ignore modifier keys
if (event.ctrlKey || event.metaKey) return;
// Ignore shortcuts if any text input is focused
let focused_tag = document.activeElement.tagName.toLowerCase();
const allowed = /^(button|checkbox|file|radio|submit)$/;
if (focused_tag === "textarea") return;
if (focused_tag === "input") {
let focused_type = document.activeElement.type.toLowerCase();
if (!allowed.test(focused_type)) return;
}
// For dynamically inserted elements
addEventListener('click', function (e) {
if (!e || !e.target) return;
var t = e.target;
var handler_name = t.getAttribute('data-onclick');
switch (handler_name) {
case 'jump_to_time':
e.preventDefault();
var time = t.getAttribute('data-jump-time');
player.currentTime(time);
break;
case 'get_youtube_replies':
var load_more = t.getAttribute('data-load-more') !== null;
var load_replies = t.getAttribute('data-load-replies') !== null;
get_youtube_replies(t, load_more, load_replies);
break;
case 'toggle_parent':
e.preventDefault();
toggle_parent(t);
break;
default:
break;
}
});
document.querySelectorAll('[data-mouse="switch_classes"]').forEach(function (el) {
var classes = el.getAttribute('data-switch-classes').split(',');
var classOnEnter = classes[0];
var classOnLeave = classes[1];
function toggle_classes(toAdd, toRemove) {
el.classList.add(toAdd);
el.classList.remove(toRemove);
}
el.onmouseenter = function () { toggle_classes(classOnEnter, classOnLeave); };
el.onmouseleave = function () { toggle_classes(classOnLeave, classOnEnter); };
});
document.querySelectorAll('[data-onsubmit="return_false"]').forEach(function (el) {
el.onsubmit = function () { return false; };
});
document.querySelectorAll('[data-onclick="mark_watched"]').forEach(function (el) {
el.onclick = function () { mark_watched(el); };
});
document.querySelectorAll('[data-onclick="mark_unwatched"]').forEach(function (el) {
el.onclick = function () { mark_unwatched(el); };
});
document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) {
el.onclick = function () { add_playlist_video(el); };
});
document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) {
el.onclick = function () { add_playlist_item(el); };
});
document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) {
el.onclick = function () { remove_playlist_item(el); };
});
document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) {
el.onclick = function () { revoke_token(el); };
});
document.querySelectorAll('[data-onclick="remove_subscription"]').forEach(function (el) {
el.onclick = function () { remove_subscription(el); };
});
document.querySelectorAll('[data-onclick="notification_requestPermission"]').forEach(function (el) {
el.onclick = function () { Notification.requestPermission(); };
});
document.querySelectorAll('[data-onrange="update_volume_value"]').forEach(function (el) {
function update_volume_value() {
document.getElementById('volume-value').textContent = el.value;
}
el.oninput = update_volume_value;
el.onchange = update_volume_value;
});
function revoke_token(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.textContent--;
var url = '/token_ajax?action=revoke_token&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&session=' + target.getAttribute('data-session');
var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value;
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
count.textContent++;
row.style.display = '';
}
});
// Focus search bar on '/'
if (event.key === "/") {
document.getElementById("searchbox").focus();
event.preventDefault();
}
function remove_subscription(target) {
var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
row.style.display = 'none';
var count = document.getElementById('count');
count.textContent--;
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&referer=' + encodeURIComponent(location.href) +
'&c=' + target.getAttribute('data-ucid');
var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value;
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
count.textContent++;
row.style.display = '';
}
});
}
// Handle keypresses
addEventListener('keydown', function (event) {
// Ignore modifier keys
if (event.ctrlKey || event.metaKey) return;
// Ignore shortcuts if any text input is focused
let focused_tag = document.activeElement.tagName.toLowerCase();
const allowed = /^(button|checkbox|file|radio|submit)$/;
if (focused_tag === 'textarea') return;
if (focused_tag === 'input') {
let focused_type = document.activeElement.type.toLowerCase();
if (!allowed.test(focused_type)) return;
}
// Focus search bar on '/'
if (event.key === '/') {
document.getElementById('searchbox').focus();
event.preventDefault();
}
});
});
})();

View File

@ -1,131 +1,200 @@
'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', {
withCredentials: true,
payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId; }).join(','),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
delivered = [];
// 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", {
withCredentials: true,
payload:
"topics=" +
subscriptions
.map(function (subscription) {
return subscription.authorId;
})
.join(","),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
delivered = [];
var start_time = Math.round(new Date() / 1000);
var start_time = Math.round(new Date() / 1000);
notifications.onmessage = function (event) {
if (!event.id) return;
notifications.onmessage = function (event) {
if (!event.id) return;
var notification = JSON.parse(event.data);
console.info('Got notification:', notification);
var notification = JSON.parse(event.data);
console.info("Got notification:", notification);
// Ignore not actual and delivered notifications
if (start_time > notification.published || delivered.includes(notification.videoId)) return;
// Ignore not actual and delivered notifications
if (
start_time > notification.published ||
delivered.includes(notification.videoId)
)
return;
delivered.push(notification.videoId);
delivered.push(notification.videoId);
let notification_count = helpers.storage.get(STORAGE_KEY_NOTIF_COUNT) || 0;
notification_count++;
helpers.storage.set(STORAGE_KEY_NOTIF_COUNT, notification_count);
let notification_count = helpers.storage.get(STORAGE_KEY_NOTIF_COUNT) || 0;
notification_count++;
helpers.storage.set(STORAGE_KEY_NOTIF_COUNT, notification_count);
update_ticker_count();
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);
// 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);
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
});
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,
});
system_notification.onclick = function (e) {
open('/watch?v=' + notification.videoId, '_blank');
};
}
};
system_notification.onclick = function (e) {
open("/watch?v=" + notification.videoId, "_blank");
};
}
};
notifications.addEventListener('error', function (e) {
console.warn('Something went wrong with notifications, trying to reconnect...');
notifications = notifications_mock;
setTimeout(get_subscriptions, 1000);
});
notifications.addEventListener("error", function (e) {
console.warn(
"Something went wrong with notifications, trying to reconnect...",
);
notifications = notifications_mock;
});
notifications.stream();
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>';
} else {
notification_ticker.innerHTML =
'<i class="icon ion-ios-notifications-outline"></i>';
}
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>';
} else {
notification_ticker.innerHTML =
'<i class="icon ion-ios-notifications-outline"></i>';
}
}
function start_stream_if_needed() {
// random wait for other tabs set 'stream' flag
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
// random wait for other tabs set 'stream' flag
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
}
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 (notifications) {
// restore it if we have active stream
helpers.storage.set(STORAGE_KEY_STREAM, true);
} else {
start_stream_if_needed();
}
// if 'stream' key was removed
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);
} else {
start_stream_if_needed();
}
}
});
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);
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))
helpers.storage.remove(STORAGE_KEY_STREAM);
start_stream_if_needed();
if (helpers.storage.get(STORAGE_KEY_STREAM))
helpers.storage.remove(STORAGE_KEY_STREAM);
start_stream_if_needed();
});
addEventListener('unload', function () {
// let chance to other tabs to be a streamer via firing 'storage' event
if (notifications) helpers.storage.remove(STORAGE_KEY_STREAM);
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

@ -1,93 +1,103 @@
'use strict';
"use strict";
const CURRENT_CONTINUATION = (new URL(document.location)).searchParams.get("continuation");
const CURRENT_CONTINUATION = new URL(document.location).searchParams.get(
"continuation",
);
const CONT_CACHE_KEY = `continuation_cache_${encodeURIComponent(window.location.pathname)}`;
function get_data(){
return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
function get_data() {
return JSON.parse(sessionStorage.getItem(CONT_CACHE_KEY)) || [];
}
function save_data(){
const prev_data = get_data();
prev_data.push(CURRENT_CONTINUATION);
function save_data() {
const prev_data = get_data();
prev_data.push(CURRENT_CONTINUATION);
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
}
function button_press(){
let prev_data = get_data();
if (!prev_data.length) return null;
function button_press() {
let prev_data = get_data();
if (!prev_data.length) return null;
// Sanity check. Nowhere should the current continuation token exist in the cache
// but it can happen when using the browser's back feature. As such we'd need to travel
// back to the point where the current continuation token first appears in order to
// account for the rewind.
const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
if (conflict_at != -1) {
prev_data.length = conflict_at;
}
// Sanity check. Nowhere should the current continuation token exist in the cache
// but it can happen when using the browser's back feature. As such we'd need to travel
// back to the point where the current continuation token first appears in order to
// account for the rewind.
const conflict_at = prev_data.indexOf(CURRENT_CONTINUATION);
if (conflict_at != -1) {
prev_data.length = conflict_at;
}
const prev_ctoken = prev_data.pop();
// On the first page, the stored continuation token is null.
if (prev_ctoken === null) {
sessionStorage.removeItem(CONT_CACHE_KEY);
let url = set_continuation();
window.location.href = url;
return;
}
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
let url = set_continuation(prev_ctoken);
const prev_ctoken = prev_data.pop();
// On the first page, the stored continuation token is null.
if (prev_ctoken === null) {
sessionStorage.removeItem(CONT_CACHE_KEY);
let url = set_continuation();
window.location.href = url;
};
return;
}
sessionStorage.setItem(CONT_CACHE_KEY, JSON.stringify(prev_data));
let url = set_continuation(prev_ctoken);
window.location.href = url;
}
// Method to set the current page's continuation token
// Removes the continuation parameter when a continuation token is not given
function set_continuation(prev_ctoken = null){
let url = window.location.href.split('?')[0];
let params = window.location.href.split('?')[1];
let url_params = new URLSearchParams(params);
function set_continuation(prev_ctoken = null) {
let url = window.location.href.split("?")[0];
let params = window.location.href.split("?")[1];
let url_params = new URLSearchParams(params);
if (prev_ctoken) {
url_params.set("continuation", prev_ctoken);
} else {
url_params.delete('continuation');
};
if (prev_ctoken) {
url_params.set("continuation", prev_ctoken);
} else {
url_params.delete("continuation");
}
if(Array.from(url_params).length > 0){
return `${url}?${url_params.toString()}`;
} else {
return url;
}
if (Array.from(url_params).length > 0) {
return `${url}?${url_params.toString()}`;
} else {
return url;
}
}
addEventListener('DOMContentLoaded', function(){
const pagination_data = JSON.parse(document.getElementById('pagination-data').textContent);
const next_page_containers = document.getElementsByClassName("page-next-container");
addEventListener("DOMContentLoaded", function () {
const pagination_data = JSON.parse(
document.getElementById("pagination-data").textContent,
);
const next_page_containers = document.getElementsByClassName(
"page-next-container",
);
for (let container of next_page_containers){
const next_page_button = container.getElementsByClassName("pure-button")
for (let container of next_page_containers) {
const next_page_button = container.getElementsByClassName("pure-button");
// exists?
if (next_page_button.length > 0){
next_page_button[0].addEventListener("click", save_data);
}
// exists?
if (next_page_button.length > 0) {
next_page_button[0].addEventListener("click", save_data);
}
}
// Only add previous page buttons when not on the first page
if (CURRENT_CONTINUATION) {
const prev_page_containers = document.getElementsByClassName("page-prev-container")
// Only add previous page buttons when not on the first page
if (CURRENT_CONTINUATION) {
const prev_page_containers = document.getElementsByClassName(
"page-prev-container",
);
for (let container of prev_page_containers) {
if (pagination_data.is_rtl) {
container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page}&nbsp;&nbsp;<i class="icon ion-ios-arrow-forward"></i></button>`
} else {
container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i>&nbsp;&nbsp;${pagination_data.prev_page}</button>`
}
container.getElementsByClassName("pure-button")[0].addEventListener("click", button_press);
}
for (let container of prev_page_containers) {
if (pagination_data.is_rtl) {
container.innerHTML = `<button class="pure-button pure-button-secondary">${pagination_data.prev_page}&nbsp;&nbsp;<i class="icon ion-ios-arrow-forward"></i></button>`;
} else {
container.innerHTML = `<button class="pure-button pure-button-secondary"><i class="icon ion-ios-arrow-back"></i>&nbsp;&nbsp;${pagination_data.prev_page}</button>`;
}
container
.getElementsByClassName("pure-button")[0]
.addEventListener("click", button_press);
}
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,83 @@
'use strict';
var playlist_data = JSON.parse(document.getElementById('playlist_data').textContent);
var payload = 'csrf_token=' + playlist_data.csrf_token;
"use strict";
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];
var option = select.children[select.selectedIndex];
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&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + option.getAttribute('data-plid');
var url =
"/playlist_ajax?action=add_video&redirect=false" +
"&video_id=" +
target.getAttribute("data-id") +
"&playlist_id=" +
option.getAttribute("data-plid");
helpers.xhr('POST', url, {payload: payload}, {
on200: function (response) {
option.textContent = '✓' + option.textContent;
}
});
helpers.xhr(
"POST",
url,
{ payload: payload },
{
on200: function (response) {
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&redirect=false' +
'&video_id=' + target.getAttribute('data-id') +
'&playlist_id=' + target.getAttribute('data-plid');
var url =
"/playlist_ajax?action=add_video&redirect=false" +
"&video_id=" +
target.getAttribute("data-id") +
"&playlist_id=" +
target.getAttribute("data-plid");
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
tile.style.display = '';
}
});
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
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&redirect=false' +
'&set_video_id=' + target.getAttribute('data-index') +
'&playlist_id=' + target.getAttribute('data-plid');
var url =
"/playlist_ajax?action=remove_video&redirect=false" +
"&set_video_id=" +
target.getAttribute("data-index") +
"&playlist_id=" +
target.getAttribute("data-plid");
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
tile.style.display = '';
}
});
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
card.classList.remove("hide");
},
},
);
}

View File

@ -1,3 +1,3 @@
addEventListener('load', function (e) {
get_youtube_comments();
addEventListener("load", function (e) {
get_youtube_comments();
});

File diff suppressed because one or more lines are too long

View File

@ -17,18 +17,18 @@ var SSE = function (url, options) {
options = options || {};
this.headers = options.headers || {};
this.payload = options.payload !== undefined ? options.payload : '';
this.method = options.method || (this.payload && 'POST' || 'GET');
this.payload = options.payload !== undefined ? options.payload : "";
this.method = options.method || (this.payload && "POST") || "GET";
this.FIELD_SEPARATOR = ':';
this.FIELD_SEPARATOR = ":";
this.listeners = {};
this.xhr = null;
this.readyState = this.INITIALIZING;
this.progress = 0;
this.chunk = '';
this.chunk = "";
this.addEventListener = function(type, listener) {
this.addEventListener = function (type, listener) {
if (this.listeners[type] === undefined) {
this.listeners[type] = [];
}
@ -38,13 +38,13 @@ var SSE = function (url, options) {
}
};
this.removeEventListener = function(type, listener) {
this.removeEventListener = function (type, listener) {
if (this.listeners[type] === undefined) {
return;
}
var filtered = [];
this.listeners[type].forEach(function(element) {
this.listeners[type].forEach(function (element) {
if (element !== listener) {
filtered.push(element);
}
@ -56,14 +56,14 @@ var SSE = function (url, options) {
}
};
this.dispatchEvent = function(e) {
this.dispatchEvent = function (e) {
if (!e) {
return true;
}
e.source = this;
var onHandler = 'on' + e.type;
var onHandler = "on" + e.type;
if (this.hasOwnProperty(onHandler)) {
this[onHandler].call(this, e);
if (e.defaultPrevented) {
@ -72,7 +72,7 @@ var SSE = function (url, options) {
}
if (this.listeners[e.type]) {
return this.listeners[e.type].every(function(callback) {
return this.listeners[e.type].every(function (callback) {
callback(e);
return !e.defaultPrevented;
});
@ -82,78 +82,82 @@ var SSE = function (url, options) {
};
this._setReadyState = function (state) {
var event = new CustomEvent('readystatechange');
var event = new CustomEvent("readystatechange");
event.readyState = state;
this.readyState = state;
this.dispatchEvent(event);
};
this._onStreamFailure = function(e) {
this.dispatchEvent(new CustomEvent('error'));
this._onStreamFailure = function (e) {
this.dispatchEvent(new CustomEvent("error"));
this.close();
}
};
this._onStreamProgress = function(e) {
this._onStreamProgress = function (e) {
if (this.xhr.status !== 200 && this.readyState !== this.CLOSED) {
this._onStreamFailure(e);
return;
}
if (this.readyState == this.CONNECTING) {
this.dispatchEvent(new CustomEvent('open'));
this.dispatchEvent(new CustomEvent("open"));
this._setReadyState(this.OPEN);
}
var data = this.xhr.responseText.substring(this.progress);
this.progress += data.length;
data.split(/(\r\n|\r|\n){2}/g).forEach(function(part) {
if (part.trim().length === 0) {
this.dispatchEvent(this._parseEventChunk(this.chunk.trim()));
this.chunk = '';
} else {
this.chunk += part;
}
}.bind(this));
data.split(/(\r\n|\r|\n){2}/g).forEach(
function (part) {
if (part.trim().length === 0) {
this.dispatchEvent(this._parseEventChunk(this.chunk.trim()));
this.chunk = "";
} else {
this.chunk += part;
}
}.bind(this),
);
};
this._onStreamLoaded = function(e) {
this._onStreamLoaded = function (e) {
this._onStreamProgress(e);
// Parse the last chunk.
this.dispatchEvent(this._parseEventChunk(this.chunk));
this.chunk = '';
this.chunk = "";
};
/**
* Parse a received SSE event chunk into a constructed event object.
*/
this._parseEventChunk = function(chunk) {
this._parseEventChunk = function (chunk) {
if (!chunk || chunk.length === 0) {
return null;
}
var e = {'id': null, 'retry': null, 'data': '', 'event': 'message'};
chunk.split(/\n|\r\n|\r/).forEach(function(line) {
line = line.trimRight();
var index = line.indexOf(this.FIELD_SEPARATOR);
if (index <= 0) {
// Line was either empty, or started with a separator and is a comment.
// Either way, ignore.
return;
}
var e = { id: null, retry: null, data: "", event: "message" };
chunk.split(/\n|\r\n|\r/).forEach(
function (line) {
line = line.trimRight();
var index = line.indexOf(this.FIELD_SEPARATOR);
if (index <= 0) {
// Line was either empty, or started with a separator and is a comment.
// Either way, ignore.
return;
}
var field = line.substring(0, index);
if (!(field in e)) {
return;
}
var field = line.substring(0, index);
if (!(field in e)) {
return;
}
var value = line.substring(index + 1).trimLeft();
if (field === 'data') {
e[field] += value;
} else {
e[field] = value;
}
}.bind(this));
var value = line.substring(index + 1).trimLeft();
if (field === "data") {
e[field] += value;
} else {
e[field] = value;
}
}.bind(this),
);
var event = new CustomEvent(e.event);
event.data = e.data;
@ -161,21 +165,24 @@ var SSE = function (url, options) {
return event;
};
this._checkStreamClosed = function() {
this._checkStreamClosed = function () {
if (this.xhr.readyState === XMLHttpRequest.DONE) {
this._setReadyState(this.CLOSED);
}
};
this.stream = function() {
this.stream = function () {
this._setReadyState(this.CONNECTING);
this.xhr = new XMLHttpRequest();
this.xhr.addEventListener('progress', this._onStreamProgress.bind(this));
this.xhr.addEventListener('load', this._onStreamLoaded.bind(this));
this.xhr.addEventListener('readystatechange', this._checkStreamClosed.bind(this));
this.xhr.addEventListener('error', this._onStreamFailure.bind(this));
this.xhr.addEventListener('abort', this._onStreamFailure.bind(this));
this.xhr.addEventListener("progress", this._onStreamProgress.bind(this));
this.xhr.addEventListener("load", this._onStreamLoaded.bind(this));
this.xhr.addEventListener(
"readystatechange",
this._checkStreamClosed.bind(this),
);
this.xhr.addEventListener("error", this._onStreamFailure.bind(this));
this.xhr.addEventListener("abort", this._onStreamFailure.bind(this));
this.xhr.open(this.method, this.url);
for (var header in this.headers) {
this.xhr.setRequestHeader(header, this.headers[header]);
@ -183,7 +190,7 @@ var SSE = function (url, options) {
this.xhr.send(this.payload);
};
this.close = function() {
this.close = function () {
if (this.readyState === this.CLOSED) {
return;
}
@ -195,6 +202,6 @@ var SSE = function (url, options) {
};
// Export our SSE module for npm.js
if (typeof exports !== 'undefined') {
if (typeof exports !== "undefined") {
exports.SSE = SSE;
}

View File

@ -1,44 +1,80 @@
'use strict';
var subscribe_data = JSON.parse(document.getElementById('subscribe_data').textContent);
var payload = 'csrf_token=' + subscribe_data.csrf_token;
"use strict";
var subscribe_data = JSON.parse(
document.getElementById("subscribe_data").textContent,
);
var payload = "csrf_token=" + subscribe_data.csrf_token;
var subscribe_button = document.getElementById('subscribe');
subscribe_button.parentNode.action = 'javascript:void(0)';
var subscribe_button = document.getElementById("subscribe");
if (subscribe_button.getAttribute('data-type') === 'subscribe') {
subscribe_button.onclick = subscribe;
if (subscribe_button.getAttribute("data-type") === "subscribe") {
subscribe_button.onclick = subscribe;
} else {
subscribe_button.onclick = unsubscribe;
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>';
var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' +
'&c=' + subscribe_data.ucid;
helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, {
onNon200: function (xhr) {
subscribe_button.onclick = subscribe;
subscribe_button.innerHTML = fallback;
}
});
}
function unsubscribe() {
var fallback = subscribe_button.innerHTML;
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.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>';
var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' +
'&c=' + subscribe_data.ucid;
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.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&redirect=false" +
"&c=" +
subscribe_data.ucid;
helpers.xhr(
"POST",
url,
{ payload: payload, retries: 5, entity_name: "subscribe request" },
{
onNon200: function (xhr) {
subscribe_button.onclick = subscribe;
subscribe_button.textContent = fallback;
},
},
);
}
function unsubscribe(e) {
e.preventDefault();
var fallback = subscribe_button.textContent;
toggleSubscribeButton();
var url =
"/subscription_ajax?action=remove_subscriptions&redirect=false" +
"&c=" +
subscribe_data.ucid;
helpers.xhr(
"POST",
url,
{ payload: payload, retries: 5, entity_name: "unsubscribe request" },
{
onNon200: function (xhr) {
subscribe_button.onclick = unsubscribe;
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,46 +1,46 @@
'use strict';
var toggle_theme = document.getElementById('toggle_theme');
toggle_theme.href = 'javascript:void(0)';
"use strict";
var toggle_theme = document.getElementById("toggle_theme");
const STORAGE_KEY_THEME = 'dark_mode';
const THEME_DARK = 'dark';
const THEME_LIGHT = 'light';
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 () {
const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK;
const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK;
setTheme(newTheme);
helpers.storage.set(STORAGE_KEY_THEME, newTheme);
helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {});
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);
helpers.storage.set(STORAGE_KEY_THEME, newTheme);
helpers.xhr("GET", "/toggle_theme?redirect=false", {}, {});
});
/** @param {THEME_DARK|THEME_LIGHT} theme */
function setTheme(theme) {
// By default body element has .no-theme class that uses OS theme via CSS @media rules
// It rewrites using hard className below
if (theme === THEME_DARK) {
toggle_theme.children[0].className = 'icon ion-ios-sunny';
document.body.className = 'dark-theme';
} else if (theme === THEME_LIGHT) {
toggle_theme.children[0].className = 'icon ion-ios-moon';
document.body.className = 'light-theme';
} else {
document.body.className = 'no-theme';
}
// By default body element has .no-theme class that uses OS theme via CSS @media rules
// It rewrites using hard className below
if (theme === THEME_DARK) {
toggle_theme.children[0].className = "icon ion-ios-sunny";
document.body.className = "dark-theme";
} else if (theme === THEME_LIGHT) {
toggle_theme.children[0].className = "icon ion-ios-moon";
document.body.className = "light-theme";
} else {
document.body.className = "no-theme";
}
}
// Handles theme change event caused by other tab
addEventListener('storage', function (e) {
if (e.key === STORAGE_KEY_THEME)
setTheme(helpers.storage.get(STORAGE_KEY_THEME));
addEventListener("storage", function (e) {
if (e.key === STORAGE_KEY_THEME)
setTheme(helpers.storage.get(STORAGE_KEY_THEME));
});
// Set theme from preferences on page load
addEventListener('DOMContentLoaded', function () {
const prefTheme = document.getElementById('dark_mode_pref').textContent;
if (prefTheme) {
setTheme(prefTheme);
helpers.storage.set(STORAGE_KEY_THEME, prefTheme);
}
addEventListener("DOMContentLoaded", function () {
const prefTheme = document.getElementById("dark_mode_pref").textContent;
if (prefTheme) {
setTheme(prefTheme);
helpers.storage.set(STORAGE_KEY_THEME, prefTheme);
}
});

File diff suppressed because one or more lines are too long

View File

@ -1,133 +1,159 @@
'use strict';
"use strict";
function toggle_parent(target) {
var body = target.parentNode.parentNode.children[1];
if (body.style.display === 'none') {
target.textContent = '[ ]';
body.style.display = '';
} else {
target.textContent = '[ + ]';
body.style.display = 'none';
}
var body = target.parentNode.parentNode.children[1];
if (body.style.display === "none") {
target.textContent = "[ ]";
body.style.display = "";
} else {
target.textContent = "[ + ]";
body.style.display = "none";
}
}
function swap_comments(event) {
var source = event.target.getAttribute('data-comments');
var source = event.target.getAttribute("data-comments");
if (source === 'youtube') {
get_youtube_comments();
} else if (source === 'reddit') {
get_reddit_comments();
}
if (source === "youtube") {
get_youtube_comments();
} else if (source === "reddit") {
get_reddit_comments();
}
}
var continue_button = document.getElementById('continue');
var continue_button = document.getElementById("continue");
if (continue_button) {
continue_button.onclick = continue_autoplay;
continue_button.onclick = continue_autoplay;
}
function next_video() {
var url = new URL('https://example.com/watch?v=' + video_data.next_video);
var url = new URL("https://example.com/watch?v=" + video_data.next_video);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set('autoplay', '1');
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set('listen', video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set('speed', video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set('local', video_data.params.local);
url.searchParams.set('continue', '1');
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set("autoplay", "1");
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set("listen", video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set("speed", video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set("local", video_data.params.local);
url.searchParams.set("continue", "1");
location.assign(url.pathname + url.search);
location.assign(url.pathname + url.search);
}
function continue_autoplay(event) {
if (event.target.checked) {
player.on('ended', next_video);
} else {
player.off('ended');
}
if (event.target.checked) {
player.on("ended", next_video);
} else {
player.off("ended");
}
}
function get_playlist(plid) {
var playlist = document.getElementById('playlist');
var playlist = document.getElementById("playlist");
playlist.innerHTML = spinnerHTMLwithHR;
playlist.innerHTML = spinnerHTMLwithHR;
var plid_url;
if (plid.startsWith('RD')) {
plid_url = '/api/v1/mixes/' + plid +
'?continuation=' + video_data.id +
'&format=html&hl=' + video_data.preferences.locale;
} else {
plid_url = '/api/v1/playlists/' + plid +
'?index=' + video_data.index +
'&continuation=' + video_data.id +
'&format=html&hl=' + video_data.preferences.locale;
}
var plid_url;
if (plid.startsWith("RD")) {
plid_url =
"/api/v1/mixes/" +
plid +
"?continuation=" +
video_data.id +
"&format=html&hl=" +
video_data.preferences.locale;
} else {
plid_url =
"/api/v1/playlists/" +
plid +
"?index=" +
video_data.index +
"&continuation=" +
video_data.id +
"&format=html&hl=" +
video_data.preferences.locale;
}
if (video_data.params.listen) {
plid_url += '&listen=1'
}
if (video_data.params.listen) {
plid_url += "&listen=1";
}
helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, {
on200: function (response) {
playlist.innerHTML = response.playlistHtml;
helpers.xhr(
"GET",
plid_url,
{ retries: 5, entity_name: "playlist" },
{
on200: function (response) {
if (response === null) return;
if (!response.nextVideo) return;
playlist.innerHTML = response.playlistHtml;
var nextVideo = document.getElementById(response.nextVideo);
nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop;
if (!response.nextVideo) return;
player.on('ended', function () {
var url = new URL('https://example.com/watch?v=' + response.nextVideo);
var nextVideo = document.getElementById(response.nextVideo);
nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop;
url.searchParams.set('list', plid);
if (!plid.startsWith('RD'))
url.searchParams.set('index', response.index);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set('autoplay', '1');
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set('listen', video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set('speed', video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set('local', video_data.params.local);
player.on("ended", function () {
var url = new URL(
"https://example.com/watch?v=" + response.nextVideo,
);
location.assign(url.pathname + url.search);
});
},
onNon200: function (xhr) {
playlist.innerHTML = '';
document.getElementById('continue').style.display = '';
},
onError: function (xhr) {
playlist.innerHTML = spinnerHTMLwithHR;
},
onTimeout: function (xhr) {
playlist.innerHTML = spinnerHTMLwithHR;
}
});
url.searchParams.set("list", plid);
if (!plid.startsWith("RD"))
url.searchParams.set("index", response.index);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set("autoplay", "1");
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set("listen", video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set("speed", video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set("local", video_data.params.local);
location.assign(url.pathname + url.search);
});
},
onNon200: function (xhr) {
playlist.innerHTML = "";
document.getElementById("continue").style.display = "";
},
onError: function (xhr) {
playlist.innerHTML = spinnerHTMLwithHR;
},
onTimeout: function (xhr) {
playlist.innerHTML = spinnerHTMLwithHR;
},
},
);
}
function get_reddit_comments() {
var comments = document.getElementById('comments');
var comments = document.getElementById("comments");
var fallback = comments.innerHTML;
comments.innerHTML = spinnerHTML;
var fallback = comments.innerHTML;
comments.innerHTML = spinnerHTML;
var url = '/api/v1/comments/' + video_data.id +
'?source=reddit&format=html' +
'&hl=' + video_data.preferences.locale;
var url =
"/api/v1/comments/" +
video_data.id +
"?source=reddit&format=html" +
"&hl=" +
video_data.preferences.locale;
var onNon200 = function (xhr) { comments.innerHTML = fallback; };
if (video_data.params.comments[1] === 'youtube')
onNon200 = function (xhr) {};
var onNon200 = function (xhr) {
comments.innerHTML = fallback;
};
if (video_data.params.comments[1] === "youtube") onNon200 = function (xhr) {};
helpers.xhr('GET', url, {retries: 5, entity_name: ''}, {
on200: function (response) {
comments.innerHTML = ' \
helpers.xhr(
"GET",
url,
{ retries: 5, entity_name: "" },
{
on200: function (response) {
comments.innerHTML = ' \
<div> \
<h3> \
<a href="javascript:void(0)">[ ]</a> \
@ -146,52 +172,52 @@ function get_reddit_comments() {
</div> \
<div>{contentHtml}</div> \
<hr>'.supplant({
title: response.title,
youtubeCommentsText: video_data.youtube_comments_text,
redditPermalinkText: video_data.reddit_permalink_text,
permalink: response.permalink,
contentHtml: response.contentHtml
});
title: response.title,
youtubeCommentsText: video_data.youtube_comments_text,
redditPermalinkText: video_data.reddit_permalink_text,
permalink: response.permalink,
contentHtml: response.contentHtml,
});
comments.children[0].children[0].children[0].onclick = toggle_comments;
comments.children[0].children[1].children[0].onclick = swap_comments;
},
onNon200: onNon200, // declared above
});
comments.children[0].children[0].children[0].onclick = toggle_comments;
comments.children[0].children[1].children[0].onclick = swap_comments;
},
onNon200: onNon200, // declared above
},
);
}
if (video_data.play_next) {
player.on('ended', function () {
var url = new URL('https://example.com/watch?v=' + video_data.next_video);
player.on("ended", function () {
var url = new URL("https://example.com/watch?v=" + video_data.next_video);
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set('autoplay', '1');
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set('listen', video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set('speed', video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set('local', video_data.params.local);
url.searchParams.set('continue', '1');
if (video_data.params.autoplay || video_data.params.continue_autoplay)
url.searchParams.set("autoplay", "1");
if (video_data.params.listen !== video_data.preferences.listen)
url.searchParams.set("listen", video_data.params.listen);
if (video_data.params.speed !== video_data.preferences.speed)
url.searchParams.set("speed", video_data.params.speed);
if (video_data.params.local !== video_data.preferences.local)
url.searchParams.set("local", video_data.params.local);
url.searchParams.set("continue", "1");
location.assign(url.pathname + url.search);
});
location.assign(url.pathname + url.search);
});
}
addEventListener('load', function (e) {
if (video_data.plid)
get_playlist(video_data.plid);
addEventListener("load", function (e) {
if (video_data.plid) get_playlist(video_data.plid);
if (video_data.params.comments[0] === 'youtube') {
get_youtube_comments();
} else if (video_data.params.comments[0] === 'reddit') {
get_reddit_comments();
} else if (video_data.params.comments[1] === 'youtube') {
get_youtube_comments();
} else if (video_data.params.comments[1] === 'reddit') {
get_reddit_comments();
} else {
var comments = document.getElementById('comments');
comments.innerHTML = '';
}
if (video_data.params.comments[0] === "youtube") {
get_youtube_comments();
} else if (video_data.params.comments[0] === "reddit") {
get_reddit_comments();
} else if (video_data.params.comments[1] === "youtube") {
get_youtube_comments();
} else if (video_data.params.comments[1] === "reddit") {
get_reddit_comments();
} else {
var comments = document.getElementById("comments");
comments.innerHTML = "";
}
});

View File

@ -1,24 +1,24 @@
'use strict';
var save_player_pos_key = 'save_player_pos';
"use strict";
var save_player_pos_key = "save_player_pos";
function get_all_video_times() {
return helpers.storage.get(save_player_pos_key) || {};
return helpers.storage.get(save_player_pos_key) || {};
}
document.querySelectorAll('.watched-indicator').forEach(function (indicator) {
var watched_part = get_all_video_times()[indicator.dataset.id];
var total = parseInt(indicator.dataset.length, 10);
if (watched_part === undefined) {
watched_part = total;
}
var percentage = Math.round((watched_part / total) * 100);
document.querySelectorAll(".watched-indicator").forEach(function (indicator) {
var watched_part = get_all_video_times()[indicator.dataset.id];
var total = parseInt(indicator.dataset.length, 10);
if (watched_part === undefined) {
watched_part = total;
}
var percentage = Math.round((watched_part / total) * 100);
if (percentage < 5) {
percentage = 5;
}
if (percentage > 90) {
percentage = 100;
}
if (percentage < 5) {
percentage = 5;
}
if (percentage > 90) {
percentage = 100;
}
indicator.style.width = percentage + '%';
indicator.style.width = percentage + "%";
});

View File

@ -1,34 +1,50 @@
'use strict';
var watched_data = JSON.parse(document.getElementById('watched_data').textContent);
var payload = 'csrf_token=' + watched_data.csrf_token;
"use strict";
var watched_data = JSON.parse(
document.getElementById("watched_data").textContent,
);
var payload = "csrf_token=" + watched_data.csrf_token;
function mark_watched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = "none";
var url = '/watch_ajax?action=mark_watched&redirect=false' +
'&id=' + target.getAttribute('data-id');
var url =
"/watch_ajax?action=mark_watched&redirect=false" +
"&id=" +
target.getAttribute("data-id");
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
tile.style.display = '';
}
});
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
tile.style.display = "";
},
},
);
}
function mark_unwatched(target) {
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = 'none';
var count = document.getElementById('count');
count.textContent--;
var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
tile.style.display = "none";
var count = document.getElementById("count");
count.textContent--;
var url = '/watch_ajax?action=mark_unwatched&redirect=false' +
'&id=' + target.getAttribute('data-id');
var url =
"/watch_ajax?action=mark_unwatched&redirect=false" +
"&id=" +
target.getAttribute("data-id");
helpers.xhr('POST', url, {payload: payload}, {
onNon200: function (xhr) {
count.textContent++;
tile.style.display = '';
}
});
helpers.xhr(
"POST",
url,
{ payload: payload },
{
onNon200: function (xhr) {
count.textContent++;
tile.style.display = "";
},
},
);
}

View File

@ -4,6 +4,8 @@
# 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:
@ -11,9 +13,10 @@ services:
build:
context: .
dockerfile: docker/Dockerfile
image: rockerboo/invidious
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:9999:3000"
environment:
# Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax:

View File

@ -462,6 +462,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

@ -136,6 +136,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

@ -183,6 +183,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

@ -24,22 +24,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)
# Video tab doesn't have the last path component
url = tab.videos? ? base_url : "#{base_url}/#{tab_name}"
selected_class = tab == selected_tab ? "selected" : ""
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}"
str << %(\t<a href=") << url << %(">)
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</a>\n"
end
str << "</div>"
str << %(<li class=") << selected_class << %(">\n)
str << %(\t<a href=") << url << %(">)
str << translate(locale, "channel_tab_#{tab_name}_label")
str << "</a>\n"
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,75 @@ 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 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>
#{sponsor_icon}
</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>
<div class="pure-u-20-24 pure-u-md-22-24">
<p>
<b>
<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>
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 +138,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"
@ -142,49 +191,6 @@ module Invidious::Frontend::Comments
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 +199,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>
<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

View File

@ -23,7 +23,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)
@ -42,12 +42,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)
@ -61,61 +61,50 @@ 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?, first_page : Bool, params : URI::Params)
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)
str << %(<li>)
if !first_page
self.first_page(str, locale, base_url.to_s)
end
str << %(</div>\n)
str << %(<div class="page-next-container flex-right">)
if !ctoken.nil?
params["continuation"] = ctoken
url_next = HttpServer::Utils.add_params_to_url(base_url, params)
self.next_page(str, locale, url_next.to_s)
end
str << %(</div>\n)
self.next_page(str, locale, url_next.to_s)
str << %(</div>\n)
str << %(</div>\n\n)
str << %(</li>\n)
str << %(</ul>\n</nav>\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

@ -31,7 +31,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='#{url}'"
str << " method='post'"
str << " rel='noopener'"
@ -42,7 +42,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: ")
@ -98,14 +98,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, listen)
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}#{listen ? "&listen=1" : ""}">
<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 | ProblematicTimelineItem
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
@ -514,23 +519,29 @@ def template_playlist(playlist, listen)
#{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"]}#{listen ? "&listen=1" : ""}">
<li id="#{video["videoId"]}">
<div class="thumbnail">
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
<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>
</a>
</div>
<p style="width:100%">#{video["title"]}</p>
<p>
<b style="width:100%">#{video["author"]}</b>
</p>
</a>
<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
@ -538,7 +549,6 @@ def template_playlist(playlist, listen)
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

@ -71,6 +71,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,25 +3,38 @@
<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>
<fieldset>
<input class="pure-input-1" type="search" name="q"
<% if query %>value="<%= HTML.escape(query.text) %>"<% end %>
placeholder="<%= translate(locale, "Search for videos") %>">
<input type="hidden" name="list" value="<%= plid %>">
</fieldset>
</form>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-5"></div>
<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>
<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") %>"
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>
<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

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

View File

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

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="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>
</div>
<div class="title" dir="auto">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<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">
<% sub_count_text = number_to_short_text(channel.sub_count) %>
<%= rendered "components/subscribe_widget" %>
</div>
<div class="button-container">
<% sub_count_text = number_to_short_text(channel.sub_count) %>
<%= rendered "components/subscribe_widget" %>
<div class="pure-u">
<a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>">
<i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
</a>
</div>
<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="h-box">
<div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div>
<div class="watch-context">
<div id="description" dir="auto">
<p class="raw-text"><%= channel.description_html %></p>
</div>
<nav dir="auto">
<ul>
<li>
<a href="<%= youtube_url %>"><%= translate(locale, "View channel on YouTube") %></a>
</li>
<li>
<a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</li>
</ul>
</nav>
</div>
<div class="pure-g h-box">
<div class="pure-u-1-2">
<div class="pure-u-1 pure-md-1-3">
<a href="<%= youtube_url %>"><%= translate(locale, "View channel on YouTube") %></a>
</div>
<div class="pure-u-1 pure-md-1-3">
<a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a>
</div>
<nav class="menu" dir="auto">
<ul>
<%= 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>
</div>
</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 | ProblematicTimelineItem) && 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) %>
<%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
</p>
</a></div>
</div>
<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 -%>
</a>
</h3>
<% 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>
<% 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 %>
<% if !thin_mode %>
<a tabindex="-1" href="<%= item.url %>">
<center><img style="width:56.25%" src="/hashtag.svg" alt="" /></center>
</a>
<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>
<%- if item.video_count != 0 -%>
<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,36 +60,27 @@
<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>
<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">
<% 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>
<% end %>
</div>
<div class="video-meta-sub">
<h3 class="channel-name">
<% if !item.ucid.to_s.empty? %>
<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>
<% when Category %>
<% when ProblematicTimelineItem %>
@ -110,6 +96,13 @@
</details>
</div>
<% 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)
@ -123,10 +116,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 %>
@ -134,15 +130,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&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>
@ -153,65 +147,60 @@
<%- form_parameters = "action=add_video&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&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>
<%- elsif item.length_seconds != 0 -%>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<%- end -%>
</div>
<%- if item.responds_to?(:live_now) && item.live_now -%>
<div class="length"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></div>
<%- elsif item.length_seconds != 0 -%>
<div class="length"><%= recode_length_seconds(item.length_seconds) %></div>
<%- end -%>
</div>
<div class="video-card-row">
<a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
<h3><a href="<%= link_url %>" title="<%= HTML.escape(item.title) %>"><%= HTML.escape(item.title) %></a></h3>
<div class="video-meta-sub">
<h3 class="channel-name">
<% if !item.ucid.to_s.empty? %>
<a href="/channel/<%= item.ucid %>" title="<%= HTML.escape(item.author) %>">
<% 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 %>
</h3>
<%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
<div class="flex-left">
<% 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>
<% end %>
</div>
<%= rendered "components/video-context-buttons" %>
</div>
<div class="video-card-row flexible">
<div class="flex-left">
<% 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>
<div class="video-meta-sub">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<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") %>">
<i class="icon ion-ios-search"></i>
</button>
<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&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&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,38 +2,31 @@
<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>
<legend><%= translate(locale, "Create playlist") %></legend>
<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">
<label for="title"><%= translate(locale, "Title") %> :</label>
<input required name="title" type="text" placeholder="<%= translate(locale, "Title") %>">
</div>
<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">
<label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label>
<select name="privacy" id="privacy">
<% PlaylistPrivacy.names.each do |option| %>
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="control-group">
<label for="privacy"><%= translate(locale, "Playlist privacy") %> :</label>
<select name="privacy" id="privacy">
<% PlaylistPrivacy.names.each do |option| %>
<option value="<%= option %>" <% if option == "Public" %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-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>
<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>
</form>

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>
</div>
</li>
</ul>
<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>
<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="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">
<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">
<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">
<a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a>
</h3>
</div>
<div class="history-header">
<h3><%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %></h3>
<nav>
<ul>
<li>
<a href="/feed/subscriptions"><%= translate_count(locale, "generic_subscriptions_count", user.subscriptions.size, NumberFormatting::HtmlSpan) %></a>
</li>
<li>
<a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a>
</li>
</ul>
</nav>
</div>
<script id="watched_data" type="application/json">
@ -27,26 +25,24 @@
</script>
<script src="/js/watched_widget.js"></script>
<div class="pure-g">
<% watched.each do |item| %>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<div class="thumbnail">
<a style="width:100%" href="/watch?v=<%= item %>">
<img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
</a>
<div class="videos">
<% watched.each do |item| %>
<div class="video-card">
<div class="thumbnail">
<a href="/watch?v=<%= item %>">
<img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
</a>
<div class="top-left-overlay"><div class="watched">
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
</form>
</div></div>
</div>
<p></p>
</div>
<div class="top-left-overlay"><div class="watched">
<form data-onsubmit="return_false" action="/watch_ajax?action=mark_unwatched&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<button type="submit" class="pure-button pure-button-secondary low-profile"
data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
</form>
</div>
</div>
</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">
<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">
<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">
<a href="/data_control?referer=<%= URI.encode_www_form("/feed/playlists") %>">
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
<div class="playlist-header">
<h3><%= translate(locale, "user_created_playlists", %(<span id="count">#{items_created.size}</span>)) %></h3>
<nav>
<ul>
<li>
<a href="/create_playlist?referer=<%= URI.encode_www_form("/feed/playlists") %>"><%= translate(locale, "Create playlist") %></a>
</li>
<li>
<a href="/data_control?referer=<%= URI.encode_www_form("/feed/playlists") %>">
<%= translate(locale, "Import/export") %>
</a>
</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 %>
<div>
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</div>
<% else %>
<div></div>
<% end %>
<center>
<%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
</center>
<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 !notifications.empty? %>
<div class="h-box">
<hr>
</div>
<% end %>
<% if CONFIG.enable_user_notifications %>
<% if !notifications.empty? %>
<div class="notifications videos">
<% notifications.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>
<div class="pure-g">
<% notifications.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>
<% end %>
<div class="h-box">
<hr>
</div>
<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>
<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>
<div class="pure-g">
<% videos.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<div class="videos">
<% videos.each do |item| %>
<%= rendered "components/item" %>
<% 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">
<% {"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 %>
<a href="/feed/trending?type=<%= option %>&region=<%= region %>">
<%= translate(locale, option) %>
</a>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</ul>
<ul>
<% {"Default", "Music", "Gaming", "Movies"}.each do |option| %>
<li <% if trending_type == option %>class="selected"<% end %>>
<a href="/feed/trending?type=<%= option %>&region=<%= region %>">
<%= translate(locale, option) %></b>
</a>
</li>
<% end %>
</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,109 +6,141 @@
<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">
<% if playlist.is_a? InvidiousPlaylist %>
<b>
<% if playlist.author == user.try &.email %>
<a href="/feed/playlists"><%= author %></a> |
<% else %>
<%= author %> |
<% end %>
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
<%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
<% case playlist.as(InvidiousPlaylist).privacy when %>
<% when PlaylistPrivacy::Public %>
<i class="icon ion-md-globe"></i> <%= translate(locale, "Public") %>
<% when PlaylistPrivacy::Unlisted %>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
<% when PlaylistPrivacy::Private %>
<i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %>
<% end %>
</b>
<% 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>
<% end %>
<nav>
<ul>
<% if playlist.is_a? InvidiousPlaylist %>
<li>
<% if playlist.author == user.try &.email %>
<a href="/feed/playlists"><%= author %></a>
<% else %>
<%= author %>
<% end %>
</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>
<% if !playlist.is_a? InvidiousPlaylist %>
<div class="pure-u-2-3">
<a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %>
</a>
<span> | </span>
<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") %>
<% when PlaylistPrivacy::Unlisted %>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
<% when PlaylistPrivacy::Private %>
<i class="icon ion-ios-lock"></i> <%= translate(locale, "Private") %>
<% end %>
</li>
<% else %>
<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 %>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a href="/redirect?referer=<%= env.get?("current_page") %>">
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% else %>
<a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>">
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% end %>
</div>
<% end %>
</div>
</ul>
<nav>
<ul>
<% if !playlist.is_a? InvidiousPlaylist %>
<li>
<a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>">
<%= translate(locale, "View playlist on YouTube") %>
</a>
</li>
<li>
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a href="/redirect?referer=<%= env.get?("current_page") %>">
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% else %>
<a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>">
<%= translate(locale, "Switch Invidious Instance") %>
</a>
<% end %>
</li>
<% end %>
</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,15 +5,17 @@
<!-- 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") %><br/><br/>
<%= translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %>
<%= translate(locale, "search_message_no_results") %>
</div>
<div>
<%= translate(locale, "search_message_change_filters_or_query") %>
</div>
<div>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %>
</div>
</div>
<%- else -%>

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">
<% autofocus = true %><%= rendered "components/search_box" %>
</div>
<div class="pure-u-1-4"></div>
<div id="search-widget">
<h1>Invidious</h1>
<% autofocus = true %><%= rendered "components/search_box" %>
</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,135 +30,209 @@
<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">
<% if navbar_search %>
<div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">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">
<% 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") %>">
<% 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">
<% 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>
<% else %>
<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">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<% if env.get("preferences").as(Preferences).show_nick %>
<div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;">
<span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
</div>
<% end %>
<div class="pure-u-1-4">
<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>
</form>
</div>
<% 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") %>">
<% 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">
<i class="icon ion-ios-cog"></i>
</a>
</div>
<% if CONFIG.login_enabled %>
<div class="pure-u-1-3">
<a href="/login?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
<%= translate(locale, "Log in") %>
</a>
</div>
<% end %>
<% end %>
</div>
</div>
<header class="container">
<nav class="navbar">
<% if navbar_search %>
<div id="index-link">
<a href="/">Invidious</a>
</div>
<% autofocus = false %><%= rendered "components/search_box" %>
<% end %>
<% if CONFIG.banner %>
<div class="h-box">
<h3><%= CONFIG.banner %></h3>
</div>
<% end %>
<ul id="user-nav">
<% if env.get? "user" %>
<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>
</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>
<% else %>
<i class="icon ion-ios-notifications-outline"></i>
<% end %>
</a>
</li>
<li>
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>">
<i class="icon ion-ios-cog"></i>
</a>
</li>
<% if env.get("preferences").as(Preferences).show_nick %>
<li class="nick">
<span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
</li>
<% end %>
<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) || "") %>">
<input class="logout" type="submit" value="<%= translate(locale, "Log out") %>">
</form>
</li>
<% else %>
<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>
</li>
<li>
<a title="<%= translate(locale, "Preferences") %>" href="/preferences?referer=<%= env.get?("current_page") %>">
<i class="icon ion-ios-cog"></i>
</a>
</li>
<% if CONFIG.login_enabled %>
<li>
<a href="/login?referer=<%= env.get?("current_page") %>">
<%= translate(locale, "Log in") %>
</a>
</li>
<% end %>
<%= content %>
<% end %>
</ul>
</nav>
</header>
<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>
<% if CONFIG.banner %>
<h3 class="container"><%= CONFIG.banner %></h3>
<% end %>
<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>
</div>
<div id="contents" class="container">
<%= content %>
</div>
<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>
</div>
</div>
</footer>
<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>
</div>
</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>
<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,28 +2,35 @@
<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 %>
<label for="email"><%= translate(locale, "User ID") %> :</label>
<input required class="pure-input-1" name="email" type="text" placeholder="<%= translate(locale, "User ID") %>">
<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 %>
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
<div class="control-group">
<input name="password" type="hidden" value="<%= HTML.escape(password) %>">
</div>
<% else %>
<label for="password"><%= translate(locale, "Password") %> :</label>
<input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
<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 %>
<% captcha = captcha.not_nil! %>
<img style="width:50%" src='<%= captcha[:question] %>'/>
@ -33,18 +40,15 @@
<label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label>
<input type="text" name="answer" type="text" placeholder="h:mm:ss">
<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>
<% 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,362 +2,382 @@
<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>
<legend><%= translate(locale, "preferences_category_player") %></legend>
<h1>Preferences</h1>
<div class="pure-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>
<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">
<label for="preload"><%= translate(locale, "preferences_preload_label") %></label>
<input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>>
</div>
<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">
<label for="autoplay"><%= translate(locale, "preferences_autoplay_label") %></label>
<input name="autoplay" id="autoplay" type="checkbox" <% if preferences.autoplay %>checked<% end %>>
</div>
<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">
<label for="continue"><%= translate(locale, "preferences_continue_label") %></label>
<input name="continue" id="continue" type="checkbox" <% if preferences.continue %>checked<% end %>>
</div>
<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">
<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="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">
<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="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">
<label for="listen"><%= translate(locale, "preferences_listen_label") %></label>
<input name="listen" id="listen" type="checkbox" <% if preferences.listen %>checked<% end %>>
</div>
<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">
<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| %>
<option <% if preferences.speed == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<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">
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
<select name="quality" id="quality">
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
<% end %>
<% end %>
</select>
</div>
<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| %>
<option <% if preferences.speed == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<% if !CONFIG.disabled?("dash") %>
<div class="pure-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| %>
<option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option>
<% end %>
</select>
</div>
<% end %>
<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| %>
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
<% end %>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
<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>
</div>
<% if !CONFIG.disabled?("dash") %>
<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| %>
<option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option>
<% end %>
</select>
</div>
<% end %>
<div class="pure-control-group">
<label for="comments[0]"><%= translate(locale, "preferences_comments_label") %></label>
<% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %>
<option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<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 id="volume-value"><%= preferences.volume %></span>
</div>
</div>
<div class="pure-control-group">
<label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label>
<% 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| %>
<option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<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| %>
<option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
</div>
<div class="pure-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="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| %>
<option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
</div>
<div class="pure-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="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">
<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="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">
<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="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">
<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>
<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>
<legend><%= translate(locale, "preferences_category_visual") %></legend>
<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">
<label for="locale"><%= translate(locale, "preferences_locale_label") %></label>
<select name="locale" id="locale">
<% LOCALES_LIST.each do |iso_name, full_name| %>
<option value="<%= iso_name %>" <% if preferences.locale == iso_name %> selected <% end %>><%= HTML.escape(full_name) %></option>
<% end %>
</select>
</div>
<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| %>
<option value="<%= iso_name %>" <% if preferences.locale == iso_name %> selected <% end %>><%= HTML.escape(full_name) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="region"><%= translate(locale, "preferences_region_label") %></label>
<select name="region" id="region">
<% CONTENT_REGIONS.each do |option| %>
<option value="<%= option %>" <% if preferences.region == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<div class="control-group">
<label for="region"><%= translate(locale, "preferences_region_label") %></label>
<select name="region" id="region">
<% CONTENT_REGIONS.each do |option| %>
<option value="<%= option %>" <% if preferences.region == option %> selected <% end %>><%= option %></option>
<% end %>
</select>
</div>
<div class="pure-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| %>
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<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| %>
<option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<div class="pure-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| %>
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
<% end %>
</select>
</div>
<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| %>
<option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
<% end %>
</select>
</div>
<div class="pure-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>
<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>
<% if env.get?("user") %>
<% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %>
<% else %>
<% feed_options = {"", "Popular", "Trending"} %>
<% end %>
<% if env.get?("user") %>
<% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %>
<% else %>
<% feed_options = {"", "Popular", "Trending"} %>
<% end %>
<div class="pure-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| %>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
</div>
<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| %>
<option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
</div>
<div class="pure-control-group">
<label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label>
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<% if env.get? "user" %>
<div class="pure-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 %>
<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| %>
<option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option>
<% end %>
</select>
<% end %>
</div>
</div>
<% if env.get? "user" %>
<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 %>
<legend><%= translate(locale, "preferences_category_misc") %></legend>
</fieldset>
<fieldset class="preferences">
<div class="pure-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>
<legend><%= translate(locale, "preferences_category_misc") %></legend>
<% if env.get? "user" %>
<legend><%= translate(locale, "preferences_category_subscription") %></legend>
<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>
<div class="pure-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>
</fieldset>
<div class="pure-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>
<% if env.get? "user" %>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_subscription") %></legend>
<div class="pure-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="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">
<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| %>
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<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">
<% if preferences.unseen_only %>
<label for="latest_only"><%= translate(locale, "Only show latest unwatched video from channel: ") %></label>
<% else %>
<label for="latest_only"><%= translate(locale, "Only show latest video from channel: ") %></label>
<% end %>
<input name="latest_only" id="latest_only" type="checkbox" <% if preferences.latest_only %>checked<% end %>>
</div>
<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">
<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>
<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| %>
<option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= translate(locale, option) %></option>
<% end %>
</select>
</div>
<% if CONFIG.enable_user_notifications %>
<div class="pure-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>
<div class="control-group">
<% if preferences.unseen_only %>
<label for="latest_only"><%= translate(locale, "Only show latest unwatched video from channel: ") %></label>
<% else %>
<label for="latest_only"><%= translate(locale, "Only show latest video from channel: ") %></label>
<% end %>
<input name="latest_only" id="latest_only" type="checkbox" <% if preferences.latest_only %>checked<% end %>>
</div>
<% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || CONFIG.https_only %>
<div class="pure-control-group">
<a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
<% end %>
<% end %>
<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 env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
<legend><%= translate(locale, "preferences_category_admin") %></legend>
<% if CONFIG.enable_user_notifications %>
<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>
<div class="pure-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| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
</div>
<% # Web notifications are only supported over HTTPS %>
<% if Kemal.config.ssl || CONFIG.https_only %>
<div class="control-group">
<a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
<% end %>
</fieldset>
<% end %>
<div class="pure-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 %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<% 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">
<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="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| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
</div>
<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 %>]">
<% feed_options.each do |option| %>
<option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<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">
<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="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">
<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="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">
<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="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">
<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="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">
<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>
<% end %>
<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" %>
<legend><%= translate(locale, "preferences_category_data") %></legend>
<% if env.get? "user" %>
<fieldset class="preferences">
<legend><%= translate(locale, "preferences_category_data") %></legend>
<div class="pure-control-group">
<a href="/clear_watch_history?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Clear watch history") %></a>
</div>
<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">
<a href="/change_password?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Change password") %></a>
</div>
<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">
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Import/export data") %></a>
</div>
<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">
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</div>
<div class="control-group">
<a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a>
</div>
<div class="pure-control-group">
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
</div>
<div class="control-group">
<a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
</div>
<div class="pure-control-group">
<a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a>
</div>
<div class="control-group">
<a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a>
</div>
<div class="pure-control-group">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div>
<div class="control-group">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div>
<div class="pure-control-group">
<a href="/delete_account?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Delete account") %></a>
</div>
<% end %>
<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>
</fieldset>
</form>
</div>
<div class="action-controls">
<button type="submit" class="primary"><%= translate(locale, "Save preferences") %></button>
</div>
</form>

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>
<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">
<a href="/feed/history">
<%= translate(locale, "Watch history") %>
</a>
</h3>
</div>
<div class="pure-u-1-3">
<h3 style="text-align:right">
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>">
<%= translate(locale, "Import/export") %>
</a>
</h3>
</div>
<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>
</li>
<li>
<a href="/feed/history">
<%= translate(locale, "Watch history") %>
</a>
</li>
<li>
<a href="/data_control?referer=<%= URI.encode_www_form(referer) %>">
<%= translate(locale, "Import/export") %>
</a>
</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&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") %>">
</form>
</h3>
</div>
</div>
<% if subscriptions[-1].author != channel.author %>
<hr>
<% end %>
</div>
</form>
</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,304 +70,317 @@ we're going to need to do it here in order to allow for translations.
%>
</script>
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<main>
<article>
<div id="player-container">
<%= rendered "components/player" %>
</div>
<div class="h-box">
<h1>
<%= title %>
<% if params.listen %>
<a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam"></i>
</a>
<% else %>
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?<%= env.params.query %>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
<% end %>
</h1>
<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>
</a>
<% else %>
<a title="<%=translate(locale, "Audio mode")%>" href="/watch?<%= env.params.query %>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
<% end %>
</div>
</header>
<% if !video.is_listed %>
<h3>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
</h3>
<% end %>
<div class="watch">
<aside class="watch-meta">
<% if !video.is_listed %>
<h4>
<i class="icon ion-ios-unlock"></i> <%= translate(locale, "Unlisted") %>
</h4>
<% end %>
<% if video.reason %>
<h3>
<%= video.reason %>
</h3>
<% 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>
<% 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>
<% end %>
</div>
<% if video.reason %>
<h4>
<%= video.reason %>
</h4>
<% elsif video.premiere_timestamp.try &.> Time.utc %>
<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 %>
<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 %>
<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}")
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-5">
<div class="h-box">
<span 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}")
if !plid.nil? && !continuation.nil?
link_yt_param = URI::Params{"list" => [plid], "index" => [continuation.to_s]}
link_yt_watch = IV::HttpServer::Utils.add_params_to_url(link_yt_watch, link_yt_param)
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
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>
</li>
if !plid.nil? && !continuation.nil?
link_yt_param = URI::Params{"list" => [plid], "index" => [continuation.to_s]}
link_yt_watch = IV::HttpServer::Utils.add_params_to_url(link_yt_watch, link_yt_param)
link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param)
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>
<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>
</li>
<p 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 id="embed-link">
<%-
params_iv_embed = env.params.query.dup
params_iv_embed.delete_all("v")
<p id="embed-link">
<%-
params_iv_embed = env.params.query.dup
params_iv_embed.delete_all("v")
link_iv_embed = URI.new(path: "/embed/#{id}")
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>
</li>
link_iv_embed = URI.new(path: "/embed/#{id}")
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 id="annotations">
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
<%= translate(locale, "Hide annotations") %>
</a>
<% else %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=1">
<%=translate(locale, "Show annotations")%>
</a>
<% end %>
</li>
</ul>
</nav>
<p id="annotations">
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">
<%= translate(locale, "Hide annotations") %>
</a>
<% else %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=1">
<%=translate(locale, "Show annotations")%>
</a>
<% end %>
</p>
<% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
<% if !playlists.empty? %>
<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>
<% 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?action=add_video" 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">
<% 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="secondary">
<%= translate(locale, "Add to playlist") %>
</button>
</form>
<script id="playlist_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/playlist_widget.js?v=<%= Time.utc.to_unix_ms %>"></script>
<% end %>
<% end %>
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
<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>
</form>
<script id="playlist_data" type="application/json">
<%=
{
"csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "")
}.to_pretty_json
%>
</script>
<script src="/js/playlist_widget.js?v=<%= Time.utc.to_unix_ms %>"></script>
<% end %>
<% end %>
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>
<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 %>
</li>
<% if video.license %>
<li id="license">
<% if video.license.empty? %>
<%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %>
<% else %>
<%= translate(locale, "License: ") %><%= video.license %>
<% end %>
</li>
<% end %>
<li id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></li>
<% if video.allowed_regions.size != REGIONS.size %>
<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 %>
</li>
<% end %>
</ul>
</aside>
<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: ") %>
<% if !video.genre_url %>
<%= video.genre %>
<% else %>
<a href="<%= video.genre_url %>"><%= video.genre %></a>
<% end %>
</p>
<% if video.license %>
<% if video.license.empty? %>
<p id="license"><%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %></p>
<% else %>
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
<% end %>
<% 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>
<% if video.allowed_regions.size != REGIONS.size %>
<p 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>
<% end %>
</div>
</div>
<div class="video">
<div class="channel">
<div class="title">
<div class="channel-profile">
<% if !video.author_thumbnail.empty? %>
<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 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
<div class="button-container">
<% sub_count_text = video.sub_count_text %>
<%= rendered "components/subscribe_widget" %>
</div>
</div>
<div class="pure-g h-box flexible title">
<div class="pure-u-1-2 flex-left flexible">
<a href="/channel/<%= video.ucid %>">
<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>
</div>
<div class="video-meta">
<div class="pure-u-1-2 flex-right flexible button-container">
<div class="pure-u">
<% sub_count_text = video.sub_count_text %>
<%= rendered "components/subscribe_widget" %>
</div>
</div>
</div>
<div>
<% if video.premiere_timestamp.try &.> Time.utc %>
<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 %>
<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>
<div class="h-box">
<p id="published-date">
<% 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>
<% else %>
<b><%= translate(locale, "Shared `x`", video.published.to_s("%B %-d, %Y")) %></b>
<% end %>
</p>
<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>
<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>
<% end %>
</div>
<% if !video.music.empty? %>
<input id="music-desc-expansion" type="checkbox"/>
<label for="music-desc-expansion">
<h3 id="music-description-title">
<%= translate(locale, "Music in this video") %>
<span class="icon ion-ios-arrow-up"></span>
<span class="icon ion-ios-arrow-down"></span>
</h3>
</label>
<hr>
<div id="music-description-box">
<% video.music.each do |music| %>
<div class="music-item">
<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>
<% end %>
<% if !video.music.empty? %>
<input id="music-desc-expansion" type="checkbox"/>
<label for="music-desc-expansion">
<h3 id="music-description-title">
<%= translate(locale, "Music in this video") %>
<span class="icon ion-ios-arrow-up"></span>
<span class="icon ion-ios-arrow-down"></span>
</h3>
</label>
<aside id="comments">
<% if nojs %>
<%= comment_html %>
<% else %>
<noscript>
<a href="/watch?<%= env.params.query %>&nojs=1">
<%= translate(locale, "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.") %>
</a>
</noscript>
<% end %>
</aside>
</div>
<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>
<% end %>
</div>
<hr>
<% if params.related_videos || plid %>
<aside class="more-videos">
<% if plid %>
<div id="playlist"></div>
<% end %>
<% end %>
<div id="comments" class="comments">
<% if nojs %>
<%= comment_html %>
<% else %>
<noscript>
<a href="/watch?<%= env.params.query %>&nojs=1">
<%= translate(locale, "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.") %>
</a>
</noscript>
<% end %>
</div>
</div>
</div>
<% if params.related_videos %>
<div class="related-videos">
<% if !video.related_videos.empty? %>
<% 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>
<% end %>
<% end %>
<% if params.related_videos || plid %>
<div class="pure-u-1 pure-u-lg-1-5">
<% if plid %>
<div id="playlist" class="h-box"></div>
<% end %>
<% video.related_videos.each do |rv| %>
<% if rv["id"]? %>
<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 %>" title="<%= HTML.escape(rv["title"]) %>">
<div class="thumbnail-placeholder"></div>
</a>
<%- else -%>
<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 -%>
<% if params.related_videos %>
<div class="h-box">
<% if !video.related_videos.empty? %>
<div <% if plid %>style="display:none"<% end %>>
<div class="pure-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 %>
<div class="bottom-right-overlay">
<%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%>
<div class="length"><%= recode_length_seconds(length_seconds) %></div>
<%- end -%>
</div>
</div>
<% video.related_videos.each do |rv| %>
<% if rv["id"]? %>
<div class="pure-u-1">
<h3>
<a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>" title="<%= HTML.escape(rv["title"]) %>">
<%= HTML.escape(rv["title"]) %>
</a>
</h3>
<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="" />
</a>
<%- else -%>
<div class="thumbnail-placeholder"></div>
<%- end -%>
<div class="video-meta-sub">
<h4>
<% if !rv["ucid"].empty? %>
<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 %>
<%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% end %>
</h4>
<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>
<%- 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>
<h5 class="pure-g">
<div class="pure-u-14-24">
<% 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>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<% end %>
</div>
<div class="pure-u-10-24" style="text-align:right">
<b class="width:100%"><%=
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 %>
</div>
<% end %>
</div>
<% end %>
</div>
<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)
%>
</div>
</div>
</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>
</article>
</main>
<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script>