1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge remote-tracking branch 'upstream/master' into blurhash

This commit is contained in:
ferferga 2020-05-30 14:26:11 +02:00
commit 2a0f971e76
53 changed files with 3192 additions and 616 deletions

View file

@ -35,6 +35,7 @@
- [Thibault Nocchi](https://github.com/ThibaultNocchi) - [Thibault Nocchi](https://github.com/ThibaultNocchi)
- [MrTimscampi](https://github.com/MrTimscampi) - [MrTimscampi](https://github.com/MrTimscampi)
- [Sarab Singh](https://github.com/sarab97) - [Sarab Singh](https://github.com/sarab97)
- [Andrei Oanca](https://github.com/OancaAndrei)
# Emby Contributors # Emby Contributors

View file

@ -44,7 +44,7 @@ Jellyfin Web is the frontend used for most of the clients available for end user
### Dependencies ### Dependencies
- Yarn - [Yarn 1.22.4](https://classic.yarnpkg.com/en/docs/install)
- Gulp-cli - Gulp-cli
### Getting Started ### Getting Started
@ -78,4 +78,4 @@ Jellyfin Web is the frontend used for most of the clients available for end user
```sh ```sh
yarn build:standalone yarn build:standalone
``` ```

View file

@ -45,7 +45,7 @@ const options = {
query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg'] query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg']
}, },
copy: { copy: {
query: ['src/**/*.json', 'src/**/*.ico'] query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.mp3']
}, },
injectBundle: { injectBundle: {
query: 'src/index.html' query: 'src/index.html'

View file

@ -60,13 +60,14 @@
"core-js": "^3.6.5", "core-js": "^3.6.5",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"document-register-element": "^1.14.3", "document-register-element": "^1.14.3",
"epubjs": "^0.3.85",
"fast-text-encoding": "^1.0.1", "fast-text-encoding": "^1.0.1",
"flv.js": "^1.5.0", "flv.js": "^1.5.0",
"headroom.js": "^0.11.0", "headroom.js": "^0.11.0",
"hls.js": "^0.13.1", "hls.js": "^0.13.1",
"howler": "^2.2.0", "howler": "^2.2.0",
"intersection-observer": "^0.10.0", "intersection-observer": "^0.10.0",
"jellyfin-apiclient": "^1.1.1", "jellyfin-apiclient": "^1.2.0",
"jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto", "jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"jstree": "^3.3.7", "jstree": "^3.3.7",
@ -98,6 +99,12 @@
"src/components/playback/mediasession.js", "src/components/playback/mediasession.js",
"src/components/sanatizefilename.js", "src/components/sanatizefilename.js",
"src/components/scrollManager.js", "src/components/scrollManager.js",
"src/components/bookPlayer/plugin.js",
"src/components/bookPlayer/tableOfContent.js",
"src/components/syncplay/playbackPermissionManager.js",
"src/components/syncplay/groupSelectionMenu.js",
"src/components/syncplay/timeSyncManager.js",
"src/components/syncplay/syncPlayManager.js",
"src/scripts/dfnshelper.js", "src/scripts/dfnshelper.js",
"src/scripts/dom.js", "src/scripts/dom.js",
"src/scripts/filesystem.js", "src/scripts/filesystem.js",
@ -107,6 +114,7 @@
"src/components/actionSheet/actionSheet.js", "src/components/actionSheet/actionSheet.js",
"src/components/playmenu.js", "src/components/playmenu.js",
"src/components/indicators/indicators.js", "src/components/indicators/indicators.js",
"src/components/photoPlayer/plugin.js",
"src/scripts/keyboardNavigation.js", "src/scripts/keyboardNavigation.js",
"src/scripts/settings/appSettings.js", "src/scripts/settings/appSettings.js",
"src/scripts/settings/userSettings.js", "src/scripts/settings/userSettings.js",

Binary file not shown.

View file

@ -30,7 +30,7 @@
opacity: 0; opacity: 0;
} }
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) { .osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) {
display: none; display: none;
} }

View file

@ -108,6 +108,11 @@ _define('jellyfin-noto', function () {
return noto; return noto;
}); });
var epubjs = require('epubjs');
_define('epubjs', function () {
return epubjs;
});
// page.js // page.js
var page = require('page'); var page = require('page');
_define('page', function() { _define('page', function() {

View file

@ -0,0 +1,288 @@
import connectionManager from 'connectionManager';
import loading from 'loading';
import keyboardnavigation from 'keyboardnavigation';
import dialogHelper from 'dialogHelper';
import events from 'events';
import 'css!./style';
import 'material-icons';
import 'paper-icon-button-light';
import TableOfContent from './tableOfContent';
export class BookPlayer {
constructor() {
this.name = 'Book Player';
this.type = 'mediaplayer';
this.id = 'bookplayer';
this.priority = 1;
this.onDialogClosed = this.onDialogClosed.bind(this);
this.openTableOfContents = this.openTableOfContents.bind(this);
this.onWindowKeyUp = this.onWindowKeyUp.bind(this);
}
play(options) {
this._progress = 0;
this._loaded = false;
loading.show();
let elem = this.createMediaElement();
return this.setCurrentSrc(elem, options);
}
stop() {
this.unbindEvents();
let elem = this._mediaElement;
let tocElement = this._tocElement;
let rendition = this._rendition;
if (elem) {
dialogHelper.close(elem);
this._mediaElement = null;
}
if (tocElement) {
tocElement.destroy();
this._tocElement = null;
}
if (rendition) {
rendition.destroy();
}
// Hide loader in case player was not fully loaded yet
loading.hide();
this._cancellationToken.shouldCancel = true;
}
currentItem() {
return this._currentItem;
}
currentTime() {
return this._progress * 1000;
}
duration() {
return 1000;
}
getBufferedRanges() {
return [{
start: 0,
end: 10000000
}];
}
volume() {
return 100;
}
isMuted() {
return false;
}
paused() {
return false;
}
seekable() {
return true;
}
onWindowKeyUp(e) {
let key = keyboardnavigation.getKeyName(e);
let rendition = this._rendition;
let book = rendition.book;
switch (key) {
case 'l':
case 'ArrowRight':
case 'Right':
if (this._loaded) {
book.package.metadata.direction === 'rtl' ? rendition.prev() : rendition.next();
}
break;
case 'j':
case 'ArrowLeft':
case 'Left':
if (this._loaded) {
book.package.metadata.direction === 'rtl' ? rendition.next() : rendition.prev();
}
break;
case 'Escape':
if (this._tocElement) {
// Close table of contents on ESC if it is open
this._tocElement.destroy();
} else {
// Otherwise stop the entire book player
this.stop();
}
break;
}
}
onDialogClosed() {
this.stop();
}
bindMediaElementEvents() {
let elem = this._mediaElement;
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerExit').addEventListener('click', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerToc').addEventListener('click', this.openTableOfContents);
}
bindEvents() {
this.bindMediaElementEvents();
document.addEventListener('keyup', this.onWindowKeyUp);
// FIXME: I don't really get why document keyup event is not triggered when epub is in focus
this._rendition.on('keyup', this.onWindowKeyUp);
}
unbindMediaElementEvents() {
let elem = this._mediaElement;
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('.btnBookplayerExit').removeEventListener('click', this.onDialogClosed);
elem.querySelector('.btnBookplayerToc').removeEventListener('click', this.openTableOfContents);
}
unbindEvents() {
if (this._mediaElement) {
this.unbindMediaElementEvents();
}
document.removeEventListener('keyup', this.onWindowKeyUp);
if (this._rendition) {
this._rendition.off('keyup', this.onWindowKeyUp);
}
}
openTableOfContents() {
if (this._loaded) {
this._tocElement = new TableOfContent(this);
}
}
createMediaElement() {
let elem = this._mediaElement;
if (elem) {
return elem;
}
elem = document.getElementById('bookPlayer');
if (!elem) {
elem = dialogHelper.createDialog({
exitAnimationDuration: 400,
size: 'fullscreen',
autoFocus: false,
scrollY: false,
exitAnimation: 'fadeout',
removeOnClose: true
});
elem.id = 'bookPlayer';
let html = '';
html += '<div class="topRightActionButtons">';
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerExit hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon close"></i></button>';
html += '</div>';
html += '<div class="topLeftActionButtons">';
html += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerToc hide-mouse-idle-tv" tabindex="-1"><i class="material-icons bookplayerButtonIcon toc"></i></button>';
html += '</div>';
elem.innerHTML = html;
dialogHelper.open(elem);
}
this._mediaElement = elem;
return elem;
}
setCurrentSrc(elem, options) {
let item = options.items[0];
this._currentItem = item;
this.streamInfo = {
started: true,
ended: false,
mediaSource: {
Id: item.Id
}
};
if (!item.Path.endsWith('.epub')) {
return new Promise((resolve, reject) => {
let errorDialog = dialogHelper.createDialog({
size: 'small',
autoFocus: false,
removeOnClose: true
});
errorDialog.innerHTML = '<h1 class="bookplayerErrorMsg">This book type is not supported yet</h1>';
this.stop();
dialogHelper.open(errorDialog);
loading.hide();
return resolve();
});
}
let serverId = item.ServerId;
let apiClient = connectionManager.getApiClient(serverId);
return new Promise((resolve, reject) => {
require(['epubjs'], (epubjs) => {
let downloadHref = apiClient.getItemDownloadUrl(item.Id);
let book = epubjs.default(downloadHref, {openAs: 'epub'});
let rendition = book.renderTo(elem, {width: '100%', height: '97%'});
this._currentSrc = downloadHref;
this._rendition = rendition;
let cancellationToken = {
shouldCancel: false
};
this._cancellationToken = cancellationToken;
return rendition.display().then(() => {
let epubElem = document.querySelector('.epub-container');
epubElem.style.display = 'none';
this.bindEvents();
return this._rendition.book.locations.generate(1024).then(() => {
if (cancellationToken.shouldCancel) {
return reject();
}
this._loaded = true;
epubElem.style.display = 'block';
rendition.on('relocated', (locations) => {
this._progress = book.locations.percentageFromCfi(locations.start.cfi);
events.trigger(this, 'timeupdate');
});
loading.hide();
return resolve();
});
}, () => {
console.error('Failed to display epub');
return reject();
});
});
});
}
canPlayMediaType(mediaType) {
return (mediaType || '').toLowerCase() === 'book';
}
}
export default BookPlayer;

View file

@ -0,0 +1,39 @@
#bookPlayer {
position: relative;
height: 100%;
width: 100%;
overflow: auto;
z-index: 100;
background: #fff;
}
.topRightActionButtons {
right: 0.5vh;
top: 0.5vh;
z-index: 1002;
position: absolute;
}
.topLeftActionButtons {
left: 0.5vh;
top: 0.5vh;
z-index: 1002;
position: absolute;
}
.bookplayerButtonIcon {
color: black;
opacity: 0.7;
}
#dialogToc {
background-color: white;
}
.toc li {
margin-bottom: 5px;
}
.bookplayerErrorMsg {
text-align: center;
}

View file

@ -0,0 +1,90 @@
import dialogHelper from 'dialogHelper';
export default class TableOfContent {
constructor(bookPlayer) {
this._bookPlayer = bookPlayer;
this._rendition = bookPlayer._rendition;
this.onDialogClosed = this.onDialogClosed.bind(this);
this.createMediaElement();
}
destroy() {
let elem = this._elem;
if (elem) {
this.unbindEvents();
dialogHelper.close(elem);
}
this._bookPlayer._tocElement = null;
}
bindEvents() {
let elem = this._elem;
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('.btnBookplayerTocClose').addEventListener('click', this.onDialogClosed, {once: true});
}
unbindEvents() {
let elem = this._elem;
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('.btnBookplayerTocClose').removeEventListener('click', this.onDialogClosed);
}
onDialogClosed() {
this.destroy();
}
replaceLinks(contents, f) {
let links = contents.querySelectorAll('a[href]');
links.forEach((link) => {
let href = link.getAttribute('href');
link.onclick = () => {
f(href);
return false;
};
});
}
createMediaElement() {
let rendition = this._rendition;
let elem = dialogHelper.createDialog({
size: 'small',
autoFocus: false,
removeOnClose: true
});
elem.id = 'dialogToc';
let tocHtml = '<div class="topRightActionButtons">';
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close"></span></button>';
tocHtml += '</div>';
tocHtml += '<ul class="toc">';
rendition.book.navigation.forEach((chapter) => {
tocHtml += '<li>';
// Remove '../' from href
let link = chapter.href.startsWith('../') ? chapter.href.substr(3) : chapter.href;
tocHtml += `<a href="${rendition.book.path.directory + link}">${chapter.label}</a>`;
tocHtml += '</li>';
});
tocHtml += '</ul>';
elem.innerHTML = tocHtml;
this.replaceLinks(elem, (href) => {
let relative = rendition.book.path.relative(href);
rendition.display(relative);
this.destroy();
});
this._elem = elem;
this.bindEvents();
dialogHelper.open(elem);
}
}

View file

@ -182,6 +182,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos(); context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos();
context.querySelector('#chkFadein').checked = userSettings.enableFastFadein(); context.querySelector('#chkFadein').checked = userSettings.enableFastFadein();
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops(); context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
context.querySelector('#selectLanguage').value = userSettings.language() || ''; context.querySelector('#selectLanguage').value = userSettings.language() || '';
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || ''; context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
@ -223,6 +224,7 @@ define(['require', 'browser', 'layoutManager', 'appSettings', 'pluginManager', '
userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked); userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked);
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked); userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
if (user.Id === apiClient.getCurrentUserId()) { if (user.Id === apiClient.getCurrentUserId()) {
skinManager.setTheme(userSettingsInstance.theme()); skinManager.setTheme(userSettingsInstance.theme());

View file

@ -163,6 +163,13 @@
</label> </label>
<div class="fieldDescription checkboxFieldDescription">${EnableBlurhashHelp}</div> <div class="fieldDescription checkboxFieldDescription">${EnableBlurhashHelp}</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription fldDetailsBanner">
<label>
<input type="checkbox" is="emby-checkbox" id="chkDetailsBanner" />
<span>${EnableDetailsBanner}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${EnableDetailsBannerHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldBackdrops hide"> <div class="checkboxContainer checkboxContainer-withDescription fldBackdrops hide">
<label> <label>

View file

@ -37,18 +37,19 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
var list = []; var list = [];
if (type === 'movies') { if (type === 'movies') {
list.push({ list.push({
name: globalize.translate('Movies'), name: globalize.translate('Movies'),
value: 'movies', value: 'movies',
isDefault: true isDefault: true
}); });
list.push({ list.push({
name: globalize.translate('Suggestions'), name: globalize.translate('Suggestions'),
value: 'suggestions' value: 'suggestions'
}); });
list.push({
name: globalize.translate('Genres'),
value: 'genres'
});
list.push({ list.push({
name: globalize.translate('Favorites'), name: globalize.translate('Favorites'),
value: 'favorites' value: 'favorites'
@ -58,7 +59,6 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
value: 'collections' value: 'collections'
}); });
} else if (type === 'tvshows') { } else if (type === 'tvshows') {
list.push({ list.push({
name: globalize.translate('Shows'), name: globalize.translate('Shows'),
value: 'shows', value: 'shows',
@ -68,49 +68,45 @@ define(['require', 'apphost', 'layoutManager', 'focusManager', 'globalize', 'loa
name: globalize.translate('Suggestions'), name: globalize.translate('Suggestions'),
value: 'suggestions' value: 'suggestions'
}); });
list.push({ list.push({
name: globalize.translate('Latest'), name: globalize.translate('Latest'),
value: 'latest' value: 'latest'
}); });
list.push({
name: globalize.translate('Genres'),
value: 'genres'
});
list.push({ list.push({
name: globalize.translate('Favorites'), name: globalize.translate('Favorites'),
value: 'favorites' value: 'favorites'
}); });
} else if (type === 'music') { } else if (type === 'music') {
list.push({ list.push({
name: globalize.translate('Suggestions'), name: globalize.translate('Suggestions'),
value: 'suggestions', value: 'suggestions',
isDefault: true isDefault: true
}); });
list.push({ list.push({
name: globalize.translate('Albums'), name: globalize.translate('Albums'),
value: 'albums' value: 'albums'
}); });
list.push({ list.push({
name: globalize.translate('HeaderAlbumArtists'), name: globalize.translate('HeaderAlbumArtists'),
value: 'albumartists' value: 'albumartists'
}); });
list.push({ list.push({
name: globalize.translate('Artists'), name: globalize.translate('Artists'),
value: 'artists' value: 'artists'
}); });
list.push({ list.push({
name: globalize.translate('Playlists'), name: globalize.translate('Playlists'),
value: 'playlists' value: 'playlists'
}); });
list.push({ list.push({
name: globalize.translate('Genres'), name: globalize.translate('Genres'),
value: 'genres' value: 'genres'
}); });
} else if (type === 'livetv') { } else if (type === 'livetv') {
list.push({ list.push({
name: globalize.translate('Suggestions'), name: globalize.translate('Suggestions'),
value: 'suggestions', value: 'suggestions',

View file

@ -177,6 +177,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.addEventListener('pause', onPause); elem.addEventListener('pause', onPause);
elem.addEventListener('playing', onPlaying); elem.addEventListener('playing', onPlaying);
elem.addEventListener('play', onPlay); elem.addEventListener('play', onPlay);
elem.addEventListener('waiting', onWaiting);
} }
function unBindEvents(elem) { function unBindEvents(elem) {
@ -186,6 +187,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.removeEventListener('pause', onPause); elem.removeEventListener('pause', onPause);
elem.removeEventListener('playing', onPlaying); elem.removeEventListener('playing', onPlaying);
elem.removeEventListener('play', onPlay); elem.removeEventListener('play', onPlay);
elem.removeEventListener('waiting', onWaiting);
} }
self.stop = function (destroyPlayer) { self.stop = function (destroyPlayer) {
@ -300,6 +302,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
events.trigger(self, 'pause'); events.trigger(self, 'pause');
} }
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() { function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0; var errorCode = this.error ? (this.error.code || 0) : 0;
@ -456,6 +462,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
return false; return false;
}; };
HtmlAudioPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlAudioPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlAudioPlayer.prototype.setVolume = function (val) { HtmlAudioPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement; var mediaElement = this._mediaElement;
if (mediaElement) { if (mediaElement) {
@ -499,5 +520,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
}; };
var supportedFeatures;
function getSupportedFeatures() {
var list = [];
var audio = document.createElement('audio');
if (typeof audio.playbackRate === 'number') {
list.push('PlaybackRate');
}
return list;
}
HtmlAudioPlayer.prototype.supports = function (feature) {
if (!supportedFeatures) {
supportedFeatures = getSupportedFeatures();
}
return supportedFeatures.indexOf(feature) !== -1;
};
return HtmlAudioPlayer; return HtmlAudioPlayer;
}); });

View file

@ -799,6 +799,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.removeEventListener('play', onPlay); videoElement.removeEventListener('play', onPlay);
videoElement.removeEventListener('click', onClick); videoElement.removeEventListener('click', onClick);
videoElement.removeEventListener('dblclick', onDblClick); videoElement.removeEventListener('dblclick', onDblClick);
videoElement.removeEventListener('waiting', onWaiting);
videoElement.parentNode.removeChild(videoElement); videoElement.parentNode.removeChild(videoElement);
} }
@ -927,6 +928,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
events.trigger(self, 'pause'); events.trigger(self, 'pause');
} }
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() { function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0; var errorCode = this.error ? (this.error.code || 0) : 0;
var errorMessage = this.error ? (this.error.message || '') : ''; var errorMessage = this.error ? (this.error.message || '') : '';
@ -1349,6 +1354,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.addEventListener('play', onPlay); videoElement.addEventListener('play', onPlay);
videoElement.addEventListener('click', onClick); videoElement.addEventListener('click', onClick);
videoElement.addEventListener('dblclick', onDblClick); videoElement.addEventListener('dblclick', onDblClick);
videoElement.addEventListener('waiting', onWaiting);
if (options.backdropUrl) { if (options.backdropUrl) {
videoElement.poster = options.backdropUrl; videoElement.poster = options.backdropUrl;
} }
@ -1436,6 +1442,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
list.push('AirPlay'); list.push('AirPlay');
} }
if (typeof video.playbackRate === 'number') {
list.push('PlaybackRate');
}
list.push('SetBrightness'); list.push('SetBrightness');
list.push('SetAspectRatio'); list.push('SetAspectRatio');
@ -1656,6 +1666,21 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
return false; return false;
}; };
HtmlVideoPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlVideoPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlVideoPlayer.prototype.setVolume = function (val) { HtmlVideoPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement; var mediaElement = this._mediaElement;
if (mediaElement) { if (mediaElement) {

View file

@ -203,9 +203,9 @@ define(['dom', 'loading', 'apphost', 'dialogHelper', 'connectionManager', 'image
html += '<div class="cardContent">'; html += '<div class="cardContent">';
if (layoutManager.tv || !appHost.supports('externallinks')) { if (layoutManager.tv || !appHost.supports('externallinks')) {
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></div>'; html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain;"></div>';
} else { } else {
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center bottom;"></a>'; html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain"></a>';
} }
html += '</div>'; html += '</div>';

View file

@ -132,7 +132,7 @@ define(['dialogHelper', 'connectionManager', 'loading', 'dom', 'layoutManager',
var imageUrl = getImageUrl(currentItem, apiClient, image.ImageType, image.ImageIndex, { maxWidth: imageSize }); var imageUrl = getImageUrl(currentItem, apiClient, image.ImageType, image.ImageIndex, { maxWidth: imageSize });
html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center bottom;"></div>'; html += '<div class="cardImageContainer" style="background-image:url(\'' + imageUrl + '\');background-position:center center;background-size:contain;"></div>';
html += '</div>'; html += '</div>';
html += '</div>'; html += '</div>';

View file

@ -46,7 +46,7 @@ define(['serverNotifications', 'playbackManager', 'events', 'globalize', 'requir
function showNonPersistentNotification(title, options, timeoutMs) { function showNonPersistentNotification(title, options, timeoutMs) {
try { try {
var notif = new Notification(title, options); var notif = new Notification(title, options); /* eslint-disable-line compat/compat */
if (notif.show) { if (notif.show) {
notif.show(); notif.show();

View file

@ -1,23 +1,18 @@
define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackManager', 'appRouter', 'appSettings', 'connectionManager'], function (browser, require, events, appHost, loading, dom, playbackManager, appRouter, appSettings, connectionManager) { import connectionManager from 'connectionManager';
'use strict';
function PhotoPlayer() { export default class PhotoPlayer {
constructor() {
var self = this; this.name = 'Photo Player';
this.type = 'mediaplayer';
self.name = 'Photo Player'; this.id = 'photoplayer';
self.type = 'mediaplayer'; this.priority = 1;
self.id = 'photoplayer';
// Let any players created by plugins take priority
self.priority = 1;
} }
PhotoPlayer.prototype.play = function (options) { play(options) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
require(['slideshow'], function (slideshow) { import('slideshow').then(({default: slideshow}) => {
var index = options.startIndex || 0; var index = options.startIndex || 0;
@ -41,12 +36,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
}); });
}); });
}); });
}; }
PhotoPlayer.prototype.canPlayMediaType = function (mediaType) { canPlayMediaType(mediaType) {
return (mediaType || '').toLowerCase() === 'photo'; return (mediaType || '').toLowerCase() === 'photo';
}; }
}
return PhotoPlayer;
});

View file

@ -119,6 +119,7 @@ import connectionManager from 'connectionManager';
const canSeek = playState.CanSeek || false; const canSeek = playState.CanSeek || false;
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: title, title: title,
artist: artist, artist: artist,
@ -179,6 +180,7 @@ import connectionManager from 'connectionManager';
function hideMediaControls() { function hideMediaControls() {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.metadata = null; navigator.mediaSession.metadata = null;
} else { } else {
window.NativeShell.hideMediaSession(); window.NativeShell.hideMediaSession();
@ -210,26 +212,32 @@ import connectionManager from 'connectionManager';
} }
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('previoustrack', function () { navigator.mediaSession.setActionHandler('previoustrack', function () {
execute('previousTrack'); execute('previousTrack');
}); });
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('nexttrack', function () { navigator.mediaSession.setActionHandler('nexttrack', function () {
execute('nextTrack'); execute('nextTrack');
}); });
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('play', function () { navigator.mediaSession.setActionHandler('play', function () {
execute('unpause'); execute('unpause');
}); });
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('pause', function () { navigator.mediaSession.setActionHandler('pause', function () {
execute('pause'); execute('pause');
}); });
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('seekbackward', function () { navigator.mediaSession.setActionHandler('seekbackward', function () {
execute('rewind'); execute('rewind');
}); });
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler('seekforward', function () { navigator.mediaSession.setActionHandler('seekforward', function () {
execute('fastForward'); execute('fastForward');
}); });

View file

@ -54,6 +54,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
if (!serverId) { if (!serverId) {
// Not a server item // Not a server item
// We can expand on this later and possibly report them // We can expand on this later and possibly report them
events.trigger(playbackManagerInstance, 'reportplayback', [false]);
return; return;
} }
@ -77,7 +78,11 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
} }
var apiClient = connectionManager.getApiClient(serverId); var apiClient = connectionManager.getApiClient(serverId);
apiClient[method](info); var reportPlaybackPromise = apiClient[method](info);
// Notify that report has been sent
reportPlaybackPromise.then(() => {
events.trigger(playbackManagerInstance, 'reportplayback', [true]);
});
} }
function getPlaylistSync(playbackManagerInstance, player) { function getPlaylistSync(playbackManagerInstance, player) {
@ -2182,7 +2187,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
// Only used internally // Only used internally
self.getCurrentTicks = getCurrentTicks; self.getCurrentTicks = getCurrentTicks;
function playPhotos(items, options, user) { function playOther(items, options, user) {
var playStartIndex = options.startIndex || 0; var playStartIndex = options.startIndex || 0;
var player = getPlayer(items[playStartIndex], options); var player = getPlayer(items[playStartIndex], options);
@ -2211,9 +2216,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
return Promise.reject(); return Promise.reject();
} }
if (firstItem.MediaType === 'Photo') { if (firstItem.MediaType === 'Photo' || firstItem.MediaType === 'Book') {
return playPhotos(items, options, user); return playOther(items, options, user);
} }
var apiClient = connectionManager.getApiClient(firstItem.ServerId); var apiClient = connectionManager.getApiClient(firstItem.ServerId);
@ -3775,6 +3780,20 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
} }
}; };
PlaybackManager.prototype.setPlaybackRate = function (value, player = this._currentPlayer) {
if (player && player.setPlaybackRate) {
player.setPlaybackRate(value);
}
};
PlaybackManager.prototype.getPlaybackRate = function (player = this._currentPlayer) {
if (player && player.getPlaybackRate) {
return player.getPlaybackRate();
}
return null;
};
PlaybackManager.prototype.instantMix = function (item, player) { PlaybackManager.prototype.instantMix = function (item, player) {
player = player || this._currentPlayer; player = player || this._currentPlayer;
@ -3885,6 +3904,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
if (player.supports('SetAspectRatio')) { if (player.supports('SetAspectRatio')) {
list.push('SetAspectRatio'); list.push('SetAspectRatio');
} }
if (player.supports('PlaybackRate')) {
list.push('PlaybackRate');
}
} }
return list; return list;

View file

@ -1,4 +1,4 @@
define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, playMethodHelper, layoutManager, serverNotifications) { define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncPlayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncPlayManager, playMethodHelper, layoutManager, serverNotifications) {
'use strict'; 'use strict';
function init(instance) { function init(instance) {
@ -327,6 +327,28 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
return sessionStats; return sessionStats;
} }
function getSyncPlayStats() {
var syncStats = [];
var stats = syncPlayManager.getStats();
syncStats.push({
label: globalize.translate('LabelSyncPlayTimeOffset'),
value: stats.TimeOffset + globalize.translate('MillisecondsUnit')
});
syncStats.push({
label: globalize.translate('LabelSyncPlayPlaybackDiff'),
value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit')
});
syncStats.push({
label: globalize.translate('LabelSyncPlaySyncMethod'),
value: stats.SyncMethod
});
return syncStats;
}
function getStats(instance, player) { function getStats(instance, player) {
var statsPromise = player.getStats ? player.getStats() : Promise.resolve({}); var statsPromise = player.getStats ? player.getStats() : Promise.resolve({});
@ -383,6 +405,13 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
name: 'Original Media Info' name: 'Original Media Info'
}); });
if (syncPlayManager.isSyncPlayEnabled()) {
categories.push({
stats: getSyncPlayStats(),
name: 'SyncPlay Info'
});
}
return Promise.resolve(categories); return Promise.resolve(categories);
}); });
} }

View file

@ -58,7 +58,7 @@ define(['events', 'globalize'], function (events, globalize) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
require([pluginSpec], (pluginFactory) => { require([pluginSpec], (pluginFactory) => {
var plugin = new pluginFactory(); var plugin = pluginFactory.default ? new pluginFactory.default() : new pluginFactory();
// See if it's already installed // See if it's already installed
var existing = instance.pluginsList.filter(function (p) { var existing = instance.pluginsList.filter(function (p) {

View file

@ -0,0 +1,189 @@
import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import syncPlayManager from 'syncPlayManager';
import loading from 'loading';
import toast from 'toast';
import actionsheet from 'actionsheet';
import globalize from 'globalize';
import playbackPermissionManager from 'playbackPermissionManager';
/**
* Gets active player id.
* @returns {string} The player's id.
*/
function getActivePlayerId () {
var info = playbackManager.getPlayerInfo();
return info ? info.id : null;
}
/**
* Used when user needs to join a group.
* @param {HTMLElement} button - Element where to place the menu.
* @param {Object} user - Current user.
* @param {Object} apiClient - ApiClient.
*/
function showNewJoinGroupSelection (button, user, apiClient) {
const sessionId = getActivePlayerId() || 'none';
const inSession = sessionId !== 'none';
const policy = user.localUser ? user.localUser.Policy : {};
let playingItemId;
try {
const playState = playbackManager.getPlayerState();
playingItemId = playState.NowPlayingItem.Id;
console.debug('Item', playingItemId, 'is currently playing.');
} catch (error) {
playingItemId = '';
console.debug('No item is currently playing.');
}
apiClient.sendSyncPlayCommand(sessionId, 'ListGroups').then(function (response) {
response.json().then(function (groups) {
var menuItems = groups.map(function (group) {
return {
name: group.PlayingItemName,
icon: 'group',
id: group.GroupId,
selected: false,
secondaryText: group.Participants.join(', ')
};
});
if (inSession && policy.SyncPlayAccess === 'CreateAndJoinGroups') {
menuItems.push({
name: globalize.translate('LabelSyncPlayNewGroup'),
icon: 'add',
id: 'new-group',
selected: true,
secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription')
});
}
if (menuItems.length === 0) {
if (inSession && policy.SyncPlayAccess === 'JoinGroups') {
toast({
text: globalize.translate('MessageSyncPlayCreateGroupDenied')
});
} else {
toast({
text: globalize.translate('MessageSyncPlayNoGroupsAvailable')
});
}
loading.hide();
return;
}
var menuOptions = {
title: globalize.translate('HeaderSyncPlaySelectGroup'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'new-group') {
apiClient.sendSyncPlayCommand(sessionId, 'NewGroup');
} else {
apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', {
GroupId: id,
PlayingItemId: playingItemId
});
}
}).catch((error) => {
console.error('SyncPlay: unexpected error listing groups:', error);
});
loading.hide();
});
}).catch(function (error) {
console.error(error);
loading.hide();
toast({
text: globalize.translate('MessageSyncPlayErrorAccessingGroups')
});
});
}
/**
* Used when user has joined a group.
* @param {HTMLElement} button - Element where to place the menu.
* @param {Object} user - Current user.
* @param {Object} apiClient - ApiClient.
*/
function showLeaveGroupSelection (button, user, apiClient) {
const sessionId = getActivePlayerId();
if (!sessionId) {
syncPlayManager.signalError();
toast({
text: globalize.translate('MessageSyncPlayErrorNoActivePlayer')
});
showNewJoinGroupSelection(button, user, apiClient);
return;
}
const menuItems = [{
name: globalize.translate('LabelSyncPlayLeaveGroup'),
icon: 'meeting_room',
id: 'leave-group',
selected: true,
secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription')
}];
var menuOptions = {
title: globalize.translate('HeaderSyncPlayEnabled'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == 'leave-group') {
apiClient.sendSyncPlayCommand(sessionId, 'LeaveGroup');
}
}).catch((error) => {
console.error('SyncPlay: unexpected error showing group menu:', error);
});
loading.hide();
}
// Register to SyncPlay events
let syncPlayEnabled = false;
events.on(syncPlayManager, 'enabled', function (e, enabled) {
syncPlayEnabled = enabled;
});
/**
* Shows a menu to handle SyncPlay groups.
* @param {HTMLElement} button - Element where to place the menu.
*/
export function show (button) {
loading.show();
// TODO: should feature be disabled if playback permission is missing?
playbackPermissionManager.check().then(() => {
console.debug('Playback is allowed.');
}).catch((error) => {
console.error('Playback not allowed!', error);
toast({
text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired')
});
});
const apiClient = connectionManager.currentApiClient();
connectionManager.user(apiClient).then((user) => {
if (syncPlayEnabled) {
showLeaveGroupSelection(button, user, apiClient);
} else {
showNewJoinGroupSelection(button, user, apiClient);
}
}).catch((error) => {
console.error(error);
loading.hide();
toast({
text: globalize.translate('MessageSyncPlayNoGroupsAvailable')
});
});
}

View file

@ -0,0 +1,51 @@
/**
* Creates an audio element that plays a silent sound.
* @returns {HTMLMediaElement} The audio element.
*/
function createTestMediaElement () {
const elem = document.createElement('audio');
elem.classList.add('testMediaPlayerAudio');
elem.classList.add('hide');
document.body.appendChild(elem);
elem.volume = 1; // Volume should not be zero to trigger proper permissions
elem.src = 'assets/audio/silence.mp3'; // Silent sound
return elem;
}
/**
* Destroys a media element.
* @param {HTMLMediaElement} elem The element to destroy.
*/
function destroyTestMediaElement (elem) {
elem.pause();
elem.remove();
}
/**
* Class that manages the playback permission.
*/
class PlaybackPermissionManager {
/**
* Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction).
* @returns {Promise} Promise that resolves succesfully if playback permission is allowed.
*/
check () {
return new Promise((resolve, reject) => {
const media = createTestMediaElement();
media.play().then(() => {
resolve();
}).catch((error) => {
reject(error);
}).finally(() => {
destroyTestMediaElement(media);
});
});
}
}
/** PlaybackPermissionManager singleton. */
export default new PlaybackPermissionManager();

View file

@ -0,0 +1,839 @@
/**
* Module that manages the SyncPlay feature.
* @module components/syncplay/syncPlayManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import timeSyncManager from 'timeSyncManager';
import toast from 'toast';
import globalize from 'globalize';
/**
* Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected.
* @param {Object} emitter Object on which to listen for events.
* @param {string} eventType Event name to listen for.
* @param {number} timeout Time in milliseconds before rejecting promise if event does not trigger.
* @returns {Promise} A promise that resolves when the event is triggered.
*/
function waitForEventOnce(emitter, eventType, timeout) {
return new Promise((resolve, reject) => {
let rejectTimeout;
if (timeout) {
rejectTimeout = setTimeout(() => {
reject('Timed out.');
}, timeout);
}
const callback = () => {
events.off(emitter, eventType, callback);
if (rejectTimeout) {
clearTimeout(rejectTimeout);
}
resolve(arguments);
};
events.on(emitter, eventType, callback);
});
}
/**
* Gets active player id.
* @returns {string} The player's id.
*/
function getActivePlayerId() {
var info = playbackManager.getPlayerInfo();
return info ? info.id : null;
}
/**
* Playback synchronization
*/
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
const SpeedToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync
const MaxAttemptsSync = 5; // attempts before disabling syncing at all
/**
* Other constants
*/
const WaitForEventDefaultTimeout = 30000; // milliseconds
const WaitForPlayerEventTimeout = 500; // milliseconds
/**
* Class that manages the SyncPlay feature.
*/
class SyncPlayManager {
constructor() {
this.playbackRateSupported = false;
this.syncEnabled = false;
this.playbackDiffMillis = 0; // used for stats
this.syncMethod = 'None'; // used for stats
this.syncAttempts = 0;
this.lastSyncTime = new Date();
this.syncWatcherTimeout = null; // interval that watches playback time and syncs it
this.lastPlaybackWaiting = null; // used to determine if player's buffering
this.minBufferingThresholdMillis = 1000;
this.currentPlayer = null;
this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate
this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled
this.syncPlayReady = false; // SyncPlay is ready after first ping to server
this.lastCommand = null;
this.queuedCommand = null;
this.scheduledCommand = null;
this.syncTimeout = null;
this.timeOffsetWithServer = 0; // server time minus local time
this.roundTripDuration = 0;
this.notifySyncPlayReady = false;
events.on(playbackManager, 'playbackstart', (player, state) => {
this.onPlaybackStart(player, state);
});
events.on(playbackManager, 'playbackstop', (stopInfo) => {
this.onPlaybackStop(stopInfo);
});
events.on(playbackManager, 'playerchange', () => {
this.onPlayerChange();
});
this.bindToPlayer(playbackManager.getCurrentPlayer());
events.on(this, 'timeupdate', (event) => {
this.syncPlaybackTime();
});
events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => {
if (error) {
console.debug('SyncPlay, time update issue', error);
return;
}
this.timeOffsetWithServer = timeOffset;
this.roundTripDuration = ping * 2;
if (this.notifySyncPlayReady) {
this.syncPlayReady = true;
events.trigger(this, 'ready');
this.notifySyncPlayReady = false;
}
// Report ping
if (this.syncEnabled) {
const apiClient = connectionManager.currentApiClient();
const sessionId = getActivePlayerId();
if (!sessionId) {
this.signalError();
toast({
text: globalize.translate('MessageSyncPlayErrorMissingSession')
});
return;
}
apiClient.sendSyncPlayCommand(sessionId, 'UpdatePing', {
Ping: ping
});
}
});
}
/**
* Called when playback starts.
*/
onPlaybackStart (player, state) {
events.trigger(this, 'playbackstart', [player, state]);
}
/**
* Called when playback stops.
*/
onPlaybackStop (stopInfo) {
events.trigger(this, 'playbackstop', [stopInfo]);
if (this.isSyncPlayEnabled()) {
this.disableSyncPlay(false);
}
}
/**
* Called when the player changes.
*/
onPlayerChange () {
this.bindToPlayer(playbackManager.getCurrentPlayer());
events.trigger(this, 'playerchange', [this.currentPlayer]);
}
/**
* Called when playback unpauses.
*/
onPlayerUnpause () {
events.trigger(this, 'unpause', [this.currentPlayer]);
}
/**
* Called when playback pauses.
*/
onPlayerPause() {
events.trigger(this, 'pause', [this.currentPlayer]);
}
/**
* Called on playback progress.
* @param {Object} e The time update event.
*/
onTimeUpdate (e) {
// NOTICE: this event is unreliable, at least in Safari
// which just stops firing the event after a while.
events.trigger(this, 'timeupdate', [e]);
}
/**
* Called when playback is resumed.
*/
onPlaying () {
// TODO: implement group wait
this.lastPlaybackWaiting = null;
events.trigger(this, 'playing');
}
/**
* Called when playback is buffering.
*/
onWaiting () {
// TODO: implement group wait
if (!this.lastPlaybackWaiting) {
this.lastPlaybackWaiting = new Date();
}
events.trigger(this, 'waiting');
}
/**
* Gets playback buffering status.
* @returns {boolean} _true_ if player is buffering, _false_ otherwise.
*/
isBuffering () {
if (this.lastPlaybackWaiting === null) return false;
return (new Date() - this.lastPlaybackWaiting) > this.minBufferingThresholdMillis;
}
/**
* Binds to the player's events.
* @param {Object} player The player.
*/
bindToPlayer (player) {
if (player !== this.currentPlayer) {
this.releaseCurrentPlayer();
this.currentPlayer = player;
if (!player) return;
}
// FIXME: the following are needed because the 'events' module
// is changing the scope when executing the callbacks.
// For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this'
// points to 'player' (the event emitter) instead of pointing to the SyncPlayManager singleton.
const self = this;
this._onPlayerUnpause = () => {
self.onPlayerUnpause();
};
this._onPlayerPause = () => {
self.onPlayerPause();
};
this._onTimeUpdate = (e) => {
self.onTimeUpdate(e);
};
this._onPlaying = () => {
self.onPlaying();
};
this._onWaiting = () => {
self.onWaiting();
};
events.on(player, 'unpause', this._onPlayerUnpause);
events.on(player, 'pause', this._onPlayerPause);
events.on(player, 'timeupdate', this._onTimeUpdate);
events.on(player, 'playing', this._onPlaying);
events.on(player, 'waiting', this._onWaiting);
this.playbackRateSupported = player.supports('PlaybackRate');
// Save player current PlaybackRate value
if (this.playbackRateSupported) {
this.localPlayerPlaybackRate = player.getPlaybackRate();
}
}
/**
* Removes the bindings to the current player's events.
*/
releaseCurrentPlayer () {
var player = this.currentPlayer;
if (player) {
events.off(player, 'unpause', this._onPlayerUnpause);
events.off(player, 'pause', this._onPlayerPause);
events.off(player, 'timeupdate', this._onTimeUpdate);
events.off(player, 'playing', this._onPlaying);
events.off(player, 'waiting', this._onWaiting);
// Restore player original PlaybackRate value
if (this.playbackRateSupported) {
player.setPlaybackRate(this.localPlayerPlaybackRate);
this.localPlayerPlaybackRate = 1.0;
}
this.currentPlayer = null;
this.playbackRateSupported = false;
}
}
/**
* Handles a group update from the server.
* @param {Object} cmd The group update.
* @param {Object} apiClient The ApiClient.
*/
processGroupUpdate (cmd, apiClient) {
switch (cmd.Type) {
case 'PrepareSession':
this.prepareSession(apiClient, cmd.GroupId, cmd.Data);
break;
case 'UserJoined':
toast({
text: globalize.translate('MessageSyncPlayUserJoined', cmd.Data)
});
break;
case 'UserLeft':
toast({
text: globalize.translate('MessageSyncPlayUserLeft', cmd.Data)
});
break;
case 'GroupJoined':
this.enableSyncPlay(apiClient, new Date(cmd.Data), true);
break;
case 'NotInGroup':
case 'GroupLeft':
this.disableSyncPlay(true);
break;
case 'GroupWait':
toast({
text: globalize.translate('MessageSyncPlayGroupWait', cmd.Data)
});
break;
case 'GroupDoesNotExist':
toast({
text: globalize.translate('MessageSyncPlayGroupDoesNotExist')
});
break;
case 'CreateGroupDenied':
toast({
text: globalize.translate('MessageSyncPlayCreateGroupDenied')
});
break;
case 'JoinGroupDenied':
toast({
text: globalize.translate('MessageSyncPlayJoinGroupDenied')
});
break;
case 'LibraryAccessDenied':
toast({
text: globalize.translate('MessageSyncPlayLibraryAccessDenied')
});
break;
default:
console.error('processSyncPlayGroupUpdate: command is not recognised: ' + cmd.Type);
break;
}
}
/**
* Handles a playback command from the server.
* @param {Object} cmd The playback command.
* @param {Object} apiClient The ApiClient.
*/
processCommand (cmd, apiClient) {
if (cmd === null) return;
if (!this.isSyncPlayEnabled()) {
console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command', cmd);
return;
}
if (!this.syncPlayReady) {
console.debug('SyncPlay processCommand: SyncPlay not ready, queued command', cmd);
this.queuedCommand = cmd;
return;
}
cmd.When = new Date(cmd.When);
cmd.EmittedAt = new Date(cmd.EmitttedAt);
if (cmd.EmitttedAt < this.syncPlayEnabledAt) {
console.debug('SyncPlay processCommand: ignoring old command', cmd);
return;
}
// Check if new command differs from last one
if (this.lastCommand &&
this.lastCommand.When === cmd.When &&
this.lastCommand.PositionTicks === cmd.PositionTicks &&
this.Command === cmd.Command
) {
console.debug('SyncPlay processCommand: ignoring duplicate command', cmd);
return;
}
this.lastCommand = cmd;
console.log('SyncPlay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks);
switch (cmd.Command) {
case 'Play':
this.schedulePlay(cmd.When, cmd.PositionTicks);
break;
case 'Pause':
this.schedulePause(cmd.When, cmd.PositionTicks);
break;
case 'Seek':
this.scheduleSeek(cmd.When, cmd.PositionTicks);
break;
default:
console.error('processCommand: command is not recognised: ' + cmd.Type);
break;
}
}
/**
* Prepares this client to join a group by loading the required content.
* @param {Object} apiClient The ApiClient.
* @param {string} groupId The group to join.
* @param {Object} sessionData Info about the content to load.
*/
prepareSession (apiClient, groupId, sessionData) {
const serverId = apiClient.serverInfo().Id;
playbackManager.play({
ids: sessionData.ItemIds,
startPositionTicks: sessionData.StartPositionTicks,
mediaSourceId: sessionData.MediaSourceId,
audioStreamIndex: sessionData.AudioStreamIndex,
subtitleStreamIndex: sessionData.SubtitleStreamIndex,
startIndex: sessionData.StartIndex,
serverId: serverId
}).then(() => {
waitForEventOnce(this, 'playbackstart', WaitForEventDefaultTimeout).then(() => {
var sessionId = getActivePlayerId();
if (!sessionId) {
console.error('Missing sessionId!');
toast({
text: globalize.translate('MessageSyncPlayErrorMissingSession')
});
return;
}
// Get playing item id
let playingItemId;
try {
const playState = playbackManager.getPlayerState();
playingItemId = playState.NowPlayingItem.Id;
} catch (error) {
playingItemId = '';
}
// Make sure the server has received the player state
waitForEventOnce(playbackManager, 'reportplayback', WaitForEventDefaultTimeout).then((success) => {
this.localPause();
if (!success) {
console.warning('Error reporting playback state to server. Joining group will fail.');
}
apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', {
GroupId: groupId,
PlayingItemId: playingItemId
});
}).catch(() => {
console.error('Timed out while waiting for `reportplayback` event!');
toast({
text: globalize.translate('MessageSyncPlayErrorMedia')
});
return;
});
}).catch(() => {
console.error('Timed out while waiting for `playbackstart` event!');
if (!this.isSyncPlayEnabled()) {
toast({
text: globalize.translate('MessageSyncPlayErrorMedia')
});
}
return;
});
}).catch((error) => {
console.error(error);
toast({
text: globalize.translate('MessageSyncPlayErrorMedia')
});
});
}
/**
* Enables SyncPlay.
* @param {Object} apiClient The ApiClient.
* @param {Date} enabledAt When SyncPlay has been enabled. Server side date.
* @param {boolean} showMessage Display message.
*/
enableSyncPlay (apiClient, enabledAt, showMessage = false) {
this.syncPlayEnabledAt = enabledAt;
this.injectPlaybackManager();
events.trigger(this, 'enabled', [true]);
waitForEventOnce(this, 'ready').then(() => {
this.processCommand(this.queuedCommand, apiClient);
this.queuedCommand = null;
});
this.syncPlayReady = false;
this.notifySyncPlayReady = true;
timeSyncManager.forceUpdate();
if (showMessage) {
toast({
text: globalize.translate('MessageSyncPlayEnabled')
});
}
}
/**
* Disables SyncPlay.
* @param {boolean} showMessage Display message.
*/
disableSyncPlay (showMessage = false) {
this.syncPlayEnabledAt = null;
this.syncPlayReady = false;
this.lastCommand = null;
this.queuedCommand = null;
this.syncEnabled = false;
events.trigger(this, 'enabled', [false]);
this.restorePlaybackManager();
if (showMessage) {
toast({
text: globalize.translate('MessageSyncPlayDisabled')
});
}
}
/**
* Gets SyncPlay status.
* @returns {boolean} _true_ if user joined a group, _false_ otherwise.
*/
isSyncPlayEnabled () {
return this.syncPlayEnabledAt !== null;
}
/**
* Schedules a resume playback on the player at the specified clock time.
* @param {Date} playAtTime The server's UTC time at which to resume playback.
* @param {number} positionTicks The PositionTicks from where to resume.
*/
schedulePlay (playAtTime, positionTicks) {
this.clearScheduledCommand();
const currentTime = new Date();
const playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime);
if (playAtTimeLocal > currentTime) {
const playTimeout = playAtTimeLocal - currentTime;
this.localSeek(positionTicks);
this.scheduledCommand = setTimeout(() => {
this.localUnpause();
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, SyncMethodThreshold / 2);
}, playTimeout);
console.debug('Scheduled play in', playTimeout / 1000.0, 'seconds.');
} else {
// Group playback already started
const serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000;
waitForEventOnce(this, 'unpause').then(() => {
this.localSeek(serverPositionTicks);
});
this.localUnpause();
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, SyncMethodThreshold / 2);
}
}
/**
* Schedules a pause playback on the player at the specified clock time.
* @param {Date} pauseAtTime The server's UTC time at which to pause playback.
* @param {number} positionTicks The PositionTicks where player will be paused.
*/
schedulePause (pauseAtTime, positionTicks) {
this.clearScheduledCommand();
const currentTime = new Date();
const pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime);
const callback = () => {
waitForEventOnce(this, 'pause', WaitForPlayerEventTimeout).then(() => {
this.localSeek(positionTicks);
}).catch(() => {
// Player was already paused, seeking
this.localSeek(positionTicks);
});
this.localPause();
};
if (pauseAtTimeLocal > currentTime) {
const pauseTimeout = pauseAtTimeLocal - currentTime;
this.scheduledCommand = setTimeout(callback, pauseTimeout);
console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.');
} else {
callback();
}
}
/**
* Schedules a seek playback on the player at the specified clock time.
* @param {Date} pauseAtTime The server's UTC time at which to seek playback.
* @param {number} positionTicks The PositionTicks where player will be seeked.
*/
scheduleSeek (seekAtTime, positionTicks) {
this.schedulePause(seekAtTime, positionTicks);
}
/**
* Clears the current scheduled command.
*/
clearScheduledCommand () {
clearTimeout(this.scheduledCommand);
clearTimeout(this.syncTimeout);
this.syncEnabled = false;
if (this.currentPlayer) {
this.currentPlayer.setPlaybackRate(1);
}
this.clearSyncIcon();
}
/**
* Overrides some PlaybackManager's methods to intercept playback commands.
*/
injectPlaybackManager () {
if (!this.isSyncPlayEnabled()) return;
if (playbackManager.syncPlayEnabled) return;
// TODO: make this less hacky
playbackManager._localUnpause = playbackManager.unpause;
playbackManager._localPause = playbackManager.pause;
playbackManager._localSeek = playbackManager.seek;
playbackManager.unpause = this.playRequest;
playbackManager.pause = this.pauseRequest;
playbackManager.seek = this.seekRequest;
playbackManager.syncPlayEnabled = true;
}
/**
* Restores original PlaybackManager's methods.
*/
restorePlaybackManager () {
if (this.isSyncPlayEnabled()) return;
if (!playbackManager.syncPlayEnabled) return;
playbackManager.unpause = playbackManager._localUnpause;
playbackManager.pause = playbackManager._localPause;
playbackManager.seek = playbackManager._localSeek;
playbackManager.syncPlayEnabled = false;
}
/**
* Overrides PlaybackManager's unpause method.
*/
playRequest (player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'PlayRequest');
}
/**
* Overrides PlaybackManager's pause method.
*/
pauseRequest (player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'PauseRequest');
// Pause locally as well, to give the user some little control
playbackManager._localUnpause(player);
}
/**
* Overrides PlaybackManager's seek method.
*/
seekRequest (PositionTicks, player) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
apiClient.sendSyncPlayCommand(sessionId, 'SeekRequest', {
PositionTicks: PositionTicks
});
}
/**
* Calls original PlaybackManager's unpause method.
*/
localUnpause(player) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localUnpause(player);
} else {
playbackManager.unpause(player);
}
}
/**
* Calls original PlaybackManager's pause method.
*/
localPause(player) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localPause(player);
} else {
playbackManager.pause(player);
}
}
/**
* Calls original PlaybackManager's seek method.
*/
localSeek(PositionTicks, player) {
if (playbackManager.syncPlayEnabled) {
playbackManager._localSeek(PositionTicks, player);
} else {
playbackManager.seek(PositionTicks, player);
}
}
/**
* Attempts to sync playback time with estimated server time.
*
* When sync is enabled, the following will be checked:
* - check if local playback time is close enough to the server playback time
* If it is not, then a playback time sync will be attempted.
* Two methods of syncing are available:
* - SpeedToSync: speeds up the media for some time to catch up (default is one second)
* - SkipToSync: seeks the media to the estimated correct time
* SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious.
*/
syncPlaybackTime () {
// Attempt to sync only when media is playing.
if (!this.lastCommand || this.lastCommand.Command !== 'Play' || this.isBuffering()) return;
const currentTime = new Date();
// Avoid overloading the browser
const elapsed = currentTime - this.lastSyncTime;
if (elapsed < SyncMethodThreshold / 2) return;
this.lastSyncTime = currentTime;
const playAtTime = this.lastCommand.When;
const currentPositionTicks = playbackManager.currentTime();
// Estimate PositionTicks on server
const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000;
// Measure delay that needs to be recovered
// diff might be caused by the player internally starting the playback
const diffMillis = (serverPositionTicks - currentPositionTicks) / 10000.0;
this.playbackDiffMillis = diffMillis;
if (this.syncEnabled) {
const absDiffMillis = Math.abs(diffMillis);
// TODO: SpeedToSync sounds bad on songs
// TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist
if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) {
// Disable SpeedToSync if it keeps failing
if (this.syncAttempts > MaxAttemptsSpeedToSync) {
this.playbackRateSupported = false;
}
// SpeedToSync method
const speed = 1 + diffMillis / SpeedToSyncTime;
this.currentPlayer.setPlaybackRate(speed);
this.syncEnabled = false;
this.syncAttempts++;
this.showSyncIcon('SpeedToSync (x' + speed + ')');
this.syncTimeout = setTimeout(() => {
this.currentPlayer.setPlaybackRate(1);
this.syncEnabled = true;
this.clearSyncIcon();
}, SpeedToSyncTime);
} else if (absDiffMillis > MaxAcceptedDelaySkipToSync) {
// Disable SkipToSync if it keeps failing
if (this.syncAttempts > MaxAttemptsSync) {
this.syncEnabled = false;
this.showSyncIcon('Sync disabled (too many attempts)');
}
// SkipToSync method
this.localSeek(serverPositionTicks);
this.syncEnabled = false;
this.syncAttempts++;
this.showSyncIcon('SkipToSync (' + this.syncAttempts + ')');
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.clearSyncIcon();
}, SyncMethodThreshold / 2);
} else {
// Playback is synced
if (this.syncAttempts > 0) {
console.debug('Playback has been synced after', this.syncAttempts, 'attempts.');
}
this.syncAttempts = 0;
}
}
}
/**
* Gets SyncPlay stats.
* @returns {Object} The SyncPlay stats.
*/
getStats () {
return {
TimeOffset: this.timeOffsetWithServer,
PlaybackDiff: this.playbackDiffMillis,
SyncMethod: this.syncMethod
};
}
/**
* Emits an event to update the SyncPlay status icon.
*/
showSyncIcon (syncMethod) {
this.syncMethod = syncMethod;
events.trigger(this, 'syncing', [true, this.syncMethod]);
}
/**
* Emits an event to clear the SyncPlay status icon.
*/
clearSyncIcon () {
this.syncMethod = 'None';
events.trigger(this, 'syncing', [false, this.syncMethod]);
}
/**
* Signals an error state, which disables and resets SyncPlay for a new session.
*/
signalError () {
this.disableSyncPlay();
}
}
/** SyncPlayManager singleton. */
export default new SyncPlayManager();

View file

@ -0,0 +1,207 @@
/**
* Module that manages time syncing with server.
* @module components/syncplay/timeSyncManager
*/
import events from 'events';
import connectionManager from 'connectionManager';
/**
* Time estimation
*/
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // milliseconds
const PollingIntervalLowProfile = 60000; // milliseconds
const GreedyPingCount = 3;
/**
* Class that stores measurement data.
*/
class Measurement {
/**
* Creates a new measurement.
* @param {Date} requestSent Client's timestamp of the request transmission
* @param {Date} requestReceived Server's timestamp of the request reception
* @param {Date} responseSent Server's timestamp of the response transmission
* @param {Date} responseReceived Client's timestamp of the response reception
*/
constructor(requestSent, requestReceived, responseSent, responseReceived) {
this.requestSent = requestSent.getTime();
this.requestReceived = requestReceived.getTime();
this.responseSent = responseSent.getTime();
this.responseReceived = responseReceived.getTime();
}
/**
* Time offset from server.
*/
getOffset () {
return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2;
}
/**
* Get round-trip delay.
*/
getDelay () {
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
}
/**
* Get ping time.
*/
getPing () {
return this.getDelay() / 2;
}
}
/**
* Class that manages time syncing with server.
*/
class TimeSyncManager {
constructor() {
this.pingStop = true;
this.pollingInterval = PollingIntervalGreedy;
this.poller = null;
this.pings = 0; // number of pings
this.measurement = null; // current time sync
this.measurements = [];
this.startPing();
}
/**
* Gets status of time sync.
* @returns {boolean} _true_ if a measurement has been done, _false_ otherwise.
*/
isReady() {
return !!this.measurement;
}
/**
* Gets time offset with server.
* @returns {number} The time offset.
*/
getTimeOffset () {
return this.measurement ? this.measurement.getOffset() : 0;
}
/**
* Gets ping time to server.
* @returns {number} The ping time.
*/
getPing () {
return this.measurement ? this.measurement.getPing() : 0;
}
/**
* Updates time offset between server and client.
* @param {Measurement} measurement The new measurement.
*/
updateTimeOffset(measurement) {
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// Pick measurement with minimum delay
const sortedMeasurements = this.measurements.slice(0);
sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay());
this.measurement = sortedMeasurements[0];
}
/**
* Schedules a ping request to the server. Triggers time offset update.
*/
requestPing() {
if (!this.poller) {
this.poller = setTimeout(() => {
this.poller = null;
const apiClient = connectionManager.currentApiClient();
const requestSent = new Date();
apiClient.getServerTime().then((response) => {
const responseReceived = new Date();
response.json().then((data) => {
const requestReceived = new Date(data.RequestReceptionTime);
const responseSent = new Date(data.ResponseTransmissionTime);
const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived);
this.updateTimeOffset(measurement);
// Avoid overloading server
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
});
}).catch((error) => {
console.error(error);
events.trigger(this, 'update', [error, null, null]);
}).finally(() => {
this.requestPing();
});
}, this.pollingInterval);
}
}
/**
* Drops accumulated measurements.
*/
resetMeasurements () {
this.measurement = null;
this.measurements = [];
}
/**
* Starts the time poller.
*/
startPing() {
this.requestPing();
}
/**
* Stops the time poller.
*/
stopPing() {
if (this.poller) {
clearTimeout(this.poller);
this.poller = null;
}
}
/**
* Resets poller into greedy mode.
*/
forceUpdate() {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/**
* Converts server time to local time.
* @param {Date} server The time to convert.
* @returns {Date} Local time.
*/
serverDateToLocal(server) {
// server - local = offset
return new Date(server.getTime() - this.getTimeOffset());
}
/**
* Converts local time to server time.
* @param {Date} local The time to convert.
* @returns {Date} Server time.
*/
localDateToServer(local) {
// server - local = offset
return new Date(local.getTime() + this.getTimeOffset());
}
}
/** TimeSyncManager singleton. */
export default new TimeSyncManager();

View file

@ -104,6 +104,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function
$('#chkEnableSharing', page).checked(user.Policy.EnablePublicSharing); $('#chkEnableSharing', page).checked(user.Policy.EnablePublicSharing);
$('#txtRemoteClientBitrateLimit', page).val(user.Policy.RemoteClientBitrateLimit / 1e6 || ''); $('#txtRemoteClientBitrateLimit', page).val(user.Policy.RemoteClientBitrateLimit / 1e6 || '');
$('#txtLoginAttemptsBeforeLockout', page).val(user.Policy.LoginAttemptsBeforeLockout || '0'); $('#txtLoginAttemptsBeforeLockout', page).val(user.Policy.LoginAttemptsBeforeLockout || '0');
$('#selectSyncPlayAccess').val(user.Policy.SyncPlayAccess);
loading.hide(); loading.hide();
} }
@ -145,6 +146,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function
}).map(function (c) { }).map(function (c) {
return c.getAttribute('data-id'); return c.getAttribute('data-id');
}); });
user.Policy.SyncPlayAccess = page.querySelector('#selectSyncPlayAccess').value;
ApiClient.updateUser(user).then(function () { ApiClient.updateUser(user).then(function () {
ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete(page, user); onSaveComplete(page, user);

View file

@ -475,7 +475,7 @@ define(['loading', 'appRouter', 'layoutManager', 'connectionManager', 'userSetti
item.Type === 'MusicAlbum' || item.Type === 'MusicAlbum' ||
item.Type === 'Person'; item.Type === 'Person';
if (!layoutManager.mobile && !userSettings.enableBackdrops()) { if (!layoutManager.mobile && !userSettings.detailsBanner()) {
return false; return false;
} }

View file

@ -1,4 +1,4 @@
define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, browser, globalize, imageHelper) { define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncPlayManager', 'groupSelectionMenu', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncPlayManager, groupSelectionMenu, browser, globalize, imageHelper) {
'use strict'; 'use strict';
function renderHeader() { function renderHeader() {
@ -12,6 +12,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
html += '</div>'; html += '</div>';
html += '<div class="headerRight">'; html += '<div class="headerRight">';
html += '<span class="headerSelectedPlayer"></span>'; html += '<span class="headerSelectedPlayer"></span>';
html += '<button is="paper-icon-button-light" class="headerSyncButton syncButton headerButton headerButtonRight hide"><span class="material-icons sync_disabled"></span></button>';
html += '<button is="paper-icon-button-light" class="headerAudioPlayerButton audioPlayerButton headerButton headerButtonRight hide"><span class="material-icons music_note"></span></button>'; html += '<button is="paper-icon-button-light" class="headerAudioPlayerButton audioPlayerButton headerButton headerButtonRight hide"><span class="material-icons music_note"></span></button>';
html += '<button is="paper-icon-button-light" class="headerCastButton castButton headerButton headerButtonRight hide"><span class="material-icons cast"></span></button>'; html += '<button is="paper-icon-button-light" class="headerCastButton castButton headerButton headerButtonRight hide"><span class="material-icons cast"></span></button>';
html += '<button type="button" is="paper-icon-button-light" class="headerButton headerButtonRight headerSearchButton hide"><span class="material-icons search"></span></button>'; html += '<button type="button" is="paper-icon-button-light" class="headerButton headerButtonRight headerSearchButton hide"><span class="material-icons search"></span></button>';
@ -30,6 +31,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
headerCastButton = skinHeader.querySelector('.headerCastButton'); headerCastButton = skinHeader.querySelector('.headerCastButton');
headerAudioPlayerButton = skinHeader.querySelector('.headerAudioPlayerButton'); headerAudioPlayerButton = skinHeader.querySelector('.headerAudioPlayerButton');
headerSearchButton = skinHeader.querySelector('.headerSearchButton'); headerSearchButton = skinHeader.querySelector('.headerSearchButton');
headerSyncButton = skinHeader.querySelector('.headerSyncButton');
lazyLoadViewMenuBarImages(); lazyLoadViewMenuBarImages();
bindMenuEvents(); bindMenuEvents();
@ -84,9 +86,16 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
if (!layoutManager.tv) { if (!layoutManager.tv) {
headerCastButton.classList.remove('hide'); headerCastButton.classList.remove('hide');
} }
var policy = user.Policy ? user.Policy : user.localUser.Policy;
if (headerSyncButton && policy && policy.SyncPlayAccess !== 'None') {
headerSyncButton.classList.remove('hide');
}
} else { } else {
headerHomeButton.classList.add('hide'); headerHomeButton.classList.add('hide');
headerCastButton.classList.add('hide'); headerCastButton.classList.add('hide');
headerSyncButton.classList.add('hide');
if (headerSearchButton) { if (headerSearchButton) {
headerSearchButton.classList.add('hide'); headerSearchButton.classList.add('hide');
@ -147,6 +156,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
} }
headerAudioPlayerButton.addEventListener('click', showAudioPlayer); headerAudioPlayerButton.addEventListener('click', showAudioPlayer);
headerSyncButton.addEventListener('click', onSyncButtonClicked);
if (layoutManager.mobile) { if (layoutManager.mobile) {
initHeadRoom(skinHeader); initHeadRoom(skinHeader);
@ -177,6 +187,31 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
}); });
} }
function onSyncButtonClicked() {
var btn = this;
groupSelectionMenu.show(btn);
}
function onSyncPlayEnabled(event, enabled) {
var icon = headerSyncButton.querySelector('span');
icon.classList.remove('sync', 'sync_disabled', 'sync_problem');
if (enabled) {
icon.classList.add('sync');
} else {
icon.classList.add('sync_disabled');
}
}
function onSyncPlaySyncing(event, is_syncing, syncMethod) {
var icon = headerSyncButton.querySelector('span');
icon.classList.remove('sync', 'sync_disabled', 'sync_problem');
if (is_syncing) {
icon.classList.add('sync_problem');
} else {
icon.classList.add('sync');
}
}
function getItemHref(item, context) { function getItemHref(item, context) {
return appRouter.getRouteUrl(item, { return appRouter.getRouteUrl(item, {
context: context context: context
@ -799,6 +834,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
var headerCastButton; var headerCastButton;
var headerSearchButton; var headerSearchButton;
var headerAudioPlayerButton; var headerAudioPlayerButton;
var headerSyncButton;
var enableLibraryNavDrawer = layoutManager.desktop; var enableLibraryNavDrawer = layoutManager.desktop;
var skinHeader = document.querySelector('.skinHeader'); var skinHeader = document.querySelector('.skinHeader');
var requiresUserRefresh = true; var requiresUserRefresh = true;
@ -931,6 +967,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
updateUserInHeader(); updateUserInHeader();
}); });
events.on(playbackManager, 'playerchange', updateCastIcon); events.on(playbackManager, 'playerchange', updateCastIcon);
events.on(syncPlayManager, 'enabled', onSyncPlayEnabled);
events.on(syncPlayManager, 'syncing', onSyncPlaySyncing);
loadNavDrawer(); loadNavDrawer();
return LibraryMenu; return LibraryMenu;
}); });

View file

@ -1,4 +1,4 @@
define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, events, inputManager, focusManager, appRouter) { define(['connectionManager', 'playbackManager', 'syncPlayManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, syncPlayManager, events, inputManager, focusManager, appRouter) {
'use strict'; 'use strict';
var serverNotifications = {}; var serverNotifications = {};
@ -187,6 +187,10 @@ define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focus
events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]); events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]);
} }
} }
} else if (msg.MessageType === 'SyncPlayCommand') {
syncPlayManager.processCommand(msg.Data, apiClient);
} else if (msg.MessageType === 'SyncPlayGroupUpdate') {
syncPlayManager.processGroupUpdate(msg.Data, apiClient);
} else { } else {
events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]); events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]);
} }

View file

@ -107,7 +107,7 @@ import events from 'events';
} }
val = this.get('enableThemeSongs', false); val = this.get('enableThemeSongs', false);
return val !== 'false'; return val === 'true';
} }
export function enableThemeVideos(val) { export function enableThemeVideos(val) {
@ -116,7 +116,7 @@ import events from 'events';
} }
val = this.get('enableThemeVideos', false); val = this.get('enableThemeVideos', false);
return val !== 'false'; return val === 'true';
} }
export function enableFastFadein(val) { export function enableFastFadein(val) {
@ -137,6 +137,15 @@ import events from 'events';
return val !== 'false'; return val !== 'false';
} }
export function detailsBanner(val) {
if (val !== undefined) {
return this.set('detailsBanner', val.toString(), false);
}
val = this.get('detailsBanner', false);
return val !== 'false';
}
export function language(val) { export function language(val) {
if (val !== undefined) { if (val !== undefined) {
return this.set('language', val.toString(), false); return this.set('language', val.toString(), false);

View file

@ -314,6 +314,13 @@ var AppInfo = {};
return obj; return obj;
} }
function returnDefault(obj) {
if (obj.default === null) {
throw new Error('Object has no default!');
}
return obj.default;
}
function getBowerPath() { function getBowerPath() {
return 'libraries'; return 'libraries';
} }
@ -484,6 +491,7 @@ var AppInfo = {};
'components/htmlAudioPlayer/plugin', 'components/htmlAudioPlayer/plugin',
'components/htmlVideoPlayer/plugin', 'components/htmlVideoPlayer/plugin',
'components/photoPlayer/plugin', 'components/photoPlayer/plugin',
'components/bookPlayer/plugin',
'components/youtubeplayer/plugin', 'components/youtubeplayer/plugin',
'components/backdropScreensaver/plugin', 'components/backdropScreensaver/plugin',
'components/logoScreensaver/plugin' 'components/logoScreensaver/plugin'
@ -554,6 +562,7 @@ var AppInfo = {};
require(['components/playback/volumeosd']); require(['components/playback/volumeosd']);
} }
/* eslint-disable-next-line compat/compat */
if (navigator.mediaSession || window.NativeShell) { if (navigator.mediaSession || window.NativeShell) {
require(['mediaSession']); require(['mediaSession']);
} }
@ -670,6 +679,7 @@ var AppInfo = {};
'fetch', 'fetch',
'flvjs', 'flvjs',
'jstree', 'jstree',
'epubjs',
'jQuery', 'jQuery',
'hlsjs', 'hlsjs',
'howler', 'howler',
@ -817,6 +827,10 @@ var AppInfo = {};
define('playbackSettings', [componentsPath + '/playbackSettings/playbackSettings'], returnFirstDependency); define('playbackSettings', [componentsPath + '/playbackSettings/playbackSettings'], returnFirstDependency);
define('homescreenSettings', [componentsPath + '/homeScreenSettings/homeScreenSettings'], returnFirstDependency); define('homescreenSettings', [componentsPath + '/homeScreenSettings/homeScreenSettings'], returnFirstDependency);
define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager); define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager);
define('timeSyncManager', [componentsPath + '/syncplay/timeSyncManager'], returnDefault);
define('groupSelectionMenu', [componentsPath + '/syncplay/groupSelectionMenu'], returnFirstDependency);
define('syncPlayManager', [componentsPath + '/syncplay/syncPlayManager'], returnDefault);
define('playbackPermissionManager', [componentsPath + '/syncplay/playbackPermissionManager'], returnDefault);
define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager); define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager);
define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency); define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency);
define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency); define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency);

View file

@ -1540,8 +1540,8 @@
"CopyStreamURLError": "Při kopírování URL došlo k chybě.", "CopyStreamURLError": "Při kopírování URL došlo k chybě.",
"LabelVideoResolution": "Rozlišení videa:", "LabelVideoResolution": "Rozlišení videa:",
"LabelStreamType": "Typ streamu:", "LabelStreamType": "Typ streamu:",
"EnableFastImageFadeInHelp": "Povolte rychlejší animaci pro načtené obrázky", "EnableFastImageFadeInHelp": "Zobrazí plakáty a další obrázky s rychlejší animací přechodu po dokončení načítání.",
"EnableFastImageFadeIn": "Rychlé zmizení obrazu", "EnableFastImageFadeIn": "Rychlé animace přechodů obrazu",
"LabelPlayerDimensions": "Zobrazené rozlišení:", "LabelPlayerDimensions": "Zobrazené rozlišení:",
"LabelDroppedFrames": "Vynechané snímky:", "LabelDroppedFrames": "Vynechané snímky:",
"LabelCorruptedFrames": "Poškozené snímky:", "LabelCorruptedFrames": "Poškozené snímky:",
@ -1601,5 +1601,37 @@
"LabelRequireHttps": "Vyžadovat HTTPS", "LabelRequireHttps": "Vyžadovat HTTPS",
"TabDVR": "Nahrávání", "TabDVR": "Nahrávání",
"HeaderDVR": "Nahrávání", "HeaderDVR": "Nahrávání",
"SaveChanges": "Uložit změny" "SaveChanges": "Uložit změny",
"LabelSyncPlayPlaybackDiff": "Rozdíl v době přehrávání:",
"SyncPlayAccessHelp": "Určuje úroveň přístupu k synchronizaci přehrávání, kterou tento uživatel bude mít. Tato funkce umožňuje synchronizovat přehrávání s dalšími uživateli.",
"MessageSyncPlayErrorMedia": "Zapnutí synchronizace přehrávání se nezdařilo. Chyba média.",
"MessageSyncPlayErrorMissingSession": "Zapnutí synchronizace přehrávání se nezdařilo. Nebyla nalezena relace.",
"MessageSyncPlayErrorNoActivePlayer": "Nebyl nalezen žádný aktivní přehrávač. Synchronizace přehrávání byla vypnuta.",
"MessageSyncPlayErrorAccessingGroups": "Při načítání seznamu skupin došlo k chybě.",
"MessageSyncPlayLibraryAccessDenied": "Přístup k tomuto obsahu je omezen.",
"MessageSyncPlayJoinGroupDenied": "K použití synchronizace přehrávání je vyžadováno povolení.",
"MessageSyncPlayCreateGroupDenied": "K vytvoření skupiny je vyžadováno povolení.",
"MessageSyncPlayGroupDoesNotExist": "Připojení ke skupině se nezdařilo, protože skupina neexistuje.",
"MessageSyncPlayPlaybackPermissionRequired": "K přehrávání je vyžadováno povolení.",
"MessageSyncPlayNoGroupsAvailable": "Neexistují žádné skupiny. Začněte něco přehrávat.",
"MessageSyncPlayGroupWait": "Přehrávání uživatele <b>{0}</b> se načítá…",
"MessageSyncPlayUserLeft": "Uživatel <b>{0}</b> opustil skupinu.",
"MessageSyncPlayUserJoined": "Uživatel <b>{0}</b> se připojil do skupiny.",
"MessageSyncPlayDisabled": "Synchronizace přehrávání zakázána.",
"MessageSyncPlayEnabled": "Synchronizace přehrávání povolena.",
"LabelSyncPlayAccess": "Přístup k funkci synchronizace přehrávání",
"LabelSyncPlayAccessNone": "Zakázáno pro tohoto uživatele",
"LabelSyncPlayAccessJoinGroups": "Povolit uživateli připojovat se do skupin",
"LabelSyncPlayAccessCreateAndJoinGroups": "Povolit uživateli vytvářet a připojovat se do skupin",
"LabelSyncPlayLeaveGroupDescription": "Zakázat synchronizaci přehrávání",
"LabelSyncPlayLeaveGroup": "Opustit skupinu",
"LabelSyncPlayNewGroupDescription": "Vytvořit skupinu",
"LabelSyncPlayNewGroup": "Nová skupina",
"LabelSyncPlaySyncMethod": "Způsob synchronizace:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Časový rozdíl mezi serverem:",
"HeaderSyncPlayEnabled": "Synchronizace přehrávání povolena",
"HeaderSyncPlaySelectGroup": "Připojit ke skupině",
"EnableDetailsBannerHelp": "Zobrazí obrázek ve vrchní části detailu položky.",
"EnableDetailsBanner": "Obrázek detailu"
} }

View file

@ -1478,8 +1478,8 @@
"MessageConfirmAppExit": "Wirklich verlassen?", "MessageConfirmAppExit": "Wirklich verlassen?",
"LabelVideoResolution": "Videoauflösung:", "LabelVideoResolution": "Videoauflösung:",
"LabelStreamType": "Streamtyp:", "LabelStreamType": "Streamtyp:",
"EnableFastImageFadeInHelp": "Aktiviere schnellere Einblendeanimation für geladene Bilder", "EnableFastImageFadeInHelp": "Zeige Poster und andere Bilder mit einer schnelleren Einblendeanimation, wenn diese fertig geladen sind.",
"EnableFastImageFadeIn": "Schnelle Bildeinblendung", "EnableFastImageFadeIn": "Schnelle Bildeinblendungsanimationen",
"LabelPlayerDimensions": "Playerabmessungen:", "LabelPlayerDimensions": "Playerabmessungen:",
"LabelDroppedFrames": "Verlorene Frames:", "LabelDroppedFrames": "Verlorene Frames:",
"LabelCorruptedFrames": "Fehlerhafte Frames:", "LabelCorruptedFrames": "Fehlerhafte Frames:",
@ -1539,5 +1539,37 @@
"LabelEnableHttps": "Aktiviere HTTPS", "LabelEnableHttps": "Aktiviere HTTPS",
"HeaderServerAddressSettings": "Server-Adresseinstellungen", "HeaderServerAddressSettings": "Server-Adresseinstellungen",
"HeaderRemoteAccessSettings": "Fernzugriffs-Einstellungen", "HeaderRemoteAccessSettings": "Fernzugriffs-Einstellungen",
"HeaderHttpsSettings": "HTTPS-Einstellungen" "HeaderHttpsSettings": "HTTPS-Einstellungen",
"SyncPlayAccessHelp": "Wähle die Berechtigungsstufe, die dieser Benutzer auf das SyncPlay-Feature hat. SyncPlay ermöglicht die Synchronisierung der Wiedergabe mit anderen Geräten.",
"MessageSyncPlayErrorMedia": "SyncPlay konnte nicht aktiviert werden! Medienfehler.",
"MessageSyncPlayErrorMissingSession": "SyncPlay konnte nicht aktiviert werden! Fehlende Sitzung.",
"MessageSyncPlayErrorNoActivePlayer": "Keine aktive Wiedergabe gefunden. SyncPlay wurde deaktiviert.",
"MessageSyncPlayErrorAccessingGroups": "Beim Zugriff auf die Gruppen ist ein Fehler aufgetreten.",
"MessageSyncPlayLibraryAccessDenied": "Der Zugang zu diesem Inhalt ist beschränkt.",
"MessageSyncPlayJoinGroupDenied": "Eine Berechtigung ist erforderlich um SyncPlay zu benutzen.",
"MessageSyncPlayCreateGroupDenied": "Zum Erstellen einer Gruppe ist eine Genehmigung erforderlich.",
"MessageSyncPlayGroupDoesNotExist": "Konnte der Gruppe nicht beitreten, da sie nicht existiert.",
"MessageSyncPlayPlaybackPermissionRequired": "Wiedergabegenehmigung erforderlich.",
"MessageSyncPlayNoGroupsAvailable": "Keine Gruppen verfügbar. Fange an, etwas abzuspielen.",
"MessageSyncPlayGroupWait": "<b>{0}</b> ist am laden...",
"MessageSyncPlayUserLeft": "<b>{0}</b> hat die Gruppe verlassen.",
"MessageSyncPlayUserJoined": "<b>{0}</b> ist der Gruppe beigetreten.",
"MessageSyncPlayDisabled": "SyncPlay deaktiviert.",
"MessageSyncPlayEnabled": "SyncPlay aktiviert.",
"LabelSyncPlayAccess": "SyncPlay-Zugriff",
"LabelSyncPlayAccessNone": "Deaktiviert für diesen Benutzer",
"LabelSyncPlayAccessJoinGroups": "Erlaube dem Benutzer, Gruppen beizutreten",
"LabelSyncPlayAccessCreateAndJoinGroups": "Erlaube dem Benutzer, Gruppen zu erstellen und beizutreten",
"LabelSyncPlayLeaveGroupDescription": "Deaktiviere SyncPlay",
"LabelSyncPlayLeaveGroup": "Gruppe verlassen",
"LabelSyncPlayNewGroupDescription": "Erstelle eine neue Gruppe",
"LabelSyncPlayNewGroup": "Neue Gruppe",
"LabelSyncPlaySyncMethod": "Sync-Methode:",
"LabelSyncPlayPlaybackDiff": "Zeitversatz bei der Wiedergabe:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Zeitversatz mit dem Server:",
"HeaderSyncPlayEnabled": "SyncPlay aktiviert",
"HeaderSyncPlaySelectGroup": "Tritt einer Gruppe bei",
"EnableDetailsBannerHelp": "Zeigt ein Bannerbild im oberen Bereich der Seite Item-Details.",
"EnableDetailsBanner": "Detailbanner"
} }

View file

@ -572,7 +572,7 @@
"Repeat": "Repeat", "Repeat": "Repeat",
"RemoveFromPlaylist": "Remove from playlist", "RemoveFromPlaylist": "Remove from playlist",
"RemoveFromCollection": "Remove from collection", "RemoveFromCollection": "Remove from collection",
"RememberMe": "Remember me", "RememberMe": "Remember Me",
"ReleaseDate": "Release date", "ReleaseDate": "Release date",
"RefreshMetadata": "Refresh metadata", "RefreshMetadata": "Refresh metadata",
"RefreshDialogHelp": "Metadata is refreshed based on settings and internet services that are enabled in the Jellyfin Server dashboard.", "RefreshDialogHelp": "Metadata is refreshed based on settings and internet services that are enabled in the Jellyfin Server dashboard.",

View file

@ -239,6 +239,8 @@
"EnableThemeSongsHelp": "Play theme songs in the background while browsing the library.", "EnableThemeSongsHelp": "Play theme songs in the background while browsing the library.",
"EnableThemeVideos": "Theme videos", "EnableThemeVideos": "Theme videos",
"EnableThemeVideosHelp": "Play theme videos in the background while browsing the library.", "EnableThemeVideosHelp": "Play theme videos in the background while browsing the library.",
"EnableDetailsBanner": "Details Banner",
"EnableDetailsBannerHelp": "Display a banner image at the top of the item details page.",
"Ended": "Ended", "Ended": "Ended",
"EndsAtValue": "Ends at {0}", "EndsAtValue": "Ends at {0}",
"Episode": "Episode", "Episode": "Episode",
@ -495,6 +497,8 @@
"HeaderSubtitleProfile": "Subtitle Profile", "HeaderSubtitleProfile": "Subtitle Profile",
"HeaderSubtitleProfiles": "Subtitle Profiles", "HeaderSubtitleProfiles": "Subtitle Profiles",
"HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.", "HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.",
"HeaderSyncPlaySelectGroup": "Join a group",
"HeaderSyncPlayEnabled": "SyncPlay enabled",
"HeaderSystemDlnaProfiles": "System Profiles", "HeaderSystemDlnaProfiles": "System Profiles",
"HeaderTags": "Tags", "HeaderTags": "Tags",
"HeaderTaskTriggers": "Task Triggers", "HeaderTaskTriggers": "Task Triggers",
@ -823,7 +827,7 @@
"LabelScheduledTaskLastRan": "Last ran {0}, taking {1}.", "LabelScheduledTaskLastRan": "Last ran {0}, taking {1}.",
"LabelScreensaver": "Screensaver:", "LabelScreensaver": "Screensaver:",
"EnableFastImageFadeIn": "Faster Animations", "EnableFastImageFadeIn": "Faster Animations",
"EnableFastImageFadeInHelp": "Enable faster fade-in animation for images", "EnableFastImageFadeInHelp": "Use faster animations and transitions",
"LabelSeasonNumber": "Season number:", "LabelSeasonNumber": "Season number:",
"LabelSelectFolderGroups": "Automatically group content from the following folders into views such as Movies, Music and TV:", "LabelSelectFolderGroups": "Automatically group content from the following folders into views such as Movies, Music and TV:",
"LabelSelectFolderGroupsHelp": "Folders that are unchecked will be displayed by themselves in their own view.", "LabelSelectFolderGroupsHelp": "Folders that are unchecked will be displayed by themselves in their own view.",
@ -863,6 +867,18 @@
"LabelSubtitlePlaybackMode": "Subtitle mode:", "LabelSubtitlePlaybackMode": "Subtitle mode:",
"LabelSubtitles": "Subtitles", "LabelSubtitles": "Subtitles",
"LabelSupportedMediaTypes": "Supported Media Types:", "LabelSupportedMediaTypes": "Supported Media Types:",
"LabelSyncPlayTimeOffset": "Time offset with the server:",
"MillisecondsUnit": "ms",
"LabelSyncPlayPlaybackDiff": "Playback time difference:",
"LabelSyncPlaySyncMethod": "Sync method:",
"LabelSyncPlayNewGroup": "New group",
"LabelSyncPlayNewGroupDescription": "Create a new group",
"LabelSyncPlayLeaveGroup": "Leave group",
"LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay",
"LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups",
"LabelSyncPlayAccessJoinGroups": "Allow user to join groups",
"LabelSyncPlayAccessNone": "Disabled for this user",
"LabelSyncPlayAccess": "SyncPlay access",
"LabelTVHomeScreen": "TV mode home screen:", "LabelTVHomeScreen": "TV mode home screen:",
"LabelTag": "Tag:", "LabelTag": "Tag:",
"LabelTagline": "Tagline:", "LabelTagline": "Tagline:",
@ -1025,6 +1041,21 @@
"MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.", "MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.",
"MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.", "MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.",
"MessageYouHaveVersionInstalled": "You currently have version {0} installed.", "MessageYouHaveVersionInstalled": "You currently have version {0} installed.",
"MessageSyncPlayEnabled": "SyncPlay enabled.",
"MessageSyncPlayDisabled": "SyncPlay disabled.",
"MessageSyncPlayUserJoined": "<b>{0}</b> has joined the group.",
"MessageSyncPlayUserLeft": "<b>{0}</b> has left the group.",
"MessageSyncPlayGroupWait": "<b>{0}</b> is buffering...",
"MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.",
"MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.",
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.",
"MessageSyncPlayCreateGroupDenied": "Permission required to create a group.",
"MessageSyncPlayJoinGroupDenied": "Permission required to use SyncPlay.",
"MessageSyncPlayLibraryAccessDenied": "Access to this content is restricted.",
"MessageSyncPlayErrorAccessingGroups": "An error occurred while accessing groups list.",
"MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.",
"MessageSyncPlayErrorMissingSession": "Failed to enable SyncPlay! Missing session.",
"MessageSyncPlayErrorMedia": "Failed to enable SyncPlay! Media error.",
"Metadata": "Metadata", "Metadata": "Metadata",
"MetadataManager": "Metadata Manager", "MetadataManager": "Metadata Manager",
"MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.",
@ -1293,7 +1324,7 @@
"RefreshQueued": "Refresh queued.", "RefreshQueued": "Refresh queued.",
"ReleaseDate": "Release date", "ReleaseDate": "Release date",
"ReleaseGroup": "Release Group", "ReleaseGroup": "Release Group",
"RememberMe": "Remember me", "RememberMe": "Remember Me",
"RemoveFromCollection": "Remove from collection", "RemoveFromCollection": "Remove from collection",
"RemoveFromPlaylist": "Remove from playlist", "RemoveFromPlaylist": "Remove from playlist",
"Repeat": "Repeat", "Repeat": "Repeat",
@ -1372,6 +1403,7 @@
"Suggestions": "Suggestions", "Suggestions": "Suggestions",
"Sunday": "Sunday", "Sunday": "Sunday",
"Sync": "Sync", "Sync": "Sync",
"SyncPlayAccessHelp": "Select the level of access this user has to the SyncPlay feature. SyncPlay enables to sync playback with other devices.",
"SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.", "SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.",
"TV": "TV", "TV": "TV",
"TabAccess": "Access", "TabAccess": "Access",

View file

@ -32,7 +32,7 @@
"AnamorphicVideoNotSupported": "Video anamorfico no soportado", "AnamorphicVideoNotSupported": "Video anamorfico no soportado",
"AnyLanguage": "Cualquier idioma", "AnyLanguage": "Cualquier idioma",
"Anytime": "En cualquier momento", "Anytime": "En cualquier momento",
"AroundTime": "Alrededor de {0}", "AroundTime": "Alrededor de",
"Art": "Arte", "Art": "Arte",
"Artists": "Artistas", "Artists": "Artistas",
"AsManyAsPossible": "Tantos como sea posible", "AsManyAsPossible": "Tantos como sea posible",
@ -103,7 +103,7 @@
"ButtonRemove": "Remover", "ButtonRemove": "Remover",
"ButtonRename": "Renombrar", "ButtonRename": "Renombrar",
"ButtonRepeat": "Repetir", "ButtonRepeat": "Repetir",
"ButtonResetEasyPassword": "Reiniciar el código pin sencillo", "ButtonResetEasyPassword": "Restablecer código PIN sencillo",
"ButtonResetPassword": "Restablecer contraseña", "ButtonResetPassword": "Restablecer contraseña",
"ButtonRestart": "Reiniciar", "ButtonRestart": "Reiniciar",
"ButtonResume": "Continuar", "ButtonResume": "Continuar",
@ -203,7 +203,7 @@
"EnableBackdrops": "Imágenes de fondo", "EnableBackdrops": "Imágenes de fondo",
"EnableBackdropsHelp": "Muestra imágenes de fondo en el fondo de algunas páginas mientras se navega por la biblioteca.", "EnableBackdropsHelp": "Muestra imágenes de fondo en el fondo de algunas páginas mientras se navega por la biblioteca.",
"EnableCinemaMode": "Modo cine", "EnableCinemaMode": "Modo cine",
"EnableColorCodedBackgrounds": "Fondos de color codificados", "EnableColorCodedBackgrounds": "Fondos de colores codificados",
"EnableDisplayMirroring": "Duplicado de pantalla", "EnableDisplayMirroring": "Duplicado de pantalla",
"EnableExternalVideoPlayers": "Reproductores de video externos", "EnableExternalVideoPlayers": "Reproductores de video externos",
"EnableExternalVideoPlayersHelp": "Un menú de reproductor externo se mostrara cuando inicie la reproducción de un video.", "EnableExternalVideoPlayersHelp": "Un menú de reproductor externo se mostrara cuando inicie la reproducción de un video.",
@ -410,8 +410,8 @@
"HeaderRecordingOptions": "Opciones de grabación", "HeaderRecordingOptions": "Opciones de grabación",
"HeaderRecordingPostProcessing": "Post procesado de las grabaciones", "HeaderRecordingPostProcessing": "Post procesado de las grabaciones",
"HeaderRemoteControl": "Control remoto", "HeaderRemoteControl": "Control remoto",
"HeaderRemoveMediaFolder": "Eliminar carpeta de medios", "HeaderRemoveMediaFolder": "Remover carpeta de medios",
"HeaderRemoveMediaLocation": "Eliminar ubicación de medios", "HeaderRemoveMediaLocation": "Remover ubicación de medios",
"HeaderResponseProfile": "Perfil de respuesta", "HeaderResponseProfile": "Perfil de respuesta",
"HeaderResponseProfileHelp": "Los perfiles de respuesta proporcionan un medio para personalizar la información enviada al dispositivo cuando se reproducen ciertos tipos de medios.", "HeaderResponseProfileHelp": "Los perfiles de respuesta proporcionan un medio para personalizar la información enviada al dispositivo cuando se reproducen ciertos tipos de medios.",
"HeaderRestart": "Reiniciar", "HeaderRestart": "Reiniciar",
@ -697,7 +697,7 @@
"LabelNumberOfGuideDays": "Número de días de datos de la programación a descargar:", "LabelNumberOfGuideDays": "Número de días de datos de la programación a descargar:",
"LabelNumberOfGuideDaysHelp": "Descargar más días de datos de programación permite programar con mayor anticipación y ver más listados, pero tomará más tiempo en descargar. Auto hará la selección basada en el número de canales.", "LabelNumberOfGuideDaysHelp": "Descargar más días de datos de programación permite programar con mayor anticipación y ver más listados, pero tomará más tiempo en descargar. Auto hará la selección basada en el número de canales.",
"LabelOptionalNetworkPath": "(Opcional) Carpeta de red compartida:", "LabelOptionalNetworkPath": "(Opcional) Carpeta de red compartida:",
"LabelOptionalNetworkPathHelp": "Si esta carpeta es compartida en su red, proveer la ruta del recurso compartido de red puede permitir a las aplicaciones Jellyfin en otros dispositivos acceder a los archivos de medios directamente.", "LabelOptionalNetworkPathHelp": "Si esta carpeta es compartida en su red, proveer la ruta del recurso compartido de red puede permitir a las aplicaciones Jellyfin en otros dispositivos acceder a los archivos de medios directamente. Por ejemplo, {0} o {1}.",
"LabelOriginalAspectRatio": "Relación de aspecto original:", "LabelOriginalAspectRatio": "Relación de aspecto original:",
"LabelOriginalTitle": "Título original:", "LabelOriginalTitle": "Título original:",
"LabelOverview": "Resumen:", "LabelOverview": "Resumen:",
@ -878,7 +878,7 @@
"MessageConfirmDeleteTunerDevice": "¿Estás seguro de querer eliminar este dispositivo?", "MessageConfirmDeleteTunerDevice": "¿Estás seguro de querer eliminar este dispositivo?",
"MessageConfirmProfileDeletion": "¿Estás seguro de querer eliminar este perfil?", "MessageConfirmProfileDeletion": "¿Estás seguro de querer eliminar este perfil?",
"MessageConfirmRecordingCancellation": "¿Cancelar grabación?", "MessageConfirmRecordingCancellation": "¿Cancelar grabación?",
"MessageConfirmRemoveMediaLocation": "¿Estás seguro de querer eliminar esta ubicación?", "MessageConfirmRemoveMediaLocation": "¿Estás seguro de querer remover esta ubicación?",
"MessageConfirmRestart": "¿Estás seguro de que deseas reiniciar el servidor Jellyfin?", "MessageConfirmRestart": "¿Estás seguro de que deseas reiniciar el servidor Jellyfin?",
"MessageConfirmRevokeApiKey": "¿Estás seguro de querer revocar esta clave API? La conexión de la aplicación con el servidor Jellyfin será terminada abruptamente.", "MessageConfirmRevokeApiKey": "¿Estás seguro de querer revocar esta clave API? La conexión de la aplicación con el servidor Jellyfin será terminada abruptamente.",
"MessageConfirmShutdown": "¿Estás seguro de que deseas apagar el servidor?", "MessageConfirmShutdown": "¿Estás seguro de que deseas apagar el servidor?",
@ -912,7 +912,7 @@
"MessagePluginInstallDisclaimer": "Los complementos desarrollados por miembros de la comunidad Jellyfin son una gran forma de mejorar tu experiencia con Jellyfin con características y beneficios adicionales. Antes de instalar, por favor, conoce el impacto que pueden ocasionar en tu servidor Jellyfin, tales como escaneo más largo de bibliotecas, procesamiento en segundo plano adicional y reducción de la estabilidad del sistema.", "MessagePluginInstallDisclaimer": "Los complementos desarrollados por miembros de la comunidad Jellyfin son una gran forma de mejorar tu experiencia con Jellyfin con características y beneficios adicionales. Antes de instalar, por favor, conoce el impacto que pueden ocasionar en tu servidor Jellyfin, tales como escaneo más largo de bibliotecas, procesamiento en segundo plano adicional y reducción de la estabilidad del sistema.",
"MessageReenableUser": "Ver abajo para volver a habilitar", "MessageReenableUser": "Ver abajo para volver a habilitar",
"MessageSettingsSaved": "Configuraciones guardadas.", "MessageSettingsSaved": "Configuraciones guardadas.",
"MessageTheFollowingLocationWillBeRemovedFromLibrary": "Las siguientes ubicaciones de medios se eliminarán de tu biblioteca:", "MessageTheFollowingLocationWillBeRemovedFromLibrary": "Las siguientes ubicaciones de medios se removerán de tu biblioteca:",
"MessageUnableToConnectToServer": "No podemos conectarnos al servidor seleccionado en este momento. Por favor, asegúrate de que está funcionando e inténtalo de nuevo.", "MessageUnableToConnectToServer": "No podemos conectarnos al servidor seleccionado en este momento. Por favor, asegúrate de que está funcionando e inténtalo de nuevo.",
"MessageUnsetContentHelp": "El contenido será mostrado como carpetas simples. Para mejores resultados utiliza el administrador de metadatos para establecer los tipos de contenido para las subcarpetas.", "MessageUnsetContentHelp": "El contenido será mostrado como carpetas simples. Para mejores resultados utiliza el administrador de metadatos para establecer los tipos de contenido para las subcarpetas.",
"MessageYouHaveVersionInstalled": "Actualmente cuentas con la versión {0} instalada.", "MessageYouHaveVersionInstalled": "Actualmente cuentas con la versión {0} instalada.",
@ -1453,7 +1453,7 @@
"LabelTranscodingFramerate": "Velocidad de cuadros de la transcodificación:", "LabelTranscodingFramerate": "Velocidad de cuadros de la transcodificación:",
"LabelSize": "Tamaño:", "LabelSize": "Tamaño:",
"SelectAdminUsername": "Por favor, selecciona un nombre de usuario para la cuenta de administrador.", "SelectAdminUsername": "Por favor, selecciona un nombre de usuario para la cuenta de administrador.",
"EnableFastImageFadeInHelp": "Habilita una animación más rápida de desvanecimiento para las imágenes cargadas", "EnableFastImageFadeInHelp": "Habilita una animación más rápida de desvanecimiento para las imágenes cargadas.",
"LabelDroppedFrames": "Cuadros saltados:", "LabelDroppedFrames": "Cuadros saltados:",
"CopyStreamURLError": "Hubo un error al copiar la URL.", "CopyStreamURLError": "Hubo un error al copiar la URL.",
"ButtonSplit": "Dividir", "ButtonSplit": "Dividir",
@ -1484,7 +1484,7 @@
"MessageConfirmAppExit": "¿Deseas salir?", "MessageConfirmAppExit": "¿Deseas salir?",
"LabelVideoResolution": "Resolución de video:", "LabelVideoResolution": "Resolución de video:",
"LabelStreamType": "Tipo de transmisión:", "LabelStreamType": "Tipo de transmisión:",
"EnableFastImageFadeIn": "Desvanecimiento rápido de las imágenes", "EnableFastImageFadeIn": "Desvanecimiento rápido de animaciones",
"LabelPlayerDimensions": "Dimensiones del reproductor:", "LabelPlayerDimensions": "Dimensiones del reproductor:",
"LabelCorruptedFrames": "Cuadros corruptos:", "LabelCorruptedFrames": "Cuadros corruptos:",
"HeaderNavigation": "Navegación", "HeaderNavigation": "Navegación",
@ -1524,5 +1524,37 @@
"HeaderRemoteAccessSettings": "Opciones de acceso remoto", "HeaderRemoteAccessSettings": "Opciones de acceso remoto",
"HeaderHttpsSettings": "Opciones HTTPS", "HeaderHttpsSettings": "Opciones HTTPS",
"HeaderDVR": "DVR", "HeaderDVR": "DVR",
"ApiKeysCaption": "Lista de claves API actualmente habilitadas" "ApiKeysCaption": "Lista de claves API actualmente habilitadas",
"SyncPlayAccessHelp": "Selecciona el nivel de acceso que este usuario tiene a la función SyncPlay. SyncPlay permite sincronizar la reproducción con otros dispositivos.",
"MessageSyncPlayErrorMedia": "¡Fallo al activar SyncPlay! Error en el archivo de medios.",
"MessageSyncPlayErrorMissingSession": "¡Fallo al activar SyncPlay! Falta la sesión.",
"MessageSyncPlayErrorNoActivePlayer": "No se ha encontrado ningún reproductor activo. SyncPlay ha sido desactivado.",
"MessageSyncPlayErrorAccessingGroups": "Se produjo un error al acceder a la lista de grupos.",
"MessageSyncPlayLibraryAccessDenied": "El acceso a este contenido está restringido.",
"MessageSyncPlayJoinGroupDenied": "Permiso requerido para usar SyncPlay.",
"MessageSyncPlayCreateGroupDenied": "Permiso requerido para crear un grupo.",
"MessageSyncPlayGroupDoesNotExist": "Fallo al unirse al grupo porque éste no existe.",
"MessageSyncPlayPlaybackPermissionRequired": "Permiso de reproducción requerido.",
"MessageSyncPlayNoGroupsAvailable": "No hay grupos disponibles. Empieza a reproducir algo primero.",
"MessageSyncPlayGroupWait": "<b>{0}</b> está cargando...",
"MessageSyncPlayUserLeft": "<b>{0}</b> abandonó el grupo.",
"MessageSyncPlayUserJoined": "<b>{0}</b> se ha unido al grupo.",
"MessageSyncPlayDisabled": "SyncPlay deshabilitado.",
"MessageSyncPlayEnabled": "SyncPlay habilitado.",
"LabelSyncPlayAccess": "Acceso a SyncPlay",
"LabelSyncPlayAccessNone": "Deshabilitado para este usuario",
"LabelSyncPlayAccessJoinGroups": "Permitir al usuario unirse a grupos",
"LabelSyncPlayAccessCreateAndJoinGroups": "Permitir al usuario crear y unirse a grupos",
"LabelSyncPlayLeaveGroupDescription": "Deshabilitar SyncPlay",
"LabelSyncPlayLeaveGroup": "Abandonar grupo",
"LabelSyncPlayNewGroupDescription": "Crear un nuevo grupo",
"LabelSyncPlayNewGroup": "Nuevo grupo",
"LabelSyncPlaySyncMethod": "Método de sincronización:",
"LabelSyncPlayPlaybackDiff": "Diferencia de tiempo de reproducción:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Tiempo compensado respecto al servidor:",
"HeaderSyncPlayEnabled": "SyncPlay habilitado",
"HeaderSyncPlaySelectGroup": "Unirse a un grupo",
"EnableDetailsBannerHelp": "Mostrar una imagen banner en la parte superior de la página de detalles del elemento.",
"EnableDetailsBanner": "Banner de detalles"
} }

View file

@ -1228,7 +1228,7 @@
"Aired": "Emitido", "Aired": "Emitido",
"AnyLanguage": "Cualquier idioma", "AnyLanguage": "Cualquier idioma",
"Anytime": "En cualquier momento", "Anytime": "En cualquier momento",
"AroundTime": "Aproximadamente {0}", "AroundTime": "Aproximadamente",
"Ascending": "Ascendente", "Ascending": "Ascendente",
"Audio": "Audio", "Audio": "Audio",
"Auto": "Automático", "Auto": "Automático",
@ -1458,8 +1458,13 @@
"ButtonSplit": "Dividir", "ButtonSplit": "Dividir",
"HeaderNavigation": "Navegación", "HeaderNavigation": "Navegación",
"MessageConfirmAppExit": "¿Quieres salir?", "MessageConfirmAppExit": "¿Quieres salir?",
<<<<<<< HEAD
"EnableFastImageFadeInHelp": "Las animaciones durarán menos tiempo", "EnableFastImageFadeInHelp": "Las animaciones durarán menos tiempo",
"EnableFastImageFadeIn": "Animaciones más rápidas", "EnableFastImageFadeIn": "Animaciones más rápidas",
=======
"EnableFastImageFadeInHelp": "Mostrar carteles y otras imágenes con difuminado rápido cuando termine la carga.",
"EnableFastImageFadeIn": "Difuminado rápido de imágenes",
>>>>>>> upstream/master
"CopyStreamURLError": "Ha habido un error copiando la dirección.", "CopyStreamURLError": "Ha habido un error copiando la dirección.",
"AllowFfmpegThrottlingHelp": "Cuando una transcodificación o un remux se adelanta lo suficiente desde la posición de reproducción actual, pause el proceso para que consuma menos recursos. Esto es más útil cuando se reproduce de forma linear, sin saltar de posición de reproducción a menudo. Desactívelo si experimenta problemas de reproducción.", "AllowFfmpegThrottlingHelp": "Cuando una transcodificación o un remux se adelanta lo suficiente desde la posición de reproducción actual, pause el proceso para que consuma menos recursos. Esto es más útil cuando se reproduce de forma linear, sin saltar de posición de reproducción a menudo. Desactívelo si experimenta problemas de reproducción.",
"PlaybackErrorNoCompatibleStream": "Este contenido no es compatible con este dispositivo y no se puede reproducir: No se puede obtener del servidor en un formato compatible.", "PlaybackErrorNoCompatibleStream": "Este contenido no es compatible con este dispositivo y no se puede reproducir: No se puede obtener del servidor en un formato compatible.",
@ -1520,10 +1525,43 @@
"HeaderHttpsSettings": "Opciones HTTPS", "HeaderHttpsSettings": "Opciones HTTPS",
"LabelRequireHttpsHelp": "Si se marca, el servidor redirigirá automáticamente todas las solicitudes de HTTP hacia HTTPS. Esto no tiene efecto si el servidor no está escuchando en HTTPS.", "LabelRequireHttpsHelp": "Si se marca, el servidor redirigirá automáticamente todas las solicitudes de HTTP hacia HTTPS. Esto no tiene efecto si el servidor no está escuchando en HTTPS.",
"LabelRequireHttps": "Necesita HTTPS", "LabelRequireHttps": "Necesita HTTPS",
"LabelEnableHttpsHelp": "Permite que el servidor escuche en el puesto HTTPS configurado. También se debe configurar un certificado válido para que esto surta efecto.", "LabelEnableHttpsHelp": "Permite que el servidor escuche en el puerto HTTPS configurado. También se debe configurar un certificado válido para que esto surta efecto.",
"LabelEnableHttps": "Activar HTTPS", "LabelEnableHttps": "Activar HTTPS",
"TabDVR": "DVR", "TabDVR": "DVR",
"SaveChanges": "Guardar cambios", "SaveChanges": "Guardar cambios",
"EnableBlurhash": "Mostrar una representación de las imágenes mientras cargan", "EnableBlurhash": "Mostrar una representación de las imágenes mientras cargan",
"EnableBlurhashHelp": "Aparecerá una representación de los colores de las imágenes antes de que terminen de cargar" "EnableBlurhashHelp": "Aparecerá una representación de los colores de las imágenes antes de que terminen de cargar",
"HeaderDVR": "DVR",
"SyncPlayAccessHelp": "Selecciona los permisos de este usuario para utilizar SyncPlay. SyncPlay te permite sincroniza la reproducción entre varios dispositivos.",
"MessageSyncPlayErrorMedia": "¡No se pudo activar SyncPlay! Error de medio.",
"MessageSyncPlayErrorMissingSession": "¡No se pudo activar SyncPlay! Sesión desconectada.",
"MessageSyncPlayErrorNoActivePlayer": "No hay reproductor activo. SyncPlay ha sido desactivado.",
"MessageSyncPlayErrorAccessingGroups": "Ocurrió un error al acceder a la lista de grupos.",
"MessageSyncPlayLibraryAccessDenied": "Acceso restringido a este contenido.",
"MessageSyncPlayJoinGroupDenied": "Requiere permiso para usar SyncPlay.",
"MessageSyncPlayCreateGroupDenied": "Requiere permiso para crear un grupo.",
"MessageSyncPlayGroupDoesNotExist": "No se pudo unir al grupo porque no existe.",
"MessageSyncPlayPlaybackPermissionRequired": "Requiere permiso para reproducir.",
"MessageSyncPlayNoGroupsAvailable": "No hay grupos disponibles. Reproduce algo primero.",
"MessageSyncPlayGroupWait": "<b>{0}</b> se está cargando...",
"MessageSyncPlayUserLeft": "<b>{0}</b> abandonó el grupo.",
"MessageSyncPlayUserJoined": "<b>{0}</b> se ha unido al grupo.",
"MessageSyncPlayDisabled": "SyncPlay inactivo.",
"MessageSyncPlayEnabled": "SyncPlay activo.",
"LabelSyncPlayAccess": "Acceso a SyncPlay",
"LabelSyncPlayAccessNone": "Inactivo para este usuario",
"LabelSyncPlayAccessJoinGroups": "Permitir a usuarios unirse a grupos",
"LabelSyncPlayAccessCreateAndJoinGroups": "Permitir a usuarios crear y unirse a grupos",
"LabelSyncPlayLeaveGroupDescription": "Inhabilitar SyncPlay",
"LabelSyncPlayLeaveGroup": "Abandonar grupo",
"LabelSyncPlayNewGroupDescription": "Crear un nuevo grupo",
"LabelSyncPlayNewGroup": "Nuevo grupo",
"LabelSyncPlaySyncMethod": "Método de sincronización:",
"LabelSyncPlayPlaybackDiff": "Diferencia del tiempo de reproducción:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Huso horario de el servidor:",
"HeaderSyncPlayEnabled": "Syncplay activo",
"HeaderSyncPlaySelectGroup": "Unirse a un grupo",
"EnableDetailsBannerHelp": "Mostrar imagen de banner en el tope de la página de detalles del elemento.",
"EnableDetailsBanner": "Barra de Detalles"
} }

42
src/strings/es_419.json Normal file
View file

@ -0,0 +1,42 @@
{
"ValueSpecialEpisodeName": "Especial - {0}",
"Sync": "Sincronizar",
"Songs": "Canciones",
"Shows": "Programas",
"Playlists": "Listas de reproducción",
"Photos": "Fotos",
"Movies": "Películas",
"HeaderNextUp": "A continuación",
"HeaderLiveTV": "TV en vivo",
"HeaderFavoriteSongs": "Canciones favoritas",
"HeaderFavoriteShows": "Programas favoritos",
"HeaderFavoriteEpisodes": "Episodios favoritos",
"HeaderFavoriteArtists": "Artistas favoritos",
"HeaderFavoriteAlbums": "Álbumes favoritos",
"HeaderContinueWatching": "Continuar viendo",
"HeaderAlbumArtists": "Artistas del álbum",
"Genres": "Géneros",
"Folders": "Carpetas",
"Favorites": "Favoritos",
"Collections": "Colecciones",
"Channels": "Canales",
"Books": "Libros",
"Artists": "Artistas",
"Albums": "Álbumes",
"TabLatest": "Recientes",
"HeaderUser": "Usuario",
"AlbumArtist": "Artista del álbum",
"Album": "Álbum",
"Aired": "Transmitido",
"AirDate": "Fecha de emisión",
"AdditionalNotificationServices": "Explora el catálogo de complementos para instalar servicios de notificaciones adicionales.",
"AddedOnValue": "Agregado {0}",
"AddToPlaylist": "Agregar a lista de reproducción",
"AddToPlayQueue": "Agregar a la cola de reproducción",
"AddToCollection": "Agregar a colección",
"AddItemToCollectionHelp": "Agrega elementos a las colecciones buscándolos y utilizando sus menúes al hacer clic derecho o al tocarlos para agregarlos a una colección.",
"Add": "Agregar",
"Actor": "Actor",
"AccessRestrictedTryAgainLater": "El acceso está restringido actualmente. Por favor, inténtalo más tarde.",
"Absolute": "Absoluto"
}

View file

@ -31,7 +31,7 @@
"AlwaysPlaySubtitlesHelp": "Les sous-titres correspondant à la préférence linguistique seront chargés indépendamment de la langue de l'audio.", "AlwaysPlaySubtitlesHelp": "Les sous-titres correspondant à la préférence linguistique seront chargés indépendamment de la langue de l'audio.",
"AnyLanguage": "N'importe quel langage", "AnyLanguage": "N'importe quel langage",
"Anytime": "N'importe quand", "Anytime": "N'importe quand",
"AroundTime": "Aux environs de {0}", "AroundTime": "Aux environs de",
"Artists": "Artistes", "Artists": "Artistes",
"AsManyAsPossible": "Autant que possible", "AsManyAsPossible": "Autant que possible",
"Ascending": "Croissant", "Ascending": "Croissant",
@ -274,7 +274,7 @@
"HeaderAddUser": "Ajouter un utilisateur", "HeaderAddUser": "Ajouter un utilisateur",
"HeaderAdditionalParts": "Parties additionelles", "HeaderAdditionalParts": "Parties additionelles",
"HeaderAdmin": "Administrateur", "HeaderAdmin": "Administrateur",
"HeaderAlbumArtists": "Artistes de l'album", "HeaderAlbumArtists": "Artistes",
"HeaderAlert": "Alerte", "HeaderAlert": "Alerte",
"HeaderAllowMediaDeletionFrom": "Autoriser la suppression de médias à partir de", "HeaderAllowMediaDeletionFrom": "Autoriser la suppression de médias à partir de",
"HeaderApiKey": "Clé API", "HeaderApiKey": "Clé API",
@ -692,7 +692,7 @@
"LabelNumberOfGuideDays": "Nombre de jours de données du guide à télécharger :", "LabelNumberOfGuideDays": "Nombre de jours de données du guide à télécharger :",
"LabelNumberOfGuideDaysHelp": "Télécharger plus de journées du guide permet de programmer des enregistrements plus longtemps à l'avance et de visualiser plus de contenus, mais prendra également plus de temps. Automatique permettra une sélection automatique basée sur le nombre de chaînes.", "LabelNumberOfGuideDaysHelp": "Télécharger plus de journées du guide permet de programmer des enregistrements plus longtemps à l'avance et de visualiser plus de contenus, mais prendra également plus de temps. Automatique permettra une sélection automatique basée sur le nombre de chaînes.",
"LabelOptionalNetworkPath": "(Optionnel) Dossier réseau partagé :", "LabelOptionalNetworkPath": "(Optionnel) Dossier réseau partagé :",
"LabelOptionalNetworkPathHelp": "Si le dossier est partagé sur votre réseau, donner accès au chemin du dossier réseau peut autoriser les applications Jellyfin sur d'autres appareils à avoir accès à ses fichiers directement.", "LabelOptionalNetworkPathHelp": "Si le dossier est partagé sur votre réseau, donner le chemin d'accès au dossier réseau peut permettre aux applications Jellyfin sur d'autres appareils d'avoir accès à ses fichiers directement. Par exemple, {0} ou {1}.",
"LabelOriginalAspectRatio": "Ratio d'aspect original :", "LabelOriginalAspectRatio": "Ratio d'aspect original :",
"LabelOriginalTitle": "Titre original :", "LabelOriginalTitle": "Titre original :",
"LabelOverview": "Synopsis :", "LabelOverview": "Synopsis :",
@ -1448,7 +1448,7 @@
"FetchingData": "Récuperer des données suplémentaires", "FetchingData": "Récuperer des données suplémentaires",
"CopyStreamURLSuccess": "URL copiée avec succès.", "CopyStreamURLSuccess": "URL copiée avec succès.",
"CopyStreamURL": "Copier l'URL du flux", "CopyStreamURL": "Copier l'URL du flux",
"LabelBaseUrlHelp": "Ajoute un sous-répertoire personnalisé à l'adresse URL du serveur. Par exemple: <code>http://example.com/<b>&lt;baseurl&gt;</b></code>", "LabelBaseUrlHelp": "Ajoute un sous-répertoire personnalisé à l'adresse URL du serveur. Par exemple: <code>http://example.com/<b>&lt;baseurl&gt;</b></code>",
"HeaderFavoritePeople": "Personnes préférées", "HeaderFavoritePeople": "Personnes préférées",
"OptionRandom": "Aléatoire", "OptionRandom": "Aléatoire",
"ButtonSplit": "Séparer", "ButtonSplit": "Séparer",
@ -1458,7 +1458,7 @@
"MessageConfirmAppExit": "Voulez-vous quitter ?", "MessageConfirmAppExit": "Voulez-vous quitter ?",
"LabelVideoResolution": "Résolution vidéo :", "LabelVideoResolution": "Résolution vidéo :",
"LabelStreamType": "Type de flux :", "LabelStreamType": "Type de flux :",
"EnableFastImageFadeInHelp": "Activer un fondu plus rapide pour l'animation des images chargées", "EnableFastImageFadeInHelp": "Activer un fondu plus rapide pour l'animation des images chargées.",
"EnableFastImageFadeIn": "Fondu d'image rapide", "EnableFastImageFadeIn": "Fondu d'image rapide",
"LabelPlayerDimensions": "Dimension du lecteur :", "LabelPlayerDimensions": "Dimension du lecteur :",
"LabelDroppedFrames": "Images perdues :", "LabelDroppedFrames": "Images perdues :",
@ -1510,7 +1510,7 @@
"HeaderFavoritePlaylists": "Listes de lecture favorites", "HeaderFavoritePlaylists": "Listes de lecture favorites",
"TabDVR": "DVR", "TabDVR": "DVR",
"LabelChromecastVersion": "Version de Chromecast", "LabelChromecastVersion": "Version de Chromecast",
"LabelEnableHttpsHelp": "Autorise le serveur à écouter les requêtes HTTPS configurées. Un certificat valide doit être configuré pour permettre ce mode de fonctionnement.", "LabelEnableHttpsHelp": "Autorise le serveur à écouter les requêtes HTTPS sur le port configurée. Un certificat valide doit être configuré pour permettre ce mode de fonctionnement.",
"LabelEnableHttps": "Activer HTTPS", "LabelEnableHttps": "Activer HTTPS",
"HeaderServerAddressSettings": "Paramètres adresses serveur", "HeaderServerAddressSettings": "Paramètres adresses serveur",
"HeaderRemoteAccessSettings": "Paramètres d'accès distant", "HeaderRemoteAccessSettings": "Paramètres d'accès distant",
@ -1521,5 +1521,37 @@
"LabelRequireHttpsHelp": "Si activé, le serveur va automatiquement rediriger toutes les requêtes en HTTP vers HTTPS. Cette option n'a aucun effet si le serveur n'écoute pas HTTPS.", "LabelRequireHttpsHelp": "Si activé, le serveur va automatiquement rediriger toutes les requêtes en HTTP vers HTTPS. Cette option n'a aucun effet si le serveur n'écoute pas HTTPS.",
"LabelRequireHttps": "Nécessite HTTPS", "LabelRequireHttps": "Nécessite HTTPS",
"LabelNightly": "De nuit", "LabelNightly": "De nuit",
"LabelStable": "Stable" "LabelStable": "Stable",
"EnableDetailsBanner": "Bannière des détails",
"EnableDetailsBannerHelp": "Affichez une image de bannière en haut de la page de détails de l'article.",
"HeaderSyncPlaySelectGroup": "Rejoindre un groupe",
"LabelSyncPlayAccessCreateAndJoinGroups": "Autoriser l'utilisateur à créer un ou rejoindre un groupe",
"LabelSyncPlayLeaveGroupDescription": "Désactiver SyncPlay",
"LabelSyncPlayLeaveGroup": "Quitter le groupe",
"LabelSyncPlayNewGroupDescription": "Créer un nouveau groupe",
"LabelSyncPlayNewGroup": "Nouveau groupe",
"LabelSyncPlaySyncMethod": "Méthode de synchronisation :",
"LabelSyncPlayPlaybackDiff": "Décalage de la lecture :",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Décalage de temps avec le serveur :",
"HeaderSyncPlayEnabled": "SyncPlay activé",
"MessageSyncPlayLibraryAccessDenied": "L'accès à ce contenu est restreint.",
"MessageSyncPlayJoinGroupDenied": "Permission requise pour utiliser SyncPlay.",
"MessageSyncPlayCreateGroupDenied": "Permission requise pour créer un groupe.",
"MessageSyncPlayGroupDoesNotExist": "Impossible de rejoindre le groupe car il n'existe pas.",
"MessageSyncPlayPlaybackPermissionRequired": "Autorisation de lecture requise.",
"MessageSyncPlayNoGroupsAvailable": "Aucun groupe disponible. Commencez par lancer quelque chose.",
"MessageSyncPlayGroupWait": "<b>{0}</b> est en train de charger...",
"MessageSyncPlayUserLeft": "<b>{0}</b> a quitté le groupe.",
"MessageSyncPlayUserJoined": "<b>{0}</b> a rejoint le groupe.",
"MessageSyncPlayDisabled": "SyncPlay désactivé.",
"MessageSyncPlayEnabled": "SyncPlay activé.",
"LabelSyncPlayAccess": "Accès SyncPlay",
"LabelSyncPlayAccessNone": "Désactivé pour cet utilisateur",
"LabelSyncPlayAccessJoinGroups": "Autoriser l'utilisateur à rejoindre un groupe",
"SyncPlayAccessHelp": "Sélectionner le niveau d'accès de cet utilisateur pour la fonctionnalité SyncPlay. SyncPlay permet de synchroniser la lecture avec d'autres utilisateurs.",
"MessageSyncPlayErrorMedia": "Impossible d'activer SyncPlay ! Erreur média.",
"MessageSyncPlayErrorMissingSession": "Impossible d'activer SyncPlay ! Session manquante.",
"MessageSyncPlayErrorNoActivePlayer": "Aucun player actif trouvé. SyncPlay a été désactivé.",
"MessageSyncPlayErrorAccessingGroups": "Une erreur s'est produite pendant l'accès à la liste de groupes."
} }

View file

@ -1393,7 +1393,7 @@
"ButtonSplit": "Szétvág", "ButtonSplit": "Szétvág",
"Absolute": "Abszolút", "Absolute": "Abszolút",
"LabelSkipIfAudioTrackPresentHelp": "Vedd ki a pipát, ha minden videóhoz szeretnél feliratot az audio nyelvétől függetlenül.", "LabelSkipIfAudioTrackPresentHelp": "Vedd ki a pipát, ha minden videóhoz szeretnél feliratot az audio nyelvétől függetlenül.",
"EnableFastImageFadeInHelp": "Gyorsabb előtűnés animáció a betöltött képekhez", "EnableFastImageFadeInHelp": "Poszterek és más képek megjelenítése gyorsabb animációkkal.",
"EnableFastImageFadeIn": "Gyors kép-előtűnés", "EnableFastImageFadeIn": "Gyors kép-előtűnés",
"SubtitleOffset": "Felirat eltolása", "SubtitleOffset": "Felirat eltolása",
"SeriesDisplayOrderHelp": "Rakd sorba az epizódokat az adásba kerülésük dátuma, a DVD sorszám, vagy az abszolút számozás szerint.", "SeriesDisplayOrderHelp": "Rakd sorba az epizódokat az adásba kerülésük dátuma, a DVD sorszám, vagy az abszolút számozás szerint.",
@ -1524,5 +1524,37 @@
"HeaderHttpsSettings": "HTTPS Beállítások", "HeaderHttpsSettings": "HTTPS Beállítások",
"TabDVR": "DVR", "TabDVR": "DVR",
"HeaderDVR": "DVR", "HeaderDVR": "DVR",
"SaveChanges": "Változtatások mentése" "SaveChanges": "Változtatások mentése",
"MessageSyncPlayGroupWait": "<b>{0}</b> bufferel...",
"MessageSyncPlayUserLeft": "<b>{0}</b> elhagyta a csoportot.",
"MessageSyncPlayUserJoined": "<b>{0}</b> csatlakozott a csoporthoz.",
"MessageSyncPlayDisabled": "SyncPlay letiltva.",
"MessageSyncPlayEnabled": "SyncPlay engedélyezve.",
"LabelSyncPlayAccess": "SyncPlay hozzáférés",
"LabelSyncPlayAccessCreateAndJoinGroups": "A felhasználó létrehozhat csoportokat és csatlakozhat hozzájuk",
"LabelSyncPlayLeaveGroupDescription": "SyncPlay letiltása",
"LabelSyncPlayLeaveGroup": "Csoport elhagyása",
"LabelSyncPlayNewGroupDescription": "Új csoport létrehozása",
"LabelSyncPlayNewGroup": "Új csoport",
"LabelSyncPlaySyncMethod": "Szinkronizálási mód:",
"MillisecondsUnit": "ms",
"HeaderSyncPlayEnabled": "SyncPlay engedélyezve",
"HeaderSyncPlaySelectGroup": "Csatlakozás csoporthoz",
"SyncPlayAccessHelp": "Válaszd ki, hogy ez a felhasználó milyen szinten férhet hozzá a SyncPlay funkcióhoz. A SyncPlay lehetőséget biztosít a lejátszások közötti szinkronizációra.",
"MessageSyncPlayErrorMedia": "Nem sikerült a SyncPlay engedélyezése! Média hiba.",
"MessageSyncPlayErrorMissingSession": "A SyncPlay lejátszása sikertelen! Hiányzó munkamenet.",
"MessageSyncPlayErrorNoActivePlayer": "Nem található aktív lejátszó. A SyncPlay letiltásra került.",
"MessageSyncPlayErrorAccessingGroups": "Hiba történt a csoportok listájának betöltésekor.",
"MessageSyncPlayLibraryAccessDenied": "A tartalomhoz való hozzáférés korlátozva van.",
"MessageSyncPlayJoinGroupDenied": "A SyncPlay használatához jogosultság szükséges.",
"MessageSyncPlayCreateGroupDenied": "Jogosultság szükséges a csoportok létrehozásához.",
"MessageSyncPlayGroupDoesNotExist": "Nem sikerült csatlakozni a csoporthoz, mivel az nem létezik.",
"MessageSyncPlayPlaybackPermissionRequired": "Lejátszási jogosultság szükséges.",
"MessageSyncPlayNoGroupsAvailable": "Nincsenek elérhető csoportok. Először kezdj el lejátszani valamit.",
"LabelSyncPlayAccessNone": "Letiltva ennél a felhasználónál",
"LabelSyncPlayAccessJoinGroups": "A felhasználó csoportokhoz való csatlakozásának engedélyezése",
"LabelSyncPlayPlaybackDiff": "Lejátszási időkülönbség:",
"LabelSyncPlayTimeOffset": "Időeltolás a szerverhez képest:",
"EnableDetailsBannerHelp": "Megjelenít egy banner képet a részletes információoldal tetején.",
"EnableDetailsBanner": "Banner a részletes oldalon"
} }

View file

@ -547,7 +547,7 @@
"LabelEmbedAlbumArtDidl": "Insluiten van albumhoezen in Didl", "LabelEmbedAlbumArtDidl": "Insluiten van albumhoezen in Didl",
"LabelEmbedAlbumArtDidlHelp": "Sommige apparaten prefereren deze methode voor het verkrijgen van albumhoezen. Anderen kunnen falen om af te spelen met deze optie ingeschakeld.", "LabelEmbedAlbumArtDidlHelp": "Sommige apparaten prefereren deze methode voor het verkrijgen van albumhoezen. Anderen kunnen falen om af te spelen met deze optie ingeschakeld.",
"LabelEnableAutomaticPortMap": "Schakel automatisch poort vertalen in", "LabelEnableAutomaticPortMap": "Schakel automatisch poort vertalen in",
"LabelEnableAutomaticPortMapHelp": "Poging om de publieke poort automatisch om te zetten naar een lokale poort via UPnP. Dit werkt niet op alle routers. De wijzigingen worden pas actief na een herstart van de server.", "LabelEnableAutomaticPortMapHelp": "Publieke poort automatisch doorsturen naar een lokale poort via UPnP. Dit werkt niet op alle routers en netwerk configuraties. De wijzigingen worden pas actief na een herstart van de server.",
"LabelEnableBlastAliveMessages": "Alive berichten zenden", "LabelEnableBlastAliveMessages": "Alive berichten zenden",
"LabelEnableBlastAliveMessagesHelp": "Zet dit aan als de server niet betrouwbaar door andere UPnP-apparaten op uw netwerk wordt gedetecteerd.", "LabelEnableBlastAliveMessagesHelp": "Zet dit aan als de server niet betrouwbaar door andere UPnP-apparaten op uw netwerk wordt gedetecteerd.",
"LabelEnableDlnaClientDiscoveryInterval": "Interval voor het zoeken naar clients (seconden)", "LabelEnableDlnaClientDiscoveryInterval": "Interval voor het zoeken naar clients (seconden)",
@ -666,7 +666,7 @@
"LabelNumberOfGuideDays": "Aantal dagen van de gids om te downloaden:", "LabelNumberOfGuideDays": "Aantal dagen van de gids om te downloaden:",
"LabelNumberOfGuideDaysHelp": "Het downloaden van meer dagen van de gids gegevens biedt de mogelijkheid verder vooruit te plannen en een beter overzicht geven, maar het zal ook langer duren om te downloaden. Auto kiest op basis van het aantal kanalen.", "LabelNumberOfGuideDaysHelp": "Het downloaden van meer dagen van de gids gegevens biedt de mogelijkheid verder vooruit te plannen en een beter overzicht geven, maar het zal ook langer duren om te downloaden. Auto kiest op basis van het aantal kanalen.",
"LabelOptionalNetworkPath": "(Optioneel) Gedeelde netwerkmap:", "LabelOptionalNetworkPath": "(Optioneel) Gedeelde netwerkmap:",
"LabelOptionalNetworkPathHelp": "Als deze map wordt gedeeld op uw netwerk, kunnen middels het netwerkpad Jellyfin apps op andere apparaten rechtstreeks toegang tot mediabestanden krijgen.", "LabelOptionalNetworkPathHelp": "Als deze map wordt gedeeld op uw netwerk, kunnen middels het netwerkpad Jellyfin apps op andere apparaten rechtstreeks toegang tot mediabestanden krijgen. Bijvoorbeeld {0} or {1}.",
"LabelOriginalAspectRatio": "Originele aspect ratio:", "LabelOriginalAspectRatio": "Originele aspect ratio:",
"LabelOriginalTitle": "Orginele titel:", "LabelOriginalTitle": "Orginele titel:",
"LabelOverview": "Overzicht:", "LabelOverview": "Overzicht:",
@ -980,7 +980,7 @@
"OptionMissingEpisode": "Ontbrekende Afleveringen", "OptionMissingEpisode": "Ontbrekende Afleveringen",
"OptionMonday": "Maandag", "OptionMonday": "Maandag",
"OptionNameSort": "Naam", "OptionNameSort": "Naam",
"OptionNew": "Nieuw ...", "OptionNew": "Nieuw",
"OptionNone": "Geen", "OptionNone": "Geen",
"OptionOnAppStartup": "Op applicatie start", "OptionOnAppStartup": "Op applicatie start",
"OptionOnInterval": "Op interval", "OptionOnInterval": "Op interval",
@ -1406,14 +1406,14 @@
"LabelAudioCodec": "Audio codec:", "LabelAudioCodec": "Audio codec:",
"LabelAudioChannels": "Audio kanalen:", "LabelAudioChannels": "Audio kanalen:",
"LabelBitrate": "Bitrate:", "LabelBitrate": "Bitrate:",
"LabelBaseUrlHelp": "Hier kunt u een eigen subdirectory toevoegen om de server te bereiken doormiddel van een meer unieke URL.", "LabelBaseUrlHelp": "Voegt een aangepaste submap toe aan de server-URL. Bijvoorbeeld: <code>http://example.com/<b>&lt;baseurl&gt;</b></code>",
"LabelFolder": "Folder:", "LabelFolder": "Folder:",
"LabelLineup": "Lineup:", "LabelLineup": "Lineup:",
"LabelPlayer": "Speler:", "LabelPlayer": "Speler:",
"LabelPlayMethod": "Afspeel methode:", "LabelPlayMethod": "Afspeel methode:",
"LabelPleaseRestart": "De wijzigingen zullen worden toegepast na het handmatig herladen van de web cliënt.", "LabelPleaseRestart": "De wijzigingen zullen worden toegepast na het handmatig herladen van de web cliënt.",
"LabelStatus": "Status:", "LabelStatus": "Status:",
"LabelTagline": "Label lijn:", "LabelTagline": "Label tekst:",
"LabelTranscodingContainer": "Container:", "LabelTranscodingContainer": "Container:",
"LabelTranscodePath": "Transcodeer pad:", "LabelTranscodePath": "Transcodeer pad:",
"LabelTranscodes": "Transcoderen:", "LabelTranscodes": "Transcoderen:",
@ -1471,8 +1471,8 @@
"Artist": "Artiest", "Artist": "Artiest",
"AllowFfmpegThrottlingHelp": "Wanneer een transcode of remux ver genoeg voorloopt op de huidige afspeelpositie, pauzeer het proces, zodat het minder middelen verbruikt. Dit is vooral handig wanneer u kijkt zonder vaak te zoeken. Schakel dit uit als u afspeelproblemen ondervindt.", "AllowFfmpegThrottlingHelp": "Wanneer een transcode of remux ver genoeg voorloopt op de huidige afspeelpositie, pauzeer het proces, zodat het minder middelen verbruikt. Dit is vooral handig wanneer u kijkt zonder vaak te zoeken. Schakel dit uit als u afspeelproblemen ondervindt.",
"AllowFfmpegThrottling": "Throttle Transcodes", "AllowFfmpegThrottling": "Throttle Transcodes",
"EnableFastImageFadeInHelp": "Schakel snellere vervagings-animatie in voor ingeladen afbeeldingen", "EnableFastImageFadeInHelp": "Toon posters en andere afbeeldingen met een snellere fade-animatie wanneer ze klaar zijn met laden.",
"EnableFastImageFadeIn": "Snelle afbeeldingsvervaging", "EnableFastImageFadeIn": "Fast Image Fade Animaties",
"LabelPlayerDimensions": "Afspeellengte:", "LabelPlayerDimensions": "Afspeellengte:",
"LabelLibraryPageSizeHelp": "Kies het aantal artikelen dat wordt weergegeven op een bibliotheekpagina. Kies 0 om dit te verbergen.", "LabelLibraryPageSizeHelp": "Kies het aantal artikelen dat wordt weergegeven op een bibliotheekpagina. Kies 0 om dit te verbergen.",
"LabelLibraryPageSize": "Bibliotheekpagina grootte:", "LabelLibraryPageSize": "Bibliotheekpagina grootte:",
@ -1514,5 +1514,53 @@
"SelectAdminUsername": "Selecteer een gebruikersnaam voor het beheerder account.", "SelectAdminUsername": "Selecteer een gebruikersnaam voor het beheerder account.",
"HeaderFavoritePlaylists": "Favoriete afspeellijsten", "HeaderFavoritePlaylists": "Favoriete afspeellijsten",
"ButtonTogglePlaylist": "Afspeellijst", "ButtonTogglePlaylist": "Afspeellijst",
"ButtonToggleContextMenu": "Meer" "ButtonToggleContextMenu": "Meer",
"LabelRequireHttpsHelp": "Indien aangevinkt, zal de server alle verzoeken via HTTP automatisch omleiden naar HTTPS. Dit heeft geen effect als de server niet luistert op HTTPS.",
"EnableDetailsBanner": "Details Banner",
"MessageSyncPlayNoGroupsAvailable": "Geen groepen beschikbaar. Begin eerst iets te spelen.",
"EnableDetailsBannerHelp": "Toon een bannerafbeelding bovenaan de pagina met itemdetails.",
"TabDVR": "DVR",
"SyncPlayAccessHelp": "Selecteer het toegangsniveau dat deze gebruiker heeft tot de SyncPlay-functie. SyncPlay maakt het mogelijk om het afspelen met andere gebruikers te synchroniseren.",
"Filter": "Filter",
"New": "Nieuw",
"SaveChanges": "Wijzigingen opslaan",
"MessageSyncPlayErrorMedia": "Kan SyncPlay niet inschakelen! Media fout.",
"MessageSyncPlayErrorMissingSession": "Kan SyncPlay niet inschakelen! Ontbrekende sessie.",
"MessageSyncPlayErrorNoActivePlayer": "Geen actieve speler gevonden. SyncPlay is uitgeschakeld.",
"MessageSyncPlayErrorAccessingGroups": "Er is een fout opgetreden bij het openen van de groepslijst.",
"MessageSyncPlayLibraryAccessDenied": "Toegang tot deze inhoud is beperkt.",
"MessageSyncPlayJoinGroupDenied": "Toestemming vereist om SyncPlay te gebruiken.",
"MessageSyncPlayCreateGroupDenied": "Toestemming vereist om een groep te maken.",
"MessageSyncPlayGroupDoesNotExist": "Kan niet deelnemen aan de groep omdat deze niet bestaat.",
"MessageSyncPlayPlaybackPermissionRequired": "Afspeelrechten vereist.",
"MessageSyncPlayGroupWait": "<b>{0}</b> is aan het bufferen...",
"MessageSyncPlayUserLeft": "<b>{0}</b> i heeft de groep verlaten.",
"MessageSyncPlayUserJoined": "<b>{0}</b> is lid geworden van de groep.",
"MessageSyncPlayDisabled": "SyncPlay uitgeschakeld.",
"MessageSyncPlayEnabled": "SyncPlay ingeschakeld.",
"LabelSyncPlayAccess": "SyncPlay toegang",
"LabelSyncPlayAccessNone": "Uitgeschakeld voor deze gebruiker",
"LabelSyncPlayAccessJoinGroups": "Sta de gebruiker toe om groepen te maken",
"LabelSyncPlayAccessCreateAndJoinGroups": "Sta de gebruiker toe om groepen te maken en eraan deel te nemen",
"LabelSyncPlayLeaveGroupDescription": "SyncPlay uitschakelen",
"LabelSyncPlayLeaveGroup": "Groep verlaten",
"LabelSyncPlayNewGroupDescription": "Maak een nieuwe groep",
"LabelSyncPlayNewGroup": "Nieuwe groep",
"LabelSyncPlaySyncMethod": "Sync methode:",
"LabelSyncPlayPlaybackDiff": "Verschil in afspeeltijd:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Tijd offset met de server:",
"LabelRequireHttps": "HTTPS verplichten",
"LabelNightly": "Nightly",
"LabelStable": "Stabiel",
"LabelChromecastVersion": "Chromecast versie",
"LabelEnableHttpsHelp": "Hiermee kan de server luisteren op de geconfigureerde HTTPS-poort. Hiervoor moet ook een geldig certificaat worden geconfigureerd.",
"LabelEnableHttps": "HTTPS inschakelen",
"HeaderSyncPlayEnabled": "SyncPlay ingeschakeld",
"HeaderSyncPlaySelectGroup": "Word lid van een groep",
"HeaderServerAddressSettings": "Server adres instellingen",
"HeaderRemoteAccessSettings": "Externe toegang instellingen",
"HeaderHttpsSettings": "HTTPS instellingen",
"HeaderDVR": "DVR",
"ApiKeysCaption": "Lijst met de momenteel ingeschakelde API-sleutels"
} }

View file

@ -30,7 +30,7 @@
"AlwaysPlaySubtitlesHelp": "As legendas que combinarem com a preferência de idioma serão carregadas independente do idioma do áudio.", "AlwaysPlaySubtitlesHelp": "As legendas que combinarem com a preferência de idioma serão carregadas independente do idioma do áudio.",
"AnyLanguage": "Qualquer idioma", "AnyLanguage": "Qualquer idioma",
"Anytime": "A qualquer momento", "Anytime": "A qualquer momento",
"AroundTime": "Aproximadamente {0}", "AroundTime": "Aproximadamente",
"Art": "Arte", "Art": "Arte",
"Artists": "Artistas", "Artists": "Artistas",
"AsManyAsPossible": "Quantos forem possíveis", "AsManyAsPossible": "Quantos forem possíveis",
@ -1121,7 +1121,7 @@
"RefreshMetadata": "Atualizar metadados", "RefreshMetadata": "Atualizar metadados",
"RefreshQueued": "Atualização enfileirada.", "RefreshQueued": "Atualização enfileirada.",
"ReleaseDate": "Data de lançamento", "ReleaseDate": "Data de lançamento",
"RememberMe": "Lembre-me", "RememberMe": "Lembrar-me",
"RemoveFromCollection": "Remover da coletânea", "RemoveFromCollection": "Remover da coletânea",
"RemoveFromPlaylist": "Remover da lista de reprodução", "RemoveFromPlaylist": "Remover da lista de reprodução",
"Repeat": "Repetir", "Repeat": "Repetir",
@ -1465,7 +1465,7 @@
"AskAdminToCreateLibrary": "Peça a um administrador para criar uma biblioteca.", "AskAdminToCreateLibrary": "Peça a um administrador para criar uma biblioteca.",
"AllowFfmpegThrottling": "Transcodes do Acelerador", "AllowFfmpegThrottling": "Transcodes do Acelerador",
"PlaybackErrorNoCompatibleStream": "Este cliente não é compatível com a media e o servidor não está enviando um formato de mídia compatível.", "PlaybackErrorNoCompatibleStream": "Este cliente não é compatível com a media e o servidor não está enviando um formato de mídia compatível.",
"EnableFastImageFadeInHelp": "Habilitar animações rápidas de aparecimento para imagens carregadas", "EnableFastImageFadeInHelp": "Mostrar pôsteres e outras imagens com uma animação mais rápida ao terminar de carregar.",
"LabelDroppedFrames": "Quadros caídos:", "LabelDroppedFrames": "Quadros caídos:",
"AllowFfmpegThrottlingHelp": "Quando uma transcodificação ou remux estiver suficientemente avançada da posição atual de reprodução, pause o processo para que consuma menos recursos. Isso é mais proveitoso para quando não há avanço ou retrocesso do vídeo com frequência. Desative se tiver problemas de reprodução.", "AllowFfmpegThrottlingHelp": "Quando uma transcodificação ou remux estiver suficientemente avançada da posição atual de reprodução, pause o processo para que consuma menos recursos. Isso é mais proveitoso para quando não há avanço ou retrocesso do vídeo com frequência. Desative se tiver problemas de reprodução.",
"PreferEmbeddedEpisodeInfosOverFileNames": "Preferir informações dos episódios incorporadas nos arquivos ao invés dos nomes", "PreferEmbeddedEpisodeInfosOverFileNames": "Preferir informações dos episódios incorporadas nos arquivos ao invés dos nomes",
@ -1515,10 +1515,42 @@
"LabelNightly": "Nightly", "LabelNightly": "Nightly",
"LabelStable": "Estável", "LabelStable": "Estável",
"LabelChromecastVersion": "Versão do Chromecast", "LabelChromecastVersion": "Versão do Chromecast",
"LabelEnableHttpsHelp": "Habilita que o servidor escute na localização HTTPS configurada. Um certificado válido também deve ser configurado para que isso entre em vigor.", "LabelEnableHttpsHelp": "Habilita que o servidor escute na porta HTTPS configurada. Um certificado válido também deve ser configurado para que isso entre em vigor.",
"LabelEnableHttps": "Habilitar HTTPS", "LabelEnableHttps": "Habilitar HTTPS",
"HeaderServerAddressSettings": "Configurações da localização do servidor", "HeaderServerAddressSettings": "Configurações da localização do servidor",
"HeaderRemoteAccessSettings": "Configurações de acesso remoto", "HeaderRemoteAccessSettings": "Configurações de acesso remoto",
"HeaderHttpsSettings": "Configurações HTTPS", "HeaderHttpsSettings": "Configurações HTTPS",
"HeaderDVR": "DVR" "HeaderDVR": "DVR",
"LabelSyncPlayTimeOffset": "Diferença de tempo com o servidor:",
"SyncPlayAccessHelp": "Selecione o nível de acesso desse usuário aos recursos do SyncPlay. SyncPlay habilita a reprodução sincronizada com outros usuários.",
"MessageSyncPlayErrorMedia": "Falha ao ativar SyncPlay! Erro de mídia.",
"MessageSyncPlayErrorMissingSession": "Falha ao ativar SyncPlay! Sessão em falta.",
"MessageSyncPlayErrorNoActivePlayer": "Nenhum reprodutor ativo encontrado. SyncPlay foi desativado.",
"MessageSyncPlayErrorAccessingGroups": "Ocorreu um erro ao acessar a lista de grupos.",
"MessageSyncPlayLibraryAccessDenied": "O acesso a esse conteúdo é restrito.",
"MessageSyncPlayJoinGroupDenied": "Permissão necessária para usar SyncPlay.",
"MessageSyncPlayCreateGroupDenied": "Permissão necessária para criar um grupo.",
"MessageSyncPlayGroupDoesNotExist": "Falha ao participar de grupo pois o mesmo não existe.",
"MessageSyncPlayPlaybackPermissionRequired": "É necessária permissão de reprodução.",
"MessageSyncPlayNoGroupsAvailable": "Nenhum grupo disponível. Comece a reproduzir algo primeiro.",
"MessageSyncPlayGroupWait": "<b>{0}</b> está carregando...",
"MessageSyncPlayUserLeft": "<b>{0}</b> deixou o grupo.",
"MessageSyncPlayUserJoined": "<b>{0}</b> se juntou ao grupo.",
"MessageSyncPlayDisabled": "SyncPlay desativado.",
"MessageSyncPlayEnabled": "SyncPlay ativado.",
"LabelSyncPlayAccess": "Acesso ao SyncPlay",
"LabelSyncPlayAccessNone": "Desativado para esse usuário",
"LabelSyncPlayAccessJoinGroups": "Permitir que o usuário participe de grupos",
"LabelSyncPlayAccessCreateAndJoinGroups": "Permitir que o usuário crie e participe em grupos",
"LabelSyncPlayLeaveGroupDescription": "Desativar SyncPlay",
"LabelSyncPlayLeaveGroup": "Deixar grupo",
"LabelSyncPlayNewGroupDescription": "Criar novo grupo",
"LabelSyncPlayNewGroup": "Novo grupo",
"LabelSyncPlaySyncMethod": "Método de sincronização:",
"LabelSyncPlayPlaybackDiff": "Diferença no tempo de reprodução:",
"MillisecondsUnit": "ms",
"HeaderSyncPlayEnabled": "SyncPlay ativado",
"HeaderSyncPlaySelectGroup": "Entrar em um grupo",
"EnableDetailsBanner": "Banner de detalhes",
"EnableDetailsBannerHelp": "Exibe um banner na parte superior da página de detalhes do item."
} }

View file

@ -1238,7 +1238,7 @@
"Repeat": "Repetă", "Repeat": "Repetă",
"RemoveFromPlaylist": "Scoateți din lista de redare", "RemoveFromPlaylist": "Scoateți din lista de redare",
"RemoveFromCollection": "Scoateți din colecție", "RemoveFromCollection": "Scoateți din colecție",
"RememberMe": "Ține-mă minte", "RememberMe": "Ține-mă Minte",
"ReleaseDate": "Data lansării", "ReleaseDate": "Data lansării",
"RefreshQueued": "Actualizare adăugată în coadă.", "RefreshQueued": "Actualizare adăugată în coadă.",
"RefreshMetadata": "Actualizați metadatele", "RefreshMetadata": "Actualizați metadatele",
@ -1454,8 +1454,8 @@
"HeaderNavigation": "Navigare", "HeaderNavigation": "Navigare",
"MessageConfirmAppExit": "Vrei să ieși?", "MessageConfirmAppExit": "Vrei să ieși?",
"CopyStreamURLError": "A apărut o eroare la copierea adresei URL.", "CopyStreamURLError": "A apărut o eroare la copierea adresei URL.",
"EnableFastImageFadeInHelp": "Activați animația mai rapidă de tranziție pentru imaginile încărcate", "EnableFastImageFadeInHelp": "Arătați postere și alte imagini cu o animație de tranziție rapidă când sunt deja încărcate.",
"EnableFastImageFadeIn": "Tranziție a imaginii rapidă", "EnableFastImageFadeIn": "Animație de Tranziție a Imaginii Rapidă",
"LabelVideoResolution": "Rezoluția video:", "LabelVideoResolution": "Rezoluția video:",
"LabelStreamType": "Tipul streamului:", "LabelStreamType": "Tipul streamului:",
"LabelPlayerDimensions": "Dimensiunile soft redare:", "LabelPlayerDimensions": "Dimensiunile soft redare:",
@ -1519,5 +1519,37 @@
"HeaderHttpsSettings": "Setări https", "HeaderHttpsSettings": "Setări https",
"TabDVR": "DVR", "TabDVR": "DVR",
"SaveChanges": "Salvează modificările", "SaveChanges": "Salvează modificările",
"HeaderDVR": "DVR" "HeaderDVR": "DVR",
"SyncPlayAccessHelp": "Selectați nivelul de acces pe care îl are acest utilizator la funcția SyncPlay. SyncPlay permite sincronizarea redării cu alte dispozitive.",
"MessageSyncPlayErrorMedia": "Eroare la activarea SyncPlay! Eroare media.",
"MessageSyncPlayErrorMissingSession": "Eroare la activarea SyncPlay! Sesiune lipsă.",
"MessageSyncPlayErrorNoActivePlayer": "Nu a fost găsit niciun soft de redare activ. SyncPlay a fost dezactivat.",
"MessageSyncPlayErrorAccessingGroups": "A apărut o eroare la accesarea listei de grupuri.",
"MessageSyncPlayLibraryAccessDenied": "Accesul la acest conținut este restricționat.",
"MessageSyncPlayJoinGroupDenied": "Permisiune necesară pentru a utiliza SyncPlay.",
"MessageSyncPlayCreateGroupDenied": "Permisiune necesară pentru crearea unui grup.",
"MessageSyncPlayGroupDoesNotExist": "Nu a reușit să se alăture grupului, deoarece nu există.",
"MessageSyncPlayPlaybackPermissionRequired": "Este necesară permisiunea de redare.",
"MessageSyncPlayNoGroupsAvailable": "Nu există grupuri disponibile. Începe să redai ceva mai întâi.",
"MessageSyncPlayGroupWait": "<b>{0}</b> se încarcă...",
"MessageSyncPlayUserLeft": "<b>{0}</b> a părăsit grupul.",
"MessageSyncPlayUserJoined": "<b>{0}</b> s-a alăturat grupului.",
"MessageSyncPlayDisabled": "SyncPlay dezactivat.",
"MessageSyncPlayEnabled": "SyncPlay activat.",
"LabelSyncPlayAccess": "Acces SyncPlay",
"LabelSyncPlayAccessNone": "Dezactivat pentru acest utilizator",
"LabelSyncPlayAccessJoinGroups": "Permiteți utilizatorului să se alăture grupurilor",
"LabelSyncPlayAccessCreateAndJoinGroups": "Permiteți utilizatorului să creeze și să se alăture grupurilor",
"LabelSyncPlayLeaveGroupDescription": "Dezactivează SyncPlay",
"LabelSyncPlayLeaveGroup": "Parăsește grup",
"LabelSyncPlayNewGroupDescription": "Crează un grup nou",
"LabelSyncPlayNewGroup": "Grup nou",
"LabelSyncPlaySyncMethod": "Metoda de sincronizare:",
"LabelSyncPlayPlaybackDiff": "Diferența de timp de redare:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Decalare de timp cu serverul:",
"HeaderSyncPlayEnabled": "SyncPlay activat",
"HeaderSyncPlaySelectGroup": "Alăturați-vă unui grup",
"EnableDetailsBannerHelp": "Afișați o imagine de bandou în partea de sus a paginii cu detalii ale articolului.",
"EnableDetailsBanner": "Detalii Bandou"
} }

View file

@ -587,7 +587,7 @@
"LabelEmbedAlbumArtDidl": "Внедрять альбомные обложки в DIDL", "LabelEmbedAlbumArtDidl": "Внедрять альбомные обложки в DIDL",
"LabelEmbedAlbumArtDidlHelp": "Для некоторых устройств данный метод получения альбомных обложек является предпочтительным. Остальные могут быть не в состоянии воспроизводить, при включении данной опции.", "LabelEmbedAlbumArtDidlHelp": "Для некоторых устройств данный метод получения альбомных обложек является предпочтительным. Остальные могут быть не в состоянии воспроизводить, при включении данной опции.",
"LabelEnableAutomaticPortMap": "Включить автоматическое сопоставление портов", "LabelEnableAutomaticPortMap": "Включить автоматическое сопоставление портов",
"LabelEnableAutomaticPortMapHelp": "Автоматическое перенаправление публичных портов маршрутизатора на локальные порты сервера через UPnP. Это может не работать с некоторыми моделями маршрутизаторов или сетевых конфигураций. Изменения не применяются до перезапуска сервера.", "LabelEnableAutomaticPortMapHelp": "Автоматическое перенаправление публичных портов маршрутизатора на локальные порты сервера через UPnP. Это может не работать с некоторыми моделями маршрутизаторов или сетевых конфигураций. Изменения не применяются до перезапуска сервера.",
"LabelEnableBlastAliveMessages": "Бомбардировать сообщениями проверки активности", "LabelEnableBlastAliveMessages": "Бомбардировать сообщениями проверки активности",
"LabelEnableBlastAliveMessagesHelp": "Включите, если сервер надёжно не обнаруживается иными UPnP устройствами в своей сети.", "LabelEnableBlastAliveMessagesHelp": "Включите, если сервер надёжно не обнаруживается иными UPnP устройствами в своей сети.",
"LabelEnableDlnaClientDiscoveryInterval": "Интервал обнаружения клиентов", "LabelEnableDlnaClientDiscoveryInterval": "Интервал обнаружения клиентов",
@ -1460,8 +1460,8 @@
"HeaderNavigation": "Навигация", "HeaderNavigation": "Навигация",
"LabelVideoResolution": "Разрешение видео:", "LabelVideoResolution": "Разрешение видео:",
"LabelStreamType": "Тип потока:", "LabelStreamType": "Тип потока:",
"EnableFastImageFadeInHelp": "Включить быстрое появление анимации для загруженных изображений", "EnableFastImageFadeInHelp": "Показывать постеры и другие рисунки анимацией побыстрее , когда они закончат загружаться.",
"EnableFastImageFadeIn": "Быстрое появление изображения", "EnableFastImageFadeIn": "Быстрое анимация рисунка",
"LabelPlayerDimensions": "Размеры проигрывателя:", "LabelPlayerDimensions": "Размеры проигрывателя:",
"LabelDroppedFrames": "Пропущенные кадры:", "LabelDroppedFrames": "Пропущенные кадры:",
"LabelCorruptedFrames": "Испорченные кадры:", "LabelCorruptedFrames": "Испорченные кадры:",
@ -1520,5 +1520,37 @@
"HeaderServerAddressSettings": "Параметры адреса сервера", "HeaderServerAddressSettings": "Параметры адреса сервера",
"HeaderRemoteAccessSettings": "Параметры удалённого доступа", "HeaderRemoteAccessSettings": "Параметры удалённого доступа",
"HeaderHttpsSettings": "Параметры HTTPS", "HeaderHttpsSettings": "Параметры HTTPS",
"HeaderDVR": "DVR" "HeaderDVR": "DVR",
"MessageSyncPlayJoinGroupDenied": "Требуется разрешение для использования SyncPlay.",
"MessageSyncPlayDisabled": "SyncPlay отключен.",
"MessageSyncPlayEnabled": "SyncPlay включён.",
"LabelSyncPlayAccess": "Доступ к SyncPlay",
"LabelSyncPlayLeaveGroupDescription": "Отключить SyncPlay",
"HeaderSyncPlayEnabled": "SyncPlay включён",
"HeaderSyncPlaySelectGroup": "Присоединить группу",
"EnableDetailsBanner": "Баннер подробностей",
"EnableDetailsBannerHelp": "Отображает рисунок баннера вверху страницы подробностей элемента.",
"MessageSyncPlayErrorAccessingGroups": "Произошла ошибка при попытке доступа к списку групп.",
"MessageSyncPlayLibraryAccessDenied": "Доступ к данному содержанию ограничен.",
"MessageSyncPlayCreateGroupDenied": "Требуется разрешение для создания группы.",
"MessageSyncPlayGroupDoesNotExist": "Не удалось присоединиться к группе, поскольку она не существует.",
"MessageSyncPlayPlaybackPermissionRequired": "Требуется разрешение на воспроизведение.",
"MessageSyncPlayNoGroupsAvailable": "Никакие группы не доступны. Сначала начните воспроизводить что-нибудь.",
"MessageSyncPlayGroupWait": "<b>{0}</b> буферизуется...",
"MessageSyncPlayUserLeft": "<b>{0}</b> покинул группу.",
"MessageSyncPlayUserJoined": "<b>{0}</b> присоединил группу.",
"LabelSyncPlayAccessNone": "Отключено для данного пользователя",
"LabelSyncPlayAccessJoinGroups": "Разрешить пользователю присоединяться к группам",
"LabelSyncPlayAccessCreateAndJoinGroups": "Разрешить пользователю создавать и присоединять группы",
"LabelSyncPlayLeaveGroup": "Покинуть группу",
"LabelSyncPlayNewGroupDescription": "Создание новой группы",
"LabelSyncPlayNewGroup": "Новая группа",
"LabelSyncPlaySyncMethod": "Метод синхронизации:",
"LabelSyncPlayPlaybackDiff": "Разница времени воспроизведения:",
"MillisecondsUnit": "мс",
"LabelSyncPlayTimeOffset": "Сдвиг времени относительно сервера:",
"SyncPlayAccessHelp": "Выберите уровень доступа данного пользователя к функциональности SyncPlay. SyncPlay позволяет синхронизировать воспроизведение с другими устройствами.",
"MessageSyncPlayErrorMedia": "Не удалось включить SyncPlay! Ошибка медиаданных.",
"MessageSyncPlayErrorMissingSession": "Не удалось включить SyncPlay! Отсутствует сеанс.",
"MessageSyncPlayErrorNoActivePlayer": "Активный проигрыватель не найден. SyncPlay был отключен."
} }

View file

@ -721,7 +721,7 @@
"Refresh": "Obnoviť", "Refresh": "Obnoviť",
"RefreshMetadata": "Obnoviť metadáta", "RefreshMetadata": "Obnoviť metadáta",
"ReleaseDate": "Dátum vydania", "ReleaseDate": "Dátum vydania",
"RememberMe": "Zapamätať si ma", "RememberMe": "Zapamätaj si ma",
"RemoveFromCollection": "Odobrať z kolekcie", "RemoveFromCollection": "Odobrať z kolekcie",
"Repeat": "Opakovať", "Repeat": "Opakovať",
"RepeatAll": "Opakovať všetko", "RepeatAll": "Opakovať všetko",
@ -1457,8 +1457,8 @@
"MessageConfirmAppExit": "Chceli by ste odísiť?", "MessageConfirmAppExit": "Chceli by ste odísiť?",
"LabelVideoResolution": "Rozlíšenie videa:", "LabelVideoResolution": "Rozlíšenie videa:",
"LabelStreamType": "Typ streamu:", "LabelStreamType": "Typ streamu:",
"EnableFastImageFadeInHelp": "Povoliť animáciu rýchleho rozjasnenia pre nahrané obrázky", "EnableFastImageFadeInHelp": "Zobrazí plagát a ostatné obrázky s rýchlejšou animáciou prechodu po dokončení načítania.",
"EnableFastImageFadeIn": "Rýchle rozjasnenie obrázku", "EnableFastImageFadeIn": "Rýchla animácia prechodu obrázku",
"LabelPlayerDimensions": "Rozmery prehrávača:", "LabelPlayerDimensions": "Rozmery prehrávača:",
"LabelDroppedFrames": "Vynechané snímky:", "LabelDroppedFrames": "Vynechané snímky:",
"LabelCorruptedFrames": "Poškodené snímky:", "LabelCorruptedFrames": "Poškodené snímky:",
@ -1521,5 +1521,37 @@
"HeaderRemoteAccessSettings": "Nastavenie vzdialeného prístupu", "HeaderRemoteAccessSettings": "Nastavenie vzdialeného prístupu",
"HeaderHttpsSettings": "Nastavenia HTTPS", "HeaderHttpsSettings": "Nastavenia HTTPS",
"HeaderDVR": "DVR", "HeaderDVR": "DVR",
"SaveChanges": "Uložiť zmeny" "SaveChanges": "Uložiť zmeny",
"MessageSyncPlayErrorMedia": "Povolenie synchronizácie prehrávania zlyhalo! Chyba média.",
"MessageSyncPlayErrorMissingSession": "Zapnutie synchronizácie prehrávania zlyhalo! Aktívna relácia nebola nájdená.",
"MessageSyncPlayErrorNoActivePlayer": "Nebol nájdený žiadny aktívny prehrávač. Synchronizácia prehrávania bola vypnutá.",
"MessageSyncPlayErrorAccessingGroups": "Pri načítaní zoznamu skupín sa vyskytla chyba.",
"MessageSyncPlayLibraryAccessDenied": "Prístup k tomuto obsahuje je obmedzený.",
"MessageSyncPlayJoinGroupDenied": "K použitiu synchronizácie prehrávania je vyžadované povolenie.",
"MessageSyncPlayCreateGroupDenied": "K vytvoreniu skupiny je požadované povolenie.",
"MessageSyncPlayGroupDoesNotExist": "Pripojenie ku skupine zlyhalo, pretože skupina neexistuje.",
"MessageSyncPlayPlaybackPermissionRequired": "K prehrávaniu je potrebné povolenie.",
"MessageSyncPlayNoGroupsAvailable": "Nie je dostupná žiadna skupina. Skúste najskôr začať niečo prehrávať.",
"MessageSyncPlayGroupWait": "Prehrávanie používateľa <b>{0}</b> sa načítava...",
"MessageSyncPlayUserLeft": "Používateľ <b>{0}</b> opustil skupinu.",
"MessageSyncPlayUserJoined": "Používateľ <b>{0}</b> sa pripojil k skupine.",
"MessageSyncPlayDisabled": "Synchronizácia prehrávania zakázana.",
"MessageSyncPlayEnabled": "Synchronizácia prehrávania povolená.",
"LabelSyncPlayAccess": "Prístup k synchronizácií prehrávania",
"LabelSyncPlayAccessNone": "Zakázať pre tohoto používateľa",
"LabelSyncPlayAccessJoinGroups": "Povoliť použivateľovi pripájať sa do skupín",
"LabelSyncPlayAccessCreateAndJoinGroups": "Povoliť používateľovi vytvárať a pripájať sa do skupín",
"LabelSyncPlayLeaveGroupDescription": "Zakázať synchronizáciu prehrávania",
"LabelSyncPlayLeaveGroup": "Opustiť skupinu",
"LabelSyncPlayNewGroupDescription": "Vytvoriť novú skupinu",
"LabelSyncPlayNewGroup": "Nová skupina",
"LabelSyncPlaySyncMethod": "Spôsob synchronizácie:",
"LabelSyncPlayPlaybackDiff": "Rozdiel v dobe prehrávania:",
"MillisecondsUnit": "ms",
"LabelSyncPlayTimeOffset": "Časový rozdiel so serverom:",
"HeaderSyncPlayEnabled": "Synchronizácia prehrávania je povolená",
"HeaderSyncPlaySelectGroup": "Pripojiť sa k skupine",
"SyncPlayAccessHelp": "Vyberte úroveň prístupu pre tohto používateľa k funkcií synchronizácie prehrávania. Synchronizácia prehrávania umožňuje zosynchronizovať prehrávanie s ostatnými zariadeniami.",
"EnableDetailsBannerHelp": "Zobrazí banner na vrchnej časti detailu položky.",
"EnableDetailsBanner": "Detail banneru"
} }

View file

@ -13,7 +13,7 @@
"Albums": "Album", "Albums": "Album",
"All": "Alla", "All": "Alla",
"AllChannels": "Alla kanaler", "AllChannels": "Alla kanaler",
"AllComplexFormats": "Alla komplexa format (ASS, SSA, VOBSUB, PGS, SUB/IDX, etc.)", "AllComplexFormats": "Alla komplexa format (ASS, SSA, VOBSUB, PGS, SUB/IDX, ...)",
"AllEpisodes": "Alla avsnitt", "AllEpisodes": "Alla avsnitt",
"AllLanguages": "Alla språk", "AllLanguages": "Alla språk",
"AllLibraries": "Alla bibliotek", "AllLibraries": "Alla bibliotek",
@ -26,7 +26,7 @@
"AlwaysPlaySubtitlesHelp": "Undertexter på det önskade språket kommer att laddas oavsett ljudspårets språk.", "AlwaysPlaySubtitlesHelp": "Undertexter på det önskade språket kommer att laddas oavsett ljudspårets språk.",
"AnyLanguage": "Alla språk", "AnyLanguage": "Alla språk",
"Anytime": "När som helst", "Anytime": "När som helst",
"AroundTime": "Runt {0}", "AroundTime": "Runt",
"Art": "Grafik", "Art": "Grafik",
"Artists": "Artister", "Artists": "Artister",
"AsManyAsPossible": "Så många som möjligt", "AsManyAsPossible": "Så många som möjligt",
@ -40,13 +40,13 @@
"BirthDateValue": "Född: {0}", "BirthDateValue": "Född: {0}",
"BirthLocation": "Födelseort", "BirthLocation": "Födelseort",
"BirthPlaceValue": "Födelseort:{0}", "BirthPlaceValue": "Födelseort:{0}",
"BookLibraryHelp": "Ljud- och textböcker stöds. Läs {0}boknamngivningsguide{1}.", "BookLibraryHelp": "Ljud- och textböcker stöds. Läs {0} boknamngivningsguiden {1}.",
"Books": "Böcker", "Books": "Böcker",
"Box": "Omslag", "Box": "Omslag",
"BoxRear": "Omslag (baksida)", "BoxRear": "Omslag (baksida)",
"Browse": "Bläddra", "Browse": "Bläddra",
"BrowsePluginCatalogMessage": "Besök katalogen för att se tillgängliga tillägg.", "BrowsePluginCatalogMessage": "Besök katalogen för att se tillgängliga tillägg.",
"BurnSubtitlesHelp": "Avgör ifall servern ska \"bränna in\" undertexterna under transkodning. Att undvika detta förbättrar prestandan avsevärt. Välj \"Automatisk\" för att bränna bild-baserade format (ex. VOBSUB, PGS, SUB/IDX, etc.) och vissa ASS/SSA-undertexter.", "BurnSubtitlesHelp": "Avgör ifall servern ska \"bränna in\" undertexterna under transkodning. Att undvika detta förbättrar prestandan avsevärt. Välj \"Automatisk\" för att bränna bild-baserade format (ex. VOBSUB, PGS, SUB/IDX, ...) och vissa ASS/SSA-undertexter.",
"ButtonAdd": "Lägg till", "ButtonAdd": "Lägg till",
"ButtonAddMediaLibrary": "Lägg till mediabibliotek", "ButtonAddMediaLibrary": "Lägg till mediabibliotek",
"ButtonAddScheduledTaskTrigger": "Lägg till utlösare", "ButtonAddScheduledTaskTrigger": "Lägg till utlösare",
@ -988,7 +988,7 @@
"OptionMissingEpisode": "Saknade avsnitt", "OptionMissingEpisode": "Saknade avsnitt",
"OptionMonday": "Måndag", "OptionMonday": "Måndag",
"OptionNameSort": "Namn", "OptionNameSort": "Namn",
"OptionNew": "Ny...", "OptionNew": "Ny",
"OptionNone": "Inga", "OptionNone": "Inga",
"OptionOnAppStartup": "När servern startar", "OptionOnAppStartup": "När servern startar",
"OptionOnInterval": "Med visst intervall", "OptionOnInterval": "Med visst intervall",
@ -1507,5 +1507,15 @@
"UnsupportedPlayback": "Jellyfin kan inte dekryptera inehåll skyddat av DRM men allt inehåll kommer ändå försökas, även skyddade titlar. Vissa filer kan se helt svart ut på grund av kryptering eller andra funktioner som inte stöds, till exempel interaktiva titlar.", "UnsupportedPlayback": "Jellyfin kan inte dekryptera inehåll skyddat av DRM men allt inehåll kommer ändå försökas, även skyddade titlar. Vissa filer kan se helt svart ut på grund av kryptering eller andra funktioner som inte stöds, till exempel interaktiva titlar.",
"LabelLibraryPageSizeHelp": "Sätter en begränsad sidstorlek i bibliotek. Sätt 0 för att avaktivera begränsad sidstorlek.", "LabelLibraryPageSizeHelp": "Sätter en begränsad sidstorlek i bibliotek. Sätt 0 för att avaktivera begränsad sidstorlek.",
"ApiKeysCaption": "Lista av aktiva API-nycklar", "ApiKeysCaption": "Lista av aktiva API-nycklar",
"DeinterlaceMethodHelp": "Välj metod för borttagning av inflätning vid konvertering av inflätat inehåll." "DeinterlaceMethodHelp": "Välj metod för borttagning av inflätning vid konvertering av inflätat inehåll.",
"TabDVR": "PVR",
"SaveChanges": "Spara ändringar",
"LabelRequireHttps": "Kräv HTTPS",
"LabelChromecastVersion": "Chromecast-version",
"LabelEnableHttpsHelp": "Gör det möjligt för servern att lyssna på den konfigurerade HTTPS-porten. Ett giltigt certifikat måste också konfigureras för att detta ska fungera.",
"LabelEnableHttps": "Aktivera HTTPS",
"HeaderServerAddressSettings": "Serveradressinställningar",
"HeaderRemoteAccessSettings": "Inställningar för fjärråtkomst",
"HeaderHttpsSettings": "HTTPS-inställningar",
"HeaderDVR": "PVR"
} }

View file

@ -29,7 +29,7 @@
"AlwaysPlaySubtitles": "总是显示", "AlwaysPlaySubtitles": "总是显示",
"AlwaysPlaySubtitlesHelp": "无论音频为何种语言,都将加载与语言偏好匹配的字幕。", "AlwaysPlaySubtitlesHelp": "无论音频为何种语言,都将加载与语言偏好匹配的字幕。",
"Anytime": "任何时间", "Anytime": "任何时间",
"AroundTime": "{0} 左右", "AroundTime": "大约",
"Artists": "艺术家", "Artists": "艺术家",
"AsManyAsPossible": "尽可能多", "AsManyAsPossible": "尽可能多",
"Ascending": "升序", "Ascending": "升序",
@ -548,7 +548,7 @@
"LabelEmbedAlbumArtDidl": "在DIDL中嵌入专辑封面", "LabelEmbedAlbumArtDidl": "在DIDL中嵌入专辑封面",
"LabelEmbedAlbumArtDidlHelp": "有些设备首选这种方式获取专辑封面。启用该选项可能导致其他设备播放失败。", "LabelEmbedAlbumArtDidlHelp": "有些设备首选这种方式获取专辑封面。启用该选项可能导致其他设备播放失败。",
"LabelEnableAutomaticPortMap": "开启自动端口映射", "LabelEnableAutomaticPortMap": "开启自动端口映射",
"LabelEnableAutomaticPortMapHelp": "尝试通过UPnP将路由器端口自动转发到服务器端口。这可能不适用于某些型号的路由器和网络配置。需要服务器重新启动后才会应用更改。", "LabelEnableAutomaticPortMapHelp": "通过UPnP将路由器端口自动转发到服务器端口。这可能不适用于某些型号的路由器和网络配置。需要服务器重新启动后才会应用更改。",
"LabelEnableBlastAliveMessages": "爆发活动信号", "LabelEnableBlastAliveMessages": "爆发活动信号",
"LabelEnableBlastAliveMessagesHelp": "如果该服务器不能被网络中的其他UPnP设备检测到请启用此选项。", "LabelEnableBlastAliveMessagesHelp": "如果该服务器不能被网络中的其他UPnP设备检测到请启用此选项。",
"LabelEnableDlnaClientDiscoveryInterval": "客户端搜寻时间间隔(秒)", "LabelEnableDlnaClientDiscoveryInterval": "客户端搜寻时间间隔(秒)",
@ -1455,7 +1455,7 @@
"ButtonAddImage": "添加图片", "ButtonAddImage": "添加图片",
"LabelPlayer": "播放器:", "LabelPlayer": "播放器:",
"LabelBaseUrl": "基础 URL", "LabelBaseUrl": "基础 URL",
"LabelBaseUrlHelp": "在此处添加自定义子目录到服务器的 URL,例如:<code>http://example.com/<b>&lt;baseurl&gt;</b></code>。", "LabelBaseUrlHelp": "为服务器 URL添加自定义子目录,例如:<code>http://example.com/<b>&lt;baseurl&gt;</b></code>。",
"MusicLibraryHelp": "重播 {0}音乐命名指南{1}。", "MusicLibraryHelp": "重播 {0}音乐命名指南{1}。",
"HeaderFavoritePeople": "最喜欢的人物", "HeaderFavoritePeople": "最喜欢的人物",
"OptionRandom": "随机", "OptionRandom": "随机",
@ -1520,6 +1520,9 @@
"LabelRequireHttpsHelp": "开启后服务器将自动将所有 HTTP 请求重定向到 HTTPS。如果服务器没有启用 HTTPS 则不生效。", "LabelRequireHttpsHelp": "开启后服务器将自动将所有 HTTP 请求重定向到 HTTPS。如果服务器没有启用 HTTPS 则不生效。",
"LabelRequireHttps": "强制 HTTPS", "LabelRequireHttps": "强制 HTTPS",
"LabelStable": "稳定版", "LabelStable": "稳定版",
"LabelEnableHttpsHelp": "开启服务器监听 HTTPS 端口。必须配置有效的证书才会生效。", "LabelEnableHttpsHelp": "开启服务器对所配置HTTPS 端口的监听。必须配置有效的证书才会生效。",
"LabelEnableHttps": "启用 HTTPS" "LabelEnableHttps": "启用 HTTPS",
"LabelChromecastVersion": "Chromecast版本",
"HeaderDVR": "DVR",
"LabelNightly": "Nightly"
} }

View file

@ -104,6 +104,16 @@
<div class="fieldDescription">${LabelUserRemoteClientBitrateLimitHelp}</div> <div class="fieldDescription">${LabelUserRemoteClientBitrateLimitHelp}</div>
</div> </div>
</div> </div>
<div class="verticalSection">
<div class="selectContainer fldSelectSyncPlayAccess">
<select class="selectSyncPlayAccess" is="emby-select" id="selectSyncPlayAccess" label="${LabelSyncPlayAccess}">
<option value="CreateAndJoinGroups">${LabelSyncPlayAccessCreateAndJoinGroups}</option>
<option value="JoinGroups">${LabelSyncPlayAccessJoinGroups}</option>
<option value="None">${LabelSyncPlayAccessNone}</option>
</select>
<div class="fieldDescription">${SyncPlayAccessHelp}</div>
</div>
</div>
<div class="verticalSection"> <div class="verticalSection">
<h2 class="checkboxListLabel" style="margin-bottom:1em;">${HeaderAllowMediaDeletionFrom}</h2> <h2 class="checkboxListLabel" style="margin-bottom:1em;">${HeaderAllowMediaDeletionFrom}</h2>
<div class="checkboxList paperList checkboxList-paperList"> <div class="checkboxList paperList checkboxList-paperList">

View file

@ -15,7 +15,7 @@ module.exports = merge(common, {
rules: [ rules: [
{ {
test: /\.js$/, test: /\.js$/,
exclude: /node_modules[\\/](?!jellyfin-apiclient|query-string|split-on-first|strict-uri-encode)/, exclude: /node_modules[\\/](?!date-fns|jellyfin-apiclient|query-string|split-on-first|strict-uri-encode)/,
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
@ -47,6 +47,10 @@ module.exports = merge(common, {
use: [ use: [
'file-loader' 'file-loader'
] ]
},
{
test: /\.(mp3)$/i,
use: ['file-loader']
} }
] ]
} }

View file

@ -8,7 +8,7 @@ module.exports = merge(common, {
rules: [ rules: [
{ {
test: /\.js$/, test: /\.js$/,
exclude: /node_modules[\\/](?!jellyfin-apiclient|query-string|split-on-first|strict-uri-encode)/, exclude: /node_modules[\\/](?!date-fns|jellyfin-apiclient|query-string|split-on-first|strict-uri-encode)/,
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
@ -40,6 +40,10 @@ module.exports = merge(common, {
use: [ use: [
'file-loader' 'file-loader'
] ]
},
{
test: /\.(mp3)$/i,
use: ['file-loader']
} }
] ]
} }

1177
yarn.lock

File diff suppressed because it is too large Load diff