Add support for Web notifications

This commit is contained in:
Omar Roth 2019-05-05 07:46:01 -05:00
parent b3788bc143
commit 0338fd42e1
No known key found for this signature in database
GPG key ID: B8254FB7EC3D37F2
22 changed files with 456 additions and 30 deletions

View file

@ -1,4 +1,4 @@
function get_playlist(plid, timeouts = 0) {
function get_playlist(plid, timeouts) {
if (timeouts > 10) {
console.log('Failed to pull playlist');
return;
@ -53,7 +53,7 @@ function get_playlist(plid, timeouts = 0) {
xhr.ontimeout = function () {
console.log('Pulling playlist timed out.');
get_playlist(plid, timeouts + 1);
get_playlist(plid, timeouts++);
}
}

139
assets/js/notifications.js Normal file
View file

@ -0,0 +1,139 @@
var notifications, delivered;
function get_subscriptions(callback, failures) {
if (failures >= 10) {
return
}
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 20000;
xhr.open('GET', '/api/v1/auth/subscriptions', true);
xhr.send(null);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
subscriptions = xhr.response;
callback(subscriptions);
} else {
console.log('Pulling subscriptions failed... ' + failures + '/10');
get_subscriptions(callback, failures++)
}
}
}
xhr.ontimeout = function () {
console.log('Pulling subscriptions failed... ' + failures + '/10');
get_subscriptions(callback, failures++);
}
}
function create_notification_stream(subscriptions) {
notifications = new SSE(
'/api/v1/auth/notifications?fields=videoId,title,author,authorId,publishedText,published,authorThumbnails,liveNow', {
withCredentials: true,
payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId }).join(','),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
delivered = [];
var start_time = Math.round(new Date() / 1000);
notifications.onmessage = function (event) {
if (!event.id) {
return
}
var notification = JSON.parse(event.data);
console.log('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>';
}
}
}
notifications.onerror = function (event) {
console.log('Something went wrong with notifications, trying to reconnect...');
notifications.close();
get_subscriptions(create_notification_stream);
}
notifications.ontimeout = function (event) {
console.log('Something went wrong with notifications, trying to reconnect...');
notifications.close();
get_subscriptions(create_notification_stream);
}
notifications.stream();
}
window.addEventListener('storage', function (e) {
if (e.key === 'stream' && !e.newValue) {
if (notifications) {
localStorage.setItem('stream', true);
} else {
setTimeout(function () {
if (!localStorage.getItem('stream')) {
get_subscriptions(create_notification_stream);
localStorage.setItem('stream', true);
}
}, Math.random() * 1000 + 10);
}
} 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>';
}
}
});
window.addEventListener('load', function (e) {
localStorage.setItem('notification_count', document.getElementById('notification_count') ? document.getElementById('notification_count').innerText : '0');
if (localStorage.getItem('stream')) {
localStorage.removeItem('stream');
} else {
setTimeout(function () {
if (!localStorage.getItem('stream')) {
get_subscriptions(create_notification_stream);
localStorage.setItem('stream', true);
}
}, Math.random() * 1000 + 10);
}
});
window.addEventListener('unload', function (e) {
if (notifications) {
localStorage.removeItem('stream');
}
});

200
assets/js/sse.js Normal file
View file

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

View file

@ -7,8 +7,8 @@ if (subscribe_button.getAttribute('data-type') === 'subscribe') {
subscribe_button.onclick = unsubscribe;
}
function subscribe(timeouts = 0) {
if (timeouts > 10) {
function subscribe(timeouts) {
if (timeouts >= 10) {
console.log('Failed to subscribe.');
return;
}
@ -37,12 +37,12 @@ function subscribe(timeouts = 0) {
xhr.ontimeout = function () {
console.log('Subscribing timed out.');
subscribe(timeouts + 1);
subscribe(timeouts++);
}
}
function unsubscribe(timeouts = 0) {
if (timeouts > 10) {
function unsubscribe(timeouts) {
if (timeouts >= 10) {
console.log('Failed to subscribe');
return;
}
@ -71,6 +71,6 @@ function unsubscribe(timeouts = 0) {
xhr.ontimeout = function () {
console.log('Unsubscribing timed out.');
unsubscribe(timeouts + 1);
unsubscribe(timeouts++);
}
}

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ",
"Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ",
"Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "إعدادات التفضيلات",
"Clear watch history": "حذف سجل المشاهدة",
"Import/export data": "إضافة\\إستخراج البيانات",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ",
"Only show unwatched: ": "Nur ungesehene anzeigen: ",
"Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Dateneinstellungen",
"Clear watch history": "Verlauf löschen",
"Import/export data": "Daten im- exportieren",

View file

@ -91,6 +91,9 @@
"Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ",
"Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ",
"Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Προτιμήσεις δεδομένων",
"Clear watch history": "Εκκαθάριση ιστορικού προβολής",
"Import/export data": "Εισαγωγή/εξαγωγή δεδομένων",

View file

@ -91,6 +91,9 @@
"Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ",
"Only show unwatched: ": "Only show unwatched: ",
"Only show notifications (if there are any): ": "Only show notifications (if there are any): ",
"Enable web notifications": "Enable web notifications",
"`x` uploaded a video": "`x` uploaded a video",
"`x` is live": "`x` is live",
"Data preferences": "Data preferences",
"Clear watch history": "Clear watch history",
"Import/export data": "Import/export data",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan videon el kanalo: ",
"Only show unwatched: ": "Nur montri malviditajn: ",
"Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Datumagordoj",
"Clear watch history": "Forigi vidohistorion",
"Import/export data": "Importi/Eksporti datumojn",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ",
"Only show unwatched: ": "Mostrar solo los no vistos: ",
"Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferencias de los datos",
"Clear watch history": "Borrar el historial de reproducción",
"Import/export data": "Importar/Exportar datos",
@ -312,4 +315,4 @@
"Videos": "Vídeos",
"Playlists": "Listas de reproducción",
"Current version: ": "Versión actual: "
}
}

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "",
"Only show unwatched: ": "",
"Only show notifications (if there are any): ": "",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "",
"Clear watch history": "",
"Import/export data": "",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo de la chaîne non regardée : ",
"Only show unwatched: ": "Afficher uniquement les vidéos non regardées : ",
"Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Préférences liées aux données",
"Clear watch history": "Supprimer l'historique des vidéos regardées",
"Import/export data": "Importer/exporter les données",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ",
"Only show unwatched: ": "Mostra solo i video non guardati: ",
"Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferenze dati",
"Clear watch history": "Cancella la cronologia dei video guardati",
"Import/export data": "Importazione/esportazione dati",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ",
"Only show unwatched: ": "Kun vis usette: ",
"Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Datainnstillinger",
"Clear watch history": "Tøm visningshistorikk",
"Import/export data": "Importer/eksporter data",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ",
"Only show unwatched: ": "Alleen niet-bekeken videos tonen: ",
"Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Gegevensinstellingen",
"Clear watch history": "Kijkgeschiedenis wissen",
"Import/export data": "Gegevens im-/exporteren",
@ -312,4 +315,4 @@
"Videos": "Video's",
"Playlists": "Afspeellijsten",
"Current version: ": "Huidige versie: "
}
}

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ",
"Only show unwatched: ": "Pokazuj tylko nie obejrzane: ",
"Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Preferencje danych",
"Clear watch history": "Wyczyść historię",
"Import/export data": "Import/Eksport danych",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ",
"Only show unwatched: ": "Показывать только непросмотренные видео: ",
"Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Настройки данных",
"Clear watch history": "Очистить историю просмотров",
"Import/export data": "Импорт/Экспорт данных",

View file

@ -85,6 +85,9 @@
"Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ",
"Only show unwatched: ": "Показувати тільки непереглянуті відео: ",
"Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ",
"Enable web notifications": "",
"`x` uploaded a video": "",
"`x` is live": "",
"Data preferences": "Налаштування даних",
"Clear watch history": "Очистити історію переглядів",
"Import/export data": "Імпорт і експорт даних",

View file

@ -2,24 +2,24 @@
<html lang="<%= env.get("preferences").as(Preferences).locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="thumbnail" content="<%= thumbnail %>">
<%= rendered "components/player_sources" %>
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>
<style>
#player {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
z-index: -100;
}
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="thumbnail" content="<%= thumbnail %>">
<%= rendered "components/player_sources" %>
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>
<style>
#player {
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
z-index: -100;
}
</style>
</head>
<body>

View file

@ -23,6 +23,20 @@
</td>
</tr>
<tr>
<td>
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>">notifications.js</a>
</td>
<td>
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
</td>
<td>
<a href="/js/notifications.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/player.js?v=<%= ASSET_COMMIT %>">player.js</a>
@ -51,6 +65,20 @@
</td>
</tr>
<tr>
<td>
<a href="/js/sse.js?v=<%= ASSET_COMMIT %>">sse.js</a>
</td>
<td>
<a href="http://www.jclark.com/xml/copying.txt">Expat</a>
</td>
<td>
<a href="https://github.com/mpetazzoni/sse.js"><%= translate(locale, "source") %></a>
</td>
</tr>
<tr>
<td>
<a href="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>">subscribe_widget.js</a>

View file

@ -165,6 +165,13 @@ function update_value(element) {
<label for="notifications_only"><%= translate(locale, "Only show notifications (if there are any): ") %></label>
<input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>>
</div>
<% # Conditions for supporting web notifications %>
<% if CONFIG.use_pubsub_feeds && (Kemal.config.ssl || config.https_only) %>
<div class="pure-control-group">
<a href="#" onclick="Notification.requestPermission()"><%= translate(locale, "Enable web notifications") %></a>
</div>
<% end %>
<% end %>
<% if env.get?("user") && config.admins.includes? env.get?("user").as(User).email %>

View file

@ -51,10 +51,10 @@
</a>
</div>
<div class="pure-u-1-4">
<a title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
<% notification_count = env.get("user").as(User).notifications.size %>
<% if notification_count > 0 %>
<%= notification_count %> <i class="icon ion-ios-notifications"></i>
<span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
<% else %>
<i class="icon ion-ios-notifications-outline"></i>
<% end %>
@ -151,6 +151,16 @@
<div class="pure-u-1 pure-u-md-2-24"></div>
</div>
<script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script>
<% if env.get? "user" %>
<script src="/js/sse.js?v=<%= ASSET_COMMIT %>"></script>
<script>
var notification_data = {
upload_text: '<%= HTML.escape(translate(locale, "`x` uploaded a video")) %>',
live_upload_text: '<%= HTML.escape(translate(locale, "`x` is live")) %>',
}
</script>
<script src="/js/notifications.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
</body>
</html>