Merge branch 'master' of https://github.com/jellyfin/jellyfin-web into item-detail-page-
# Conflicts: # src/assets/css/librarybrowser.css # src/itemdetails.html
This commit is contained in:
commit
709b48a8a8
129 changed files with 2629 additions and 1199 deletions
|
@ -6,15 +6,12 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
function canPlayH265(videoTestElement, options) {
|
||||
|
||||
if (browser.tizen || browser.orsay || browser.xboxOne || browser.web0s || options.supportsHevc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (browser.chromecast) {
|
||||
|
||||
var isChromecastUltra = userAgent.indexOf('aarch64') !== -1;
|
||||
if (isChromecastUltra) {
|
||||
return true;
|
||||
|
@ -31,7 +28,6 @@ define(['browser'], function (browser) {
|
|||
|
||||
var _supportsTextTracks;
|
||||
function supportsTextTracks() {
|
||||
|
||||
if (browser.tizen || browser.orsay) {
|
||||
return true;
|
||||
}
|
||||
|
@ -46,15 +42,14 @@ define(['browser'], function (browser) {
|
|||
|
||||
var _canPlayHls;
|
||||
function canPlayHls(src) {
|
||||
|
||||
if (_canPlayHls == null) {
|
||||
_canPlayHls = canPlayNativeHls() || canPlayHlsWithMSE();
|
||||
}
|
||||
|
||||
return _canPlayHls;
|
||||
}
|
||||
|
||||
function canPlayNativeHls() {
|
||||
|
||||
if (browser.tizen || browser.orsay) {
|
||||
return true;
|
||||
}
|
||||
|
@ -77,8 +72,23 @@ define(['browser'], function (browser) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function canPlayAudioFormat(format) {
|
||||
function supportsAc3(videoTestElement) {
|
||||
if (browser.edgeUwp || browser.tizen || browser.orsay || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return videoTestElement.canPlayType('audio/mp4; codecs="ac-3"').replace(/no/, '');
|
||||
}
|
||||
|
||||
function supportsEac3(videoTestElement) {
|
||||
if (browser.tizen || browser.orsay || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return videoTestElement.canPlayType('audio/mp4; codecs="ec-3"').replace(/no/, '');
|
||||
}
|
||||
|
||||
function canPlayAudioFormat(format) {
|
||||
var typeString;
|
||||
|
||||
if (format === 'flac') {
|
||||
|
@ -97,14 +107,12 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
} else if (format === 'opus') {
|
||||
typeString = 'audio/ogg; codecs="opus"';
|
||||
|
||||
if (document.createElement('audio').canPlayType(typeString).replace(/no/, '')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if (format === 'mp2') {
|
||||
|
||||
// For now
|
||||
return false;
|
||||
}
|
||||
|
@ -113,14 +121,6 @@ define(['browser'], function (browser) {
|
|||
typeString = 'audio/webm';
|
||||
} else if (format === 'mp2') {
|
||||
typeString = 'audio/mpeg';
|
||||
} else if (format === 'ogg' || format === 'oga') {
|
||||
|
||||
// chrome says probably, but seeing failures
|
||||
if (browser.chrome) {
|
||||
return false;
|
||||
}
|
||||
typeString = 'audio/' + format;
|
||||
|
||||
} else {
|
||||
typeString = 'audio/' + format;
|
||||
}
|
||||
|
@ -133,7 +133,6 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
function testCanPlayMkv(videoTestElement) {
|
||||
|
||||
if (browser.tizen || browser.orsay || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
@ -147,7 +146,6 @@ define(['browser'], function (browser) {
|
|||
|
||||
// Unfortunately there's no real way to detect mkv support
|
||||
if (browser.chrome) {
|
||||
|
||||
// Not supported on opera tv
|
||||
if (browser.operaTv) {
|
||||
return false;
|
||||
|
@ -162,7 +160,6 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
if (browser.edgeUwp) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -174,17 +171,15 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
function supportsMpeg2Video() {
|
||||
return browser.orsay || browser.tizen || browser.edgeUwp || browser.web0s;
|
||||
return browser.tizen || browser.orsay || browser.web0s || browser.edgeUwp;
|
||||
}
|
||||
|
||||
function supportsVc1() {
|
||||
return browser.orsay || browser.tizen || browser.edgeUwp || browser.web0s;
|
||||
return browser.tizen || browser.orsay || browser.web0s || browser.edgeUwp;
|
||||
}
|
||||
|
||||
function getFlvMseDirectPlayProfile() {
|
||||
|
||||
var videoAudioCodecs = ['aac'];
|
||||
|
||||
if (!browser.edge && !browser.msie) {
|
||||
videoAudioCodecs.push('mp3');
|
||||
}
|
||||
|
@ -198,13 +193,11 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
function getDirectPlayProfileForVideoContainer(container, videoAudioCodecs, videoTestElement, options) {
|
||||
|
||||
var supported = false;
|
||||
var profileContainer = container;
|
||||
var videoCodecs = [];
|
||||
|
||||
switch (container) {
|
||||
|
||||
case 'asf':
|
||||
supported = browser.tizen || browser.orsay || browser.edgeUwp;
|
||||
videoAudioCodecs = [];
|
||||
|
@ -279,16 +272,12 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
function getMaxBitrate() {
|
||||
|
||||
return 120000000;
|
||||
}
|
||||
|
||||
function getGlobalMaxVideoBitrate() {
|
||||
|
||||
var userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (browser.chromecast) {
|
||||
|
||||
var isChromecastUltra = userAgent.indexOf('aarch64') !== -1;
|
||||
if (isChromecastUltra) {
|
||||
return null;
|
||||
|
@ -319,27 +308,9 @@ define(['browser'], function (browser) {
|
|||
(browser.tizen && isTizenFhd ? 20000000 : null)));
|
||||
}
|
||||
|
||||
function supportsAc3(videoTestElement) {
|
||||
|
||||
if (browser.edgeUwp || browser.tizen || browser.orsay || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (videoTestElement.canPlayType('audio/mp4; codecs="ac-3"').replace(/no/, '') && !browser.osx && !browser.iOS);
|
||||
}
|
||||
|
||||
function supportsEac3(videoTestElement) {
|
||||
|
||||
if (browser.tizen || browser.orsay || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return videoTestElement.canPlayType('audio/mp4; codecs="ec-3"').replace(/no/, '');
|
||||
}
|
||||
|
||||
return function (options) {
|
||||
|
||||
options = options || {};
|
||||
|
||||
var physicalAudioChannels = options.audioChannels || (browser.tv || browser.ps4 || browser.xboxOne ? 6 : 2);
|
||||
|
||||
var bitrateSetting = getMaxBitrate();
|
||||
|
@ -417,7 +388,6 @@ define(['browser'], function (browser) {
|
|||
|
||||
// PS4 fails to load HLS with mp3 audio
|
||||
if (!browser.ps4) {
|
||||
|
||||
// mp3 encoder only supports 2 channels, so only make that preferred if we're only requesting 2 channels
|
||||
// Also apply it for chromecast because it no longer supports AAC 5.1
|
||||
if (physicalAudioChannels <= 2) {
|
||||
|
@ -425,14 +395,15 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (canPlayAacVideoAudio) {
|
||||
|
||||
if (canPlayAacVideoAudio) {
|
||||
if (videoAudioCodecs.indexOf('aac') === -1) {
|
||||
videoAudioCodecs.push('aac');
|
||||
}
|
||||
|
||||
hlsVideoAudioCodecs.push('aac');
|
||||
}
|
||||
|
||||
if (supportsMp3VideoAudio) {
|
||||
// PS4 fails to load HLS with mp3 audio
|
||||
if (!browser.ps4) {
|
||||
|
@ -525,6 +496,7 @@ define(['browser'], function (browser) {
|
|||
if (canPlayVp8) {
|
||||
mp4VideoCodecs.push('vp8');
|
||||
}
|
||||
|
||||
if (canPlayVp9) {
|
||||
mp4VideoCodecs.push('vp9');
|
||||
}
|
||||
|
@ -563,20 +535,17 @@ define(['browser'], function (browser) {
|
|||
['opus', 'mp3', 'mp2', 'aac', 'flac', 'alac', 'webma', 'wma', 'wav', 'ogg', 'oga'].filter(canPlayAudioFormat).forEach(function (audioFormat) {
|
||||
|
||||
if (audioFormat === 'mp2') {
|
||||
|
||||
profile.DirectPlayProfiles.push({
|
||||
Container: 'mp2,mp3',
|
||||
Type: 'Audio',
|
||||
AudioCodec: audioFormat
|
||||
});
|
||||
} else if (audioFormat === 'mp3') {
|
||||
|
||||
profile.DirectPlayProfiles.push({
|
||||
Container: audioFormat,
|
||||
Type: 'Audio',
|
||||
AudioCodec: audioFormat
|
||||
});
|
||||
|
||||
} else {
|
||||
profile.DirectPlayProfiles.push({
|
||||
Container: audioFormat === 'webma' ? 'webma,webm' : audioFormat,
|
||||
|
@ -586,7 +555,6 @@ define(['browser'], function (browser) {
|
|||
|
||||
// aac also appears in the m4a container
|
||||
if (audioFormat === 'aac' || audioFormat === 'alac') {
|
||||
|
||||
profile.DirectPlayProfiles.push({
|
||||
Container: 'm4a',
|
||||
AudioCodec: audioFormat,
|
||||
|
@ -619,7 +587,6 @@ define(['browser'], function (browser) {
|
|||
|
||||
if (canPlayHls() && browser.enableHlsAudio !== false) {
|
||||
profile.TranscodingProfiles.push({
|
||||
|
||||
// hlsjs, edge, and android all seem to require ts container
|
||||
Container: !canPlayNativeHls() || browser.edge || browser.android ? 'ts' : 'aac',
|
||||
Type: 'Audio',
|
||||
|
@ -636,7 +603,6 @@ define(['browser'], function (browser) {
|
|||
// But for static (offline sync), it will be just fine.
|
||||
// Prioritize aac higher because the encoder can accept more channels than mp3
|
||||
['aac', 'mp3', 'opus', 'wav'].filter(canPlayAudioFormat).forEach(function (audioFormat) {
|
||||
|
||||
profile.TranscodingProfiles.push({
|
||||
Container: audioFormat,
|
||||
Type: 'Audio',
|
||||
|
@ -648,7 +614,6 @@ define(['browser'], function (browser) {
|
|||
});
|
||||
|
||||
['opus', 'mp3', 'aac', 'wav'].filter(canPlayAudioFormat).forEach(function (audioFormat) {
|
||||
|
||||
profile.TranscodingProfiles.push({
|
||||
Container: audioFormat,
|
||||
Type: 'Audio',
|
||||
|
@ -804,7 +769,8 @@ define(['browser'], function (browser) {
|
|||
Condition: 'LessThanEqual',
|
||||
Property: 'VideoLevel',
|
||||
Value: maxH264Level.toString()
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!browser.edgeUwp && !browser.tizen && !browser.orsay && !browser.web0s) {
|
||||
|
@ -888,7 +854,6 @@ define(['browser'], function (browser) {
|
|||
// External vtt or burn in
|
||||
profile.SubtitleProfiles = [];
|
||||
if (supportsTextTracks()) {
|
||||
|
||||
profile.SubtitleProfiles.push({
|
||||
Format: 'vtt',
|
||||
Method: 'External'
|
||||
|
@ -896,7 +861,6 @@ define(['browser'], function (browser) {
|
|||
}
|
||||
|
||||
profile.ResponseProfiles = [];
|
||||
|
||||
profile.ResponseProfiles.push({
|
||||
Type: 'Video',
|
||||
Container: 'm4v',
|
||||
|
|
|
@ -108,7 +108,9 @@ define(["dom", "layoutManager", "inputManager", "connectionManager", "events", "
|
|||
headerCastButton.addEventListener("click", onCastButtonClicked);
|
||||
}
|
||||
|
||||
initHeadRoom(skinHeader);
|
||||
if (layoutManager.mobile) {
|
||||
initHeadRoom(skinHeader);
|
||||
}
|
||||
}
|
||||
|
||||
function onCastButtonClicked() {
|
||||
|
@ -424,7 +426,7 @@ define(["dom", "layoutManager", "inputManager", "connectionManager", "events", "
|
|||
return getToolsMenuHtml(apiClient).then(function (toolsMenuHtml) {
|
||||
var html = "";
|
||||
html += '<a class="adminDrawerLogo clearLink" is="emby-linkbutton" href="home.html">';
|
||||
html += '<img src="assets/img/logo.png" />';
|
||||
html += '<img src="assets/img/icon-transparent.png" />';
|
||||
html += "</a>";
|
||||
html += toolsMenuHtml;
|
||||
navDrawerScrollContainer.innerHTML = html;
|
||||
|
|
|
@ -16,44 +16,72 @@ define([
|
|||
|
||||
function defineRoute(newRoute) {
|
||||
var path = newRoute.path;
|
||||
console.log("Defining route: " + path);
|
||||
console.log("defining route: " + path);
|
||||
newRoute.dictionary = "core";
|
||||
Emby.Page.addRoute(path, newRoute);
|
||||
}
|
||||
|
||||
console.log("Defining core routes");
|
||||
console.log("defining core routes");
|
||||
|
||||
defineRoute({
|
||||
path: "/addplugin.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "addpluginpage"
|
||||
controller: "dashboard/plugins/add"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/autoorganizelog.html",
|
||||
roles: "admin"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/channelsettings.html",
|
||||
path: "/mypreferencesmenu.html",
|
||||
autoFocus: false,
|
||||
roles: "admin"
|
||||
transition: "fade",
|
||||
controller: "user/menu"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/myprofile.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/profile"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/addserver.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
startup: true,
|
||||
controller: "addserver"
|
||||
controller: "auth/addserver"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencesdisplay.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/display"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferenceshome.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/home"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencesplayback.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/playback"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencessubtitles.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/subtitles"
|
||||
});
|
||||
|
||||
defineRoute({
|
||||
path: "/dashboard.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "dashboardpage"
|
||||
controller: "dashboard/dashboard"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/dashboardgeneral.html",
|
||||
controller: "dashboardgeneral",
|
||||
controller: "dashboard/general",
|
||||
autoFocus: false,
|
||||
roles: "admin"
|
||||
});
|
||||
|
@ -61,7 +89,7 @@ define([
|
|||
path: "/networking.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "networking"
|
||||
controller: "dashboard/networking"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/devices.html",
|
||||
|
@ -108,14 +136,14 @@ define([
|
|||
path: "/forgotpassword.html",
|
||||
anonymous: true,
|
||||
startup: true,
|
||||
controller: "forgotpassword"
|
||||
controller: "auth/forgotpassword"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/forgotpasswordpin.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
startup: true,
|
||||
controller: "forgotpasswordpin"
|
||||
controller: "auth/forgotpasswordpin"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/home.html",
|
||||
|
@ -191,19 +219,14 @@ define([
|
|||
defineRoute({
|
||||
path: "/log.html",
|
||||
roles: "admin",
|
||||
controller: "logpage"
|
||||
controller: "dashboard/logs"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/login.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
startup: true,
|
||||
controller: "loginpage"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/metadataadvanced.html",
|
||||
autoFocus: false,
|
||||
roles: "admin"
|
||||
controller: "auth/login"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/metadataimages.html",
|
||||
|
@ -229,57 +252,21 @@ define([
|
|||
autoFocus: false,
|
||||
transition: "fade"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencesmenu.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/menu"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/myprofile.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/profile"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencesdisplay.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/display"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferenceshome.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/home"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencesplayback.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/playback"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/mypreferencessubtitles.html",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
controller: "user/subtitles"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/notificationsetting.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "notificationsetting"
|
||||
controller: "dashboard/notifications/notification"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/notificationsettings.html",
|
||||
controller: "notificationsettings",
|
||||
controller: "dashboard/notifications/notifications",
|
||||
autoFocus: false,
|
||||
roles: "admin"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/nowplaying.html",
|
||||
controller: "nowplayingpage",
|
||||
controller: "playback/nowplaying",
|
||||
autoFocus: false,
|
||||
transition: "fade",
|
||||
fullscreen: true,
|
||||
|
@ -296,25 +283,25 @@ define([
|
|||
path: "/availableplugins.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "availableplugins"
|
||||
controller: "dashboard/plugins/available"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/installedplugins.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "installedplugins"
|
||||
controller: "dashboard/plugins/installed"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/scheduledtask.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "scheduledtaskpage"
|
||||
controller: "dashboard/scheduledtasks/scheduledtask"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/scheduledtasks.html",
|
||||
autoFocus: false,
|
||||
roles: "admin",
|
||||
controller: "scheduledtaskspage"
|
||||
controller: "dashboard/scheduledtasks/scheduledtasks"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/search.html",
|
||||
|
@ -325,7 +312,7 @@ define([
|
|||
autoFocus: false,
|
||||
anonymous: true,
|
||||
startup: true,
|
||||
controller: "selectserver"
|
||||
controller: "auth/selectserver"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/serveractivity.html",
|
||||
|
@ -345,11 +332,6 @@ define([
|
|||
roles: "admin",
|
||||
controller: "streamingsettings"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/support.html",
|
||||
autoFocus: false,
|
||||
roles: "admin"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/tv.html",
|
||||
autoFocus: false,
|
||||
|
@ -391,17 +373,18 @@ define([
|
|||
roles: "admin",
|
||||
controller: "userprofilespage"
|
||||
});
|
||||
|
||||
defineRoute({
|
||||
path: "/wizardremoteaccess.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
controller: "wizardremoteaccess"
|
||||
controller: "wizard/remoteaccess"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/wizardfinish.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
controller: "wizardfinishpage"
|
||||
controller: "wizard/finish"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/wizardlibrary.html",
|
||||
|
@ -413,24 +396,25 @@ define([
|
|||
path: "/wizardsettings.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
controller: "wizardsettings"
|
||||
controller: "wizard/settings"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/wizardstart.html",
|
||||
autoFocus: false,
|
||||
anonymous: true,
|
||||
controller: "wizardstart"
|
||||
controller: "wizard/start"
|
||||
});
|
||||
defineRoute({
|
||||
path: "/wizarduser.html",
|
||||
controller: "wizarduserpage",
|
||||
controller: "wizard/user",
|
||||
autoFocus: false,
|
||||
anonymous: true
|
||||
});
|
||||
|
||||
defineRoute({
|
||||
path: "/videoosd.html",
|
||||
transition: "fade",
|
||||
controller: "videoosd",
|
||||
controller: "playback/videoosd",
|
||||
autoFocus: false,
|
||||
type: "video-osd",
|
||||
supportsThemeMedia: true,
|
||||
|
@ -444,6 +428,7 @@ define([
|
|||
enableContentQueryString: true,
|
||||
roles: "admin"
|
||||
});
|
||||
|
||||
defineRoute({
|
||||
path: "/",
|
||||
isDefaultRoute: true,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue