diff --git a/assets/css/carousel.css b/assets/css/carousel.css
index 4bae92e5..e34edc65 100644
--- a/assets/css/carousel.css
+++ b/assets/css/carousel.css
@@ -97,23 +97,3 @@ DEALINGS IN THE SOFTWARE.
width: 50%;
z-index: 1;
}
-
-.light-theme .slider-nav {
- background-color: #ddd;
-}
-
-.dark-theme .slider-nav {
- background-color: #0005;
-}
-
-@media (prefers-color-scheme: light) {
- .no-theme .slider-nav {
- background-color: #ddd;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- .no-theme .slider-nav {
- background-color: #0005;
- }
-}
diff --git a/assets/css/dark.css b/assets/css/dark.css
new file mode 100644
index 00000000..debe135f
--- /dev/null
+++ b/assets/css/dark.css
@@ -0,0 +1,81 @@
+/* Contains the CSS for the Invidious dark theme
+
+--------
+This file should not get loaded by the user as the css here will be used
+to generate theme.css as to support user-agent dark/light theme
+in addition to the class-based swap method without duplicating any styles.
+------
+
+*/
+
+.dark-theme a:hover,
+.dark-theme a:active,
+.dark-theme summary:hover,
+.dark-theme a:focus,
+.dark-theme summary:focus {
+ color: rgb(0, 182, 240);
+}
+
+.dark-theme .pure-button-primary:hover,
+.dark-theme .pure-button-primary:focus,
+.dark-theme .pure-button-secondary:hover,
+.dark-theme .pure-button-secondary:focus {
+ color: #fff !important;
+ border-color: rgb(0, 182, 240) !important;
+ background-color: rgba(0, 182, 240, 1) !important;
+}
+
+.dark-theme .pure-button-secondary {
+ background-color: #0002;
+ color: #ddd;
+}
+
+.dark-theme a {
+ color: #adadad;
+ text-decoration: none;
+}
+
+body.dark-theme {
+ background-color: rgba(35, 35, 35, 1);
+ color: #f0f0f0;
+}
+
+.dark-theme .pure-form legend,
+.dark-theme .pure-menu-heading {
+ color: #f0f0f0;
+}
+
+.dark-theme input,
+.dark-theme select,
+.dark-theme textarea {
+ color: rgba(35, 35, 35, 1);
+}
+
+.dark-theme .pure-form input[type="file"] {
+ color: #f0f0f0;
+}
+
+.dark-theme .searchbar input {
+ background-color: inherit;
+ color: inherit;
+}
+
+.dark-theme .error-card {
+ border: 1px solid #5e5e5e;
+}
+
+.dark-theme footer {
+ color: #adadad;
+}
+
+.dark-theme footer a {
+ color: #adadad !important;
+}
+
+.dark-theme #filters-box {
+ background: #373737;
+}
+
+.dark-theme .slider-nav {
+ background-color: #0005;
+}
diff --git a/assets/css/default.css b/assets/css/default.css
index 01d4b736..8411df1d 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -466,22 +466,6 @@ footer {
max-height: 30vh;
}
-.light-theme footer {
- color: #7c7c7c;
-}
-
-.dark-theme footer {
- color: #adadad;
-}
-
-.light-theme footer a {
- color: #7c7c7c !important;
-}
-
-.dark-theme footer a {
- color: #adadad !important;
-}
-
footer span {
margin: 4px 0;
display: block;
@@ -508,236 +492,6 @@ span > select {
}
-/*
- * Light theme
- */
-
-.light-theme a:hover,
-.light-theme a:active,
-.light-theme summary:hover,
-.light-theme a:focus,
-.light-theme summary:focus {
- color: #075A9E !important;
-}
-
-.light-theme .pure-button-primary:hover,
-.light-theme .pure-button-primary:focus,
-.light-theme .pure-button-secondary:hover,
-.light-theme .pure-button-secondary:focus {
- color: #fff !important;
- border-color: rgba(0, 182, 240, 0.75) !important;
- background-color: rgba(0, 182, 240, 0.75) !important;
-}
-
-.light-theme .pure-button-secondary:not(.low-profile) {
- color: #335d7a;
- background-color: #fff2;
-}
-
-.light-theme a {
- color: #335d7a;
- text-decoration: none;
-}
-
-/* All links that do not fit with the default color goes here */
-.light-theme a:not([data-id]) > .icon,
-.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"],
-.light-theme .playlist-restricted > ol > li > a {
- color: #303030;
-}
-
-.light-theme .pure-menu-heading {
- color: #565d64;
-}
-
-.light-theme .error-card {
- border: 1px solid black;
-}
-
-@media (prefers-color-scheme: light) {
- .no-theme a:hover,
- .no-theme a:active,
- .no-theme summary:hover,
- .no-theme a:focus,
- .no-theme summary:focus {
- color: #075A9E !important;
- }
-
- .no-theme .pure-button-primary:hover,
- .no-theme .pure-button-primary:focus,
- .no-theme .pure-button-secondary:hover,
- .no-theme .pure-button-secondary:focus {
- color: #fff !important;
- border-color: rgba(0, 182, 240, 0.75) !important;
- background-color: rgba(0, 182, 240, 0.75) !important;
- }
-
- .no-theme .pure-button-secondary:not(.low-profile) {
- color: #335d7a;
- background-color: #fff2;
- }
-
- .no-theme a {
- color: #335d7a;
- text-decoration: none;
- }
-
- /* All links that do not fit with the default color goes here */
- .no-theme a:not([data-id]) > .icon,
- .no-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"],
- .no-theme .playlist-restricted > ol > li > a {
- color: #303030;
- }
-
- .no-theme footer {
- color: #7c7c7c;
- }
-
- .no-theme footer a {
- color: #7c7c7c !important;
- }
-
- .light-theme .pure-menu-heading {
- color: #565d64;
- }
-
- .no-theme .error-card {
- border: 1px solid black;
- }
-}
-
-
-/*
- * Dark theme
- */
-
-.dark-theme a:hover,
-.dark-theme a:active,
-.dark-theme summary:hover,
-.dark-theme a:focus,
-.dark-theme summary:focus {
- color: rgb(0, 182, 240);
-}
-
-.dark-theme .pure-button-primary:hover,
-.dark-theme .pure-button-primary:focus,
-.dark-theme .pure-button-secondary:hover,
-.dark-theme .pure-button-secondary:focus {
- color: #fff !important;
- border-color: rgb(0, 182, 240) !important;
- background-color: rgba(0, 182, 240, 1) !important;
-}
-
-.dark-theme .pure-button-secondary {
- background-color: #0002;
- color: #ddd;
-}
-
-.dark-theme a {
- color: #adadad;
- text-decoration: none;
-}
-
-body.dark-theme {
- background-color: rgba(35, 35, 35, 1);
- color: #f0f0f0;
-}
-
-.dark-theme .pure-form legend {
- color: #f0f0f0;
-}
-
-.dark-theme .pure-menu-heading {
- color: #f0f0f0;
-}
-
-.dark-theme input,
-.dark-theme select,
-.dark-theme textarea {
- color: rgba(35, 35, 35, 1);
-}
-
-.dark-theme .pure-form input[type="file"] {
- color: #f0f0f0;
-}
-
-.dark-theme .searchbar input {
- background-color: inherit;
- color: inherit;
-}
-
-.dark-theme .error-card {
- border: 1px solid #5e5e5e;
-}
-
-@media (prefers-color-scheme: dark) {
- .no-theme a:hover,
- .no-theme a:active,
- .no-theme a:focus {
- color: rgb(0, 182, 240);
- }
-
- .no-theme .pure-button-primary:hover,
- .no-theme .pure-button-primary:focus,
- .no-theme .pure-button-secondary:hover,
- .no-theme .pure-button-secondary:focus {
- color: #fff !important;
- border-color: rgb(0, 182, 240) !important;
- background-color: rgba(0, 182, 240, 1) !important;
- }
-
- .no-theme .pure-button-secondary {
- background-color: #0002;
- color: #ddd;
- }
-
- .no-theme a {
- color: #adadad;
- text-decoration: none;
- }
-
- body.no-theme {
- background-color: rgba(35, 35, 35, 1);
- color: #f0f0f0;
- }
-
- .no-theme .pure-form legend {
- color: #f0f0f0;
- }
-
- .no-theme .pure-menu-heading {
- color: #f0f0f0;
- }
-
- .no-theme input,
- .no-theme select,
- .no-theme textarea {
- color: rgba(35, 35, 35, 1);
- }
-
- .no-theme .pure-form input[type="file"] {
- color: #f0f0f0;
- }
-
- .no-theme .searchbar input {
- background-color: inherit;
- color: inherit;
- }
-
- .no-theme footer {
- color: #adadad;
- }
-
- .no-theme footer a {
- color: #adadad !important;
- }
-
- .no-theme .error-card {
- border: 1px solid #5e5e5e;
- }
-}
-
-
/*
* Miscellanous
*/
diff --git a/assets/css/light.css b/assets/css/light.css
new file mode 100644
index 00000000..ccef24b3
--- /dev/null
+++ b/assets/css/light.css
@@ -0,0 +1,67 @@
+/* Contains the CSS for the Invidious light theme
+
+--------
+This file should not get loaded by the user as the css here will be used
+to generate theme.css as to support user-agent dark/light theme
+in addition to the class-based swap method without duplicating any styles.
+------
+
+*/
+
+.light-theme a:hover,
+.light-theme a:active,
+.light-theme summary:hover,
+.light-theme a:focus,
+.light-theme summary:focus {
+ color: #075A9E !important;
+}
+
+.light-theme .pure-button-primary:hover,
+.light-theme .pure-button-primary:focus,
+.light-theme .pure-button-secondary:hover,
+.light-theme .pure-button-secondary:focus {
+ color: #fff !important;
+ border-color: rgba(0, 182, 240, 0.75) !important;
+ background-color: rgba(0, 182, 240, 0.75) !important;
+}
+
+.light-theme .pure-button-secondary:not(.low-profile) {
+ color: #335d7a;
+ background-color: #fff2;
+}
+
+.light-theme a {
+ color: #335d7a;
+ text-decoration: none;
+}
+
+/* All links that do not fit with the default color goes here */
+.light-theme a:not([data-id]) > .icon,
+.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"],
+.light-theme .playlist-restricted > ol > li > a {
+ color: #303030;
+}
+
+.light-theme .pure-menu-heading {
+ color: #565d64;
+}
+
+.light-theme footer {
+ color: #7c7c7c;
+}
+
+.light-theme footer a {
+ color: #7c7c7c !important;
+}
+
+.light-theme .error-card {
+ border: 1px solid black;
+}
+
+.light-theme #filters-box {
+ background: #dfdfdf;
+}
+
+.light-theme .slider-nav {
+ background-color: #ddd;
+}
diff --git a/assets/css/search.css b/assets/css/search.css
index 833ec7e9..0c03a45e 100644
--- a/assets/css/search.css
+++ b/assets/css/search.css
@@ -95,27 +95,3 @@ fieldset, legend {
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;
- }
-}
diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr
index a5f42261..db18592b 100644
--- a/src/ext/kemal_static_file_handler.cr
+++ b/src/ext/kemal_static_file_handler.cr
@@ -159,7 +159,9 @@ module Kemal
redirect_to context, expanded_path + '/'
end
- return call_next(context) if file_info.nil?
+ return call_next(context) if file_info.nil? ||
+ file_path.ends_with?("dark.css") ||
+ file_path.ends_with?("light.css")
if is_dir
if config.is_a?(Hash) && config["dir_listing"] == true
diff --git a/src/invidious/routes/get_theme_css_file.cr b/src/invidious/routes/get_theme_css_file.cr
new file mode 100644
index 00000000..52cc322b
--- /dev/null
+++ b/src/invidious/routes/get_theme_css_file.cr
@@ -0,0 +1,52 @@
+# No need to initialize yet another namespace
+#
+# :nodoc:
+module Invidious::Routes::Misc
+ private CSS_THEME_LIGHT = File.read("assets/css/light.css")
+ private CSS_THEME_UA_LIGHT = CSS_THEME_LIGHT.gsub(".light-theme", ".no-theme")
+
+ private CSS_THEME_DARK = File.read("assets/css/dark.css")
+ private CSS_THEME_UA_DARK = CSS_THEME_DARK.gsub(".dark-theme", ".no-theme")
+
+ private THEME_LAST_MODIFIED = {
+ File.info("assets/css/light.css").modification_time,
+ File.info("assets/css/dark.css").modification_time,
+ }.min
+
+ def self.theme_css(env)
+ env.response.headers["Content-Type"] = "text/css; charset=utf-8"
+
+ # Replicate cache header behavior of static file handler
+ env.response.headers["Etag"] = %{W/"#{THEME_LAST_MODIFIED.to_unix}"}
+ env.response.headers["Last-Modified"] = HTTP.format_time(THEME_LAST_MODIFIED)
+
+ if cache_request?(env, THEME_LAST_MODIFIED)
+ env.response.status = HTTP::Status::NOT_MODIFIED
+ return
+ end
+
+ # Usually added in `send_file` when static_headers proc is called
+ env.response.headers["Cache-Control"] = "max-age=2629800"
+
+ rendered "theme.css"
+ end
+
+ # Taken from https://github.com/crystal-lang/crystal/blob/1.16.3/src/http/server/handlers/static_file_handler.cr#L236
+ private def self.cache_request?(context : HTTP::Server::Context, last_modified : Time) : Bool
+ # According to RFC 7232:
+ # A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field
+ if if_none_match = context.request.if_none_match
+ match = {"*", context.response.headers["Etag"]}
+ if_none_match.any? { |etag| match.includes?(etag) }
+ elsif if_modified_since = context.request.headers["If-Modified-Since"]?
+ header_time = HTTP.parse_time(if_modified_since)
+ # File mtime probably has a higher resolution than the header value.
+ # An exact comparison might be slightly off, so we add 1s padding.
+ # Static files should generally not be modified in subsecond intervals, so this is perfectly safe.
+ # This might be replaced by a more sophisticated time comparison when it becomes available.
+ !!(header_time && last_modified <= header_time + 1.second)
+ else
+ false
+ end
+ end
+end
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 46b71f1f..3588db4f 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -21,6 +21,7 @@ module Invidious::Routing
get "/privacy", Routes::Misc, :privacy
get "/licenses", Routes::Misc, :licenses
get "/redirect", Routes::Misc, :cross_instance_redirect
+ get "/css/theme.css", Routes::Misc, :theme_css
self.register_channel_routes
self.register_watch_routes
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 9904b4fc..855496c4 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -21,6 +21,7 @@
+
diff --git a/src/invidious/views/theme.css.ecr b/src/invidious/views/theme.css.ecr
new file mode 100644
index 00000000..cb378ee3
--- /dev/null
+++ b/src/invidious/views/theme.css.ecr
@@ -0,0 +1,10 @@
+/*
+This files contains an automatically generated CSS file that contains the styles
+for the dark and light themes of Invidious. Automatically generating this file allows
+supporting both the user-agent color scheme preference and class-based theme preference
+without duplicating the defined styles twice for each theme.
+*/
+<%=CSS_THEME_LIGHT-%>
+@media (prefers-color-scheme: light) {<%= CSS_THEME_UA_LIGHT %>}
+<%=CSS_THEME_DARK%>
+@media (prefers-color-scheme: dark) {<%=CSS_THEME_UA_DARK%>}