fork from github/youtube-local

This commit is contained in:
Brandon4466
2025-06-29 20:42:55 -07:00
commit dce02a77a6
75 changed files with 22756 additions and 0 deletions

212
youtube/templates/base.html Normal file
View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ page_title }}</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; media-src 'self' blob: https://*.googlevideo.com;
{{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
<link href="/youtube.com/shared.css" type="text/css" rel="stylesheet">
<link href="{{ theme_path }}" type="text/css" rel="stylesheet">
<link href="/youtube.com/static/comments.css" type="text/css" rel="stylesheet">
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
<link title="Youtube local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
<style type="text/css">
{% block style %}
{{ style }}
{% endblock %}
</style>
{% if js_data %}
<script>data = {{ js_data|tojson }}</script>
{% endif %}
{% block head %}
{% endblock %}
</head>
<body>
<header>
<form id="site-search" action="/youtube.com/results">
<a href="/youtube.com" id="home-link">Home</a>
<input type="search" name="search_query" class="search-box" value="{{ search_box_value }}"
{{ "autofocus" if (request.path in ("/", "/results") or error_message) else "" }} placeholder="Type to search...">
<button type="submit" value="Search" class="button search-button">Search</button>
<label for="filter-dropdown-toggle-cbox" class="filter-dropdown-toggle-button button">Filter</label>
<input id="filter-dropdown-toggle-cbox" type="checkbox" hidden>
<div class="filter-dropdown-content">
<h3>Sort by</h3>
<input type="radio" id="sort_relevance" name="sort" value="0">
<label for="sort_relevance">Relevance</label>
<input type="radio" id="sort_upload_date" name="sort" value="2">
<label for="sort_upload_date">Upload date</label>
<input type="radio" id="sort_view_count" name="sort" value="3">
<label for="sort_view_count">View count</label>
<input type="radio" id="sort_rating" name="sort" value="1">
<label for="sort_rating">Rating</label>
<h3>Upload date</h3>
<input type="radio" id="time_any" name="time" value="0">
<label for="time_any">Any</label>
<input type="radio" id="time_last_hour" name="time" value="1">
<label for="time_last_hour">Last hour</label>
<input type="radio" id="time_today" name="time" value="2">
<label for="time_today">Today</label>
<input type="radio" id="time_this_week" name="time" value="3">
<label for="time_this_week">This week</label>
<input type="radio" id="time_this_month" name="time" value="4">
<label for="time_this_month">This month</label>
<input type="radio" id="time_this_year" name="time" value="5">
<label for="time_this_year">This year</label>
<h3>Type</h3>
<input type="radio" id="type_any" name="type" value="0">
<label for="type_any">Any</label>
<input type="radio" id="type_video" name="type" value="1">
<label for="type_video">Video</label>
<input type="radio" id="type_channel" name="type" value="2">
<label for="type_channel">Channel</label>
<input type="radio" id="type_playlist" name="type" value="3">
<label for="type_playlist">Playlist</label>
<input type="radio" id="type_movie" name="type" value="4">
<label for="type_movie">Movie</label>
<input type="radio" id="type_show" name="type" value="5">
<label for="type_show">Show</label>
<h3>Duration</h3>
<input type="radio" id="duration_any" name="duration" value="0">
<label for="duration_any">Any</label>
<input type="radio" id="duration_short" name="duration" value="1">
<label for="duration_short">Short (&lt; 4 minutes)</label>
<input type="radio" id="duration_long" name="duration" value="2">
<label for="duration_long">Long (&gt; 20 minutes)</label>
</div>
{% if header_playlist_names is defined %}
<label for="playlist-form-toggle-cbox" class="playlist-form-toggle-button button">+Playlist</label>
{% endif %}
</form>
{% if header_playlist_names is defined %}
<input id="playlist-form-toggle-cbox" type="checkbox" hidden>
<form id="playlist-edit" action="/youtube.com/edit_playlist" method="post" target="_self">
<input name="playlist_name" id="playlist-name-selection" list="playlist-options" type="text" placeholder="Playlist name">
<datalist id="playlist-options">
{% for playlist_name in header_playlist_names %}
<option value="{{ playlist_name }}">{{ playlist_name }}</option>
{% endfor %}
</datalist>
<button type="submit" id="playlist-add-button" class="button" name="action" value="add">Add to playlist</button>
<button type="reset" id="item-selection-reset" class="button">Clear selection</button>
</form>
<script>
/* Takes control of the form if javascript is enabled, so that adding stuff to a playlist will not cause things to stop loading, and will display a status message. If javascript is disabled, the form will still work using regular HTML methods, but causes things on the page (such as the video) to stop loading. */
var playlistAddForm = document.getElementById('playlist-edit');
function setStyle(element, property, value){
element.style[property] = value;
}
function removeMessage(messageBox){
messageBox.parentNode.removeChild(messageBox);
}
function displayMessage(text, error=false){
let currentMessageBox = document.getElementById('message-box');
if(currentMessageBox !== null){
currentMessageBox.parentNode.removeChild(currentMessageBox);
}
let messageBox = document.createElement('div');
if(error){
messageBox.setAttribute('role', 'alert');
} else {
messageBox.setAttribute('role', 'status');
}
messageBox.setAttribute('id', 'message-box');
let textNode = document.createTextNode(text);
messageBox.appendChild(textNode);
document.querySelector('main').appendChild(messageBox);
let currentstyle = window.getComputedStyle(messageBox);
let removalDelay;
if(error){
removalDelay = 5000;
} else {
removalDelay = 1500;
}
window.setTimeout(setStyle, 20, messageBox, 'opacity', 1);
window.setTimeout(setStyle, removalDelay, messageBox, 'opacity', 0);
window.setTimeout(removeMessage, removalDelay+300, messageBox);
}
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Sending_forms_through_JavaScript
function sendData(event){
var clicked_button = document.activeElement;
if(clicked_button === null || clicked_button.getAttribute('type') !== 'submit' || clicked_button.parentElement != event.target){
console.log('ERROR: clicked_button not valid');
return;
}
if(clicked_button.getAttribute('value') !== 'add'){
return; // video(s) are being removed from playlist, just let it refresh the page
}
event.preventDefault();
var XHR = new XMLHttpRequest();
var FD = new FormData(playlistAddForm);
if(FD.getAll('video_info_list').length === 0){
displayMessage('Error: No videos selected', true);
return;
}
if(FD.get('playlist_name') === ""){
displayMessage('Error: No playlist selected', true);
return;
}
// https://stackoverflow.com/questions/48322876/formdata-doesnt-include-value-of-buttons
FD.append('action', 'add');
XHR.addEventListener('load', function(event){
if(event.target.status == 204){
displayMessage('Added videos to playlist "' + FD.get('playlist_name') + '"');
} else {
displayMessage('Error adding videos to playlist: ' + event.target.status.toString(), true);
}
});
XHR.addEventListener('error', function(event){
if(event.target.status == 0){
displayMessage('XHR failed: Check that XHR requests are allowed', true);
} else {
displayMessage('XHR failed: Unknown error', true);
}
});
XHR.open('POST', playlistAddForm.getAttribute('action'));
XHR.send(FD);
}
playlistAddForm.addEventListener('submit', sendData);
</script>
{% endif %}
</header>
<main>
{% block main %}
{{ main }}
{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,215 @@
{% if current_tab == 'search' %}
{% set page_title = search_box_value + ' - Page ' + page_number|string %}
{% else %}
{% set page_title = channel_name|string + ' - Channel' %}
{% endif %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
padding-left: 0px;
padding-right: 0px;
}
.channel-metadata{
display: flex;
align-items: center;
}
.avatar{
height:200px;
width:200px;
}
.summary{
margin-left: 5px;
/* Prevent uninterupted words in description overflowing the page: https://daverupert.com/2017/09/breaking-the-grid/ */
min-width: 0px;
}
.short-description{
line-height: 1em;
max-height: 6em;
overflow: hidden;
}
.channel-tabs{
display: flex;
flex-wrap: wrap;
justify-content:start;
background-color: var(--interface-color);
padding: 3px;
padding-left: 6px;
}
#links-metadata{
display: flex;
flex-wrap: wrap;
justify-content: start;
padding-bottom: 8px;
padding-left: 6px;
margin-bottom: 10px;
}
#links-metadata > *{
margin-top: 8px;
margin-left: 10px;
}
#number-of-results{
font-weight:bold;
}
.content{
}
.search-content{
max-width: 800px;
margin-left: 10px;
}
.item-grid{
padding-left: 20px;
}
.item-list{
max-width:800px;
margin: auto;
}
.page-button-row{
margin-left: auto;
margin-right: auto;
}
.next-previous-button-row{
margin-left: auto;
margin-right: auto;
}
.tab{
padding: 5px 0px;
width: 200px;
}
.channel-info{
}
.channel-info ul{
padding-left: 40px;
}
.channel-info h3{
margin-left: 40px;
}
.channel-info .description{
white-space: pre-wrap;
min-width: 0;
margin-left: 40px;
}
.medium-item img{
max-width: 168px;
}
@media (max-width:500px){
.channel-metadata{
flex-direction: column;
text-align: center;
margin-bottom: 30px;
}
}
{% endblock style %}
{% block main %}
<div class="channel-metadata">
<img class="avatar" src="{{ avatar }}" width="200px" height="200px">
<div class="summary">
<h2 class="title">{{ channel_name }}</h2>
<p class="short-description">{{ short_description }}</p>
<form method="POST" action="/youtube.com/subscriptions" class="subscribe-unsubscribe">
<input type="submit" value="{{ 'Unsubscribe' if subscribed else 'Subscribe' }}">
<input type="hidden" name="channel_id" value="{{ channel_id }}">
<input type="hidden" name="channel_name" value="{{ channel_name }}">
<input type="hidden" name="action" value="{{ 'unsubscribe' if subscribed else 'subscribe' }}">
</form>
</div>
</div>
<nav class="channel-tabs">
{% for tab_name in ('Videos', 'Shorts', 'Streams', 'Playlists', 'About') %}
{% if tab_name.lower() == current_tab %}
<a class="tab page-button">{{ tab_name }}</a>
{% else %}
<a class="tab page-button" href="{{ channel_url + '/' + tab_name.lower() }}">{{ tab_name }}</a>
{% endif %}
{% endfor %}
<form class="channel-search" action="{{ channel_url + '/search' }}">
<input type="search" name="query" class="search-box" value="{{ search_box_value }}">
<button type="submit" value="Search" class="search-button">Search</button>
</form>
</nav>
{% if current_tab == 'about' %}
<div class="channel-info">
<ul>
{% for (before_text, stat, after_text) in [
('Joined ', date_joined, ''),
('', approx_view_count, ' views'),
('', approx_subscriber_count, ' subscribers'),
('', approx_video_count, ' videos'),
('Country: ', country, ''),
('Canonical Url: ', canonical_url, ''),
] %}
{% if stat %}
<li>{{ before_text + stat|string + after_text }}</li>
{% endif %}
{% endfor %}
</ul>
<hr>
<h3>Description</h3>
<div class="description">{{ common_elements.text_runs(description) }}</div>
<hr>
<ul>
{% for text, url in links%}
{% if url %}
<li><a href="{{ url }}">{{ text }}</a></li>
{% else %}
<li>{{ text }}</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% else %}
<div class="content {{ current_tab + '-content'}}">
<div id="links-metadata">
{% if current_tab in ('videos', 'shorts', 'streams') %}
{% set sorts = [('1', 'views'), ('2', 'oldest'), ('3', 'newest'), ('4', 'newest - no shorts'),] %}
<div id="number-of-results">{{ number_of_videos }} videos</div>
{% elif current_tab == 'playlists' %}
{% set sorts = [('2', 'oldest'), ('3', 'newest'), ('4', 'last video added')] %}
{% if items %}
<h2 class="page-number">Page {{ page_number }}</h2>
{% else %}
<h2 class="page-number">No items</h2>
{% endif %}
{% elif current_tab == 'search' %}
{% if items %}
<h2 class="page-number">Page {{ page_number }}</h2>
{% else %}
<h2 class="page-number">No results</h2>
{% endif %}
{% else %}
{% set sorts = [] %}
{% endif %}
{% for sort_number, sort_name in sorts %}
{% if sort_number == current_sort.__str__() %}
<a class="sort-button">{{ 'Sorted by ' + sort_name }}</a>
{% else %}
<a class="sort-button" href="{{ channel_url + '/' + current_tab + '?sort=' + sort_number }}">{{ 'Sort by ' + sort_name }}</a>
{% endif %}
{% endfor %}
</div>
<nav class="{{ 'item-list' if current_tab == 'search' else 'item-grid' }}">
{% for item_info in items %}
{{ common_elements.item(item_info, include_author=false) }}
{% endfor %}
</nav>
{% if current_tab in ('videos', 'shorts', 'streams') %}
<nav class="page-button-row">
{{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary, include_ends=(current_sort.__str__() in '34')) }}
</nav>
{% elif current_tab == 'playlists' or current_tab == 'search' %}
<nav class="next-previous-button-row">
{{ common_elements.next_previous_buttons(is_last_page, channel_url + '/' + current_tab, parameters_dictionary) }}
</nav>
{% endif %}
</div>
{% endif %}
{% endblock main %}

View File

@@ -0,0 +1,68 @@
{% import "common_elements.html" as common_elements %}
{% macro render_comment(comment, include_avatar, timestamp_links=False) %}
<div class="comment-container">
<div class="comment">
<a class="author-avatar" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">
{% if include_avatar %}
<img class="author-avatar-img" src="{{ comment['author_avatar'] }}">
{% endif %}
</a>
<address class="author-name">
<a class="author" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">{{ comment['author'] }}</a>
</address>
<a class="permalink" href="{{ comment['permalink'] }}" title="permalink">
<time datetime="">{{ comment['time_published'] }}</time>
</a>
{% if timestamp_links %}
<span class="text">{{ common_elements.text_runs(comment['text'])|timestamps|safe }}</span>
{% else %}
<span class="text">{{ common_elements.text_runs(comment['text']) }}</span>
{% endif %}
<span class="likes">{{ comment['likes_text'] if comment['approx_like_count'] else ''}}</span>
<div class="bottom-row">
{% if comment['reply_count'] %}
{% if settings.use_comments_js and comment['replies_url'] %}
<details class="replies" src="{{ comment['replies_url'] }}">
<summary>{{ comment['view_replies_text'] }}</summary>
<a href="{{ comment['replies_url'] }}" class="replies-open-new-tab" target="_blank">Open in new tab</a>
<div class="comment_page">loading..</div>
</details>
{% elif comment['replies_url'] %}
<a href="{{ comment['replies_url'] }}" class="replies">{{ comment['view_replies_text'] }}</a>
{% else %}
<a class="replies">{{ comment['view_replies_text'] }} (error constructing url)</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{% macro video_comments(comments_info) %}
<div class="comment-links">
{% for link_text, link_url in comments_info['comment_links'] %}
<a class="sort-button" href="{{ link_url }}">{{ link_text }}</a>
{% endfor %}
</div>
{% if comments_info['error'] %}
<div class="comments">
<div class="code-box"><code>{{ comments_info['error'] }}</code></div>
</div>
{% else %}
<div class="comments">
{% for comment in comments_info['comments'] %}
{{ render_comment(comment, comments_info['include_avatars'], True) }}
{% endfor %}
</div>
{% if 'more_comments_url' is in comments_info %}
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
{% endif %}
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,55 @@
{% set page_title = ('Replies' if comments_info['is_replies'] else 'Comments page ' + comments_info['page_number']|string) %}
{% import "comments.html" as comments with context %}
{% if not slim %}
{% extends "base.html" %}
{% block style %}
.comments-area{
margin: auto;
max-width:640px;
}
{% endblock style %}
{% endif %}
{% block main %}
<section class="comments-area">
{% if not comments_info['is_replies'] %}
<section class="video-metadata">
<a class="video-metadata-thumbnail-box" href="{{ comments_info['video_url'] }}" title="{{ comments_info['video_title'] }}">
<img class="video-metadata-thumbnail-img" src="{{ comments_info['video_thumbnail'] }}" height="180px" width="320px">
</a>
<a class="title" href="{{ comments_info['video_url'] }}" title="{{ comments_info['video_title'] }}">{{ comments_info['video_title'] }}</a>
<h2>Comments page {{ comments_info['page_number'] }}</h2>
<span>Sorted by {{ comments_info['sort_text'] }}</span>
</section>
{% endif %}
{% if not comments_info['is_replies'] %}
<div class="comment-links">
{% for link_text, link_url in comments_info['comment_links'] %}
<a class="sort-button" href="{{ link_url }}">{{ link_text }}</a>
{% endfor %}
</div>
{% endif %}
<div class="comments">
{% for comment in comments_info['comments'] %}
{{ comments.render_comment(comment, comments_info['include_avatars'], slim) }}
{% endfor %}
</div>
{% if 'more_comments_url' is in comments_info %}
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
{% endif %}
</section>
{% if settings.use_comments_js %}
<script src="/youtube.com/static/js/common.js"></script>
<script src="/youtube.com/static/js/comments.js"></script>
{% endif %}
{% endblock main %}

View File

@@ -0,0 +1,135 @@
{% macro text_runs(runs) %}
{%- if runs[0] is mapping -%}
{%- for text_run in runs -%}
{%- if text_run.get("bold", false) -%}
<b>{{ text_run["text"] }}</b>
{%- elif text_run.get('italics', false) -%}
<i>{{ text_run["text"] }}</i>
{%- else -%}
{{ text_run["text"] }}
{%- endif -%}
{%- endfor -%}
{%- elif runs -%}
{{ runs }}
{%- endif -%}
{% endmacro %}
{% macro item(info, description=false, horizontal=true, include_author=true, include_badges=true, lazy_load=false) %}
<div class="item-box {{ info['type'] + '-item-box' }} {{'horizontal-item-box' if horizontal else 'vertical-item-box'}} {{'has-description' if description else 'no-description'}}">
{% if info['error'] %}
{{ info['error'] }}
{% else %}
<div class="item {{ info['type'] + '-item' }}">
<a class="thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
{% if lazy_load %}
<img class="thumbnail-img lazy" data-src="{{ info['thumbnail'] }}">
{% else %}
<img class="thumbnail-img" src="{{ info['thumbnail'] }}">
{% endif %}
{% if info['type'] != 'channel' %}
<div class="thumbnail-info">
<span>{{ (info['video_count']|commatize + ' videos') if info['type'] == 'playlist' else info['duration'] }}</span>
</div>
{% endif %}
</a>
<div class="item-metadata">
<div class="title"><a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a></div>
{% if include_author %}
{% if info.get('author_url') %}
<address title="{{ info['author'] }}">By <a href="{{ info['author_url'] }}">{{ info['author'] }}</a></address>
{% else %}
<address title="{{ info['author'] }}"><b>{{ info['author'] }}</b></address>
{% endif %}
{% endif %}
<ul class="stats {{'horizontal-stats' if horizontal else 'vertical-stats'}}">
{% if info['type'] == 'channel' %}
<li><span>{{ info['approx_subscriber_count'] }} subscribers</span></li>
<li><span>{{ info['video_count']|commatize }} videos</span></li>
{% else %}
{% if info.get('approx_view_count') %}
<li><span class="views">{{ info['approx_view_count'] }} views</span></li>
{% endif %}
{% if info.get('time_published') %}
<li><time>{{ info['time_published'] }}</time></li>
{% endif %}
{% endif %}
</ul>
{% if description %}
<span class="description">{{ text_runs(info.get('description', '')) }}</span>
{% endif %}
{% if include_badges %}
<span class="badges">{{ info['badges']|join(' | ') }}</span>
{% endif %}
</div>
</div>
{% if info['type'] == 'video' %}
<input class="item-checkbox" type="checkbox" name="video_info_list" value="{{ info['video_info'] }}" form="playlist-edit">
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro page_buttons(estimated_pages, url, parameters_dictionary, include_ends=false) %}
{% set current_page = parameters_dictionary.get('page', 1)|int %}
{% set parameters_dictionary = parameters_dictionary.to_dict() %}
{% if current_page is le(5) %}
{% set page_start = 1 %}
{% set page_end = [9, estimated_pages]|min %}
{% else %}
{% set page_start = current_page - 4 %}
{% set page_end = [current_page + 4, estimated_pages]|min %}
{% endif %}
{% if include_ends and page_start is gt(1) %}
{% set _ = parameters_dictionary.__setitem__('page', 1) %}
<a class="page-button first-page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ 1 }}</a>
{% endif %}
{% for page in range(page_start, page_end+1) %}
{% if page == current_page %}
<div class="page-button">{{ page }}</div>
{% else %}
{# https://stackoverflow.com/questions/36886650/how-to-add-a-new-entry-into-a-dictionary-object-while-using-jinja2 #}
{% set _ = parameters_dictionary.__setitem__('page', page) %}
<a class="page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ page }}</a>
{% endif %}
{% endfor %}
{% if include_ends and page_end is lt(estimated_pages) %}
{% set _ = parameters_dictionary.__setitem__('page', estimated_pages) %}
<a class="page-button last-page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ estimated_pages }}</a>
{% endif %}
{% endmacro %}
{% macro next_previous_buttons(is_last_page, url, parameters_dictionary) %}
{% set current_page = parameters_dictionary.get('page', 1)|int %}
{% set parameters_dictionary = parameters_dictionary.to_dict() %}
{% if current_page != 1 %}
{% set _ = parameters_dictionary.__setitem__('page', current_page - 1) %}
<a class="page-button previous-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Previous page</a>
{% endif %}
{% if not is_last_page %}
{% set _ = parameters_dictionary.__setitem__('page', current_page + 1) %}
<a class="page-button next-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Next page</a>
{% endif %}
{% endmacro %}
{% macro next_previous_ctoken_buttons(prev_ctoken, next_ctoken, url, parameters_dictionary) %}
{% set parameters_dictionary = parameters_dictionary.to_dict() %}
{% if prev_ctoken %}
{% set _ = parameters_dictionary.__setitem__('ctoken', prev_ctoken) %}
<a class="page-button previous-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Previous page</a>
{% endif %}
{% if next_ctoken %}
{% set _ = parameters_dictionary.__setitem__('ctoken', next_ctoken) %}
<a class="page-button next-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Next page</a>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; media-src 'self' https://*.googlevideo.com;
{{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
<!--<link href="{{ theme_path }}" type="text/css" rel="stylesheet">-->
<style>
* {
box-sizing: border-box;
}
html {
font-family: {{ font_family|safe }};
}
html, body, div, ol, h2{
margin: 0px;
padding: 0px;
}
a:link {
color: #22aaff;
}
a:visited {
color: #7755ff;
}
body{
background-color: black;
color: white;
max-height: 100vh;
overflow-y: hidden;
}
.text-height{
font-size: 0.75rem;
overflow-y: hidden;
height: 1rem;
}
a.video-link{
color: white;
}
h2 {
font-weight: normal;
margin-left: 5px;
}
ol.video-info-list{
padding: 0px;
list-style: none;
display: flex;
flex-direction: row;
}
ol.video-info-list li{
margin-left: 20px;
font-size: 0.75rem;
max-width: 75%;
}
address{
font-style: normal;
}
.video-info-list span{
height: 1rem;
overflow-y: hidden;
display: inline-block;
}
body > video, body > .plyr{
max-height: calc(100vh - 2rem);
width: 100%;
height: 56.25vw; /* 360/640 == 720/1280 */
}
</style>
{% if js_data %}
<script>data = {{ js_data|tojson }}</script>
{% endif %}
{% if settings.video_player == 1 %}
<!-- plyr -->
<script>var storyboard_url = {{ storyboard_url | tojson }}</script>
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet"/>
<link href="/youtube.com/static/plyr_fixes.css" rel="stylesheet"/>
<!--/ plyr -->
{% endif %}
</head>
<body>
<a class="video-link text-height" href="{{ video_url }}" title="{{ title }}" target="_blank" rel="noopener noreferrer"><h2 class="text-height">{{ title }}</h2></a>
<div class="video-info-bar text-height">
<ol class="video-info-list text-height">
<li class="text-height"><time class="text-height"><span class="text-height">{{ time_published }}</span></time></li>
<li class="text-height"><address class="text-height"><span class="text-height">Uploaded by <a class="text-height" href="{{ uploader_channel_url }}" title="{{ uploader }}" target="_blank" rel="noopener noreferrer">{{ uploader }}</a></span></address></li>
</ol>
</div>
<video controls autofocus class="video" height="{{ video_height }}px">
{% if uni_sources %}
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
{% endif %}
{% for source in subtitle_sources %}
{% if source['on'] %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
{% else %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
{% endif %}
{% endfor %}
</video>
{% if settings.video_player == 1 %}
<!-- plyr -->
<script src="/youtube.com/static/modules/plyr/plyr.js"></script>
<script src="/youtube.com/static/js/plyr-start.js"></script>
<!-- /plyr -->
{% endif %}
{% if settings.use_video_hotkeys %}
<script src="/youtube.com/static/js/common.js"></script>
<script src="/youtube.com/static/js/hotkeys.js"></script>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,19 @@
{% set page_title = 'Error' %}
{% if not slim %}
{% extends "base.html" %}
{% endif %}
{% block main %}
{% if traceback %}
<div id="error-box">
<h1>500 Uncaught exception:</h1>
<div class="code-box"><code>{{ traceback }}</code></div>
<p>Please report this issue at <a href="https://github.com/user234683/youtube-local/issues" target="_blank">https://github.com/user234683/youtube-local/issues</a></p>
<p>Remember to include the traceback in your issue and redact any information in it you do not want to share</p>
</div>
{% else %}
<div id="error-message">{{ error_message }}</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% set page_title = title %}
{% extends "base.html" %}
{% block style %}
ul {
background-color: var(--interface-color);
padding: 20px;
width: 400px;
max-width: 100%;
margin: auto;
margin-top: 20px;
}
li {
margin-bottom: 10px;
}
.recommended {
max-width: 1200px;
margin: 40px auto;
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
}
.video-card {
background: var(--interface-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
width: 320px;
overflow: hidden;
text-align: left;
transition: box-shadow 0.2s;
}
.video-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.16);
}
.video-thumb {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.video-info {
padding: 12px 16px;
}
.video-title {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 6px;
color: var(--text-color);
text-decoration: none;
}
.video-meta {
color: #888;
font-size: 0.95em;
}
{% endblock style %}
{% block main %}
<ul>
<li><a href="/youtube.com/playlists">Local playlists</a></li>
<li><a href="/youtube.com/subscriptions">Subscriptions</a></li>
<li><a href="/youtube.com/subscription_manager">Subscription Manager</a></li>
<li><a href="/youtube.com/settings">Settings</a></li>
</ul>
{% if recommended_videos %}
<h2 style="text-align:center;margin-top:40px;">Recommended Videos</h2>
<div class="recommended">
{% for video in recommended_videos %}
<div class="video-card">
<a href="/watch?v={{ video.videoId }}">
<img class="video-thumb" src="{{ video.thumbnail.thumbnails[-1].url }}" alt="Thumbnail">
</a>
<div class="video-info">
<a class="video-title" href="/watch?v={{ video.videoId }}">{{ video.title.runs[0].text }}</a>
<div class="video-meta">
{{ video.ownerText.runs[0].text }}<br>
{{ video.viewCountText.simpleText if video.viewCountText else '' }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock main %}

View File

@@ -0,0 +1,73 @@
{% set page_title = playlist_name + ' - Local playlist' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main > *{
width: 800px;
max-width: 100%;
margin: auto;
}
.playlist-metadata{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin: 15px auto;
padding: 7px;
background-color: var(--interface-color);
}
.playlist-title{
}
#export-options{
justify-self: end;
}
#video-remove-container{
display: flex;
justify-content: space-between;
margin: 0px auto 15px auto;
}
#playlist-remove-button{
white-space: nowrap;
}
#results{
display: grid;
grid-auto-rows: 0fr;
grid-row-gap: 10px;
}
{% endblock style %}
{% block main %}
<div class="playlist-metadata">
<h2 class="playlist-title">{{ playlist_name }}</h2>
<div id="export-options">
<form id="playlist-export" method="post">
<select id="export-type" name="export_format">
<option value="json">JSON</option>
<option value="ids">Video id list (txt)</option>
<option value="urls">Video url list (txt)</option>
</select>
<button type="submit" id="playlist-export-button" name="action" value="export">Export</button>
</form>
</div>
</div>
<form id="playlist-remove" action="/youtube.com/edit_playlist" method="post" target="_self"></form>
<div id="video-remove-container">
<button type="submit" name="action" value="remove_playlist" form="playlist-remove" formaction="" onclick="return confirm('You are about to permanently delete {{ playlist_name }}\n\nOnce a playlist is permanently deleted, it cannot be recovered.');">Remove playlist</button>
<input type="hidden" name="playlist_page" value="{{ playlist_name }}" form="playlist-edit">
<button type="submit" id="playlist-remove-button" name="action" value="remove" form="playlist-edit" formaction="">Remove from playlist</button>
</div>
<div id="results">
{% for video_info in videos %}
{{ common_elements.item(video_info) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlists/' + playlist_name, parameters_dictionary) }}
</nav>
{% endblock main %}

View File

@@ -0,0 +1,34 @@
{% set page_title = 'Local playlists' %}
{% extends "base.html" %}
{% block style %}
main{
display: flex;
justify-content: center;
}
ul{
background-color: var(--interface-color);
margin-top: 20px;
padding: 20px;
width: 400px;
max-width: 100%;
align-self: start;
}
li{
margin-bottom: 10px;
}
{% endblock style %}
{% block main %}
<ul>
{% for playlist_name, playlist_url in playlists %}
<li><a href="{{ playlist_url }}">{{ playlist_name }}</a></li>
{% endfor %}
</ul>
{% endblock main %}

View File

@@ -0,0 +1,86 @@
{% set page_title = title|string + ' - Page ' + parameters_dictionary.get('page', '1') %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main > * {
max-width: 800px;
margin:auto;
}
.playlist-metadata{
display:grid;
grid-template-columns: 0fr 1fr;
grid-template-areas:
"thumbnail title"
"thumbnail author"
"thumbnail stats"
"thumbnail description";
}
.playlist-thumbnail{
grid-area: thumbnail;
width:250px;
margin-right: 10px;
}
.playlist-title{ grid-area: title }
.playlist-author{ grid-area: author }
.playlist-stats{ grid-area: stats }
.playlist-description{
grid-area: description;
min-width:0px;
white-space: pre-line;
}
#results{
margin-top:10px;
display: grid;
grid-auto-rows: 0fr;
grid-row-gap: 10px;
}
.thumbnail-box{ /* overides rule in shared.css */
height: 90px !important;
width: 120px !important;
}
@media (max-width:600px){
.playlist-metadata{
grid-template-columns: 1fr;
grid-template-areas:
"thumbnail"
"title"
"author"
"stats"
"description";
justify-items: center;
}
}
{% endblock style %}
{% block main %}
<div class="playlist-metadata">
<img class="playlist-thumbnail" src="{{ thumbnail }}">
<h2 class="playlist-title">{{ title }}</h2>
<a class="playlist-author" href="{{ author_url }}">{{ author }}</a>
<div class="playlist-stats">
<div>{{ video_count|commatize }} videos</div>
<div>{{ view_count|commatize }} views</div>
<div>Last updated {{ time_published }}</div>
</div>
<div class="playlist-description">{{ common_elements.text_runs(description) }}</div>
</div>
<div id="results">
{% for info in video_list %}
{{ common_elements.item(info) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlist', parameters_dictionary) }}
</nav>
{% endblock main %}

View File

@@ -0,0 +1,46 @@
{% set search_box_value = query %}
{% set page_title = query + ' - Search' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main > * {
max-width: 800px;
margin: auto;
}
#result-info{
margin-top: 10px;
margin-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
}
#number-of-results{
font-weight:bold;
}
.item-list{
padding-left: 10px;
padding-right: 10px;
}
.badge{
background-color:#cccccc;
}
{% endblock style %}
{% block main %}
<div id="result-info">
<div id="number-of-results">Approximately {{ '{:,}'.format(estimated_results) }} results ({{ '{:,}'.format(estimated_pages) }} pages)</div>
{% if corrections['type'] == 'showing_results_for' %}
<div>Showing results for <a>{{ common_elements.text_runs(corrections['corrected_query_text']) }}</a></div>
<div>Search instead for <a href="{{ corrections['original_query_url'] }}">{{ corrections['original_query_text'] }}</a></div>
{% elif corrections['type'] == 'did_you_mean' %}
<div>Did you mean <a href="{{ corrections['corrected_query_url'] }}">{{ common_elements.text_runs(corrections['corrected_query_text']) }}</a></div>
{% endif %}
</div>
<div class="item-list">
{% for info in results %}
{{ common_elements.item(info, description=true) }}
{% endfor %}
</div>
<nav class="page-button-row">
{{ common_elements.page_buttons(estimated_pages, '/https://www.youtube.com/results', parameters_dictionary) }}
</nav>
{% endblock main %}

View File

@@ -0,0 +1,80 @@
{% set page_title = 'Settings' %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
.settings-form {
margin: auto;
max-width: 600px;
margin-top:10px;
padding: 10px;
display: block;
background-color: var(--interface-color);
}
.settings-list{
list-style: none;
padding: 0px;
}
.setting-item{
margin-bottom: 10px;
padding: 5px;
}
.setting-item label{
display: inline-block;
width: 250px;
}
@media (max-width:650px){
h2{
text-align: center;
}
.setting-item{
}
.setting-item label{
display: block; /* make the setting input wrap */
margin-bottom: 5px;
}
}
{% endblock style %}
{% block main %}
<form method="POST" class="settings-form">
{% for categ in categories %}
<h2>{{ categ|capitalize }}</h2>
<ul class="settings-list">
{% for setting_name, setting_info, value in settings_by_category[categ] %}
{% if not setting_info.get('hidden', false) %}
<li class="setting-item">
{% if 'label' is in(setting_info) %}
<label for="{{ 'setting_' + setting_name }}">{{ setting_info['label'] }}</label>
{% else %}
<label for="{{ 'setting_' + setting_name }}">{{ setting_name.replace('_', ' ')|capitalize }}</label>
{% endif %}
{% if setting_info['type'].__name__ == 'bool' %}
<input type="checkbox" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" {{ 'checked' if value else '' }}>
{% elif setting_info['type'].__name__ == 'int' %}
{% if 'options' is in(setting_info) %}
<select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
{% for option in setting_info['options'] %}
<option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option>
{% endfor %}
</select>
{% elif 'max' in setting_info and 'min' in setting_info %}
<input type="number" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}" min="{{ setting_info['min'] }}" max="{{ setting_info['max'] }}">
{% else %}
<input type="number" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}" step="1">
{% endif %}
{% elif setting_info['type'].__name__ == 'float' %}
{% elif setting_info['type'].__name__ == 'str' %}
<input type="text" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}">
{% else %}
<span>Error: Unknown setting type: setting_info['type'].__name__</span>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
{% endfor %}
<input type="submit" value="Save settings">
</form>
{% endblock main %}

View File

@@ -0,0 +1,495 @@
* {
box-sizing: border-box;
}
h1, h2, h3, h4, h5, h6, div, button{
margin:0;
padding:0;
}
address{
font-style:normal;
}
html{
font-family: {{ font_family }};
--interface-border-color: var(--text-color);
}
body{
margin:0;
padding: 0;
color:var(--text-color);
background-color:var(--background-color);
min-height:100vh;
display: flex;
flex-direction: column;
}
header{
background-color:#333333;
min-height: 50px;
padding: 0px 5px;
display: flex;
justify-content: center;
}
#site-search{
max-width: 670px;
display: grid;
grid-template-columns: auto 1fr auto auto auto;
grid-template-rows: 50px 0fr;
grid-template-areas: "home search-bar search-button filter-button playlist"
". . . dropdown .";
grid-column-gap: 10px;
align-items: center;
flex-grow: 1;
position: relative;
}
#home-link{
align-self: center;
color: #ffffff;
grid-area: home;
}
#site-search .search-box{
align-self:center;
height:25px;
border:0;
grid-area: search-bar;
flex-grow: 1;
}
#site-search .search-button{
align-self:center;
height:25px;
grid-area: search-button;
}
#site-search .filter-dropdown-toggle-button{
align-self:center;
height:25px;
grid-area: filter-button;
}
#site-search .playlist-form-toggle-button{
height:25px;
grid-area: playlist;
display: none;
}
#site-search .filter-dropdown-content{
position: absolute;
grid-area: dropdown;
display: grid;
grid-template-columns: auto auto;
white-space: nowrap;
background-color: var(--interface-color);
padding: 0px 10px 10px 10px;
border-width: 0px 1px 1px 1px;
border-style: solid;
border-color: var(--interface-border-color);
top: 0px;
z-index:1;
}
#filter-dropdown-toggle-cbox:not(:checked) + .filter-dropdown-content{
display: none;
}
#site-search .filter-dropdown-content h3{
grid-column:1 / span 2;
}
#playlist-edit{
align-self: center;
}
#local-playlists{
margin-right:5px;
color: #ffffff;
}
#playlist-name-selection{
height:25px;
border: 0px;
}
#playlist-add-button{
height:25px;
}
#item-selection-reset{
height:25px;
}
main{
flex-grow: 1;
padding-left: 5px;
padding-right: 5px;
padding-bottom: 20px;
}
#message-box{
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-style: outset;
padding: 20px;
background-color: var(--interface-color);
opacity: 0;
transition-property: opacity;
transition-duration: 0.3s;
}
.button{
text-align: center;
white-space: nowrap;
padding-left: 10px;
padding-right: 10px;
background-color: #f0f0f0;
color: black;
border: 1px solid #919191;
border-radius: 5px;
display: inline-flex;
justify-content: center;
align-items: center; /* center text */
font-size: 0.85rem;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.button:hover{
background-color: #DCDCDC
}
.button:active{
background: #e9e9e9;
position: relative;
top: 1px;
text-shadow: none;
box-shadow: 0 1px 1px rgba(0, 0, 0, .3) inset;
}
.item-list{
display: grid;
grid-row-gap: 10px;
justify-content: center;
}
.item-grid{
display: flex;
flex-wrap: wrap;
}
.item-grid > .playlist-item-box{
margin-right: 10px;
}
.item-grid > * {
margin-bottom: 10px;
}
.item-grid .horizontal-item-box .item{
width:370px;
}
.item-grid .vertical-item-box .item{
}
.item-box{
display: inline-flex;
flex-direction: row;
/* prevent overflow due to long titles with no spaces:
https://stackoverflow.com/a/43312314 */
min-width: 0;
}
.vertical-item-box{
}
.horizontal-item-box{
}
.item{
background-color:var(--interface-color);
text-decoration:none;
font-size: 0.8125rem;
color: #767676;
}
.horizontal-item-box .item {
flex-grow: 1;
display: grid;
align-content: start;
grid-template-columns: auto 1fr;
/* prevent overflow due to long titles with no spaces:
https://stackoverflow.com/a/43312314 */
min-width: 0;
}
.vertical-item-box .item{
width: 168px;
}
.thumbnail-box{
font-size: 0px; /* prevent newlines and blank space from creating gaps */
position: relative;
display: block;
}
.horizontal-item-box .thumbnail-box{
margin-right: 4px;
}
.no-description .thumbnail-box{
width: 168px;
height:94px;
}
.has-description .thumbnail-box{
width: 246px;
height:138px;
}
.video-item .thumbnail-info{
position: absolute;
bottom: 2px;
right: 2px;
opacity: .8;
color: #ffffff;
font-size: 0.8125rem;
background-color: #000000;
}
.playlist-item .thumbnail-info{
position: absolute;
right: 0px;
bottom: 0px;
height: 100%;
width: 50%;
text-align:center;
white-space: pre-line;
opacity: .8;
color: #cfcfcf;
font-size: 0.8125rem;
background-color: #000000;
}
.playlist-item .thumbnail-info span{ /* trick to vertically center the text */
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}
.thumbnail-img{ /* center it */
margin: auto;
display: block;
max-height: 100%;
max-width: 100%;
}
.horizontal-item-box .thumbnail-img{
height: 100%;
}
.item-metadata{
overflow: hidden;
}
.item .title{
min-width: 0;
line-height:1.25em;
max-height:3.75em;
overflow-y: hidden;
overflow-wrap: break-word;
color: var(--text-color);
font-size: 1rem;
font-weight: 500;
text-decoration:initial;
}
.stats{
list-style: none;
padding: 0px;
margin: 0px;
}
.horizontal-stats{
max-height:2.4em;
overflow:hidden;
}
.horizontal-stats > li{
display: inline;
}
.horizontal-stats > li::after{
content: " | ";
}
.horizontal-stats > li:last-child::after{
content: "";
}
.vertical-stats{
display: flex;
flex-direction: column;
}
.stats address{
display: inline;
}
.vertical-stats li{
max-height: 1.3em;
overflow: hidden;
}
.item-checkbox{
justify-self:start;
align-self:center;
height:30px;
width:30px;
min-width:30px;
margin: 0px;
}
.page-button-row{
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
justify-self:center;
justify-content: center;
}
.page-button-row .page-button{
margin-top: 10px;
width: 40px;
height: 40px;
}
.next-previous-button-row{
margin: 10px 0px;
display: flex;
justify-self:center;
justify-content: center;
height: 40px;
}
.page-button{
background-color: var(--interface-color);
border-style: outset;
border-width: 2px;
font-weight: bold;
text-align: center;
padding: 5px;
}
.next-page:nth-child(2){ /* only if there's also a previous page button */
margin-left: 10px;
}
.sort-button{
background-color: var(--interface-color);
padding: 2px;
justify-self: start;
}
/* error page stuff */
h1{
font-size: 2rem;
font-weight: normal;
}
#error-box, #error-message{
background-color: var(--interface-color);
width: 80%;
margin: auto;
margin-top: 20px;
padding: 5px;
}
#error-message{
white-space: pre-wrap;
}
#error-box > div, #error-box > p, #error-box > h1{
white-space: pre-wrap;
margin-bottom: 10px;
}
.code-box{
white-space: pre-wrap;
padding: 5px;
border-style:solid;
border-width:1px;
border-radius:5px;
}
@media (max-width:950px){
#site-search{
grid-template-areas: "home search-bar search-button filter-button playlist"
". dropdown dropdown dropdown .";
}
#site-search .filter-dropdown-content{
justify-self: end;
}
}
@media (max-width:920px){
header{
flex-direction:column;
}
#site-search{
margin-bottom: 5px;
width: 100%;
align-self: center;
}
#playlist-edit > *{
margin-bottom: 10px;
}
#playlist-form-toggle-cbox:not(:checked) + #playlist-edit{
display: none;
}
#site-search .playlist-form-toggle-button{
display: inline-flex;
}
}
/* convert big items (has-description) to vertical format. e.g. search results */
@media (max-width:600px){
.has-description.horizontal-item-box .item {
flex-grow: unset;
display: block;
width: 246px;
}
.has-description.horizontal-item-box .thumbnail-box{
margin-right: 0px;
}
.has-description.horizontal-item-box .thumbnail-img{
height: 100%;
}
.has-description .horizontal-stats{
max-height: unset;
overflow:hidden;
}
.has-description .horizontal-stats > li{
display: initial;
}
.has-description .horizontal-stats > li::after{
content: "";
}
.has-description .horizontal-stats{
display: flex;
flex-direction: column;
}
.has-description .horizontal-stats li{
max-height: 1.3em;
overflow: hidden;
}
}
@media (max-width:500px){
#site-search{
grid-template-columns: 0fr auto auto auto;
grid-template-rows: 40px 40px 0fr;
grid-template-areas: "home search-bar search-bar search-bar"
". search-button filter-button playlist"
". dropdown dropdown dropdown";
}
#site-search .filter-dropdown-content{
justify-self: center;
}
}
@media (max-width:400px) {
.horizontal-item-box.no-description .thumbnail-box{
width: 120px;
}
.horizontal-item-box.no-description .thumbnail-img{
object-fit: scale-down;
object-position: center;
}
}
@media (max-width: 300px){
#site-search{
grid-template-columns: auto auto auto;
grid-template-areas: "home search-bar search-bar"
"search-button filter-button playlist"
"dropdown dropdown dropdown";
}
}

View File

@@ -0,0 +1,7 @@
{% set page_title = (title if (title is defined) else 'Status') %}
{% extends "base.html" %}
{% block main %}
{{ message }}
{% endblock %}

View File

@@ -0,0 +1,160 @@
{% set page_title = 'Subscription Manager' %}
{% extends "base.html" %}
{% block style %}
.import-export{
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding-top: 10px;
}
.subscriptions-import-export-form{
background-color: var(--interface-color);
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 600px;
padding:10px;
margin-left: 10px;
margin-bottom: 10px;
}
.subscriptions-import-export-form h2{
font-size: 1.25rem;
margin-bottom: 10px;
}
.import-export-submit-button{
margin-top:15px;
align-self: flex-end;
}
.subscriptions-export-links{
margin: 0px 0px 0px 20px;
background-color: var(--interface-color);
list-style: none;
max-width: 300px;
padding:10px;
}
.sub-list-controls{
background-color: var(--interface-color);
padding:15px;
padding-top: 0px;
padding-left: 5px;
}
.sub-list-controls > *{
margin-left: 10px;
margin-top: 15px;
}
.tag-group-list{
list-style: none;
margin-left: 10px;
margin-right: 10px;
padding: 0px;
}
.tag-group{
border-style: solid;
margin-bottom: 10px;
}
.sub-list{
list-style: none;
padding:10px;
column-width: 300px;
column-gap: 40px;
}
.sub-list-item{
display:flex;
margin-bottom: 10px;
break-inside:avoid;
}
.sub-list-item:not(.muted){
background-color: var(--interface-color);
}
.tag-list{
margin-left:15px;
font-weight:bold;
}
.sub-list-item-name{
margin-left:15px;
}
.sub-list-checkbox{
height: 1.5em;
min-width: 1.5em; // need min-width otherwise browser doesn't respect the width and squishes the checkbox down when there's too many tags
}
{% endblock style %}
{% macro subscription_list(sub_list) %}
{% for subscription in sub_list %}
<li class="sub-list-item {{ 'muted' if subscription['muted'] else '' }}">
<input class="sub-list-checkbox" name="channel_ids" value="{{ subscription['channel_id'] }}" form="subscription-manager-form" type="checkbox">
<a href="{{ subscription['channel_url'] }}" class="sub-list-item-name" title="{{ subscription['channel_name'] }}">{{ subscription['channel_name'] }}</a>
<span class="tag-list">{{ ', '.join(subscription['tags']) }}</span>
</li>
{% endfor %}
{% endmacro %}
{% block main %}
<div class="import-export">
<form class="subscriptions-import-export-form" enctype="multipart/form-data" action="/youtube.com/import_subscriptions" method="POST">
<h2>Import subscriptions</h2>
<input type="file" id="subscriptions-import" accept="application/json, application/xml, text/x-opml, text/csv" name="subscriptions_file" required>
<input type="submit" value="Import" class="import-export-submit-button">
</form>
<form class="subscriptions-import-export-form" action="/youtube.com/export_subscriptions" method="POST">
<h2>Export subscriptions</h2>
<div>
<select id="export-type" name="export_format" title="Export format">
<option value="json_newpipe">JSON (NewPipe)</option>
<option value="json_google_takeout">JSON (Old Google Takeout Format)</option>
<option value="opml">OPML (RSS, no tags)</option>
</select>
<label for="include-muted">Include muted</label>
<input id="include-muted" type="checkbox" name="include_muted" checked>
</div>
<input type="submit" value="Export" class="import-export-submit-button">
</form>
</div>
<hr>
<form id="subscription-manager-form" class="sub-list-controls" method="POST">
{% if group_by_tags %}
<a class="sort-button" href="/https://www.youtube.com/subscription_manager?group_by_tags=0">Don't group</a>
{% else %}
<a class="sort-button" href="/https://www.youtube.com/subscription_manager?group_by_tags=1">Group by tags</a>
{% endif %}
<input type="text" name="tags" placeholder="Comma-separated tags">
<button type="submit" name="action" value="add_tags">Add tags</button>
<button type="submit" name="action" value="remove_tags">Remove tags</button>
<button type="submit" name="action" value="unsubscribe_verify">Unsubscribe</button>
<button type="submit" name="action" value="mute">Mute</button>
<button type="submit" name="action" value="unmute">Unmute</button>
<input type="reset" value="Clear Selection">
</form>
{% if group_by_tags %}
<ul class="tag-group-list">
{% for tag_name, sub_list in tag_groups %}
<li class="tag-group">
<h2 class="tag-group-name">{{ tag_name }}</h2>
<ol class="sub-list">
{{ subscription_list(sub_list) }}
</ol>
</li>
{% endfor %}
</ul>
{% else %}
<ol class="sub-list">
{{ subscription_list(sub_list) }}
</ol>
{% endif %}
{% endblock main %}

View File

@@ -0,0 +1,180 @@
{% if current_tag %}
{% set page_title = 'Subscriptions - ' + current_tag %}
{% else %}
{% set page_title = 'Subscriptions' %}
{% endif %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
main{
display:flex;
flex-direction: row;
padding-right:0px;
}
.video-section{
flex-grow: 1;
padding-left: 10px;
padding-top: 10px;
}
.current-tag{
margin-bottom:10px;
}
.video-section .page-button-row{
justify-content: center;
}
.subscriptions-sidebar-fixed-container{
display: none;
}
.subscriptions-sidebar{
width: 310px;
max-width: 100%;
background-color: var(--interface-color);
border-left: 1px solid;
border-left-color: var(--interface-border-color);
}
.sidebar-links{
display:flex;
justify-content: space-between;
padding-left:10px;
padding-right: 10px;
margin-top: 10px;
}
.sidebar-list{
list-style: none;
padding-left:10px;
padding-right: 10px;
}
.sidebar-list-item{
display:flex;
justify-content: space-between;
margin-bottom: 5px;
}
.sub-refresh-list .sidebar-item-name{
text-overflow: clip;
white-space: nowrap;
overflow: hidden;
max-width: 200px;
}
@media (max-width:750px){
main{
display: initial;
position: relative;
padding-bottom: 70px;
}
.subscriptions-sidebar{
position: absolute;
right: 0px;
top: 0px;
}
#subscriptions-sidebar-toggle-cbox:not(:checked) + .subscriptions-sidebar{
visibility: hidden;
}
.subscriptions-sidebar-fixed-container{
display: flex;
align-items: center;
position: fixed;
bottom: 0px;
right: 0px;
background-color: var(--interface-color);
height: 70px;
width: 310px;
max-width: 100%;
border-width: 1px 0px 0px 1px;
border-style: solid;
border-color: var(--interface-border-color);
}
.subscriptions-sidebar-toggle-button{
display: block;
visibility: visible;
height: 60px;
width: 60px;
opacity: 0.75;
margin-left: auto;
}
.subscriptions-sidebar-toggle-button .button{
width:100%;
height:100%;
white-space: pre-wrap;
}
}
{% endblock style %}
{% block main %}
<div class="video-section">
{% if current_tag %}
<h2 class="current-tag">{{ current_tag }}</h2>
{% endif %}
<nav class="item-grid">
{% for video_info in videos %}
{{ common_elements.item(video_info) }}
{% endfor %}
</nav>
<nav class="page-button-row">
{{ common_elements.page_buttons(num_pages, '/youtube.com/subscriptions', parameters_dictionary) }}
</nav>
</div>
<input id="subscriptions-sidebar-toggle-cbox" type="checkbox" hidden>
<div class="subscriptions-sidebar">
<div class="subscriptions-sidebar-fixed-container">
<div class="subscriptions-sidebar-toggle-button">
<label class="button" for="subscriptions-sidebar-toggle-cbox">Toggle
Sidebar</label>
</div>
</div>
<div class="sidebar-links">
<a href="/youtube.com/subscription_manager" class="sub-manager-link">Subscription Manager</a>
<form method="POST" class="refresh-all">
<input type="submit" value="Check All">
<input type="hidden" name="action" value="refresh">
<input type="hidden" name="type" value="all">
</form>
</div>
<hr>
<ol class="sidebar-list tags">
{% if current_tag %}
<li class="sidebar-list-item">
<a href="/youtube.com/subscriptions" class="sidebar-item-name">Any tag</a>
</li>
{% endif %}
{% for tag in tags %}
<li class="sidebar-list-item">
{% if tag == current_tag %}
<span class="sidebar-item-name">{{ tag }}</span>
{% else %}
<a href="?tag={{ tag|urlencode }}" class="sidebar-item-name">{{ tag }}</a>
{% endif %}
<form method="POST" class="sidebar-item-refresh">
<input type="submit" value="Check">
<input type="hidden" name="action" value="refresh">
<input type="hidden" name="type" value="tag">
<input type="hidden" name="tag_name" value="{{ tag }}">
</form>
</li>
{% endfor %}
</ol>
<hr>
<ol class="sidebar-list sub-refresh-list">
{% for subscription in subscription_list %}
<li class="sidebar-list-item {{ 'muted' if subscription['muted'] else '' }}">
<a href="{{ subscription['channel_url'] }}" class="sidebar-item-name" title="{{ subscription['channel_name'] }}">{{ subscription['channel_name'] }}</a>
<form method="POST" class="sidebar-item-refresh">
<input type="submit" value="Check">
<input type="hidden" name="action" value="refresh">
<input type="hidden" name="type" value="channel">
<input type="hidden" name="channel_id" value="{{ subscription['channel_id'] }}">
</form>
</li>
{% endfor %}
</ol>
</div>
{% endblock main %}

View File

@@ -0,0 +1,9 @@
<opml version="1.1">
<body>
<outline text="YouTube Subscriptions" title="YouTube Subscriptions">
{% for sub in sub_list %}
<outline text="{{sub['channel_name']}}" title="{{sub['channel_name']}}" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id={{sub['channel_id']}}" />
{%- endfor %}
</outline>
</body>
</opml>

View File

@@ -0,0 +1,19 @@
{% set page_title = 'Unsubscribe?' %}
{% extends "base.html" %}
{% block main %}
<span>Are you sure you want to unsubscribe from these channels?</span>
<form class="subscriptions-import-form" action="/youtube.com/subscription_manager" method="POST">
{% for channel_id, channel_name in unsubscribe_list %}
<input type="hidden" name="channel_ids" value="{{ channel_id }}">
{% endfor %}
<input type="hidden" name="action" value="unsubscribe">
<input type="submit" value="Yes, unsubscribe">
</form>
<ul>
{% for channel_id, channel_name in unsubscribe_list %}
<li><a href="{{ '/https://www.youtube.com/channel/' + channel_id }}" title="{{ channel_name }}">{{ channel_name }}</a></li>
{% endfor %}
</ul>
{% endblock main %}

View File

@@ -0,0 +1,694 @@
{% set page_title = title %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% import "comments.html" as comments with context %}
{% block style %}
body {
--theater_video_target_width: {{ theater_video_target_width }};
--video_height: {{ video_height }};
--video_width: {{ video_width }};
--plyr-control-spacing-num: {{ '3' if video_height < 240 else '10' }};
--screen-width: calc(100vw - 25px);
}
details > summary{
background-color: var(--interface-color);
border-style: outset;
border-width: 2px;
font-weight: bold;
padding: 4px;
}
details > summary:hover{
text-decoration: underline;
}
.playability-error{
height: 360px;
max-width: 640px;
grid-column: 2;
background-color: var(--video-background-color);
text-align:center;
}
.playability-error span{
position: relative;
top: 50%;
transform: translate(-50%, -50%);
white-space: pre-wrap;
}
.live-url-choices{
min-height: 360px;
max-width: 640px;
grid-column: 2;
background-color: var(--video-background-color);
padding: 25px 0px 0px 25px;
}
.live-url-choices ol{
list-style: none;
padding:0px;
margin:0px;
margin-top: 15px;
}
.live-url-choices input{
max-width: 400px;
width: 100%;
}
.url-choice-label{
display: inline-block;
width: 150px;
}
{% if settings.theater_mode %}
#video-container{
grid-column: 1 / span 5;
justify-self: center;
max-width: 100%;
max-height: calc(var(--screen-width)*var(--video_height)/var(--video_width));
height: calc(var(--video_height)*1px);
width: calc(var(--theater_video_target_width)*1px);
margin-bottom: 10px;
--plyr-video-background: rgba(0, 0, 0, 0);
}
/*
Really just want this as another max-height variable in
#video-container, but have to use media queries instead because min
is only supported by newer browsers:
https://stackoverflow.com/questions/30568424/min-max-width-height-with-multiple-values
Because CSS is extra special, we cannot change
this max-height value using javascript when the video resolution
is changed, so we use this technique:
https://stackoverflow.com/a/39610272
*/
{% set heights = [] %}
{% for src in uni_sources+pair_sources %}
{% if src['height'] not in heights %}
{% do heights.append(src['height']) %}
@media(max-height:{{ src['height'] + 50 }}px){
#video-container.h{{ src['height'] }}{
height: calc(100vh - 50px); /* 50px is height of header */
}
}
{% endif %}
{% endfor %}
video{
background-color: var(--video-background-color);
}
#video-container > video, #video-container > .plyr{
width: 100%;
height: 100%;
}
.side-videos{
grid-row: 2 /span 3;
max-width: 400px;
}
.video-info{
max-width: 640px;
}
{% else %}
#video-container{
grid-column: 2;
}
#video-container, video{
height: calc(640px*var(--video_height)/var(--video_width)) !important;
width: 640px !important;
}
.plyr {
height: 100%;
width: 100%;
}
.side-videos{
grid-row: 1 /span 4;
}
{% endif %}
main{
display:grid;
/* minmax(0, 1fr) needed instead of 1fr for Chrome: https://stackoverflow.com/a/43312314 */
grid-template-columns: minmax(0, 1fr) 640px 40px 400px minmax(0, 1fr);
grid-template-rows: auto auto auto auto;
align-content: start;
padding-left: 0px;
padding-right: 0px;
}
.video-info{
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
grid-template-areas:
"v-title v-title"
"v-labels v-labels"
"v-uploader v-views"
"v-date v-likes-dislikes"
"external-player-controls v-checkbox"
"v-direct-link v-direct-link"
"v-download v-download"
"v-description v-description"
"v-music-list v-music-list"
"v-more-info v-more-info";
}
.video-info > .title{
grid-area: v-title;
min-width: 0;
}
.video-info > .labels{
grid-area: v-labels;
justify-self:start;
list-style: none;
padding: 0px;
margin: 5px 0px;
}
.video-info > .labels:empty{
margin: 0px;
}
.labels > li{
display: inline;
margin-right:5px;
background-color: var(--interface-color);
padding: 2px 5px;
border-style: solid;
border-width: 1px;
}
.video-info > address{
grid-area: v-uploader;
justify-self: start;
}
.video-info > .views{
grid-area: v-views;
justify-self:end;
}
.video-info > time{
grid-area: v-date;
justify-self:start;
}
.video-info > .likes-dislikes{
grid-area: v-likes-dislikes;
justify-self:end;
}
.video-info > .external-player-controls{
grid-area: external-player-controls;
justify-self: start;
margin-bottom: 8px;
}
#speed-control{
width: 65px;
text-align: center;
background-color: var(--interface-color);
color: var(--text-color);
}
.video-info > .checkbox{
grid-area: v-checkbox;
justify-self:end;
align-self: start;
height: 25px;
width: 25px;
}
.video-info > .direct-link{
grid-area: v-direct-link;
margin-bottom: 8px;
}
.video-info > .download-dropdown{
grid-area: v-download;
}
.video-info > .description{
background-color:var(--interface-color);
margin-top:8px;
white-space: pre-wrap;
min-width: 0;
word-wrap: break-word;
grid-area: v-description;
padding: 5px;
}
.music-list{
grid-area: v-music-list;
background-color: var(--interface-color);
padding-bottom: 7px;
}
.music-list table,th,td{
border: 1px solid;
}
.music-list th,td{
padding-left:4px;
padding-right:5px;
}
.music-list caption{
text-align:left;
font-weight:bold;
margin-bottom:5px;
}
.more-info{
grid-area: v-more-info;
background-color: var(--interface-color);
}
.more-info > summary{
font-weight: normal;
border-width: 1px 0px;
border-style: solid;
}
.more-info-content{
padding: 5px;
}
.more-info-content p{
margin: 8px 0px;
}
.comments-area-outer{
grid-column: 2;
grid-row: 3;
margin-top:10px;
}
.comments-disabled{
background-color: var(--interface-color);
padding: 5px;
font-weight: bold;
}
.comments-area-inner{
padding-top: 10px;
}
.comment{
max-width:640px;
}
.side-videos{
list-style: none;
grid-column: 4;
max-width: 640px;
}
#transcript-details{
margin-bottom: 10px;
}
table#transcript-table {
border-collapse: collapse;
width: 100%;
}
table#transcript-table td, th {
border: 1px solid #dddddd;
}
div#transcript-div {
background-color: var(--interface-color);
padding: 5px;
}
.playlist{
border-style: solid;
border-width: 2px;
border-color: lightgray;
margin-bottom: 10px;
}
.playlist-header{
background-color: var(--interface-color);
padding: 3px;
border-bottom-style: solid;
border-bottom-width: 2px;
border-bottom-color: lightgray;
}
.playlist-header h3{
margin: 2px;
}
.playlist-metadata{
list-style: none;
padding: 0px;
margin: 0px;
}
.playlist-metadata li{
display: inline;
margin: 2px;
}
.playlist-videos{
height: 300px;
overflow-y: scroll;
display: grid;
grid-auto-rows: 90px;
grid-row-gap: 10px;
padding-top: 10px;
}
.autoplay-toggle-container{
margin-bottom: 10px;
}
.related-videos-inner{
padding-top: 10px;
display: grid;
grid-auto-rows: 90px;
grid-row-gap: 10px;
}
.thumbnail-box{ /* overides rule in shared.css */
height: 90px !important;
width: 120px !important;
}
.download-dropdown-content{
background-color: var(--interface-color);
padding: 10px;
list-style: none;
margin: 0px;
}
li.download-format{
margin-bottom: 7px;
}
.download-link{
display: block;
background-color: rgba(var(--link-color-rgb), 0.07);
}
.download-link:visited{
background-color: rgba(var(--visited-link-color-rgb), 0.07);
}
.format-attributes{
list-style: none;
padding: 0px;
margin: 0px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.format-attributes li{
white-space: nowrap;
max-height: 1.2em;
}
.format-ext{
width: 60px;
}
.format-video-quality{
width: 140px;
}
.format-audio-quality{
width: 120px;
}
.format-file-size{
width: 80px;
}
.format-codecs{
}
/* Put related vids below videos when window is too small */
/* 1100px instead of 1080 because W3C is full of idiots who include scrollbar width */
@media (max-width:1100px){
main{
grid-template-columns: minmax(0, 1fr) 640px 0 minmax(0, 1fr);
}
.side-videos{
margin-top: 10px;
grid-column: 2;
grid-row: 3;
width: initial;
}
.comments-area-outer{
grid-row: 4;
}
}
@media (max-width:660px){
main{
grid-template-columns: 5px minmax(0, 1fr) 0 5px;
}
.format-attributes{
display: grid;
grid-template-columns: repeat(auto-fill, 140px);
}
.format-codecs{
grid-column: auto / span 2;
}
}
@media (max-width:500px){
.video-info{
grid-template-areas:
"v-title v-title"
"v-labels v-labels"
"v-uploader v-uploader"
"v-date v-date"
"v-views v-views"
"v-likes-dislikes v-likes-dislikes"
"external-player-controls v-checkbox"
"v-direct-link v-direct-link"
"v-download v-download"
"v-description v-description"
"v-music-list v-music-list"
"v-more-info v-more-info";
}
.video-info > .views{
justify-self: start;
}
.video-info > .likes-dislikes{
justify-self: start;
}
}
{% endblock style %}
{% block head %}
{% if settings.video_player == 1 %}
<!-- plyr -->
<link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet"/>
<link href="/youtube.com/static/plyr_fixes.css" rel="stylesheet"/>
<!--/ plyr -->
{% endif %}
{% endblock head %}
{% block main %}
{% if playability_error %}
<div class="playability-error">
<span>{{ 'Error: ' + playability_error }}
{% if invidious_reload_button %}
<a href="{{ video_url }}&use_invidious=0"><br>
Reload without invidious (for usage of new identity button).</a>
{% endif %}
</span>
</div>
{% elif (uni_sources.__len__() == 0 or live) and hls_formats.__len__() != 0 %}
<div class="live-url-choices">
<span>Copy a url into your video player:</span>
<ol>
{% for fmt in hls_formats %}
<li class="url-choice"><div class="url-choice-label">{{ fmt['video_quality'] }}: </div><input class="url-choice-copy" value="{{ fmt['url'] }}" readonly onclick="this.select();"></li>
{% endfor %}
</ol>
</div>
{% else %}
<div id="video-container" class="h{{video_height}}"> <!--Do not add other classes here, classes changed by javascript-->
<video controls autofocus class="video" {{ 'autoplay' if settings.autoplay_videos }}>
{% if uni_sources %}
<source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
{% endif %}
{% for source in subtitle_sources %}
{% if source['on'] %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
{% else %}
<track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
{% endif %}
{% endfor %}
</video>
</div>
{% endif %}
<div class="video-info">
<h2 class="title">{{ title }}</h2>
<ul class="labels">
{%- if unlisted -%}
<li class="is-unlisted">Unlisted</li>
{%- endif -%}
{%- if age_restricted -%}
<li class="age-restricted">Age-restricted</li>
{%- endif -%}
{%- if limited_state -%}
<li>Limited state</li>
{%- endif -%}
{%- if live -%}
<li>Live</li>
{%- endif -%}
</ul>
<address>Uploaded by <a href="{{ uploader_channel_url }}">{{ uploader }}</a></address>
<span class="views">{{ view_count }} views</span>
<time datetime="$upload_date">Published on {{ time_published }}</time>
<span class="likes-dislikes">{{ like_count }} likes {{ dislike_count }} dislikes</span>
<div class="external-player-controls">
<input id="speed-control" type="text" title="Video speed" placeholder="Speed">
{% if settings.video_player == 0 %}
<select id="quality-select" autocomplete="off">
{% for src in uni_sources %}
<option value='{"type": "uni", "index": {{ loop.index0 }}}' {{ 'selected' if loop.index0 == uni_idx and not using_pair_sources else '' }} >{{ src['quality_string'] }}</option>
{% endfor %}
{% for src_pair in pair_sources %}
<option value='{"type": "pair", "index": {{ loop.index0}}}' {{ 'selected' if loop.index0 == pair_idx and using_pair_sources else '' }} >{{ src_pair['quality_string'] }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<input class="checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
<span class="direct-link"><a href="https://youtu.be/{{ video_id }}">Direct Link</a></span>
<details class="download-dropdown">
<summary class="download-dropdown-label">Download</summary>
<ul class="download-dropdown-content">
{% for format in download_formats %}
<li class="download-format">
<a class="download-link" href="{{ format['url'] }}">
<ol class="format-attributes">
<li class="format-ext">{{ format['ext'] }}</li>
<li class="format-video-quality">{{ format['video_quality'] }}</li>
<li class="format-audio-quality">{{ format['audio_quality'] }}</li>
<li class="format-file-size">{{ format['file_size'] }}</li>
<li class="format-codecs">{{ format['codecs'] }}</li>
</ol>
</a>
</li>
{% endfor %}
{% for download in other_downloads %}
<li class="download-format">
<a class="download-link" href="{{ download['url'] }}">
<ol class="format-attributes">
<li class="format-ext">{{ download['ext'] }}</li>
<li class="format-label">{{ download['label'] }}</li>
</ol>
</a>
</li>
{% endfor %}
</ul>
</details>
<span class="description">{{ common_elements.text_runs(description)|escape|urlize|timestamps|safe }}</span>
<div class="music-list">
{% if music_list.__len__() != 0 %}
<hr>
<table>
<caption>Music</caption>
<tr>
{% for attribute in music_attributes %}
<th>{{ attribute }}</th>
{% endfor %}
</tr>
{% for track in music_list %}
<tr>
{% for attribute in music_attributes %}
{% if attribute.lower() == 'title' and track['url'] is not none %}
<td><a href="{{ track['url'] }}">{{ track.get(attribute.lower(), '') }}</a></td>
{% else %}
<td>{{ track.get(attribute.lower(), '') }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
{% endif %}
</div>
<details class="more-info">
<summary>More info</summary>
<div class="more-info-content">
<p>Tor exit node: {{ ip_address }}</p>
{% if invidious_used %}
<p>Used Invidious as fallback.</p>
{% endif %}
<p class="allowed-countries">Allowed countries: {{ allowed_countries|join(', ') }}</p>
{% if settings.use_sponsorblock_js %}
<ul class="more-actions">
<li><label><input type=checkbox id=skip_sponsors checked>skip sponsors</label> <span id=skip_n></span>
</ul>
{% endif %}
</div>
</details>
</div>
<div class="side-videos">
{% if playlist %}
<div class="playlist">
<div class="playlist-header">
<a href="{{ playlist['url'] }}" title="{{ playlist['title'] }}"><h3>{{ playlist['title'] }}</h3></a>
<ul class="playlist-metadata">
<li>Autoplay: <input type="checkbox" id="autoplay-toggle"></li>
{% if playlist['current_index'] is none %}
<li>[Error!]/{{ playlist['video_count'] }}</li>
{% else %}
<li>{{ playlist['current_index']+1 }}/{{ playlist['video_count'] }}</li>
{% endif %}
<li><a href="{{ playlist['author_url'] }}" title="{{ playlist['author'] }}">{{ playlist['author'] }}</a></li>
</ul>
</div>
<nav class="playlist-videos">
{% for info in playlist['items'] %}
{# non-lazy load for 5 videos surrounding current video #}
{# for non-js browsers or old such that IntersectionObserver doesn't work #}
{# -10 is sentinel to not load anything if there's no current_index for some reason #}
{% if (playlist.get('current_index', -10) - loop.index0)|abs is lt(5) %}
{{ common_elements.item(info, include_badges=false, lazy_load=false) }}
{% else %}
{{ common_elements.item(info, include_badges=false, lazy_load=true) }}
{% endif %}
{% endfor %}
</nav>
</div>
{% elif settings.related_videos_mode != 0 %}
<div class="autoplay-toggle-container"><label for="autoplay-toggle">Autoplay: </label><input type="checkbox" id="autoplay-toggle"></div>
{% endif %}
{% if subtitle_sources %}
<details id="transcript-details">
<summary>Transcript</summary>
<div id="transcript-div">
<select id="select-tt">
{% for source in subtitle_sources %}
<option>{{ source['label'] }}</option>
{% endfor %}
</select>
<label for="transcript-use-table">Table view</label>
<input type="checkbox" id="transcript-use-table">
<table id="transcript-table"></table>
</div>
</details>
{% endif %}
{% if settings.related_videos_mode != 0 %}
<details class="related-videos-outer" {{'open' if settings.related_videos_mode == 1 else ''}}>
<summary>Related Videos</summary>
<nav class="related-videos-inner">
{% for info in related %}
{{ common_elements.item(info, include_badges=false) }}
{% endfor %}
</nav>
</details>
{% endif %}
</div>
{% if settings.comments_mode != 0 %}
{% if comments_disabled %}
<div class="comments-area-outer comments-disabled">Comments disabled</div>
{% else %}
<details class="comments-area-outer" {{'open' if settings.comments_mode == 1 else ''}}>
<summary>{{ comment_count|commatize }} comment{{'s' if comment_count != '1' else ''}}</summary>
<section class="comments-area-inner comments-area">
{% if comments_info %}
{{ comments.video_comments(comments_info) }}
{% endif %}
</section>
</details>
{% endif %}
{% endif %}
<script src="/youtube.com/static/js/av-merge.js"></script>
<script src="/youtube.com/static/js/watch.js"></script>
{% if settings.video_player == 1 %}
<!-- plyr -->
<script>var storyboard_url = {{ storyboard_url | tojson }}</script>
<script src="/youtube.com/static/modules/plyr/plyr.js"></script>
<script src="/youtube.com/static/js/plyr-start.js"></script>
<!-- /plyr -->
{% endif %}
<script src="/youtube.com/static/js/common.js"></script>
<script src="/youtube.com/static/js/transcript-table.js"></script>
{% if settings.use_video_hotkeys %} <script src="/youtube.com/static/js/hotkeys.js"></script> {% endif %}
{% if settings.use_comments_js %} <script src="/youtube.com/static/js/comments.js"></script> {% endif %}
{% if settings.use_sponsorblock_js %} <script src="/youtube.com/static/js/sponsorblock.js"></script> {% endif %}
{% endblock main %}