diff options
| -rw-r--r-- | assets/js/_helpers.js | 249 | ||||
| -rw-r--r-- | assets/js/community.js | 86 | ||||
| -rw-r--r-- | assets/js/embed.js | 103 | ||||
| -rw-r--r-- | assets/js/handlers.js | 137 | ||||
| -rw-r--r-- | assets/js/notifications.js | 203 | ||||
| -rw-r--r-- | assets/js/player.js | 334 | ||||
| -rw-r--r-- | assets/js/playlist_widget.js | 52 | ||||
| -rw-r--r-- | assets/js/subscribe_widget.js | 92 | ||||
| -rw-r--r-- | assets/js/themes.js | 112 | ||||
| -rw-r--r-- | assets/js/watch.js | 475 | ||||
| -rw-r--r-- | assets/js/watched_widget.js | 39 | ||||
| -rw-r--r-- | locales/bn.json | 97 | ||||
| -rw-r--r-- | locales/en-US.json | 1 | ||||
| -rw-r--r-- | locales/fr.json | 1 | ||||
| -rw-r--r-- | locales/hr.json | 76 | ||||
| -rw-r--r-- | locales/id.json | 5 | ||||
| -rw-r--r-- | locales/pt.json | 46 | ||||
| -rw-r--r-- | locales/ru.json | 126 | ||||
| -rw-r--r-- | locales/sl.json | 58 | ||||
| -rw-r--r-- | locales/uk.json | 20 | ||||
| -rw-r--r-- | scripts/deploy-database.sh | 60 | ||||
| -rw-r--r-- | scripts/install-dependencies.sh | 174 | ||||
| -rw-r--r-- | src/invidious/comments.cr | 14 | ||||
| -rw-r--r-- | src/invidious/views/licenses.ecr | 14 | ||||
| -rw-r--r-- | src/invidious/views/template.ecr | 1 | ||||
| -rw-r--r-- | src/invidious/views/watch.ecr | 34 |
26 files changed, 1420 insertions, 1189 deletions
diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js new file mode 100644 index 00000000..7c50670e --- /dev/null +++ b/assets/js/_helpers.js @@ -0,0 +1,249 @@ +'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) { + return this.filter(condition)[0]; +}; +Array.from = Array.from || function (source) { + return Array.prototype.slice.call(source); +}; +NodeList.prototype.forEach = NodeList.prototype.forEach || function (callback) { + Array.from(this).forEach(callback); +}; +String.prototype.includes = String.prototype.includes || function (searchString) { + return this.indexOf(searchString) >= 0; +}; +String.prototype.startsWith = String.prototype.startsWith || function (prefix) { + return this.substr(0, prefix.length) === prefix; +}; +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; + + 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 + } + + 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 (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(); + }; + + helpers._xhrRetry(method, url, options, callbacks); + }, + + /** + * @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) { + if (!localStorage[key]) return; + try { + return JSON.parse(decodeURIComponent(localStorage[key])); + } catch(e) { + // Erase non parsable value + helpers.storage.remove(key); + } + }, + set: function (key, value) { localStorage[key] = encodeURIComponent(JSON.stringify(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)); + + // 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'; + } + }; + })() +}; diff --git a/assets/js/community.js b/assets/js/community.js index 44066a58..32fe4ebc 100644 --- a/assets/js/community.js +++ b/assets/js/community.js @@ -1,13 +1,6 @@ 'use strict'; var community_data = JSON.parse(document.getElementById('community_data').textContent); -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; - }); -}; - function hide_youtube_replies(event) { var target = event.target; @@ -38,13 +31,6 @@ function show_youtube_replies(event) { target.setAttribute('data-sub-text', sub_text); } -function number_with_separator(val) { - while (/(\d+)(\d{3})/.test(val.toString())) { - val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2'); - } - return val; -} - function get_youtube_replies(target, load_more) { var continuation = target.getAttribute('data-continuation'); @@ -58,47 +44,39 @@ function get_youtube_replies(target, load_more) { '&hl=' + community_data.preferences.locale + '&thin_mode=' + community_data.preferences.thin_mode + '&continuation=' + continuation; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); - - 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.innerText = community_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.innerHTML += response.contentHtml; } else { - body.innerHTML = fallback; - } - } - }; + body.removeChild(body.lastElementChild); + + 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; - xhr.ontimeout = function () { - console.warn('Pulling comments failed.'); - body.innerHTML = fallback; - }; + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; - xhr.send(); + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); } diff --git a/assets/js/embed.js b/assets/js/embed.js index 7e9ac605..b11b5e5a 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -1,14 +1,7 @@ 'use strict'; var video_data = JSON.parse(document.getElementById('video_data').textContent); -function get_playlist(plid, retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - console.warn('Failed to pull playlist'); - return; - } - +function get_playlist(plid) { var plid_url; if (plid.startsWith('RD')) { plid_url = '/api/v1/mixes/' + plid + @@ -21,85 +14,49 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/embed/' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.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); - }); - } - } + 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); + + 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); + }); } - }; - - xhr.onerror = function () { - console.warn('Pulling playlist failed... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Pulling playlist failed... ' + retries + '/5'); - get_playlist(plid, retries - 1); - }; - - xhr.send(); + }); } -window.addEventListener('load', function (e) { +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) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + 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) { + 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) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - - if (video_data.video_series.length !== 0) { + if (video_data.video_series.length !== 0) url.searchParams.set('playlist', video_data.video_series.join(',')); - } location.assign(url.pathname + url.search); }); diff --git a/assets/js/handlers.js b/assets/js/handlers.js index f6617b60..29810e72 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -1,8 +1,6 @@ 'use strict'; (function () { - var n2a = function (n) { return Array.prototype.slice.call(n); }; - 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'] = ''; }; @@ -11,8 +9,8 @@ } // For dynamically inserted elements - document.addEventListener('click', function (e) { - if (!e || !e.target) { return; } + addEventListener('click', function (e) { + if (!e || !e.target) return; var t = e.target; var handler_name = t.getAttribute('data-onclick'); @@ -29,6 +27,7 @@ get_youtube_replies(t, load_more, load_replies); break; case 'toggle_parent': + e.preventDefault(); toggle_parent(t); break; default: @@ -36,118 +35,98 @@ } }); - n2a(document.querySelectorAll('[data-mouse="switch_classes"]')).forEach(function (e) { - var classes = e.getAttribute('data-switch-classes').split(','); - var ec = classes[0]; - var lc = classes[1]; - var onoff = function (on, off) { - var cs = e.getAttribute('class'); - cs = cs.split(off).join(on); - e.setAttribute('class', cs); - }; - e.onmouseenter = function () { onoff(ec, lc); }; - e.onmouseleave = function () { onoff(lc, ec); }; + 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); }; }); - n2a(document.querySelectorAll('[data-onsubmit="return_false"]')).forEach(function (e) { - e.onsubmit = function () { return false; }; + document.querySelectorAll('[data-onsubmit="return_false"]').forEach(function (el) { + el.onsubmit = function () { return false; }; }); - n2a(document.querySelectorAll('[data-onclick="mark_watched"]')).forEach(function (e) { - e.onclick = function () { mark_watched(e); }; + document.querySelectorAll('[data-onclick="mark_watched"]').forEach(function (el) { + el.onclick = function () { mark_watched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="mark_unwatched"]')).forEach(function (e) { - e.onclick = function () { mark_unwatched(e); }; + document.querySelectorAll('[data-onclick="mark_unwatched"]').forEach(function (el) { + el.onclick = function () { mark_unwatched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_video"]')).forEach(function (e) { - e.onclick = function () { add_playlist_video(e); }; + document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) { + el.onclick = function () { add_playlist_video(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_item"]')).forEach(function (e) { - e.onclick = function () { add_playlist_item(e); }; + document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) { + el.onclick = function () { add_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_playlist_item"]')).forEach(function (e) { - e.onclick = function () { remove_playlist_item(e); }; + document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) { + el.onclick = function () { remove_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="revoke_token"]')).forEach(function (e) { - e.onclick = function () { revoke_token(e); }; + document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) { + el.onclick = function () { revoke_token(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_subscription"]')).forEach(function (e) { - e.onclick = function () { remove_subscription(e); }; + document.querySelectorAll('[data-onclick="remove_subscription"]').forEach(function (el) { + el.onclick = function () { remove_subscription(el); }; }); - n2a(document.querySelectorAll('[data-onclick="notification_requestPermission"]')).forEach(function (e) { - e.onclick = function () { Notification.requestPermission(); }; + document.querySelectorAll('[data-onclick="notification_requestPermission"]').forEach(function (el) { + el.onclick = function () { Notification.requestPermission(); }; }); - n2a(document.querySelectorAll('[data-onrange="update_volume_value"]')).forEach(function (e) { - var cb = function () { update_volume_value(e); }; - e.oninput = cb; - e.onchange = cb; + 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 update_volume_value(element) { - document.getElementById('volume-value').innerText = element.value; - } function revoke_token(target) { var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; row.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.textContent--; - var referer = window.encodeURIComponent(document.location.href); var url = '/token_ajax?action_revoke_token=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&session=' + target.getAttribute('data-session'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } - } - }; - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); + 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.innerText = count.innerText - 1; + count.textContent--; - var referer = window.encodeURIComponent(document.location.href); var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&c=' + target.getAttribute('data-ucid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } - } - }; - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); + 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 - window.addEventListener('keydown', function (event) { + addEventListener('keydown', function (event) { // Ignore modifier keys if (event.ctrlKey || event.metaKey) return; diff --git a/assets/js/notifications.js b/assets/js/notifications.js index ec5f6dd3..058553d9 100644 --- a/assets/js/notifications.js +++ b/assets/js/notifications.js @@ -1,43 +1,26 @@ 'use strict'; var notification_data = JSON.parse(document.getElementById('notification_data').textContent); -var notifications, delivered; - -function get_subscriptions(callback, retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - return; - } - - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', '/api/v1/auth/subscriptions?fields=authorId', true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - var subscriptions = xhr.response; - callback(subscriptions); - } - } - }; - - xhr.onerror = function () { - console.warn('Pulling subscriptions failed... ' + retries + '/5'); - setTimeout(function () { get_subscriptions(callback, retries - 1); }, 1000); - }; +/** Boolean meaning 'some tab have stream' */ +const STORAGE_KEY_STREAM = 'stream'; +/** Number of notifications. May be increased or reset */ +const STORAGE_KEY_NOTIF_COUNT = 'notification_count'; - xhr.ontimeout = function () { - console.warn('Pulling subscriptions failed... ' + retries + '/5'); - get_subscriptions(callback, retries - 1); - }; - - xhr.send(); +var notifications, delivered; +var notifications_mock = { close: function () { } }; + +function get_subscriptions() { + helpers.xhr('GET', '/api/v1/auth/subscriptions?fields=authorId', { + retries: 5, + entity_name: 'subscriptions' + }, { + on200: create_notification_stream + }); } 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?fields=videoId,title,author,authorId,publishedText,published,authorThumbnails,liveNow', { withCredentials: true, @@ -49,96 +32,100 @@ function create_notification_stream(subscriptions) { var start_time = Math.round(new Date() / 1000); notifications.onmessage = function (event) { - if (!event.id) { - return; - } + if (!event.id) return; var notification = JSON.parse(event.data); console.info('Got notification:', notification); - if (start_time < notification.published && !delivered.includes(notification.videoId)) { - if (Notification.permission === 'granted') { - var system_notification = - new Notification((notification.liveNow ? notification_data.live_now_text : notification_data.upload_text).replace('`x`', notification.author), { - body: notification.title, - icon: '/ggpht' + new URL(notification.authorThumbnails[2].url).pathname, - img: '/ggpht' + new URL(notification.authorThumbnails[4].url).pathname, - tag: notification.videoId - }); - - system_notification.onclick = function (event) { - window.open('/watch?v=' + event.currentTarget.tag, '_blank'); - }; - } - - delivered.push(notification.videoId); - localStorage.setItem('notification_count', parseInt(localStorage.getItem('notification_count') || '0') + 1); - var notification_ticker = document.getElementById('notification_ticker'); - - if (parseInt(localStorage.getItem('notification_count')) > 0) { - notification_ticker.innerHTML = - '<span id="notification_count">' + localStorage.getItem('notification_count') + '</span> <i class="icon ion-ios-notifications"></i>'; - } else { - notification_ticker.innerHTML = - '<i class="icon ion-ios-notifications-outline"></i>'; - } + // Ignore not actual and delivered notifications + if (start_time > notification.published || delivered.includes(notification.videoId)) return; + + 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); + + 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); + + 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'); + }; } }; - notifications.addEventListener('error', handle_notification_error); - notifications.stream(); -} + notifications.addEventListener('error', function (e) { + console.warn('Something went wrong with notifications, trying to reconnect...'); + notifications = notifications_mock; + setTimeout(get_subscriptions, 1000); + }); -function handle_notification_error(event) { - console.warn('Something went wrong with notifications, trying to reconnect...'); - notifications = { close: function () { } }; - setTimeout(function () { get_subscriptions(create_notification_stream); }, 1000); + notifications.stream(); } -window.addEventListener('load', function (e) { - localStorage.setItem('notification_count', document.getElementById('notification_count') ? document.getElementById('notification_count').innerText : '0'); +function update_ticker_count() { + var notification_ticker = document.getElementById('notification_ticker'); - if (localStorage.getItem('stream')) { - localStorage.removeItem('stream'); + 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 { - setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); - } - }, Math.random() * 1000 + 50); + notification_ticker.innerHTML = + '<i class="icon ion-ios-notifications-outline"></i>'; } +} - window.addEventListener('storage', function (e) { - if (e.key === 'stream' && !e.newValue) { - if (notifications) { - localStorage.setItem('stream', true); - } else { - setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); - } - }, Math.random() * 1000 + 50); - } - } else if (e.key === 'notification_count') { - var notification_ticker = document.getElementById('notification_ticker'); - - if (parseInt(e.newValue) > 0) { - notification_ticker.innerHTML = - '<span id="notification_count">' + e.newValue + '</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 +} + + +addEventListener('storage', function (e) { + if (e.key === STORAGE_KEY_NOTIF_COUNT) + update_ticker_count(); -window.addEventListener('unload', function (e) { - if (notifications) { - localStorage.removeItem('stream'); + // 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); + + 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); +}); diff --git a/assets/js/player.js b/assets/js/player.js index 6ddb1158..7d099e66 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -42,45 +42,53 @@ embed_url = location.origin + '/embed/' + video_data.id + embed_url.search; var save_player_pos_key = 'save_player_pos'; videojs.Vhs.xhr.beforeRequest = function(options) { - if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) { - options.uri = options.uri + '?local=true'; + // set local if requested not videoplayback + if (!options.uri.includes('videoplayback')) { + if (!options.uri.includes('local=true')) + options.uri += '?local=true'; } return options; }; var player = videojs('player', options); -player.on('error', () => { - if (video_data.params.quality !== 'dash') { - if (!player.currentSrc().includes("local=true") && !video_data.local_disabled) { - var currentSources = player.currentSources(); - for (var i = 0; i < currentSources.length; i++) { - currentSources[i]["src"] += "&local=true" - } - player.src(currentSources) - } - else if (player.error().code === 2 || player.error().code === 4) { - setTimeout(function (event) { - console.log('An error occurred in the player, reloading...'); - - var currentTime = player.currentTime(); - var playbackRate = player.playbackRate(); - var paused = player.paused(); - - player.load(); - - if (currentTime > 0.5) currentTime -= 0.5; - - player.currentTime(currentTime); - player.playbackRate(playbackRate); - - if (!paused) player.play(); - }, 10000); - } +player.on('error', function () { + if (video_data.params.quality === 'dash') return; + + var localNotDisabled = ( + !player.currentSrc().includes('local=true') && !video_data.local_disabled + ); + var reloadMakesSense = ( + player.error().code === MediaError.MEDIA_ERR_NETWORK || + player.error().code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ); + + if (localNotDisabled) { + // add local=true to all current sources + player.src(player.currentSources().map(function (source) { + source.src += '&local=true'; + })); + } else if (reloadMakesSense) { + setTimeout(function () { + console.warn('An error occurred in the player, reloading...'); + + // After load() all parameters are reset. Save them + var currentTime = player.currentTime(); + var playbackRate = player.playbackRate(); + var paused = player.paused(); + + player.load(); + + if (currentTime > 0.5) currentTime -= 0.5; + + player.currentTime(currentTime); + player.playbackRate(playbackRate); + if (!paused) player.play(); + }, 5000); } }); -if (video_data.params.quality == 'dash') { +if (video_data.params.quality === 'dash') { player.reloadSourceOnError({ errorInterval: 10 }); @@ -89,7 +97,7 @@ if (video_data.params.quality == 'dash') { /** * Function for add time argument to url * @param {String} url - * @returns urlWithTimeArg + * @returns {URL} urlWithTimeArg */ function addCurrentTimeToURL(url) { var urlUsed = new URL(url); @@ -112,18 +120,12 @@ var shareOptions = { description: player_data.description, image: player_data.thumbnail, get embedCode() { - return '<iframe id="ivplayer" width="640" height="360" src="' + - addCurrentTimeToURL(embed_url) + '" style="border:none;"></iframe>'; + // Single quotes inside here required. HTML inserted as is into value attribute of input + return "<iframe id='ivplayer' width='640' height='360' src='" + + addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>"; } }; -const storage = (function () { - try { if (localStorage.length !== -1) return localStorage; } - catch (e) { console.info('No storage available: ' + e); } - - return undefined; -})(); - if (location.pathname.startsWith('/embed/')) { var overlay_content = '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>'; player.overlay({ @@ -162,7 +164,7 @@ if (isMobile()) { buttons.forEach(function (child) {primary_control_bar.removeChild(child);}); var operations_bar_element = operations_bar.el(); - operations_bar_element.className += ' mobile-operations-bar'; + operations_bar_element.classList.add('mobile-operations-bar'); player.addChild(operations_bar); // Playback menu doesn't work when it's initialized outside of the primary control bar @@ -175,8 +177,8 @@ if (isMobile()) { operations_bar_element.append(share_element); if (video_data.params.quality === 'dash') { - var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; - operations_bar_element.append(http_source_selector); + var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; + operations_bar_element.append(http_source_selector); } }); } @@ -220,14 +222,14 @@ player.playbackRate(video_data.params.speed); * Method for getting the contents of a cookie * * @param {String} name Name of cookie - * @returns cookieValue + * @returns {String|null} cookieValue */ function getCookieValue(name) { - var value = document.cookie.split(';').filter(function (item) {return item.includes(name + '=');}); - - return (value.length >= 1) - ? value[0].substring((name + '=').length, value[0].length) - : null; + var cookiePrefix = name + '='; + var matchedCookie = document.cookie.split(';').find(function (item) {return item.includes(cookiePrefix);}); + if (matchedCookie) + return matchedCookie.replace(cookiePrefix, ''); + return null; } /** @@ -257,11 +259,11 @@ function updateCookie(newVolume, newSpeed) { date.setTime(date.getTime() + 63115200); var ipRegex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; - var domainUsed = window.location.hostname; + var domainUsed = location.hostname; // Fix for a bug in FF where the leading dot in the FQDN is not ignored if (domainUsed.charAt(0) !== '.' && !ipRegex.test(domainUsed) && domainUsed !== 'localhost') - domainUsed = '.' + window.location.hostname; + domainUsed = '.' + location.hostname; document.cookie = 'PREFS=' + cookieData + '; SameSite=Strict; path=/; domain=' + domainUsed + '; expires=' + date.toGMTString() + ';'; @@ -280,7 +282,7 @@ player.on('volumechange', function () { player.on('waiting', function () { if (player.playbackRate() > 1 && player.liveTracker.isLive() && player.liveTracker.atLiveEdge()) { - console.info('Player has caught up to source, resetting playbackRate.'); + console.info('Player has caught up to source, resetting playbackRate'); player.playbackRate(1); } }); @@ -292,12 +294,12 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data. if (video_data.params.save_player_pos) { const url = new URL(location); const hasTimeParam = url.searchParams.has('t'); - const remeberedTime = get_video_time(); + const rememberedTime = get_video_time(); let lastUpdated = 0; - if(!hasTimeParam) set_seconds_after_start(remeberedTime); + if(!hasTimeParam) set_seconds_after_start(rememberedTime); - const updateTime = function () { + player.on('timeupdate', function () { const raw = player.currentTime(); const time = Math.floor(raw); @@ -305,9 +307,7 @@ if (video_data.params.save_player_pos) { save_video_time(time); lastUpdated = time; } - }; - - player.on('timeupdate', updateTime); + }); } else remove_all_video_times(); @@ -347,53 +347,31 @@ if (!video_data.params.listen && video_data.params.quality === 'dash') { targetQualityLevel = 0; break; default: - const targetHeight = Number.parseInt(video_data.params.quality_dash, 10); + const targetHeight = parseInt(video_data.params.quality_dash); for (let i = 0; i < qualityLevels.length; i++) { - if (qualityLevels[i].height <= targetHeight) { + if (qualityLevels[i].height <= targetHeight) targetQualityLevel = i; - } else { + else break; - } } } - for (let i = 0; i < qualityLevels.length; i++) { - qualityLevels[i].enabled = (i === targetQualityLevel); - } + qualityLevels.forEach(function (level, index) { + level.enabled = (index === targetQualityLevel); + }); }); }); } } player.vttThumbnails({ - src: location.origin + '/api/v1/storyboards/' + video_data.id + '?height=90', + src: '/api/v1/storyboards/' + video_data.id + '?height=90', showTimestamp: true }); // Enable annotations if (!video_data.params.listen && video_data.params.annotations) { - window.addEventListener('load', function (e) { - var video_container = document.getElementById('player'); - let xhr = new XMLHttpRequest(); - xhr.responseType = 'text'; - xhr.timeout = 60000; - xhr.open('GET', '/api/v1/annotations/' + video_data.id, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); - if (!player.paused()) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - } else { - player.one('play', function (event) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - }); - } - } - } - }; - - window.addEventListener('__ar_annotation_click', function (e) { + addEventListener('load', function (e) { + addEventListener('__ar_annotation_click', function (e) { const url = e.detail.url, target = e.detail.target, seconds = e.detail.seconds; @@ -406,41 +384,48 @@ if (!video_data.params.listen && video_data.params.annotations) { path = path.pathname + path.search; if (target === 'current') { - window.location.href = path; + location.href = path; } else if (target === 'new') { - window.open(path, '_blank'); + open(path, '_blank'); + } + }); + + helpers.xhr('GET', '/api/v1/annotations/' + video_data.id, { + responseType: 'text', + timeout: 60000 + }, { + on200: function (response) { + var video_container = document.getElementById('player'); + videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); + if (player.paused()) { + player.one('play', function (event) { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + }); + } else { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + } } }); - xhr.send(); }); } -function increase_volume(delta) { +function change_volume(delta) { const curVolume = player.volume(); let newVolume = curVolume + delta; - if (newVolume > 1) { - newVolume = 1; - } else if (newVolume < 0) { - newVolume = 0; - } + newVolume = helpers.clamp(newVolume, 0, 1); player.volume(newVolume); } function toggle_muted() { - const isMuted = player.muted(); - player.muted(!isMuted); + player.muted(!player.muted()); } function skip_seconds(delta) { const duration = player.duration(); const curTime = player.currentTime(); let newTime = curTime + delta; - if (newTime > duration) { - newTime = duration; - } else if (newTime < 0) { - newTime = 0; - } + newTime = helpers.clamp(newTime, 0, duration); player.currentTime(newTime); } @@ -450,57 +435,21 @@ function set_seconds_after_start(delta) { } function save_video_time(seconds) { - const videoId = video_data.id; const all_video_times = get_all_video_times(); - - all_video_times[videoId] = seconds; - - set_all_video_times(all_video_times); + all_video_times[video_data.id] = seconds; + helpers.storage.set(save_player_pos_key, all_video_times); } function get_video_time() { - try { - const videoId = video_data.id; - const all_video_times = get_all_video_times(); - const timestamp = all_video_times[videoId]; - - return timestamp || 0; - } - catch (e) { - return 0; - } -} - -function set_all_video_times(times) { - if (storage) { - if (times) { - try { - storage.setItem(save_player_pos_key, JSON.stringify(times)); - } catch (e) { - console.warn('set_all_video_times: ' + e); - } - } else { - storage.removeItem(save_player_pos_key); - } - } + return get_all_video_times()[video_data.id] || 0; } function get_all_video_times() { - if (storage) { - const raw = storage.getItem(save_player_pos_key); - if (raw !== null) { - try { - return JSON.parse(raw); - } catch (e) { - console.warn('get_all_video_times: ' + e); - } - } - } - return {}; + return helpers.storage.get(save_player_pos_key) || {}; } function remove_all_video_times() { - set_all_video_times(null); + helpers.storage.remove(save_player_pos_key); } function set_time_percent(percent) { @@ -516,21 +465,23 @@ function toggle_play() { player.paused() ? play() : pause(); } const toggle_captions = (function () { let toggledTrack = null; - const onChange = function (e) { - toggledTrack = null; - }; - const bindChange = function (onOrOff) { - player.textTracks()[onOrOff]('change', onChange); - }; + + function bindChange(onOrOff) { + player.textTracks()[onOrOff]('change', function (e) { + toggledTrack = null; + }); + } + // Wrapper function to ignore our own emitted events and only listen // to events emitted by Video.js on click on the captions menu items. - const setMode = function (track, mode) { + function setMode(track, mode) { bindChange('off'); track.mode = mode; - window.setTimeout(function () { + setTimeout(function () { bindChange('on'); }, 0); - }; + } + bindChange('on'); return function () { if (toggledTrack !== null) { @@ -578,15 +529,11 @@ function increase_playback_rate(steps) { const maxIndex = options.playbackRates.length - 1; const curIndex = options.playbackRates.indexOf(player.playbackRate()); let newIndex = curIndex + steps; - if (newIndex > maxIndex) { - newIndex = maxIndex; - } else if (newIndex < 0) { - newIndex = 0; - } + newIndex = helpers.clamp(newIndex, 0, maxIndex); player.playbackRate(options.playbackRates[newIndex]); } -window.addEventListener('keydown', function (e) { +addEventListener('keydown', function (e) { if (e.target.tagName.toLowerCase() === 'input') { // Ignore input when focus is on certain elements, e.g. form fields. return; @@ -619,10 +566,10 @@ window.addEventListener('keydown', function (e) { case 'MediaStop': action = stop; break; case 'ArrowUp': - if (isPlayerFocused) action = increase_volume.bind(this, 0.1); + if (isPlayerFocused) action = change_volume.bind(this, 0.1); break; case 'ArrowDown': - if (isPlayerFocused) action = increase_volume.bind(this, -0.1); + if (isPlayerFocused) action = change_volume.bind(this, -0.1); break; case 'm': @@ -673,12 +620,11 @@ window.addEventListener('keydown', function (e) { // TODO: Add support to play back previous video. break; - case '.': - // TODO: Add support for next-frame-stepping. - break; - case ',': - // TODO: Add support for previous-frame-stepping. - break; + // TODO: More precise step. Now FPS is taken equal to 29.97 + // Common FPS: https://forum.videohelp.com/threads/81868#post323588 + // Possible solution is new HTMLVideoElement.requestVideoFrameCallback() https://wicg.github.io/video-rvfc/ + case ',': action = function () { pause(); skip_seconds(-1/29.97); }; break; + case '.': action = function () { pause(); skip_seconds( 1/29.97); }; break; case '>': action = increase_playback_rate.bind(this, 1); break; case '<': action = increase_playback_rate.bind(this, -1); break; @@ -697,10 +643,6 @@ window.addEventListener('keydown', function (e) { // Add support for controlling the player volume by scrolling over it. Adapted from // https://github.com/ctd1500/videojs-hotkeys/blob/bb4a158b2e214ccab87c2e7b95f42bc45c6bfd87/videojs.hotkeys.js#L292-L328 (function () { - const volumeStep = 0.05; - const enableVolumeScroll = true; - const enableHoverScroll = true; - const doc = document; const pEl = document.getElementById('player'); var volumeHover = false; @@ -710,39 +652,23 @@ window.addEventListener('keydown', function (e) { volumeSelector.onmouseout = function () { volumeHover = false; }; } - var mouseScroll = function mouseScroll(event) { - var activeEl = doc.activeElement; - if (enableHoverScroll) { - // If we leave this undefined then it can match non-existent elements below - activeEl = 0; - } - + function mouseScroll(event) { // When controls are disabled, hotkeys will be disabled as well - if (player.controls()) { - if (volumeHover) { - if (enableVolumeScroll) { - event = window.event || event; - var delta = Math.max(-1, Math.min(1, (event.wheelDelta || -event.detail))); - event.preventDefault(); - - if (delta === 1) { - increase_volume(volumeStep); - } else if (delta === -1) { - increase_volume(-volumeStep); - } - } - } - } - }; + if (!player.controls() || !volumeHover) return; + + event.preventDefault(); + var wheelMove = event.wheelDelta || -event.detail; + var volumeSign = Math.sign(wheelMove); + + change_volume(volumeSign * 0.05); // decrease/increase by 5% + } player.on('mousewheel', mouseScroll); player.on('DOMMouseScroll', mouseScroll); }()); // Since videojs-share can sometimes be blocked, we defer it until last -if (player.share) { - player.share(shareOptions); -} +if (player.share) player.share(shareOptions); // show the preferred caption by default if (player_data.preferred_caption_found) { @@ -763,7 +689,7 @@ if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) { } // Watch on Invidious link -if (window.location.pathname.startsWith('/embed/')) { +if (location.pathname.startsWith('/embed/')) { const Button = videojs.getComponent('Button'); let watch_on_invidious_button = new Button(player); @@ -778,3 +704,11 @@ if (window.location.pathname.startsWith('/embed/')) { var cb = player.getChild('ControlBar'); cb.addChild(watch_on_invidious_button); } + +addEventListener('DOMContentLoaded', function () { + // Save time during redirection on another instance + const changeInstanceLink = document.querySelector('#watch-on-another-invidious-instance > a'); + if (changeInstanceLink) changeInstanceLink.addEventListener('click', function () { + changeInstanceLink.href = addCurrentTimeToURL(changeInstanceLink.href); + }); +}); diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js index c2565874..c92592ac 100644 --- a/assets/js/playlist_widget.js +++ b/assets/js/playlist_widget.js @@ -1,5 +1,6 @@ '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]; @@ -8,21 +9,12 @@ function add_playlist_video(target) { var url = '/playlist_ajax?action_add_video=1&redirect=false' + '&video_id=' + target.getAttribute('data-id') + '&playlist_id=' + option.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - option.innerText = '✓' + option.innerText; - } + helpers.xhr('POST', url, {payload: payload}, { + on200: function (response) { + option.textContent = '✓' + option.textContent; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function add_playlist_item(target) { @@ -32,21 +24,12 @@ function add_playlist_item(target) { var url = '/playlist_ajax?action_add_video=1&redirect=false' + '&video_id=' + target.getAttribute('data-id') + '&playlist_id=' + target.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function remove_playlist_item(target) { @@ -56,19 +39,10 @@ function remove_playlist_item(target) { var url = '/playlist_ajax?action_remove_video=1&redirect=false' + '&set_video_id=' + target.getAttribute('data-index') + '&playlist_id=' + target.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } diff --git a/assets/js/subscribe_widget.js b/assets/js/subscribe_widget.js index 45ff5706..7665a00b 100644 --- a/assets/js/subscribe_widget.js +++ b/assets/js/subscribe_widget.js @@ -1,8 +1,9 @@ '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)'; +subscribe_button.parentNode.action = 'javascript:void(0)'; if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = subscribe; @@ -10,87 +11,34 @@ if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = unsubscribe; } -function subscribe(retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - console.warn('Failed to subscribe.'); - return; - } - - var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function subscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = unsubscribe; subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - subscribe_button.onclick = subscribe; - subscribe_button.innerHTML = fallback; - } - } - }; - - xhr.onerror = function () { - console.warn('Subscribing failed... ' + retries + '/5'); - setTimeout(function () { subscribe(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Subscribing failed... ' + retries + '/5'); - subscribe(retries - 1); - }; + var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + 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(retries) { - if (retries === undefined) - retries = 5; - - if (retries <= 0) { - console.warn('Failed to subscribe'); - return; - } - - var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function unsubscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = subscribe; subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - subscribe_button.onclick = unsubscribe; - subscribe_button.innerHTML = fallback; - } - } - }; - - xhr.onerror = function () { - console.warn('Unsubscribing failed... ' + retries + '/5'); - setTimeout(function () { unsubscribe(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Unsubscribing failed... ' + retries + '/5'); - unsubscribe(retries - 1); - }; + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { + onNon200: function (xhr) { + subscribe_button.onclick = unsubscribe; + subscribe_button.innerHTML = fallback; + } + }); } diff --git a/assets/js/themes.js b/assets/js/themes.js index 3f503b38..76767d5f 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -1,90 +1,44 @@ 'use strict'; var toggle_theme = document.getElementById('toggle_theme'); -toggle_theme.href = 'javascript:void(0);'; +toggle_theme.href = 'javascript:void(0)'; -toggle_theme.addEventListener('click', function () { - var dark_mode = document.body.classList.contains('light-theme'); - - var url = '/toggle_theme?redirect=false'; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - set_mode(dark_mode); - try { - window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light'); - } catch (e) {} +const STORAGE_KEY_THEME = 'dark_mode'; +const THEME_DARK = 'dark'; +const THEME_LIGHT = 'light'; - xhr.send(); -}); - -window.addEventListener('storage', function (e) { - if (e.key === 'dark_mode') { - update_mode(e.newValue); - } -}); - -window.addEventListener('DOMContentLoaded', function () { - const dark_mode = document.getElementById('dark_mode_pref').textContent; - try { - // Update localStorage if dark mode preference changed on preferences page - window.localStorage.setItem('dark_mode', dark_mode); - } catch (e) {} - update_mode(dark_mode); +// 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', {}, {}); }); - -var darkScheme = window.matchMedia('(prefers-color-scheme: dark)'); -var lightScheme = window.matchMedia('(prefers-color-scheme: light)'); - -darkScheme.addListener(scheme_switch); -lightScheme.addListener(scheme_switch); - -function scheme_switch (e) { - // ignore this method if we have a preference set - try { - if (localStorage.getItem('dark_mode')) { - return; - } - } catch (exception) {} - if (e.matches) { - if (e.media.includes('dark')) { - set_mode(true); - } else if (e.media.includes('light')) { - set_mode(false); - } - } -} - -function set_mode (bool) { - if (bool) { - // dark - toggle_theme.children[0].setAttribute('class', 'icon ion-ios-sunny'); - document.body.classList.remove('no-theme'); - document.body.classList.remove('light-theme'); - document.body.classList.add('dark-theme'); +/** @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 { - // light - toggle_theme.children[0].setAttribute('class', 'icon ion-ios-moon'); - document.body.classList.remove('no-theme'); - document.body.classList.remove('dark-theme'); - document.body.classList.add('light-theme'); + toggle_theme.children[0].className = 'icon ion-ios-moon'; + document.body.className = 'light-theme'; } } -function update_mode (mode) { - if (mode === 'true' /* for backwards compatibility */ || mode === 'dark') { - // If preference for dark mode indicated - set_mode(true); - } - else if (mode === 'false' /* for backwards compatibility */ || mode === 'light') { - // If preference for light mode indicated - set_mode(false); - } - else if (document.getElementById('dark_mode_pref').textContent === '' && window.matchMedia('(prefers-color-scheme: dark)').matches) { - // If no preference indicated here and no preference indicated on the preferences page (backend), but the browser tells us that the operating system has a dark theme - set_mode(true); +// 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)); +}); + +// 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); } - // else do nothing, falling back to the mode defined by the `dark_mode` preference on the preferences page (backend) -} +}); diff --git a/assets/js/watch.js b/assets/js/watch.js index 29d58be5..cff84e4d 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -1,5 +1,7 @@ 'use strict'; 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>'; String.prototype.supplant = function (o) { return this.replace(/{([^{}]*)}/g, function (a, b) { @@ -10,24 +12,24 @@ String.prototype.supplant = function (o) { function toggle_parent(target) { var body = target.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } else { + if (body.style.display === 'none') { target.textContent = '[ − ]'; body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; } } function toggle_comments(event) { var target = event.target; var body = target.parentNode.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } else { + if (body.style.display === 'none') { target.textContent = '[ − ]'; body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; } } @@ -79,56 +81,31 @@ if (continue_button) { function 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) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + 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) { + 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) { + 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); } function continue_autoplay(event) { if (event.target.checked) { - player.on('ended', function () { - next_video(); - }); + player.on('ended', next_video); } else { player.off('ended'); } } -function number_with_separator(val) { - while (/(\d+)(\d{3})/.test(val.toString())) { - val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2'); - } - return val; -} - -function get_playlist(plid, retries) { - if (retries === undefined) retries = 5; +function get_playlist(plid) { var playlist = document.getElementById('playlist'); - if (retries <= 0) { - console.warn('Failed to pull playlist'); - playlist.innerHTML = ''; - return; - } - - playlist.innerHTML = ' \ - <h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3> \ - <hr>'; + playlist.innerHTML = spinnerHTMLwithHR; var plid_url; if (plid.startsWith('RD')) { @@ -142,225 +119,148 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - playlist.innerHTML = xhr.response.playlistHtml; - var nextVideo = document.getElementById(xhr.response.nextVideo); - nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; - - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/watch?v=' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.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); - }); - } - } else { - playlist.innerHTML = ''; - document.getElementById('continue').style.display = ''; - } + helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { + on200: function (response) { + playlist.innerHTML = response.playlistHtml; + + if (!response.nextVideo) return; + + var nextVideo = document.getElementById(response.nextVideo); + nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; + + player.on('ended', function () { + var url = new URL('https://example.com/watch?v=' + 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); + + 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; } - }; - - xhr.onerror = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3><hr>'; - - console.warn('Pulling playlist timed out... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3><hr>'; - - console.warn('Pulling playlist timed out... ' + retries + '/5'); - get_playlist(plid, retries - 1); - }; - - xhr.send(); + }); } -function get_reddit_comments(retries) { - if (retries === undefined) retries = 5; +function get_reddit_comments() { var comments = document.getElementById('comments'); - if (retries <= 0) { - console.warn('Failed to pull comments'); - comments.innerHTML = ''; - return; - } - var fallback = comments.innerHTML; - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; + comments.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?source=reddit&format=html' + '&hl=' + video_data.preferences.locale; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - comments.innerHTML = ' \ - <div> \ - <h3> \ - <a href="javascript:void(0)">[ − ]</a> \ - {title} \ - </h3> \ - <p> \ - <b> \ - <a href="javascript:void(0)" data-comments="youtube"> \ - {youtubeCommentsText} \ - </a> \ - </b> \ - </p> \ + + 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 = ' \ + <div> \ + <h3> \ + <a href="javascript:void(0)">[ − ]</a> \ + {title} \ + </h3> \ + <p> \ <b> \ - <a rel="noopener" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \ + <a href="javascript:void(0)" data-comments="youtube"> \ + {youtubeCommentsText} \ + </a> \ </b> \ - </div> \ - <div>{contentHtml}</div> \ - <hr>'.supplant({ - title: xhr.response.title, - youtubeCommentsText: video_data.youtube_comments_text, - redditPermalinkText: video_data.reddit_permalink_text, - permalink: xhr.response.permalink, - contentHtml: xhr.response.contentHtml - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - } else { - comments.innerHTML = fallback; - } - } - } - }; - - xhr.onerror = function () { - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_reddit_comments(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Pulling comments failed... ' + retries + '/5'); - get_reddit_comments(retries - 1); - }; - - xhr.send(); + </p> \ + <b> \ + <a rel="noopener" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \ + </b> \ + </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 + }); + + comments.children[0].children[0].children[0].onclick = toggle_comments; + comments.children[0].children[1].children[0].onclick = swap_comments; + }, + onNon200: onNon200, // declared above + }); } -function get_youtube_comments(retries) { - if (retries === undefined) retries = 5; +function get_youtube_comments() { var comments = document.getElementById('comments'); - if (retries <= 0) { - console.warn('Failed to pull comments'); - comments.innerHTML = ''; - return; - } - var fallback = comments.innerHTML; - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; + comments.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?format=html' + '&hl=' + video_data.preferences.locale + '&thin_mode=' + video_data.preferences.thin_mode; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - comments.innerHTML = ' \ - <div> \ - <h3> \ - <a href="javascript:void(0)">[ − ]</a> \ - {commentsText} \ - </h3> \ - <b> \ - <a href="javascript:void(0)" data-comments="reddit"> \ - {redditComments} \ - </a> \ - </b> \ - </div> \ - <div>{contentHtml}</div> \ - <hr>'.supplant({ - contentHtml: xhr.response.contentHtml, - redditComments: video_data.reddit_comments_text, - commentsText: video_data.comments_text.supplant( - { commentCount: number_with_separator(xhr.response.commentCount) } - ) - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - } else { - comments.innerHTML = ''; - } - } + + 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) { + comments.innerHTML = ' \ + <div> \ + <h3> \ + <a href="javascript:void(0)">[ − ]</a> \ + {commentsText} \ + </h3> \ + <b> \ + <a href="javascript:void(0)" data-comments="reddit"> \ + {redditComments} \ + </a> \ + </b> \ + </div> \ + <div>{contentHtml}</div> \ + <hr>'.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.children[0].children[0].children[0].onclick = toggle_comments; + 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; } - }; - - xhr.onerror = function () { - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - console.warn('Pulling comments failed... ' + retries + '/5'); - get_youtube_comments(retries - 1); - }; - - xhr.send(); + }); } function get_youtube_replies(target, load_more, load_replies) { @@ -368,91 +268,72 @@ function get_youtube_replies(target, load_more, load_replies) { 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>'; + body.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?format=html' + '&hl=' + video_data.preferences.locale + '&thin_mode=' + video_data.preferences.thin_mode + '&continuation=' + continuation; - if (load_replies) { - url += '&action=action_get_comment_replies'; - } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); - - 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', video_data.hide_replies_text); - a.setAttribute('data-inner-text', video_data.show_replies_text); - a.innerText = video_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } + 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.innerHTML += response.contentHtml; } else { - body.innerHTML = fallback; - } - } - }; + body.removeChild(body.lastElementChild); + + 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', video_data.hide_replies_text); + a.setAttribute('data-inner-text', video_data.show_replies_text); + a.textContent = video_data.hide_replies_text; - xhr.ontimeout = function () { - console.warn('Pulling comments failed.'); - body.innerHTML = fallback; - }; + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; - xhr.send(); + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); } if (video_data.play_next) { 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) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + 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) { + 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) { + 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); }); } -window.addEventListener('load', function (e) { - if (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(); diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index 87989a79..f1ac9cb4 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -1,5 +1,6 @@ '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; @@ -7,45 +8,27 @@ function mark_watched(target) { var url = '/watch_ajax?action_mark_watched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + watched_data.csrf_token); + }); } function mark_unwatched(target) { var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; tile.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.textContent--; var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - count.innerText = count.innerText - 1 + 2; - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.textContent++; + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + watched_data.csrf_token); + }); } diff --git a/locales/bn.json b/locales/bn.json new file mode 100644 index 00000000..3d1cb5da --- /dev/null +++ b/locales/bn.json @@ -0,0 +1,97 @@ +{ + "Subscribe": "সাবস্ক্রাইব", + "View channel on YouTube": "ইউটিউবে চ্যানেল দেখুন", + "View playlist on YouTube": "ইউটিউবে প্লেলিস্ট দেখুন", + "newest": "সর্ব-নতুন", + "oldest": "পুরানতম", + "popular": "জনপ্রিয়", + "last": "শেষটা", + "Next page": "পরের পৃষ্ঠা", + "Previous page": "আগের পৃষ্ঠা", + "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", + "New password": "নতুন পাসওয়ার্ড", + "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", + "Cannot change password for Google accounts": "গুগল অ্যাকাউন্টগুলোর জন্য পাসওয়ার্ড পরিবর্তন করা যায় না", + "Authorize token?": "টোকেন অনুমোদন করবেন?", + "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", + "Yes": "হ্যাঁ", + "No": "না", + "Import and Export Data": "তথ্য আমদানি ও রপ্তানি", + "Import": "আমদানি", + "Import Invidious data": "ইনভিডিয়াস তথ্য আমদানি", + "Import YouTube subscriptions": "ইউটিউব সাবস্ক্রিপশন আনুন", + "Import FreeTube subscriptions (.db)": "ফ্রিটিউব সাবস্ক্রিপশন (.db) আনুন", + "Import NewPipe subscriptions (.json)": "নতুন পাইপ সাবস্ক্রিপশন আনুন (.json)", + "Import NewPipe data (.zip)": "নিউপাইপ তথ্য আনুন (.zip)", + "Export": "তথ্য বের করুন", + "Export subscriptions as OPML": "সাবস্ক্রিপশন OPML হিসাবে আনুন", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML-এ সাবস্ক্রিপশন বের করুন(নিউ পাইপ এবং ফ্রিউটিউব এর জন্য)", + "Export data as JSON": "JSON হিসাবে তথ্য বের করুন", + "Delete account?": "অ্যাকাউন্ট মুছে ফেলবেন?", + "History": "ইতিহাস", + "An alternative front-end to YouTube": "ইউটিউবের একটি বিকল্পস্বরূপ সম্মুখ-প্রান্ত", + "JavaScript license information": "জাভাস্ক্রিপ্ট লাইসেন্সের তথ্য", + "source": "সূত্র", + "Log in": "লগ ইন", + "Log in/register": "লগ ইন/রেজিস্টার", + "Log in with Google": "গুগল দিয়ে লগ ইন করুন", + "User ID": "ইউজার আইডি", + "Password": "পাসওয়ার্ড", + "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", + "Text CAPTCHA": "টেক্সট ক্যাপচা", + "Image CAPTCHA": "চিত্র ক্যাপচা", + "Sign In": "সাইন ইন", + "Register": "নিবন্ধন", + "E-mail": "ই-মেইল", + "Google verification code": "গুগল যাচাইকরণ কোড", + "Preferences": "পছন্দসমূহ", + "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", + "preferences_video_loop_label": "সর্বদা লুপ: ", + "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", + "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", + "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", + "preferences_listen_label": "সহজাতভাবে শোনো: ", + "preferences_local_label": "ভিডিও প্রক্সি করো: ", + "preferences_speed_label": "সহজাত গতি: ", + "preferences_quality_label": "পছন্দের ভিডিও মান: ", + "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: ", + "LIVE": "লাইভ", + "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", + "Unsubscribe": "আনসাবস্ক্রাইব", + "generic_views_count": "{{count}}জন দেখেছে", + "generic_views_count_plural": "{{count}}জন দেখেছে", + "generic_videos_count": "{{count}}টি ভিডিও", + "generic_videos_count_plural": "{{count}}টি ভিডিও", + "generic_subscribers_count": "{{count}}জন অনুসরণকারী", + "generic_subscribers_count_plural": "{{count}}জন অনুসরণকারী", + "preferences_watch_history_label": "দেখার ইতিহাস চালু করো: ", + "preferences_quality_option_dash": "ড্যাশ (সময়োপযোগী মান)", + "preferences_quality_dash_option_auto": "স্বয়ংক্রিয়", + "preferences_quality_dash_option_best": "সেরা", + "preferences_quality_dash_option_worst": "মন্দতম", + "preferences_quality_dash_option_4320p": "৪৩২০পি", + "preferences_quality_dash_option_2160p": "২১৬০পি", + "preferences_quality_dash_option_1440p": "১৪৪০পি", + "preferences_quality_dash_option_480p": "৪৮০পি", + "preferences_quality_dash_option_360p": "৩৬০পি", + "preferences_quality_dash_option_240p": "২৪০পি", + "preferences_quality_dash_option_144p": "১৪৪পি", + "preferences_comments_label": "সহজাত মন্তব্য: ", + "youtube": "ইউটিউব", + "Fallback captions: ": "বিকল্প উপাখ্যান: ", + "preferences_related_videos_label": "সম্পর্কিত ভিডিও দেখাও: ", + "preferences_annotations_label": "সহজাতভাবে টীকা দেখাও ", + "preferences_quality_option_hd720": "উচ্চ৭২০", + "preferences_quality_dash_label": "পছন্দের ড্যাশ ভিডিও মান: ", + "preferences_captions_label": "সহজাত উপাখ্যান: ", + "generic_playlists_count": "{{count}}টি চালুতালিকা", + "generic_playlists_count_plural": "{{count}}টি চালুতালিকা", + "reddit": "রেডিট", + "invidious": "ইনভিডিয়াস", + "generic_subscriptions_count": "{{count}}টি অনুসরণ", + "generic_subscriptions_count_plural": "{{count}}টি অনুসরণ", + "preferences_quality_option_medium": "মধ্যম", + "preferences_quality_option_small": "ছোট", + "preferences_quality_dash_option_1080p": "১০৮০পি", + "preferences_quality_dash_option_720p": "৭২০পি" +} diff --git a/locales/en-US.json b/locales/en-US.json index 7518c3a1..9701a621 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -136,6 +136,7 @@ "preferences_default_home_label": "Default homepage: ", "preferences_feed_menu_label": "Feed menu: ", "preferences_show_nick_label": "Show nickname on top: ", + "Popular enabled: ": "Popular enabled: ", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", "Login enabled: ": "Login enabled: ", diff --git a/locales/fr.json b/locales/fr.json index 6fee70f9..928a4400 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -116,6 +116,7 @@ "preferences_default_home_label": "Page d'accueil par défaut : ", "preferences_feed_menu_label": "Préferences des abonnements : ", "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ", + "Popular enabled: ": "Page \"populaire\" activée: ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", diff --git a/locales/hr.json b/locales/hr.json index 94633aac..7eb065dc 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -137,8 +137,8 @@ "Title": "Naslov", "Playlist privacy": "Privatnost zbirke", "Editing playlist `x`": "Uređivanje zbirke `x`", - "Show more": "Pokaži više", - "Show less": "Pokaži manje", + "Show more": "Prikaži više", + "Show less": "Prikaži manje", "Watch on YouTube": "Gledaj na YouTubeu", "Switch Invidious Instance": "Promijeni Invidious instancu", "Hide annotations": "Sakrij napomene", @@ -318,7 +318,7 @@ "Movies": "Filmovi", "Download": "Preuzmi", "Download as: ": "Preuzmi kao: ", - "%A %B %-d, %Y": "%A, %-d. %B %Y", + "%A %B %-d, %Y": "%A, %-d. %B %Y.", "(edited)": "(uređeno)", "YouTube comment permalink": "Stalna poveznica YouTube komentara", "permalink": "stalna poveznica", @@ -328,40 +328,40 @@ "Videos": "Videa", "Playlists": "Zbirke", "Community": "Zajednica", - "search_filters_sort_option_relevance": "značaj", - "search_filters_sort_option_rating": "ocjena", - "search_filters_sort_option_date": "datum", - "search_filters_sort_option_views": "prikazi", - "search_filters_type_label": "vrsta_sadržaja", - "search_filters_duration_label": "trajanje", - "search_filters_features_label": "funkcije", - "search_filters_sort_label": "redoslijed", - "search_filters_date_option_hour": "sat", - "search_filters_date_option_today": "danas", - "search_filters_date_option_week": "tjedan", - "search_filters_date_option_month": "mjesec", - "search_filters_date_option_year": "godina", - "search_filters_type_option_video": "video", - "search_filters_type_option_channel": "kanal", + "search_filters_sort_option_relevance": "Značaj", + "search_filters_sort_option_rating": "Ocjena", + "search_filters_sort_option_date": "Datum prijenosa", + "search_filters_sort_option_views": "Broj gledanja", + "search_filters_type_label": "Vrsta", + "search_filters_duration_label": "Trajanje", + "search_filters_features_label": "Funkcije", + "search_filters_sort_label": "Redoslijed", + "search_filters_date_option_hour": "Zadnjih sat vremena", + "search_filters_date_option_today": "Danas", + "search_filters_date_option_week": "Ovaj tjedan", + "search_filters_date_option_month": "Ovaj mjesec", + "search_filters_date_option_year": "Ova godina", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", "search_filters_type_option_playlist": "Zbirka", - "search_filters_type_option_movie": "film", - "search_filters_type_option_show": "emisija", - "search_filters_features_option_hd": "hd", - "search_filters_features_option_subtitles": "titlovi", - "search_filters_features_option_c_commons": "creative_commons", - "search_filters_features_option_three_d": "3d", - "search_filters_features_option_live": "uživo", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Emisija", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Titlovi/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Uživo", "search_filters_features_option_four_k": "4k", - "search_filters_features_option_location": "lokacija", - "search_filters_features_option_hdr": "hdr", + "search_filters_features_option_location": "Lokacija", + "search_filters_features_option_hdr": "HDR", "Current version: ": "Trenutačna verzija: ", "next_steps_error_message": "Nakon toga bi trebali pokušati sljedeće: ", "next_steps_error_message_refresh": "Aktualiziraj stranicu", "next_steps_error_message_go_to_youtube": "Idi na YouTube", "footer_donate_page": "Doniraj", "adminprefs_modified_source_code_url_label": "URL do repozitorija izmijenjenog izvornog koda", - "search_filters_duration_option_short": "Kratki (< 4 minute)", - "search_filters_duration_option_long": "Dugi (> 20 minute)", + "search_filters_duration_option_short": "Kratko (< 4 minute)", + "search_filters_duration_option_long": "Dugo (> 20 minute)", "footer_source_code": "Izvorni kod", "footer_modfied_source_code": "Izmijenjeni izvorni kod", "footer_documentation": "Dokumentacija", @@ -384,8 +384,8 @@ "search_filters_features_option_three_sixty": "360 °", "none": "bez", "videoinfo_youTube_embed_link": "Ugradi", - "user_created_playlists": "`x` stvorene zbirke", - "user_saved_playlists": "`x` spremljene zbirke", + "user_created_playlists": "`x` je stvorio/la zbirke", + "user_saved_playlists": "`x` je spremio/la zbirke", "Video unavailable": "Video nedostupan", "preferences_save_player_pos_label": "Spremi mjesto reprodukcije: ", "videoinfo_watch_on_youTube": "Gledaj na YouTubeu", @@ -432,7 +432,7 @@ "generic_subscriptions_count_2": "{{count}} pretplata", "generic_playlists_count_0": "{{count}} zbirka", "generic_playlists_count_1": "{{count}} zbirke", - "generic_playlists_count_2": "{{count}} zbirka", + "generic_playlists_count_2": "{{count}} zbiraka", "generic_videos_count_0": "{{count}} video", "generic_videos_count_1": "{{count}} videa", "generic_videos_count_2": "{{count}} videa", @@ -476,5 +476,15 @@ "Portuguese (auto-generated)": "Portugalski (automatski generiran)", "Spanish (auto-generated)": "Španjolski (automatski generiran)", "preferences_watch_history_label": "Aktiviraj povijest gledanja: ", - "search_filters_title": "Filtar" + "search_filters_title": "Filtri", + "search_filters_date_option_none": "Bilo koji datum", + "search_filters_date_label": "Datum prijenosa", + "search_message_no_results": "Nema rezultata.", + "search_message_use_another_instance": " Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.", + "search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.", + "search_filters_features_option_vr180": "VR180", + "search_filters_duration_option_none": "Bilo koje duljine", + "search_filters_duration_option_medium": "Srednje (4 – 20 minuta)", + "search_filters_apply_button": "Primijeni odabrane filtre", + "search_filters_type_option_all": "Bilo koja vrsta" } diff --git a/locales/id.json b/locales/id.json index 71b7bdb1..c96495c3 100644 --- a/locales/id.json +++ b/locales/id.json @@ -418,5 +418,8 @@ "English (United States)": "Inggris (US)", "preferences_watch_history_label": "Aktifkan riwayat tontonan: ", "English (United Kingdom)": "Inggris (UK)", - "search_filters_title": "Saring" + "search_filters_title": "Saring", + "search_message_no_results": "Tidak ada hasil yang ditemukan.", + "search_message_change_filters_or_query": "Coba perbanyak kueri pencarian dan/atau ubah filter Anda.", + "search_message_use_another_instance": " Anda juga bisa <a href=\"`x`\">mencari di peladen lain</a>." } diff --git a/locales/pt.json b/locales/pt.json index df237649..1abe46fa 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -11,7 +11,7 @@ "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", "preferences_category_misc": "Preferências diversas", - "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ", "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", "next_steps_error_message_go_to_youtube": "Ir ao YouTube", "next_steps_error_message": "Pode tentar as seguintes opções: ", @@ -246,15 +246,15 @@ "JavaScript license information": "Informação de licença do JavaScript", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "History": "Histórico", - "Export data as JSON": "Exportar dados como JSON", + "Export data as JSON": "Exportar dados Invidious como JSON", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", "Export subscriptions as OPML": "Exportar subscrições como OPML", "Export": "Exportar", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", - "Import YouTube subscriptions": "Importar subscrições do YouTube", - "Import Invidious data": "Importar dados do Invidious", + "Import YouTube subscriptions": "Importar subscrições do YouTube/OPML", + "Import Invidious data": "Importar dados JSON do Invidious", "Import": "Importar", "No": "Não", "Yes": "Sim", @@ -432,9 +432,43 @@ "crash_page_before_reporting": "Antes de reportar um erro, verifique se:", "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>", "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>", - "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>", + "crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>", "crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>", "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):", "user_created_playlists": "`x` listas de reprodução criadas", - "search_filters_title": "Filtro" + "search_filters_title": "Filtro", + "Chinese (Taiwan)": "Chinês (Taiwan)", + "search_message_no_results": "Nenhum resultado encontrado.", + "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.", + "search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.", + "English (United Kingdom)": "Inglês (Reino Unido)", + "English (United States)": "Inglês (Estados Unidos)", + "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", + "Chinese": "Chinês", + "Chinese (Hong Kong)": "Chinês (Hong Kong)", + "Dutch (auto-generated)": "Holandês (gerado automaticamente)", + "French (auto-generated)": "Francês (gerado automaticamente)", + "German (auto-generated)": "Alemão (gerado automaticamente)", + "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)", + "Interlingue": "Interlíngua", + "Italian (auto-generated)": "Italiano (gerado automaticamente)", + "Japanese (auto-generated)": "Japonês (gerado automaticamente)", + "Korean (auto-generated)": "Coreano (gerado automaticamente)", + "Portuguese (auto-generated)": "Português (gerado automaticamente)", + "Portuguese (Brazil)": "Português (Brasil)", + "Turkish (auto-generated)": "Turco (gerado automaticamente)", + "Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)", + "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Aplicar filtros selecionados", + "Spanish (auto-generated)": "Espanhol (gerado automaticamente)", + "Spanish (Mexico)": "Espanhol (México)", + "preferences_watch_history_label": "Ativar histórico de reprodução: ", + "Chinese (China)": "Chinês (China)", + "Russian (auto-generated)": "Russo (gerado automaticamente)", + "Spanish (Spain)": "Espanhol (Espanha)", + "search_filters_date_label": "Data de publicação", + "search_filters_date_option_none": "Qualquer data", + "search_filters_type_option_all": "Qualquer tipo", + "search_filters_duration_option_none": "Qualquer duração" } diff --git a/locales/ru.json b/locales/ru.json index 0199f61f..00d24502 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -41,8 +41,8 @@ "User ID": "ID пользователя", "Password": "Пароль", "Time (h:mm:ss):": "Время (ч:мм:сс):", - "Text CAPTCHA": "Текст капчи", - "Image CAPTCHA": "Изображение капчи", + "Text CAPTCHA": "Текстовая капча (англ.)", + "Image CAPTCHA": "Капча-картинка", "Sign In": "Войти", "Register": "Зарегистрироваться", "E-mail": "Электронная почта", @@ -51,7 +51,7 @@ "preferences_category_player": "Настройки проигрывателя", "preferences_video_loop_label": "Всегда повторять: ", "preferences_autoplay_label": "Автовоспроизведение: ", - "preferences_continue_label": "Всегда включать следующее видео? ", + "preferences_continue_label": "Переходить к следующему видео? ", "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ", "preferences_listen_label": "Режим «только аудио» по умолчанию: ", "preferences_local_label": "Проигрывать видео через прокси? ", @@ -71,13 +71,13 @@ "preferences_player_style_label": "Стиль проигрывателя: ", "Dark mode: ": "Тёмное оформление: ", "preferences_dark_mode_label": "Тема: ", - "dark": "темная", + "dark": "тёмная", "light": "светлая", "preferences_thin_mode_label": "Облегчённое оформление: ", "preferences_category_misc": "Прочие настройки", "preferences_automatic_instance_redirect_label": "Автоматическое перенаправление на зеркало сайта (переход на redirect.invidious.io): ", "preferences_category_subscription": "Настройки подписок", - "preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", + "preferences_annotations_subscribed_label": "Всегда показывать аннотации на каналах из ваших подписок? ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", "preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ", "preferences_sort_label": "Сортировать видео: ", @@ -96,10 +96,10 @@ "`x` is live": "`x` в прямом эфире", "preferences_category_data": "Настройки данных", "Clear watch history": "Очистить историю просмотров", - "Import/export data": "Импорт/Экспорт данных", + "Import/export data": "Импорт и экспорт данных", "Change password": "Изменить пароль", - "Manage subscriptions": "Управлять подписками", - "Manage tokens": "Управлять токенами", + "Manage subscriptions": "Управление подписками", + "Manage tokens": "Управление токенами", "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", "preferences_category_admin": "Администраторские настройки", @@ -112,8 +112,8 @@ "Registration enabled: ": "Включить регистрацию? ", "Report statistics: ": "Сообщать статистику? ", "Save preferences": "Сохранить настройки", - "Subscription manager": "Менеджер подписок", - "Token manager": "Менеджер токенов", + "Subscription manager": "Управление подписками", + "Token manager": "Управление токенами", "Token": "Токен", "Import/export": "Импорт и экспорт", "unsubscribe": "отписаться", @@ -122,9 +122,9 @@ "search": "поиск", "Log out": "Выйти", "Released under the AGPLv3 on Github.": "Выпущено под лицензией AGPLv3 на GitHub.", - "Source available here.": "Исходный код доступен здесь.", - "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", - "View privacy policy.": "Посмотреть политику конфиденциальности.", + "Source available here.": "Исходный код.", + "View JavaScript license information.": "Информация о лицензиях JavaScript.", + "View privacy policy.": "Политика конфиденциальности.", "Trending": "В тренде", "Public": "Публичный", "Unlisted": "Нет в списке", @@ -135,42 +135,42 @@ "Delete playlist": "Удалить плейлист", "Create playlist": "Создать плейлист", "Title": "Заголовок", - "Playlist privacy": "Конфиденциальность плейлиста", + "Playlist privacy": "Видимость плейлиста", "Editing playlist `x`": "Редактирование плейлиста `x`", - "Show more": "Показать больше", - "Show less": "Показать меньше", + "Show more": "Развернуть", + "Show less": "Свернуть", "Watch on YouTube": "Смотреть на YouTube", - "Switch Invidious Instance": "Сменить экземпляр Invidious", + "Switch Invidious Instance": "Сменить зеркало Invidious", "Hide annotations": "Скрыть аннотации", "Show annotations": "Показать аннотации", "Genre: ": "Жанр: ", "License: ": "Лицензия: ", "Family friendly? ": "Семейный просмотр: ", - "Wilson score: ": "Рейтинг Уилсона: ", + "Wilson score: ": "Оценка Уилсона: ", "Engagement: ": "Вовлечённость: ", "Whitelisted regions: ": "Доступно в регионах: ", "Blacklisted regions: ": "Недоступно в регионах: ", "Shared `x`": "Опубликовано `x`", "Premieres in `x`": "Премьера через `x`", "Premieres `x`": "Премьера `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.", - "View YouTube comments": "Смотреть комментарии с YouTube", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Нажмите сюда, чтобы увидеть комментарии. Но учтите: они могут загружаться немного медленнее.", + "View YouTube comments": "Показать комментарии с YouTube", "View more comments on Reddit": "Посмотреть больше комментариев на Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Показать `x` комментариев", - "": "Показать `x` комментариев" + "([^.,0-9]|^)1([^.,0-9]|$)": "Показано `x` комментариев", + "": "Показано`x` комментариев" }, "View Reddit comments": "Смотреть комментарии с Reddit", "Hide replies": "Скрыть ответы", "Show replies": "Показать ответы", "Incorrect password": "Неправильный пароль", "Quota exceeded, try again in a few hours": "Лимит превышен, попробуйте снова через несколько часов", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Войти не удаётся. Проверьте, не включена ли двухфакторная аутентификация (по коду или смс).", + "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Не удалось войти. Проверьте, не включена ли двухфакторная аутентификация (по коду или смс).", "Invalid TFA code": "Неправильный код двухфакторной аутентификации", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не удаётся войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.", + "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не удалось войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.", "Wrong answer": "Неправильный ответ", "Erroneous CAPTCHA": "Неправильная капча", - "CAPTCHA is a required field": "Необходимо пройти капчу", + "CAPTCHA is a required field": "Необходимо решить капчу", "User ID is a required field": "Необходимо ввести ID пользователя", "Password is a required field": "Необходимо ввести пароль", "Wrong username or password": "Неправильный логин или пароль", @@ -185,7 +185,7 @@ "Could not get channel info.": "Не удаётся получить информацию об этом канале.", "Could not fetch comments": "Не удаётся загрузить комментарии", "`x` ago": "`x` назад", - "Load more": "Загрузить больше", + "Load more": "Загрузить ещё", "Could not create mix.": "Не удаётся создать микс.", "Empty playlist": "Плейлист пуст", "Not a playlist.": "Некорректный плейлист.", @@ -219,7 +219,7 @@ "Croatian": "Хорватский", "Czech": "Чешский", "Danish": "Датский", - "Dutch": "Нидерландский", + "Dutch": "Голландский", "Esperanto": "Эсперанто", "Estonian": "Эстонский", "Filipino": "Филиппинский", @@ -229,8 +229,8 @@ "Georgian": "Грузинский", "German": "Немецкий", "Greek": "Греческий", - "Gujarati": "Гуджаратский", - "Haitian Creole": "Гаит. креольский", + "Gujarati": "Гуджарати", + "Haitian Creole": "Гаитянский креольский", "Hausa": "Хауса", "Hawaiian": "Гавайский", "Hebrew": "Иврит", @@ -251,7 +251,7 @@ "Kurdish": "Курдский", "Kyrgyz": "Киргизский", "Lao": "Лаосский", - "Latin": "Латинский", + "Latin": "Латынь", "Latvian": "Латышский", "Lithuanian": "Литовский", "Luxembourgish": "Люксембургский", @@ -262,9 +262,9 @@ "Maltese": "Мальтийский", "Maori": "Маори", "Marathi": "Маратхи", - "Mongolian": "Монгольская", + "Mongolian": "Монгольский", "Nepali": "Непальский", - "Norwegian Bokmål": "Норвежский", + "Norwegian Bokmål": "Норвежский букмол", "Nyanja": "Ньянджа", "Pashto": "Пушту", "Persian": "Персидский", @@ -299,7 +299,7 @@ "Vietnamese": "Вьетнамский", "Welsh": "Валлийский", "Western Frisian": "Западнофризский", - "Xhosa": "Коса", + "Xhosa": "Коса (кхоса)", "Yiddish": "Идиш", "Yoruba": "Йоруба", "Zulu": "Зулусский", @@ -311,7 +311,7 @@ "Rating: ": "Рейтинг: ", "preferences_locale_label": "Язык: ", "View as playlist": "Смотреть как плейлист", - "Default": "По-умолчанию", + "Default": "По умолчанию", "Music": "Музыка", "Gaming": "Игры", "News": "Новости", @@ -328,14 +328,14 @@ "Videos": "Видео", "Playlists": "Плейлисты", "Community": "Сообщество", - "search_filters_sort_option_relevance": "Актуальность", - "search_filters_sort_option_rating": "Рейтинг", - "search_filters_sort_option_date": "Дата загрузки", - "search_filters_sort_option_views": "Просмотры", + "search_filters_sort_option_relevance": "по актуальности", + "search_filters_sort_option_rating": "по рейтингу", + "search_filters_sort_option_date": "по дате загрузки", + "search_filters_sort_option_views": "по просмотрам", "search_filters_type_label": "Тип", "search_filters_duration_label": "Длительность", - "search_filters_features_label": "Функции", - "search_filters_sort_label": "Сортировать по", + "search_filters_features_label": "Дополнительно", + "search_filters_sort_label": "Сортировать", "search_filters_date_option_hour": "Последний час", "search_filters_date_option_today": "Сегодня", "search_filters_date_option_week": "Эта неделя", @@ -345,7 +345,7 @@ "search_filters_type_option_channel": "Канал", "search_filters_type_option_playlist": "Плейлист", "search_filters_type_option_movie": "Фильм", - "search_filters_type_option_show": "Показать", + "search_filters_type_option_show": "Сериал", "search_filters_features_option_hd": "HD", "search_filters_features_option_subtitles": "Субтитры", "search_filters_features_option_c_commons": "Creative Commons", @@ -368,28 +368,28 @@ "English (United States)": "Английский (США)", "Cantonese (Hong Kong)": "Кантонский (Гонконг)", "Chinese (Taiwan)": "Китайский (Тайвань)", - "Dutch (auto-generated)": "Голландский (автоматический)", - "German (auto-generated)": "Немецкий (автоматический)", - "Indonesian (auto-generated)": "Индонезийский (автоматический)", - "Italian (auto-generated)": "Итальянский (автоматический)", + "Dutch (auto-generated)": "Голландский (созданы автоматически)", + "German (auto-generated)": "Немецкий (созданы автоматически)", + "Indonesian (auto-generated)": "Индонезийский (созданы автоматически)", + "Italian (auto-generated)": "Итальянский (созданы автоматически)", "Interlingue": "Окциденталь", - "Russian (auto-generated)": "Русский (автоматический)", - "Spanish (auto-generated)": "Испанский (автоматический)", + "Russian (auto-generated)": "Русский (созданы автоматически)", + "Spanish (auto-generated)": "Испанский (созданы автоматически)", "Spanish (Spain)": "Испанский (Испания)", - "Turkish (auto-generated)": "Турецкий (автоматический)", - "Vietnamese (auto-generated)": "Вьетнамский (автоматический)", + "Turkish (auto-generated)": "Турецкий (созданы автоматически)", + "Vietnamese (auto-generated)": "Вьетнамский (созданы автоматически)", "footer_documentation": "Документация", "adminprefs_modified_source_code_url_label": "Ссылка на нашу ветку репозитория", "none": "ничего", "videoinfo_watch_on_youTube": "Смотреть на YouTube", - "videoinfo_youTube_embed_link": "Встраиваемый элемент", - "videoinfo_invidious_embed_link": "Встраиваемая ссылка", + "videoinfo_youTube_embed_link": "Версия для встраивания", + "videoinfo_invidious_embed_link": "Ссылка для встраивания", "download_subtitles": "Субтитры - `x` (.vtt)", "user_created_playlists": "`x` созданных плейлистов", - "crash_page_you_found_a_bug": "Похоже вы нашли баг в Invidious!", + "crash_page_you_found_a_bug": "Похоже, вы нашли ошибку в Invidious!", "crash_page_before_reporting": "Прежде чем сообщать об ошибке, убедитесь, что вы:", "crash_page_refresh": "пробовали <a href=\"`x`\"> перезагрузить страницу</a>", - "crash_page_report_issue": "Если ни один вариант не помог, пожалуйста <a href=\"`x`\">откройте новую проблему на GitHub</a> (желательно на английском) и приложите следующий текст к вашему сообщению (НЕ переводите его):", + "crash_page_report_issue": "Если ни один вариант не помог, пожалуйста <a href=\"`x`\">откройте новую проблему на GitHub</a> (на английском, пжлста) и приложите следующий текст к вашему сообщению (НЕ переводите его):", "generic_videos_count_0": "{{count}} видео", "generic_videos_count_1": "{{count}} видео", "generic_videos_count_2": "{{count}} видео", @@ -417,8 +417,8 @@ "generic_views_count_0": "{{count}} просмотр", "generic_views_count_1": "{{count}} просмотра", "generic_views_count_2": "{{count}} просмотров", - "French (auto-generated)": "Французский (автоматический)", - "Portuguese (auto-generated)": "Португальский (автоматический)", + "French (auto-generated)": "Французский (созданы автоматически)", + "Portuguese (auto-generated)": "Португальский (созданы автоматически)", "generic_count_days_0": "{{count}} день", "generic_count_days_1": "{{count}} дня", "generic_count_days_2": "{{count}} дней", @@ -438,12 +438,12 @@ "search_filters_features_option_purchased": "Приобретено", "videoinfo_started_streaming_x_ago": "Трансляция началась `x` назад", "crash_page_switch_instance": "пробовали <a href=\"`x`\">использовать другое зеркало</a>", - "crash_page_read_the_faq": "прочли <a href=\"`x`\">Частые Вопросы (ЧаВо)</a>", + "crash_page_read_the_faq": "прочли ответы на <a href=\"`x`\">Частые Вопросы (ЧаВо)</a>", "Chinese": "Китайский", "Chinese (Hong Kong)": "Китайский (Гонконг)", - "Japanese (auto-generated)": "Японский (автоматический)", + "Japanese (auto-generated)": "Японский (созданы автоматически)", "Chinese (China)": "Китайский (Китай)", - "Korean (auto-generated)": "Корейский (автоматический)", + "Korean (auto-generated)": "Корейский (созданы автоматически)", "generic_count_months_0": "{{count}} месяц", "generic_count_months_1": "{{count}} месяца", "generic_count_months_2": "{{count}} месяцев", @@ -455,7 +455,7 @@ "footer_original_source_code": "Оригинальный исходный код", "footer_modfied_source_code": "Изменённый исходный код", "user_saved_playlists": "`x` сохранённых плейлистов", - "crash_page_search_issue": "искали <a href=\"`x`\">похожую проблему на GitHub</a>", + "crash_page_search_issue": "поискали <a href=\"`x`\">похожую проблему на GitHub</a>", "comments_points_count_0": "{{count}} плюс", "comments_points_count_1": "{{count}} плюса", "comments_points_count_2": "{{count}} плюсов", @@ -464,7 +464,7 @@ "preferences_quality_option_dash": "DASH (автоматическое качество)", "preferences_quality_option_hd720": "HD720", "preferences_quality_option_medium": "Среднее", - "preferences_quality_dash_label": "Предпочтительное автоматическое качество видео: ", + "preferences_quality_dash_label": "Предпочтительное качество для DASH: ", "preferences_quality_dash_option_worst": "Очень низкое", "preferences_quality_dash_option_4320p": "4320p", "preferences_quality_dash_option_2160p": "2160p", @@ -475,16 +475,16 @@ "Video unavailable": "Видео недоступно", "preferences_save_player_pos_label": "Запоминать позицию: ", "preferences_region_label": "Страна: ", - "preferences_watch_history_label": "Включить историю просмотров ", + "preferences_watch_history_label": "Включить историю просмотров: ", "search_filters_title": "Фильтр", "search_filters_duration_option_none": "Любой длины", "search_filters_type_option_all": "Любого типа", - "search_filters_date_option_none": "Любой даты", + "search_filters_date_option_none": "Любая дата", "search_filters_date_label": "Дата загрузки", "search_message_no_results": "Ничего не найдено.", "search_message_use_another_instance": " Дополнительно вы можете <a href=\"`x`\">поискать на других зеркалах</a>.", "search_filters_features_option_vr180": "VR180", - "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос и изменить фильтры.", + "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос или изменить фильтры.", "search_filters_duration_option_medium": "Средние (4 - 20 минут)", "search_filters_apply_button": "Применить фильтры" } diff --git a/locales/sl.json b/locales/sl.json index 791a01c5..9165e714 100644 --- a/locales/sl.json +++ b/locales/sl.json @@ -80,7 +80,7 @@ "preferences_category_admin": "Skrbniške nastavitve", "preferences_default_home_label": "Privzeta domača stran: ", "preferences_feed_menu_label": "Meni vira: ", - "Top enabled: ": "Vrh je omogočen: ", + "Top enabled: ": "Vrh omogočen: ", "CAPTCHA enabled: ": "CAPTCHA omogočeni: ", "Login enabled: ": "Prijava je omogočena: ", "Registration enabled: ": "Registracija je omogočena: ", @@ -112,7 +112,7 @@ "Wilson score: ": "Wilsonov rezultat: ", "Engagement: ": "Sodelovanje: ", "Blacklisted regions: ": "Regije na seznamu nedovoljenih: ", - "Shared `x`": "V skupni rabi `x`", + "Shared `x`": "V skupni rabi od: `x`", "Premieres `x`": "Premiere `x`", "View YouTube comments": "Oglej si YouTube komentarje", "View more comments on Reddit": "Prikaži več komentarjev na Reddit", @@ -201,22 +201,22 @@ "Yiddish": "jidiš", "Yoruba": "joruba", "Xhosa": "xhosa", - "generic_count_years_0": "{{count}} leto", + "generic_count_years_0": "{{count}} letom", "generic_count_years_1": "{{count}} leti", - "generic_count_years_2": "{{count}} leta", - "generic_count_years_3": "{{count}} let", - "generic_count_days_0": "{{count}} dan", - "generic_count_days_1": "{{count}} dneva", - "generic_count_days_2": "{{count}} dni", - "generic_count_days_3": "{{count}} dni", - "generic_count_hours_0": "{{count}} ura", - "generic_count_hours_1": "{{count}} uri", - "generic_count_hours_2": "{{count}} ure", - "generic_count_hours_3": "{{count}} ur", - "generic_count_minutes_0": "{{count}} minuta", - "generic_count_minutes_1": "{{count}} minuti", - "generic_count_minutes_2": "{{count}} minute", - "generic_count_minutes_3": "{{count}} minut", + "generic_count_years_2": "{{count}} leti", + "generic_count_years_3": "{{count}} leti", + "generic_count_days_0": "{{count}} dnevom", + "generic_count_days_1": "{{count}} dnevi", + "generic_count_days_2": "{{count}} dnevi", + "generic_count_days_3": "{{count}} dnevi", + "generic_count_hours_0": "{{count}} uro", + "generic_count_hours_1": "{{count}} urami", + "generic_count_hours_2": "{{count}} urami", + "generic_count_hours_3": "{{count}} urami", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutami", + "generic_count_minutes_2": "{{count}} minutami", + "generic_count_minutes_3": "{{count}} minutami", "Search": "Iskanje", "Top": "Vrh", "About": "O aplikaciji", @@ -423,23 +423,23 @@ "Spanish (Spain)": "španščina (Španija)", "Tajik": "tadžiščina", "Tamil": "tamilščina", - "generic_count_weeks_0": "{{count}} teden", - "generic_count_weeks_1": "{{count}} tedna", - "generic_count_weeks_2": "{{count}} tedne", - "generic_count_weeks_3": "{{count}} tednov", + "generic_count_weeks_0": "{{count}} tednom", + "generic_count_weeks_1": "{{count}} tedni", + "generic_count_weeks_2": "{{count}} tedni", + "generic_count_weeks_3": "{{count}} tedni", "Swahili": "svahilščina", "Swedish": "švedščina", "Vietnamese (auto-generated)": "vietnamščina (samodejno ustvarjeno)", - "generic_count_months_0": "{{count}} mesec", - "generic_count_months_1": "{{count}} meseca", - "generic_count_months_2": "{{count}} mesece", - "generic_count_months_3": "{{count}} mesecev", + "generic_count_months_0": "{{count}} mesecem", + "generic_count_months_1": "{{count}} meseci", + "generic_count_months_2": "{{count}} meseci", + "generic_count_months_3": "{{count}} meseci", "Uzbek": "uzbeščina", "Zulu": "zulujščina", - "generic_count_seconds_0": "{{count}} sekunda", - "generic_count_seconds_1": "{{count}} sekundi", - "generic_count_seconds_2": "{{count}} sekunde", - "generic_count_seconds_3": "{{count}} sekund", + "generic_count_seconds_0": "{{count}} sekundo", + "generic_count_seconds_1": "{{count}} sekundami", + "generic_count_seconds_2": "{{count}} sekundami", + "generic_count_seconds_3": "{{count}} sekundami", "Popular": "Priljubljeni", "Music": "Glasba", "Movies": "Filmi", diff --git a/locales/uk.json b/locales/uk.json index dd03d559..23f56c9a 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -1,6 +1,6 @@ { - "LIVE": "ПРЯМИЙ ЕФІР", - "Shared `x` ago": "Розміщено `x` назад", + "LIVE": "НАЖИВО", + "Shared `x` ago": "Розміщено `x` тому", "Unsubscribe": "Відписатися", "Subscribe": "Підписатися", "View channel on YouTube": "Подивитися канал на YouTube", @@ -30,7 +30,7 @@ "Export subscriptions as OPML": "Експортувати підписки у форматі OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Експортувати підписки у форматі OPML (для NewPipe та FreeTube)", "Export data as JSON": "Експортувати дані Invidious у форматі JSON", - "Delete account?": "Видалити обліківку?", + "Delete account?": "Видалити обліковий запис?", "History": "Історія", "An alternative front-end to YouTube": "Альтернативний фронтенд до YouTube", "JavaScript license information": "Інформація щодо ліцензій JavaScript", @@ -40,9 +40,9 @@ "Log in with Google": "Увійти через Google", "User ID": "ID користувача", "Password": "Пароль", - "Time (h:mm:ss):": "Час (г:мм:сс):", - "Text CAPTCHA": "Текст капчі", - "Image CAPTCHA": "Зображення капчі", + "Time (h:mm:ss):": "Час (г:хх:сс):", + "Text CAPTCHA": "Текст CAPTCHA", + "Image CAPTCHA": "Зображення CAPTCHA", "Sign In": "Увійти", "Register": "Зареєструватися", "E-mail": "Електронна пошта", @@ -142,7 +142,7 @@ "Whitelisted regions: ": "Доступно у регіонах: ", "Blacklisted regions: ": "Недоступно у регіонах: ", "Shared `x`": "Розміщено `x`", - "Premieres in `x`": "Прем’єра через `x`", + "Premieres in `x`": "Прем’єра за `x`", "Premieres `x`": "Прем’єра `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.", "View YouTube comments": "Переглянути коментарі з YouTube", @@ -157,11 +157,11 @@ "Incorrect password": "Неправильний пароль", "Quota exceeded, try again in a few hours": "Ліміт перевищено, спробуйте знову за декілька годин", "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Не вдається увійти. Перевірте, чи не ввімкнена двофакторна аутентифікація (за кодом чи смс).", - "Invalid TFA code": "Неправильний код двофакторної аутентифікації", + "Invalid TFA code": "Неправильний код двофакторної автентифікації", "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не вдається увійти. Це може бути через те, що у вашій обліківці не ввімкнена двофакторна аутентифікація.", "Wrong answer": "Неправильна відповідь", "Erroneous CAPTCHA": "Неправильна капча", - "CAPTCHA is a required field": "Необхідно пройти капчу", + "CAPTCHA is a required field": "Необхідно пройти CAPTCHA", "User ID is a required field": "Необхідно ввести ID користувача", "Password is a required field": "Необхідно ввести пароль", "Wrong username or password": "Неправильний логін чи пароль", @@ -169,7 +169,7 @@ "Password cannot be empty": "Пароль не може бути порожнім", "Password cannot be longer than 55 characters": "Пароль не може бути довшим за 55 знаків", "Please log in": "Будь ласка, увійдіть", - "Invidious Private Feed for `x`": "Приватний поток відео Invidious для `x`", + "Invidious Private Feed for `x`": "Приватний потік відео Invidious для `x`", "channel:`x`": "канал: `x`", "Deleted or invalid channel": "Канал видалено або не знайдено", "This channel does not exist.": "Такого каналу не існує.", diff --git a/scripts/deploy-database.sh b/scripts/deploy-database.sh new file mode 100644 index 00000000..ed9464e6 --- /dev/null +++ b/scripts/deploy-database.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# +# Parameters +# + +interactive=true + +if [ "$1" == "--no-interactive" ]; then + interactive=false +fi + +# +# Enable and start Postgres +# + +sudo systemctl start postgresql.service +sudo systemctl enable postgresql.service + +# +# Create databse and user +# + +if [ "$interactive" == "true" ]; then + sudo -u postgres -- createuser -P kemal + sudo -u postgres -- createdb -O kemal invidious +else + # Generate a DB password + if [ -z "$POSTGRES_PASS" ]; then + echo "Generating database password" + POSTGRES_PASS=$(tr -dc 'A-Za-z0-9.;!?{[()]}\\/' < /dev/urandom | head -c16) + fi + + # hostname:port:database:username:password + echo "Writing .pgpass" + echo "127.0.0.1:*:invidious:kemal:${POSTGRES_PASS}" > "$HOME/.pgpass" + + sudo -u postgres -- psql -c "CREATE USER kemal WITH PASSWORD '$POSTGRES_PASS';" + sudo -u postgres -- psql -c "CREATE DATABASE invidious WITH OWNER kemal;" + sudo -u postgres -- psql -c "GRANT ALL ON DATABASE invidious TO kemal;" +fi + + +# +# Instructions for modification of pg_hba.conf +# + +if [ "$interactive" = "true" ]; then + echo + echo "-------------" + echo " NOTICE " + echo "-------------" + echo + echo "Make sure that your postgreSQL's pg_hba.conf file contains the follwong" + echo "lines before previous 'host' configurations:" + echo + echo "host invidious kemal 127.0.0.1/32 md5" + echo "host invidious kemal ::1/128 md5" + echo +fi diff --git a/scripts/install-dependencies.sh b/scripts/install-dependencies.sh new file mode 100644 index 00000000..27e0bf15 --- /dev/null +++ b/scripts/install-dependencies.sh @@ -0,0 +1,174 @@ +#!/bin/sh +# +# Script that installs the various dependencies of invidious +# +# Dependencies: +# - crystal => Language in which Invidious is developed +# - postgres => Database server +# - git => required to clone Invidious +# - librsvg2-bin => For login captcha (provides 'rsvg-convert') +# +# - libssl-dev => Used by Crystal's SSL module (standard library) +# - libxml2-dev => Used by Crystal's XML module (standard library) +# - libyaml-dev => Used by Crystal's YAML module (standard library) +# - libgmp-dev => Used by Crystal's BigNumbers module (standard library) +# - libevent-dev => Used by crystal's internal scheduler (?) +# - libpcre3-dev => Used by Crystal's regex engine (?) +# +# - libsqlite3-dev => Used to open .db files from NewPipe exports +# - zlib1g-dev => TBD +# - libreadline-dev => TBD +# +# +# Tested on: +# - OpenSUSE Leap 15.3 + +# +# Load system details +# + +if [ -e /etc/os-release ]; then + . /etc/os-release +elif [ -e /usr/lib/os-release ]; then + . /usr/lib/os-release +else + echo "Unsupported Linux system" + exit 2 +fi + +# +# Some variables +# + +repo_base_url="https://download.opensuse.org/repositories/devel:/languages:/crystal/" +repo_end_url="devel:languages:crystal.repo" + +apt_gpg_key="/usr/share/keyrings/crystal.gpg" +apt_list_file="/etc/apt/sources.list.d/crystal.list" + +yum_repo_file="/etc/yum.repos.d/crystal.repo" + +# +# Major install functions +# + +make_repo_url() { + echo "${repo_base_url}/${1}/${repo_end_url}" +} + + +install_apt() { + repo="$1" + + echo "Adding Crystal repository" + + curl -fsSL "${repo_base_url}/${repo}/Release.key" \ + | gpg --dearmor \ + | sudo tee "${apt_gpg_key}" > /dev/null + + echo "deb [signed-by=${apt_gpg_key}] ${repo_base_url}/${repo}/ /" \ + | sudo tee "$apt_list_file" + + sudo apt-get update + + sudo apt-get install --yes --no-install-recommends \ + libssl-dev libxml2-dev libyaml-dev libgmp-dev libevent-dev \ + libpcre3-dev libreadline-dev libsqlite3-dev zlib1g-dev \ + crystal postgres git librsvg2-bin make +} + +install_yum() { + repo=$(make_repo_url "$1") + + echo "Adding Crystal repository" + + cat << END | sudo tee "${yum_repo_file}" > /dev/null +[crystal] +name=Crystal +type=rpm-md +baseurl=${repo}/ +gpgcheck=1 +gpgkey=${repo}/repodata/repomd.xml.key +enabled=1 +END + + sudo yum -y install \ + openssl-devel libxml2-devel libyaml-devel gmp-devel \ + readline-devel sqlite-devel \ + crystal postgresql postgresql-server git librsvg2-tools make +} + +install_pacman() { + # TODO: find an alternative to --no-confirm? + sudo pacman -S --no-confirm \ + base-devel librsvg postgresql crystal +} + +install_zypper() +{ + repo=$(make_repo_url "$1") + + echo "Adding Crystal repository" + sudo zypper --non-interactive addrepo -f "$repo" + + sudo zypper --non-interactive --gpg-auto-import-keys install --no-recommends \ + libopenssl-devel libxml2-devel libyaml-devel gmp-devel libevent-devel \ + pcre-devel readline-devel sqlite3-devel zlib-devel \ + crystal postgresql postgresql-server git rsvg-convert make +} + + +# +# System-specific logic +# + +case "$ID" in + archlinux) install_pacman;; + + centos) install_dnf "CentOS_${VERSION_ID}";; + + debian) + case "$VERSION_CODENAME" in + sid) install_apt "Debian_Unstable";; + bookworm) install_apt "Debian_Testing";; + *) install_apt "Debian_${VERSION_ID}";; + esac + ;; + + fedora) + if [ "$VERSION" == *"Prerelease"* ]; then + install_dnf "Fedora_Rawhide" + else + install_dnf "Fedora_${VERSION}" + fi + ;; + + opensuse-leap) install_zypper "openSUSE_Leap_${VERSION}";; + + opensuse-tumbleweed) install_zypper "openSUSE_Tumbleweed";; + + rhel) install_dnf "RHEL_${VERSION_ID}";; + + ubuntu) + # Small workaround for recently released 22.04 + case "$VERSION_ID" in + 22.04) install_apt "xUbuntu_21.04";; + *) install_apt "xUbuntu_${VERSION_ID}";; + esac + ;; + + *) + # Try to match on ID_LIKE instead + # Not guaranteed to 100% work + case "$ID_LIKE" in + archlinux) install_pacman;; + centos) install_dnf "CentOS_${VERSION_ID}";; + debian) install_apt "Debian_${VERSION_ID}";; + *) + echo "Error: distribution ${CODENAME} is not supported" + echo "Please install dependencies manually" + exit 2 + ;; + esac + ;; +esac diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index d8496978..593189fd 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -481,7 +481,7 @@ def template_reddit_comments(root, locale) html << <<-END_HTML <p> - <a href="javascript:void(0)" data-onclick="toggle_parent">[ - ]</a> + <a href="javascript:void(0)" data-onclick="toggle_parent">[ − ]</a> <b><a href="https://www.reddit.com/user/#{child.author}">#{child.author}</a></b> #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} <span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span> @@ -500,6 +500,12 @@ def template_reddit_comments(root, locale) end def replace_links(html) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + html = XML.parse_html(html) html.xpath_nodes(%q(//a)).each do |anchor| @@ -541,6 +547,12 @@ def replace_links(html) end def fill_links(html, scheme, host) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + html = XML.parse_html(html) html.xpath_nodes("//a").each do |match| diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 861913d0..25b24ed4 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -11,6 +11,20 @@ <table id="jslicense-labels1"> <tr> <td> + <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>">_helpers.js</a> + </td> + + <td> + <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a> + </td> + + <td> + <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a> + </td> + </tr> + + <tr> + <td> <a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a> </td> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index bd908dd6..4e2b29f0 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -17,6 +17,7 @@ <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/default.css?v=<%= ASSET_COMMIT %>"> + <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script> </head> <% diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 783eff1d..d1fdcce2 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -278,24 +278,24 @@ we're going to need to do it here in order to allow for translations. </div> <% end %> <p style="width:100%"><%= rv["title"] %></p> - <h5 class="pure-g"> - <div class="pure-u-14-24"> - <% if rv["ucid"]? %> - <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b> - <% else %> - <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <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> </a> + <h5 class="pure-g"> + <div class="pure-u-14-24"> + <% if rv["ucid"]? %> + <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b> + <% else %> + <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <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> <% end %> <% end %> </div> |
