mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
update sync processes
This commit is contained in:
parent
cad461b296
commit
c23547137d
19 changed files with 400 additions and 326 deletions
|
@ -1,126 +0,0 @@
|
||||||
(function (globalScope, angular) {
|
|
||||||
|
|
||||||
globalScope.HttpClient = {
|
|
||||||
|
|
||||||
param: function(params) {
|
|
||||||
return serialize(params);
|
|
||||||
},
|
|
||||||
|
|
||||||
send: function(options) {
|
|
||||||
var request = getAngularRequest(options),
|
|
||||||
defer = globalScope.DeferredBuilder.Deferred();
|
|
||||||
|
|
||||||
request.then(function(results) {
|
|
||||||
defer.resolve(results.data);
|
|
||||||
}, function() {
|
|
||||||
defer.reject();
|
|
||||||
});
|
|
||||||
|
|
||||||
return defer.promise();
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
// Code from: http://stackoverflow.com/questions/1714786/querystring-encoding-of-a-javascript-object
|
|
||||||
function serialize (obj, prefix) {
|
|
||||||
var str = [];
|
|
||||||
for(var p in obj) {
|
|
||||||
if (obj.hasOwnProperty(p)) {
|
|
||||||
var k = prefix ? prefix + "[" + p + "]" : p, v = obj[p];
|
|
||||||
str.push(typeof v == "object" ?
|
|
||||||
serialize(v, k) :
|
|
||||||
encodeURIComponent(k) + "=" + encodeURIComponent(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return str.join("&");
|
|
||||||
}
|
|
||||||
|
|
||||||
var $http = angular.injector(['ng']).get('$http');
|
|
||||||
|
|
||||||
function getAngularRequest(jParams) {
|
|
||||||
var optionTransforms = [],
|
|
||||||
promiseTransforms = [],
|
|
||||||
options = {},
|
|
||||||
// paramMap houses the param transforms in one of the following formats:
|
|
||||||
// string - This means there is a direct mapping from jQuery option to Angular option, but allows for a different option name
|
|
||||||
// function - This means some logic is required in applying this option to the Angular request. Functions should add functions
|
|
||||||
// to the optionTransforms or promiseTransforms arrays, which will be executed after direct mappings are complete.
|
|
||||||
paramMap = {
|
|
||||||
'accepts': undefined,
|
|
||||||
'async': undefined,
|
|
||||||
'beforeSend': undefined,
|
|
||||||
'cache': undefined,
|
|
||||||
'complete': undefined,
|
|
||||||
'contents': undefined,
|
|
||||||
'contentType': function(val) {
|
|
||||||
optionTransforms.push(function(opt) {
|
|
||||||
opt.headers = opt.headers || {};
|
|
||||||
opt.headers['Content-Type'] = val;
|
|
||||||
return opt;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
'context': undefined,
|
|
||||||
'converters': undefined,
|
|
||||||
'crossDomain': undefined,
|
|
||||||
'data': 'data',
|
|
||||||
'dataFilter': undefined,
|
|
||||||
'dataType': 'responseType',
|
|
||||||
'error': undefined,
|
|
||||||
'global': undefined,
|
|
||||||
'headers': 'headers',
|
|
||||||
'ifModified': undefined,
|
|
||||||
'isLocal': undefined,
|
|
||||||
'jsonp': undefined,
|
|
||||||
'jsonpCallback': undefined,
|
|
||||||
'mimeType': undefined,
|
|
||||||
'password': undefined,
|
|
||||||
'processData': undefined,
|
|
||||||
'scriptCharset': undefined,
|
|
||||||
'statusCode': undefined,
|
|
||||||
'success': undefined,
|
|
||||||
'timeout': 'timeout',
|
|
||||||
'traditional': undefined,
|
|
||||||
'type': 'method',
|
|
||||||
'url': 'url',
|
|
||||||
'username': undefined,
|
|
||||||
'xhr': undefined,
|
|
||||||
'xhrFields': undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Iterate through each key in the jQuery format options object
|
|
||||||
for (var key in jParams) {
|
|
||||||
if (!paramMap[key]) {
|
|
||||||
// This parameter hasn't been implemented in the paramMap object
|
|
||||||
Logger.log('ERROR: ajax option property "' + key + '" not implemented by HttpClient.');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof paramMap[key] === 'string') {
|
|
||||||
// Direct mapping between two properties
|
|
||||||
options[paramMap[key]] = jParams[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof paramMap[key] === 'function') {
|
|
||||||
// Extra logic required. Execute the function with the jQuery option as the only function argument
|
|
||||||
paramMap[key](jParams[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through any optionTransforms functions and execute them with the options object as argument
|
|
||||||
while (optionTransforms.length > 0) {
|
|
||||||
options = optionTransforms.pop()(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the Angular http request (returns the request's promise object)
|
|
||||||
var promise = $http(options);
|
|
||||||
|
|
||||||
// Iterate through any promiseTransforms functions and execute them with the promise as argument.
|
|
||||||
while (promiseTransforms.length > 0) {
|
|
||||||
promiseTransforms.pop()(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
})(window, angular);
|
|
|
@ -533,7 +533,7 @@
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// If that produced a fairly high speed, try again with a larger size to get a more accurate result
|
// If that produced a fairly high speed, try again with a larger size to get a more accurate result
|
||||||
self.getDownloadSpeed(3000000).done(function (bitrate) {
|
self.getDownloadSpeed(2400000).done(function (bitrate) {
|
||||||
|
|
||||||
deferred.resolveWith(null, [Math.round(bitrate * .8)]);
|
deferred.resolveWith(null, [Math.round(bitrate * .8)]);
|
||||||
|
|
||||||
|
@ -1587,6 +1587,36 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current server configuration
|
||||||
|
*/
|
||||||
|
self.getDevicesOptions = function () {
|
||||||
|
|
||||||
|
var url = self.getUrl("System/Configuration/devices");
|
||||||
|
|
||||||
|
return self.ajax({
|
||||||
|
type: "GET",
|
||||||
|
url: url,
|
||||||
|
dataType: "json"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current server configuration
|
||||||
|
*/
|
||||||
|
self.getContentUploadHistory = function () {
|
||||||
|
|
||||||
|
var url = self.getUrl("Devices/CameraUploads", {
|
||||||
|
DeviceId: self.deviceId()
|
||||||
|
});
|
||||||
|
|
||||||
|
return self.ajax({
|
||||||
|
type: "GET",
|
||||||
|
url: url,
|
||||||
|
dataType: "json"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
self.getNamedConfiguration = function (name) {
|
self.getNamedConfiguration = function (name) {
|
||||||
|
|
||||||
var url = self.getUrl("System/Configuration/" + name);
|
var url = self.getUrl("System/Configuration/" + name);
|
||||||
|
@ -2538,29 +2568,31 @@
|
||||||
|
|
||||||
var url = self.getUrl("Users/authenticatebyname");
|
var url = self.getUrl("Users/authenticatebyname");
|
||||||
|
|
||||||
var postData = {
|
require(["cryptojs-sha1"], function () {
|
||||||
password: CryptoJS.SHA1(password || "").toString(),
|
var postData = {
|
||||||
Username: name
|
password: CryptoJS.SHA1(password || "").toString(),
|
||||||
};
|
Username: name
|
||||||
|
};
|
||||||
|
|
||||||
self.ajax({
|
self.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
url: url,
|
url: url,
|
||||||
data: JSON.stringify(postData),
|
data: JSON.stringify(postData),
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
contentType: "application/json"
|
contentType: "application/json"
|
||||||
|
|
||||||
}).done(function (result) {
|
}).done(function (result) {
|
||||||
|
|
||||||
if (self.onAuthenticated) {
|
if (self.onAuthenticated) {
|
||||||
self.onAuthenticated(self, result);
|
self.onAuthenticated(self, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.resolveWith(null, [result]);
|
deferred.resolveWith(null, [result]);
|
||||||
|
|
||||||
}).fail(function () {
|
}).fail(function () {
|
||||||
|
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return deferred.promise();
|
return deferred.promise();
|
||||||
|
@ -2574,20 +2606,35 @@
|
||||||
*/
|
*/
|
||||||
self.updateUserPassword = function (userId, currentPassword, newPassword) {
|
self.updateUserPassword = function (userId, currentPassword, newPassword) {
|
||||||
|
|
||||||
|
var deferred = DeferredBuilder.Deferred();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("null userId");
|
deferred.reject();
|
||||||
|
return deferred.promise();
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = self.getUrl("Users/" + userId + "/Password");
|
var url = self.getUrl("Users/" + userId + "/Password");
|
||||||
|
|
||||||
return self.ajax({
|
require(["cryptojs-sha1"], function () {
|
||||||
type: "POST",
|
|
||||||
url: url,
|
self.ajax({
|
||||||
data: {
|
type: "POST",
|
||||||
currentPassword: CryptoJS.SHA1(currentPassword).toString(),
|
url: url,
|
||||||
newPassword: CryptoJS.SHA1(newPassword).toString()
|
data: {
|
||||||
}
|
currentPassword: CryptoJS.SHA1(currentPassword).toString(),
|
||||||
|
newPassword: CryptoJS.SHA1(newPassword).toString()
|
||||||
|
}
|
||||||
|
}).done(function (result) {
|
||||||
|
|
||||||
|
deferred.resolveWith(null, [result]);
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
|
||||||
|
deferred.reject();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return deferred.promise();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2597,19 +2644,34 @@
|
||||||
*/
|
*/
|
||||||
self.updateEasyPassword = function (userId, newPassword) {
|
self.updateEasyPassword = function (userId, newPassword) {
|
||||||
|
|
||||||
|
var deferred = DeferredBuilder.Deferred();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("null userId");
|
deferred.reject();
|
||||||
|
return deferred.promise();
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = self.getUrl("Users/" + userId + "/EasyPassword");
|
var url = self.getUrl("Users/" + userId + "/EasyPassword");
|
||||||
|
|
||||||
return self.ajax({
|
require(["cryptojs-sha1"], function () {
|
||||||
type: "POST",
|
|
||||||
url: url,
|
self.ajax({
|
||||||
data: {
|
type: "POST",
|
||||||
newPassword: CryptoJS.SHA1(newPassword).toString()
|
url: url,
|
||||||
}
|
data: {
|
||||||
|
newPassword: CryptoJS.SHA1(newPassword).toString()
|
||||||
|
}
|
||||||
|
}).done(function (result) {
|
||||||
|
|
||||||
|
deferred.resolveWith(null, [result]);
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
|
||||||
|
deferred.reject();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return deferred.promise();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -126,6 +126,17 @@
|
||||||
return credentialProvider.credentials().ConnectAccessToken;
|
return credentialProvider.credentials().ConnectAccessToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.getServerInfo = function (id) {
|
||||||
|
|
||||||
|
var servers = credentialProvider.credentials().Servers;
|
||||||
|
|
||||||
|
return servers.filter(function () {
|
||||||
|
|
||||||
|
return s.Id == id;
|
||||||
|
|
||||||
|
})[0];
|
||||||
|
};
|
||||||
|
|
||||||
self.getLastUsedServer = function () {
|
self.getLastUsedServer = function () {
|
||||||
|
|
||||||
var servers = credentialProvider.credentials().Servers;
|
var servers = credentialProvider.credentials().Servers;
|
||||||
|
@ -287,7 +298,9 @@
|
||||||
|
|
||||||
var server = servers.length ? servers[0] : apiClient.serverInfo();
|
var server = servers.length ? servers[0] : apiClient.serverInfo();
|
||||||
|
|
||||||
server.DateLastAccessed = new Date().getTime();
|
if (options.updateDateLastAccessed !== false) {
|
||||||
|
server.DateLastAccessed = new Date().getTime();
|
||||||
|
}
|
||||||
server.Id = result.ServerId;
|
server.Id = result.ServerId;
|
||||||
|
|
||||||
if (saveCredentials) {
|
if (saveCredentials) {
|
||||||
|
@ -879,6 +892,7 @@
|
||||||
|
|
||||||
var wakeOnLanSendTime = new Date().getTime();
|
var wakeOnLanSendTime = new Date().getTime();
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
testNextConnectionMode(tests, 0, server, wakeOnLanSendTime, options, deferred);
|
testNextConnectionMode(tests, 0, server, wakeOnLanSendTime, options, deferred);
|
||||||
|
|
||||||
return deferred.promise();
|
return deferred.promise();
|
||||||
|
@ -987,7 +1001,9 @@
|
||||||
|
|
||||||
updateServerInfo(server, systemInfo);
|
updateServerInfo(server, systemInfo);
|
||||||
|
|
||||||
server.DateLastAccessed = new Date().getTime();
|
if (options.updateDateLastAccessed !== false) {
|
||||||
|
server.DateLastAccessed = new Date().getTime();
|
||||||
|
}
|
||||||
server.LastConnectionMode = connectionMode;
|
server.LastConnectionMode = connectionMode;
|
||||||
credentialProvider.addOrUpdateServer(credentials.Servers, server);
|
credentialProvider.addOrUpdateServer(credentials.Servers, server);
|
||||||
credentialProvider.credentials(credentials);
|
credentialProvider.credentials(credentials);
|
||||||
|
|
55
dashboard-ui/apiclient/sync/contentuploader.js
Normal file
55
dashboard-ui/apiclient/sync/contentuploader.js
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
(function (globalScope) {
|
||||||
|
|
||||||
|
function contentUploader(connectionManager) {
|
||||||
|
|
||||||
|
self.uploadImages = function (server) {
|
||||||
|
|
||||||
|
var deferred = DeferredBuilder.Deferred();
|
||||||
|
|
||||||
|
var apiClient = self.getApiClient(server.Id);
|
||||||
|
|
||||||
|
apiClient.getDevicesOptions().done(function (devicesOptions) {
|
||||||
|
|
||||||
|
if (!devicesOptions.EnabledCameraUploadDevices || devicesOptions.EnabledCameraUploadDevices.indexOf(apiClient.deviceId()) == -1) {
|
||||||
|
Logger.log("Camera upload is not enabled for this device.");
|
||||||
|
deferred.reject();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uploadImagesInternal(server, apiClient, deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
deferred.reject();
|
||||||
|
});
|
||||||
|
|
||||||
|
return deferred.promise();
|
||||||
|
};
|
||||||
|
|
||||||
|
function uploadImagesInternal(server, apiClient, deferred) {
|
||||||
|
|
||||||
|
apiClient.getContentUploadHistory().done(function (result) {
|
||||||
|
|
||||||
|
uploadImagesWithHistory(server, result, apiClient, deferred);
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
deferred.reject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadImagesWithHistory(server, uploadHistory, apiClient, deferred) {
|
||||||
|
|
||||||
|
require(['localassetmanager'], function () {
|
||||||
|
|
||||||
|
// TODO: Mimic java version of ContentUploader.UploadImagesInternal
|
||||||
|
deferred.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalScope.MediaBrowser) {
|
||||||
|
globalScope.MediaBrowser = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalScope.MediaBrowser.ContentUploader = contentUploader;
|
||||||
|
|
||||||
|
})(this);
|
58
dashboard-ui/apiclient/sync/multiserversync.js
Normal file
58
dashboard-ui/apiclient/sync/multiserversync.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
(function (globalScope) {
|
||||||
|
|
||||||
|
function multiServerSync(connectionManager) {
|
||||||
|
|
||||||
|
self.sync = function () {
|
||||||
|
|
||||||
|
var deferred = DeferredBuilder.Deferred();
|
||||||
|
|
||||||
|
connectionManager.getAvailableServers().done(function (result) {
|
||||||
|
syncNext(result, 0, deferred);
|
||||||
|
});
|
||||||
|
|
||||||
|
return deferred.promise();
|
||||||
|
};
|
||||||
|
|
||||||
|
function syncNext(servers, index, deferred) {
|
||||||
|
|
||||||
|
var length = servers.length;
|
||||||
|
|
||||||
|
if (index >= length) {
|
||||||
|
|
||||||
|
deferred.resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.isCancelled) {
|
||||||
|
deferred.reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var server = servers[index];
|
||||||
|
|
||||||
|
// get fresh info from connection manager
|
||||||
|
server = connectionManager.getServerInfo(server.Id) || server;
|
||||||
|
|
||||||
|
Logger.log("Creating ServerSync to server: " + server.Id);
|
||||||
|
|
||||||
|
require(['serversync'], function () {
|
||||||
|
|
||||||
|
new MediaBrowser.ServerSync(connectionManager).sync(server).done(function () {
|
||||||
|
|
||||||
|
syncNext(servers, index + 1, deferred);
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
|
||||||
|
syncNext(servers, index + 1, deferred);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalScope.MediaBrowser) {
|
||||||
|
globalScope.MediaBrowser = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalScope.MediaBrowser.MultiServerSync = multiServerSync;
|
||||||
|
|
||||||
|
})(this);
|
64
dashboard-ui/apiclient/sync/serversync.js
Normal file
64
dashboard-ui/apiclient/sync/serversync.js
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
(function (globalScope) {
|
||||||
|
|
||||||
|
function serverSync(connectionManager) {
|
||||||
|
|
||||||
|
self.sync = function (server) {
|
||||||
|
|
||||||
|
var deferred = DeferredBuilder.Deferred();
|
||||||
|
|
||||||
|
if (!server.AccessToken && !server.ExchangeToken) {
|
||||||
|
|
||||||
|
Logger.log('Skipping sync to server ' + server.Id + ' because there is no saved authentication information.');
|
||||||
|
deferred.resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionOptions = {
|
||||||
|
updateDateLastAccessed: false,
|
||||||
|
enableWebSocket: false,
|
||||||
|
reportCapabilities: false
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionManager.connectToServer(server, connectionOptions).done(function (result) {
|
||||||
|
|
||||||
|
if (result.State == MediaBrowser.ConnectionState.SignedIn) {
|
||||||
|
performSync(server, deferred);
|
||||||
|
} else {
|
||||||
|
Logger.log('Unable to connect to server id: ' + server.Id);
|
||||||
|
deferred.reject();
|
||||||
|
}
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
|
||||||
|
Logger.log('Unable to connect to server id: ' + server.Id);
|
||||||
|
deferred.reject();
|
||||||
|
});
|
||||||
|
|
||||||
|
return deferred.promise();
|
||||||
|
};
|
||||||
|
|
||||||
|
function performSync(server, deferred) {
|
||||||
|
|
||||||
|
Logger.log("Creating ContentUploader to server: " + server.Id);
|
||||||
|
|
||||||
|
require(['contentuploader'], function () {
|
||||||
|
|
||||||
|
new MediaBrowser.ContentUploader(connectionManager).uploadImages(server).done(function () {
|
||||||
|
|
||||||
|
deferred.resolve();
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
|
||||||
|
deferred.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalScope.MediaBrowser) {
|
||||||
|
globalScope.MediaBrowser = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalScope.MediaBrowser.ServerSync = serverSync;
|
||||||
|
|
||||||
|
})(this);
|
|
@ -27,14 +27,14 @@
|
||||||
"web-component-tester": "*",
|
"web-component-tester": "*",
|
||||||
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/polymerelements/iron-behaviors",
|
"homepage": "https://github.com/PolymerElements/iron-behaviors",
|
||||||
"_release": "1.0.8",
|
"_release": "1.0.8",
|
||||||
"_resolution": {
|
"_resolution": {
|
||||||
"type": "version",
|
"type": "version",
|
||||||
"tag": "v1.0.8",
|
"tag": "v1.0.8",
|
||||||
"commit": "663ad706b43989f4961d945b8116cf4db346532f"
|
"commit": "663ad706b43989f4961d945b8116cf4db346532f"
|
||||||
},
|
},
|
||||||
"_source": "git://github.com/polymerelements/iron-behaviors.git",
|
"_source": "git://github.com/PolymerElements/iron-behaviors.git",
|
||||||
"_target": "^1.0.0",
|
"_target": "^1.0.0",
|
||||||
"_originalSource": "polymerelements/iron-behaviors"
|
"_originalSource": "PolymerElements/iron-behaviors"
|
||||||
}
|
}
|
|
@ -23,14 +23,14 @@
|
||||||
"paper-styles": "polymerelements/paper-styles#^1.0.0",
|
"paper-styles": "polymerelements/paper-styles#^1.0.0",
|
||||||
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/polymerelements/iron-flex-layout",
|
"homepage": "https://github.com/PolymerElements/iron-flex-layout",
|
||||||
"_release": "1.0.3",
|
"_release": "1.0.3",
|
||||||
"_resolution": {
|
"_resolution": {
|
||||||
"type": "version",
|
"type": "version",
|
||||||
"tag": "v1.0.3",
|
"tag": "v1.0.3",
|
||||||
"commit": "e6c2cfec18354973ac03e70dcd8afcc3c72d09b9"
|
"commit": "e6c2cfec18354973ac03e70dcd8afcc3c72d09b9"
|
||||||
},
|
},
|
||||||
"_source": "git://github.com/polymerelements/iron-flex-layout.git",
|
"_source": "git://github.com/PolymerElements/iron-flex-layout.git",
|
||||||
"_target": "^1.0.0",
|
"_target": "^1.0.0",
|
||||||
"_originalSource": "polymerelements/iron-flex-layout"
|
"_originalSource": "PolymerElements/iron-flex-layout"
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "paper-slider",
|
"name": "paper-slider",
|
||||||
"version": "1.0.4",
|
"version": "1.0.5",
|
||||||
"description": "A material design-style slider",
|
"description": "A material design-style slider",
|
||||||
"license": "http://polymer.github.io/LICENSE.txt",
|
"license": "http://polymer.github.io/LICENSE.txt",
|
||||||
"authors": "The Polymer Authors",
|
"authors": "The Polymer Authors",
|
||||||
|
@ -37,11 +37,11 @@
|
||||||
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
"webcomponentsjs": "webcomponents/webcomponentsjs#^0.7.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/PolymerElements/paper-slider",
|
"homepage": "https://github.com/PolymerElements/paper-slider",
|
||||||
"_release": "1.0.4",
|
"_release": "1.0.5",
|
||||||
"_resolution": {
|
"_resolution": {
|
||||||
"type": "version",
|
"type": "version",
|
||||||
"tag": "v1.0.4",
|
"tag": "v1.0.5",
|
||||||
"commit": "e1307d8323c2f91a3f2a514c210b4d7dd498e3ac"
|
"commit": "8672cf9466fe8387f04ef5065ea83a4a18f8b06d"
|
||||||
},
|
},
|
||||||
"_source": "git://github.com/PolymerElements/paper-slider.git",
|
"_source": "git://github.com/PolymerElements/paper-slider.git",
|
||||||
"_target": "~1.0.3",
|
"_target": "~1.0.3",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "paper-slider",
|
"name": "paper-slider",
|
||||||
"version": "1.0.4",
|
"version": "1.0.5",
|
||||||
"description": "A material design-style slider",
|
"description": "A material design-style slider",
|
||||||
"license": "http://polymer.github.io/LICENSE.txt",
|
"license": "http://polymer.github.io/LICENSE.txt",
|
||||||
"authors": "The Polymer Authors",
|
"authors": "The Polymer Authors",
|
||||||
|
|
|
@ -79,11 +79,11 @@ subject to an additional IP rights grant found at http://polymer.github.io/PATEN
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 2px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
margin: -8px 0;
|
margin: -8px 0;
|
||||||
background-color: var(--paper-slider-bar-color, transparent);
|
background-color: var(--paper-slider-bar-color, transparent);
|
||||||
|
--paper-progress-height: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring #sliderBar {
|
.ring #sliderBar {
|
||||||
|
|
111
dashboard-ui/cordova/android/androidcredentials.js
vendored
111
dashboard-ui/cordova/android/androidcredentials.js
vendored
|
@ -26,109 +26,6 @@
|
||||||
var capabilities = ConnectionManager.capabilities();
|
var capabilities = ConnectionManager.capabilities();
|
||||||
|
|
||||||
ApiClientBridge.init(AppInfo.appName, AppInfo.appVersion, AppInfo.deviceId, AppInfo.deviceName, JSON.stringify(capabilities));
|
ApiClientBridge.init(AppInfo.appName, AppInfo.appVersion, AppInfo.deviceId, AppInfo.deviceName, JSON.stringify(capabilities));
|
||||||
|
|
||||||
initAjax();
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseAjaxMethod;
|
|
||||||
var currentId = 0;
|
|
||||||
function getNewRequestId() {
|
|
||||||
var id = currentId++;
|
|
||||||
return id.toString();
|
|
||||||
}
|
|
||||||
function initAjax() {
|
|
||||||
baseAjaxMethod = HttpClient.send;
|
|
||||||
HttpClient.send = sendRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendRequest(request) {
|
|
||||||
|
|
||||||
// For now, we can only handle json responses
|
|
||||||
if (request.dataType) {
|
|
||||||
if (request.dataType != 'json') {
|
|
||||||
return baseAjaxMethod(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.data) {
|
|
||||||
// For now, we can only handle request bodies that are strings
|
|
||||||
if (typeof (request.data) != 'string') {
|
|
||||||
return baseAjaxMethod(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deferred = DeferredBuilder.Deferred();
|
|
||||||
|
|
||||||
var id = getNewRequestId();
|
|
||||||
|
|
||||||
request.headers = request.headers || {};
|
|
||||||
|
|
||||||
if (request.dataType == 'json') {
|
|
||||||
request.headers.accept = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
var method = request.type || "GET";
|
|
||||||
|
|
||||||
var javaRequest = {
|
|
||||||
Method: method,
|
|
||||||
Url: request.url,
|
|
||||||
RequestHeaders: request.headers
|
|
||||||
};
|
|
||||||
|
|
||||||
if (request.timeout) {
|
|
||||||
javaRequest.Timeout = request.timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.data) {
|
|
||||||
javaRequest.RequestContent = request.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.contentType) {
|
|
||||||
javaRequest.RequestContentType = request.contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.log("Sending request: " + JSON.stringify(javaRequest));
|
|
||||||
|
|
||||||
ApiClientBridge.sendRequest(JSON.stringify(javaRequest), request.dataType, id);
|
|
||||||
|
|
||||||
Events.on(AndroidAjax, 'response' + id, function (e, isSuccess, response) {
|
|
||||||
|
|
||||||
Events.off(AndroidAjax, 'response' + id);
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
deferred.resolveWith(null, [response]);
|
|
||||||
} else {
|
|
||||||
deferred.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
|
|
||||||
// Need to mimic the jquery ajax error response
|
|
||||||
deferred.rejectWith(request, [getErrorResponse(response)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
return deferred.promise();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorResponse(response) {
|
|
||||||
|
|
||||||
var error = {};
|
|
||||||
|
|
||||||
if (response.StatusCode) {
|
|
||||||
error.status = response.StatusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
error.ResponseHeaders = response.ResponseHeaders || {};
|
|
||||||
|
|
||||||
error.getResponseHeader = function (name) {
|
|
||||||
return error.ResponseHeaders[name];
|
|
||||||
};
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDownloadSpeed(bytes, url) {
|
function getDownloadSpeed(bytes, url) {
|
||||||
|
@ -181,14 +78,6 @@
|
||||||
|
|
||||||
window.AndroidAjax = {
|
window.AndroidAjax = {
|
||||||
|
|
||||||
onResponse: function (id, response) {
|
|
||||||
|
|
||||||
Events.trigger(AndroidAjax, 'response' + id, [true, response]);
|
|
||||||
},
|
|
||||||
onError: function (id, response) {
|
|
||||||
|
|
||||||
Events.trigger(AndroidAjax, 'response' + id, [false, response]);
|
|
||||||
},
|
|
||||||
onDownloadSpeedResponse: function (response) {
|
onDownloadSpeedResponse: function (response) {
|
||||||
|
|
||||||
Events.trigger(AndroidAjax, 'downloadspeedresponse', [response]);
|
Events.trigger(AndroidAjax, 'downloadspeedresponse', [response]);
|
||||||
|
|
11
dashboard-ui/cordova/localassetmanager.js
vendored
Normal file
11
dashboard-ui/cordova/localassetmanager.js
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
function getLocalMediaSource(serverId, itemId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.LocalAssetManager = {
|
||||||
|
getLocalMediaSource: getLocalMediaSource
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
|
@ -1549,7 +1549,7 @@ span.itemCommunityRating:not(:empty) + .userDataIcons {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: contain;
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1557,7 +1557,7 @@ span.itemCommunityRating:not(:empty) + .userDataIcons {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: contain;
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -193,6 +193,11 @@
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.libraryViewNav::-webkit-scrollbar {
|
||||||
|
height: 0 !important;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.libraryViewNavWithMinHeight {
|
.libraryViewNavWithMinHeight {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -568,11 +568,24 @@
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSeekableDuration() {
|
||||||
|
|
||||||
|
if (self.currentMediaSource && self.currentMediaSource.RunTimeTicks) {
|
||||||
|
return self.currentMediaSource.RunTimeTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.currentMediaRenderer) {
|
||||||
|
return self.getCurrentTicks(self.currentMediaRenderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function onPositionSliderChange() {
|
function onPositionSliderChange() {
|
||||||
|
|
||||||
var newPercent = parseInt(this.value);
|
var newPercent = parseInt(this.value);
|
||||||
|
|
||||||
var newPositionTicks = (newPercent / 100) * self.currentMediaSource.RunTimeTicks;
|
var newPositionTicks = (newPercent / 100) * getSeekableDuration();
|
||||||
|
|
||||||
self.changeStream(Math.floor(newPositionTicks));
|
self.changeStream(Math.floor(newPositionTicks));
|
||||||
}
|
}
|
||||||
|
@ -718,12 +731,13 @@
|
||||||
|
|
||||||
positionSlider._setPinValue = function (value) {
|
positionSlider._setPinValue = function (value) {
|
||||||
|
|
||||||
if (!self.currentMediaSource || !self.currentMediaSource.RunTimeTicks) {
|
var seekableDuration = getSeekableDuration();
|
||||||
|
if (!self.currentMediaSource || !seekableDuration) {
|
||||||
this.pinValue = '--:--';
|
this.pinValue = '--:--';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticks = self.currentMediaSource.RunTimeTicks;
|
var ticks = seekableDuration;
|
||||||
ticks /= 100;
|
ticks /= 100;
|
||||||
ticks *= value;
|
ticks *= value;
|
||||||
|
|
||||||
|
|
|
@ -503,9 +503,14 @@
|
||||||
|
|
||||||
self.updateCanClientSeek = function (mediaRenderer) {
|
self.updateCanClientSeek = function (mediaRenderer) {
|
||||||
|
|
||||||
var duration = mediaRenderer.duration();
|
var currentSrc = self.getCurrentSrc(mediaRenderer);
|
||||||
|
|
||||||
canClientSeek = duration && !isNaN(duration) && duration != Number.POSITIVE_INFINITY && duration != Number.NEGATIVE_INFINITY;
|
if ((currentSrc || '').indexOf('.m3u8') != -1) {
|
||||||
|
canClientSeek = true;
|
||||||
|
} else {
|
||||||
|
var duration = mediaRenderer.duration();
|
||||||
|
canClientSeek = duration && !isNaN(duration) && duration != Number.POSITIVE_INFINITY && duration != Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.getCurrentSrc = function (mediaRenderer) {
|
self.getCurrentSrc = function (mediaRenderer) {
|
||||||
|
@ -709,6 +714,7 @@
|
||||||
ticks = Math.floor(ticks);
|
ticks = Math.floor(ticks);
|
||||||
|
|
||||||
var timeText = Dashboard.getDisplayTime(ticks);
|
var timeText = Dashboard.getDisplayTime(ticks);
|
||||||
|
var mediaRenderer = self.currentMediaRenderer;
|
||||||
|
|
||||||
if (self.currentDurationTicks) {
|
if (self.currentDurationTicks) {
|
||||||
|
|
||||||
|
@ -719,22 +725,20 @@
|
||||||
var percent = ticks / self.currentDurationTicks;
|
var percent = ticks / self.currentDurationTicks;
|
||||||
percent *= 100;
|
percent *= 100;
|
||||||
|
|
||||||
positionSlider.disabled = false;
|
|
||||||
positionSlider.value = percent;
|
positionSlider.value = percent;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
if (positionSlider) {
|
if (positionSlider) {
|
||||||
|
|
||||||
positionSlider.disabled = true;
|
positionSlider.disabled = !canClientSeek;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTimeElement) {
|
if (currentTimeElement) {
|
||||||
currentTimeElement.html(timeText);
|
currentTimeElement.html(timeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
var state = self.getPlayerStateInternal(self.currentMediaRenderer, self.currentItem, self.currentMediaSource);
|
var state = self.getPlayerStateInternal(mediaRenderer, self.currentItem, self.currentMediaSource);
|
||||||
|
|
||||||
Events.trigger(self, 'positionchange', [state]);
|
Events.trigger(self, 'positionchange', [state]);
|
||||||
};
|
};
|
||||||
|
@ -1552,6 +1556,8 @@
|
||||||
PlayState: {}
|
PlayState: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var currentSrc = mediaRenderer ? mediaRenderer.currentSrc() : null;
|
||||||
|
|
||||||
if (mediaRenderer) {
|
if (mediaRenderer) {
|
||||||
|
|
||||||
state.PlayState.VolumeLevel = mediaRenderer.volume() * 100;
|
state.PlayState.VolumeLevel = mediaRenderer.volume() * 100;
|
||||||
|
@ -1560,8 +1566,6 @@
|
||||||
state.PlayState.PositionTicks = self.getCurrentTicks(mediaRenderer);
|
state.PlayState.PositionTicks = self.getCurrentTicks(mediaRenderer);
|
||||||
state.PlayState.RepeatMode = self.getRepeatMode();
|
state.PlayState.RepeatMode = self.getRepeatMode();
|
||||||
|
|
||||||
var currentSrc = mediaRenderer.currentSrc();
|
|
||||||
|
|
||||||
if (currentSrc) {
|
if (currentSrc) {
|
||||||
|
|
||||||
var audioStreamIndex = getParameterByName('AudioStreamIndex', currentSrc);
|
var audioStreamIndex = getParameterByName('AudioStreamIndex', currentSrc);
|
||||||
|
@ -1588,7 +1592,7 @@
|
||||||
RunTimeTicks: mediaSource.RunTimeTicks
|
RunTimeTicks: mediaSource.RunTimeTicks
|
||||||
};
|
};
|
||||||
|
|
||||||
state.PlayState.CanSeek = mediaSource.RunTimeTicks && mediaSource.RunTimeTicks > 0;
|
state.PlayState.CanSeek = (mediaSource.RunTimeTicks || 0) > 0 || canClientSeek;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
|
|
|
@ -1763,7 +1763,6 @@ var AppInfo = {};
|
||||||
|
|
||||||
AppInfo.enableUserImage = true;
|
AppInfo.enableUserImage = true;
|
||||||
AppInfo.hasPhysicalVolumeButtons = isCordova || isMobile;
|
AppInfo.hasPhysicalVolumeButtons = isCordova || isMobile;
|
||||||
AppInfo.hasPhysicalVolumeButtons = true;
|
|
||||||
AppInfo.enableBackButton = isIOS && (window.navigator.standalone || AppInfo.isNativeApp);
|
AppInfo.enableBackButton = isIOS && (window.navigator.standalone || AppInfo.isNativeApp);
|
||||||
|
|
||||||
AppInfo.supportsFullScreen = isCordova && isAndroid;
|
AppInfo.supportsFullScreen = isCordova && isAndroid;
|
||||||
|
@ -2009,6 +2008,8 @@ var AppInfo = {};
|
||||||
|
|
||||||
if (Dashboard.isRunningInCordova() && $.browser.android) {
|
if (Dashboard.isRunningInCordova() && $.browser.android) {
|
||||||
define("localassetmanager", ["cordova/android/localassetmanager"]);
|
define("localassetmanager", ["cordova/android/localassetmanager"]);
|
||||||
|
} else if (Dashboard.isRunningInCordova()) {
|
||||||
|
define("localassetmanager", ["cordova/localassetmanager"]);
|
||||||
} else {
|
} else {
|
||||||
define("localassetmanager", ["apiclient/localassetmanager"]);
|
define("localassetmanager", ["apiclient/localassetmanager"]);
|
||||||
}
|
}
|
||||||
|
@ -2135,7 +2136,26 @@ var AppInfo = {};
|
||||||
return Hammer;
|
return Hammer;
|
||||||
});
|
});
|
||||||
|
|
||||||
$.extend(AppInfo, Dashboard.getAppInfo(appName, deviceId, deviceName));
|
define("cryptojs-sha1", ["apiclient/sha1"]);
|
||||||
|
|
||||||
|
define("contentuploader", ["apiclient/contentuploader"]);
|
||||||
|
define("serversync", ["apiclient/serversync"]);
|
||||||
|
define("multiserversync", ["apiclient/multiserversync"]);
|
||||||
|
|
||||||
|
var deps = [];
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
deps.push('cryptojs-sha1');
|
||||||
|
}
|
||||||
|
|
||||||
|
require(deps, function () {
|
||||||
|
$.extend(AppInfo, Dashboard.getAppInfo(appName, deviceId, deviceName));
|
||||||
|
|
||||||
|
initAfterDependencies(deferred, capabilities);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAfterDependencies(deferred, capabilities) {
|
||||||
|
|
||||||
var drawer = document.querySelector('.mainDrawerPanel');
|
var drawer = document.querySelector('.mainDrawerPanel');
|
||||||
drawer.classList.remove('mainDrawerPanelPreInit');
|
drawer.classList.remove('mainDrawerPanelPreInit');
|
||||||
|
|
|
@ -80,38 +80,40 @@
|
||||||
|
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
|
|
||||||
var info = {
|
require(["cryptojs-sha1"], function () {
|
||||||
Type: 'SchedulesDirect',
|
|
||||||
Username: page.querySelector('.txtUser').value,
|
|
||||||
Password: CryptoJS.SHA1(page.querySelector('.txtPass').value).toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
var id = providerId;
|
var info = {
|
||||||
|
Type: 'SchedulesDirect',
|
||||||
|
Username: page.querySelector('.txtUser').value,
|
||||||
|
Password: CryptoJS.SHA1(page.querySelector('.txtPass').value).toString()
|
||||||
|
};
|
||||||
|
|
||||||
if (id) {
|
var id = providerId;
|
||||||
info.Id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiClient.ajax({
|
if (id) {
|
||||||
type: "POST",
|
info.Id = id;
|
||||||
url: ApiClient.getUrl('LiveTv/ListingProviders', {
|
}
|
||||||
ValidateLogin: true
|
|
||||||
}),
|
|
||||||
data: JSON.stringify(info),
|
|
||||||
contentType: "application/json"
|
|
||||||
|
|
||||||
}).done(function (result) {
|
ApiClient.ajax({
|
||||||
|
type: "POST",
|
||||||
|
url: ApiClient.getUrl('LiveTv/ListingProviders', {
|
||||||
|
ValidateLogin: true
|
||||||
|
}),
|
||||||
|
data: JSON.stringify(info),
|
||||||
|
contentType: "application/json"
|
||||||
|
|
||||||
Dashboard.processServerConfigurationUpdateResult();
|
}).done(function (result) {
|
||||||
providerId = result.Id;
|
|
||||||
reload();
|
|
||||||
|
|
||||||
}).fail(function () {
|
Dashboard.processServerConfigurationUpdateResult();
|
||||||
Dashboard.alert({
|
providerId = result.Id;
|
||||||
message: Globalize.translate('ErrorSavingTvProvider')
|
reload();
|
||||||
|
|
||||||
|
}).fail(function () {
|
||||||
|
Dashboard.alert({
|
||||||
|
message: Globalize.translate('ErrorSavingTvProvider')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitListingsForm() {
|
function submitListingsForm() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue