Merge branch 'master' of https://github.com/jellyfin/jellyfin-web into fix-accese

# Conflicts:
#	src/scripts/site.js
This commit is contained in:
grafixeyehero 2021-09-08 02:38:25 +03:00
commit 8e724d3119
168 changed files with 12834 additions and 5898 deletions

View file

@ -52,8 +52,6 @@
z-index: 1;
margin: 0 !important;
top: 6.9em !important;
-webkit-transition: -webkit-transform 0.2s ease-out;
-o-transition: transform 0.2s ease-out;
transition: transform 0.2s ease-out;
}
@ -62,17 +60,14 @@
}
.headerUserImage {
-webkit-background-size: contain;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
-webkit-border-radius: 100em;
border-radius: 100em;
display: inline-block;
}
.headerUserButtonRound div {
-webkit-border-radius: 100em;
border-radius: 100em;
background-size: cover;
background-repeat: no-repeat;
@ -80,7 +75,6 @@
}
.headerButton {
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
@ -90,23 +84,15 @@
.headerLeft {
display: flex;
-webkit-align-items: center;
align-items: center;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
flex-grow: 1;
overflow: hidden;
justify-content: flex-start;
}
.headerRight {
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-align-items: center;
align-items: center;
-webkit-box-pack: end;
-webkit-justify-content: flex-end;
justify-content: flex-end;
}
@ -116,15 +102,10 @@
}
.pageTitle {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: inline-flex;
margin: 0 0 0 0.5em;
height: 1.7em;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
-webkit-flex-shrink: 1;
flex-shrink: 1;
}
@ -134,21 +115,16 @@
.headerLeft,
.skinHeader {
display: -webkit-box;
display: -webkit-flex;
display: flex;
}
.detailButton,
.skinHeader {
flex-direction: column;
-webkit-flex-direction: column;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
}
.pageTitleWithLogo {
background-position: left center;
-webkit-background-size: contain;
background-size: contain;
background-repeat: no-repeat;
width: 13.2em;
@ -194,27 +170,19 @@
}
.navMenuOption {
display: -webkit-box !important;
display: -webkit-flex !important;
display: flex !important;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
text-decoration: none;
color: inherit;
padding: 0.9em 0 0.9em 2.4em !important;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
flex-grow: 1;
font-weight: 400 !important;
margin: 0 !important;
-webkit-border-radius: 0 !important;
border-radius: 0 !important;
}
.navMenuOptionIcon {
margin-right: 1.2em;
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
@ -229,8 +197,6 @@
}
.dashboardDocument .skinBody {
-webkit-transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
-o-transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
position: absolute;
top: 0;
@ -250,26 +216,6 @@
padding-bottom: 10vh;
}
.primaryImageWrapper {
display: none;
}
.primaryImageWrapper > img {
display: block;
margin: 0 auto;
max-width: 80vw;
max-height: 50vh;
}
.primaryImageWrapper > img.aspect-square {
max-height: 45vh;
}
.layout-mobile .primaryImageWrapper {
display: block;
flex: 1 0 auto;
}
@media all and (min-width: 40em) {
.dashboardDocument .adminDrawerLogo,
.dashboardDocument .mainDrawerButton {
@ -280,9 +226,7 @@
z-index: inherit !important;
left: 0 !important;
top: 0 !important;
-webkit-transform: none !important;
transform: none !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
width: 20.205em !important;
font-size: 94%;
@ -317,14 +261,9 @@
}
.headerTabs {
-webkit-align-self: center;
align-self: center;
width: auto;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
margin-top: -4.3em;
@ -381,8 +320,6 @@
}
.flexPageTabContent.is-active {
display: -webkit-box !important;
display: -webkit-flex !important;
display: flex !important;
}
@ -403,13 +340,17 @@
margin: 1.5em 0;
background: #222;
padding: 0.8em 0.8em 0.8em 3em;
-webkit-border-radius: 0.3em;
border-radius: 0.3em;
position: relative;
}
.detailLogo,
.itemBackdrop {
.detailLogo {
width: 25vw;
height: 16vh;
position: absolute;
top: 10vh;
right: 25vw;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
}
@ -462,30 +403,33 @@
}
.itemBackdrop {
-webkit-background-size: cover;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-position: center 0;
background-attachment: fixed;
height: 40vh;
position: relative;
animation: backdrop-fadein 800ms ease-in normal both;
.layout-mobile & {
background-attachment: initial;
margin-top: 3rem;
@media all and (orientation: portrait) and (max-width: 40em) {
height: 30vh;
}
}
.layout-desktop &::after {
content: "";
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.65);
display: block;
}
}
.layout-mobile .itemBackdrop {
display: none;
}
.layout-desktop .itemBackdrop::after {
content: "";
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.65);
display: block;
}
.layout-tv .itemBackdrop,
.layout-desktop .noBackdrop .itemBackdrop {
.layout-tv .itemBackdrop {
display: none;
}
@ -494,26 +438,18 @@
flex-direction: column;
padding-left: 32.45vw;
padding-right: 2%;
}
.layout-mobile .detailPageContent {
padding-left: 5%;
padding-right: 5%;
}
.layout-mobile & {
padding-left: 5%;
padding-right: 5%;
}
.layout-desktop .detailPageContent .emby-scroller,
.layout-tv .detailPageContent .emby-scroller {
margin-left: 0;
}
.layout-desktop .noBackdrop .detailPageContent,
.layout-tv .noBackdrop .detailPageContent {
margin-top: 2.5em;
}
.layout-desktop .noBackdrop .detailImageContainer img,
.layout-tv .noBackdrop .detailImageContainer img {
margin-top: 0;
.layout-desktop &,
.layout-tv & {
.emby-scroller {
margin-left: 0;
}
}
}
.detailSectionContent a {
@ -559,8 +495,6 @@
.mainDetailButtons {
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
margin: 1em 0;
}
@ -568,13 +502,19 @@
.detailButton,
.mainDetailButtons {
display: flex;
display: -webkit-box;
display: -webkit-flex;
}
.itemName {
margin: 0.5em 0;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.layout-mobile & {
white-space: normal;
overflow: visible;
}
}
.itemName.originalTitle {
@ -613,14 +553,19 @@
}
.itemMiscInfo {
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
.layout-mobile & {
@media all and (orientation: portrait) and (max-width: 40em) {
margin-bottom: 0 !important;
.mediaInfoItem {
margin-top: 0.5em;
}
}
}
}
.layout-mobile .parentName,
@ -633,9 +578,14 @@
}
.layout-mobile .mainDetailButtons {
flex: 2 0 70%;
margin-top: 0.5em;
margin-top: 1em;
margin-bottom: 0.5em;
margin-left: 37.5%;
@media all and (max-width: 32em) {
margin-bottom: 0;
margin-left: 0;
}
}
.subtitle {
@ -651,23 +601,22 @@
align-items: center;
align-content: center;
z-index: 2;
}
.layout-tv .detailPagePrimaryContainer {
display: block;
}
.layout-mobile & {
display: block;
position: relative;
padding: 0.5rem 5%;
}
.layout-mobile .detailPagePrimaryContainer {
flex-wrap: wrap;
position: relative;
padding: 4.5rem 3.3% 0.5rem;
}
.layout-desktop & {
position: relative;
padding-left: 32.45vw;
}
.layout-tv #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer,
.layout-desktop #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer {
position: relative;
top: 0;
padding-left: 32.45vw;
.layout-tv & {
display: block;
padding-left: 32.45vw;
}
}
.layout-desktop .detailRibbon {
@ -680,30 +629,24 @@
height: inherit;
}
.layout-desktop .noBackdrop .detailRibbon,
.layout-tv .noBackdrop .detailRibbon {
margin-top: 0;
}
.infoWrapper {
min-width: 0;
max-width: 100%;
flex: 1 0 0;
}
.layout-mobile .infoWrapper {
flex: 2 0 70%;
.layout-mobile & {
padding-left: 37.5%;
@media all and (max-width: 32em) {
position: relative;
}
}
}
.infoText {
white-space: nowrap;
text-overflow: ellipsis;
text-align: left;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.layout-mobile .infoText {
white-space: normal;
}
.detailPageSecondaryContainer {
@ -714,46 +657,63 @@
margin: 1em 0;
}
.layout-mobile .detailImageContainer {
display: none;
}
.detailImageContainer .card {
position: absolute;
top: 50%;
float: left;
width: 25vw;
// important is needed here to override :focus setting
// the position to relative in the tv layout
position: absolute !important;
top: 20%;
max-width: 25vw;
max-height: 80vh;
z-index: 3;
transform: translateY(-50%);
}
.detailImageContainer .card.backdropCard {
top: 35%;
}
.cardBox {
margin: 0;
}
.detailImageContainer .card.squareCard {
top: 40%;
}
&.backdropCard {
top: 35%;
}
.layout-desktop .noBackdrop .detailImageContainer,
.layout-tv .noBackdrop .detailImageContainer {
margin-top: 0;
&.squareCard {
top: 40%;
}
.layout-mobile & {
left: 5%;
bottom: 1rem;
max-width: 30vw;
filter: drop-shadow(0 0 0.5rem #000);
@media all and (max-width: 32em) {
left: 0;
bottom: 0;
}
&,
&.backdropCard,
&.squareCard {
top: auto;
}
}
.layout-desktop & {
left: 3.3%;
top: -80%;
width: 25vw;
}
.layout-tv & {
left: 5%;
top: 50%;
width: 25vw;
transform: translateY(-50%);
}
}
.detailPagePrimaryContent {
position: relative;
}
.detailLogo {
width: 25vw;
height: 16vh;
position: absolute;
top: 10vh;
right: 25vw;
background-size: contain;
}
.noBackdrop .detailLogo,
.layout-mobile .detailLogo {
display: none;
}
@ -766,7 +726,6 @@
.itemDetailImage {
width: 100% !important;
-webkit-box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);
box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);
}
@ -873,7 +832,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
.recordingFields button {
margin-left: 0;
margin-right: 0.5em;
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
@ -884,11 +842,7 @@ div.itemDetailGalleryLink.defaultCardBackground {
.detailButton {
display: flex;
flex-direction: column;
-webkit-box-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
margin: 0 !important;
padding: 0.7em 0.7em !important;
@ -916,18 +870,9 @@ div.itemDetailGalleryLink.defaultCardBackground {
}
.detailButton-content {
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-webkit-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
}
@ -984,8 +929,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
@media all and (max-width: 31.25em) {
.mobileDetails .itemMiscInfo {
text-align: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
@ -1007,11 +950,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
border-collapse: collapse;
}
.layout-desktop .noBackdrop .detailPageWrapperContainer,
.layout-tv .noBackdrop .detailPageWrapperContainer {
margin-top: 3.8em;
}
.mediaInfoStream {
margin: 0 3em 0 0;
display: inline-block;
@ -1071,14 +1009,9 @@ div.itemDetailGalleryLink.defaultCardBackground {
}
.mediaInfoIcons {
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
align-items: center;
margin: 1em 0;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
}
@ -1126,7 +1059,6 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
.sectionTitleButton {
margin-left: 1.5em !important;
-webkit-flex-shrink: 0;
flex-shrink: 0;
}
@ -1136,22 +1068,17 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
.sectionTitleIconButton {
margin-left: 1.5em !important;
-webkit-flex-shrink: 0;
flex-shrink: 0;
font-size: 84% !important;
padding: 0.5em !important;
}
.horizontalItemsContainer {
display: -webkit-box;
display: -webkit-flex;
display: flex;
}
.sectionTitleTextButton {
margin: 0 !important;
display: -webkit-inline-box !important;
display: -webkit-inline-flex !important;
display: inline-flex !important;
color: inherit !important;
}
@ -1219,8 +1146,6 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
}
.itemsViewSettingsContainer {
-webkit-box-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
@ -1245,7 +1170,7 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
}
.itemDetailsGroup {
margin-bottom: 1.5em;
margin-top: 1.5em;
}
.trackSelections {

View file

@ -0,0 +1,37 @@
import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
import AlphaPicker from './alphaPicker';
type AlphaPickerProps = {
onAlphaPicked?: () => void
};
// React compatibility wrapper component for alphaPicker.js
// eslint-disable-next-line @typescript-eslint/no-empty-function
const AlphaPickerComponent: FunctionComponent<AlphaPickerProps> = ({ onAlphaPicked = () => {} }: AlphaPickerProps) => {
const [ alphaPicker, setAlphaPicker ] = useState(null);
const element = useRef(null);
useEffect(() => {
setAlphaPicker(new AlphaPicker({
element: element.current,
mode: 'keyboard'
}));
element.current?.addEventListener('alphavalueclicked', onAlphaPicked);
return () => {
alphaPicker?.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Disabled for wrapper components
}, []);
return (
<div
ref={element}
className='alphaPicker align-items-center'
/>
);
};
export default AlphaPickerComponent;

View file

@ -11,6 +11,7 @@ import viewManager from './viewManager/viewManager';
import Dashboard from '../scripts/clientUtils';
import ServerConnections from './ServerConnections';
import alert from './alert';
import reactControllerFactory from './reactControllerFactory';
class AppRouter {
allRoutes = [];
@ -24,14 +25,23 @@ class AppRouter {
msgTimeout;
popstateOccurred = false;
resolveOnNextShow;
previousRoute = {};
/**
* Pages of "no return" (when "Go back" should behave differently, probably quitting the application).
*/
startPages = ['home', 'login', 'selectserver'];
constructor() {
window.addEventListener('popstate', () => {
this.popstateOccurred = true;
// WebKit fires a popstate event on document load
// Skip it using timeout
// For Tizen 2.x
// https://stackoverflow.com/a/12214354
window.addEventListener('load', () => {
setTimeout(() => {
window.addEventListener('popstate', () => {
this.popstateOccurred = true;
});
}, 0);
});
document.addEventListener('viewshow', () => {
@ -341,7 +351,9 @@ class AppRouter {
this.sendRouteToViewManager(ctx, next, route, controllerFactory);
};
if (route.controller) {
if (route.pageComponent) {
onInitComplete(reactControllerFactory);
} else if (route.controller) {
import('../controllers/' + route.controller).then(onInitComplete);
} else {
onInitComplete();
@ -373,6 +385,7 @@ class AppRouter {
fullscreen: route.fullscreen,
controllerFactory: controllerFactory,
options: {
pageComponent: route.pageComponent,
supportsThemeMedia: route.supportsThemeMedia || false,
enableMediaControl: route.enableMediaControl !== false
},
@ -621,8 +634,13 @@ class AppRouter {
getHandler(route) {
return (ctx, next) => {
ctx.isBack = this.popstateOccurred;
this.handleRoute(ctx, next, route);
this.popstateOccurred = false;
const ignore = route.dummyRoute === true || this.previousRoute.dummyRoute === true;
this.previousRoute = route;
if (ignore) return;
this.handleRoute(ctx, next, route);
};
}

View file

@ -1,4 +1,4 @@
import { version as appVersion } from '../../package.json';
import Package from '../../package.json';
import appSettings from '../scripts/settings/appSettings';
import browser from '../scripts/browser';
import { Events } from 'jellyfin-apiclient';
@ -33,7 +33,7 @@ function getDeviceProfile(item) {
let profile;
if (window.NativeShell) {
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, appVersion);
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, Package.version);
} else {
const builderOpts = getBaseProfileOptions(item);
profile = profileBuilder(builderOpts);
@ -275,7 +275,7 @@ const supportedFeatures = function () {
*/
function doExit() {
try {
if (window.NativeShell) {
if (window.NativeShell?.AppHost?.exit) {
window.NativeShell.AppHost.exit();
} else if (browser.tizen) {
tizen.application.getCurrentApplication().exit();
@ -360,16 +360,20 @@ export const appHost = {
};
},
deviceName: function () {
return window.NativeShell ? window.NativeShell.AppHost.deviceName() : getDeviceName();
return window.NativeShell?.AppHost?.deviceName
? window.NativeShell.AppHost.deviceName() : getDeviceName();
},
deviceId: function () {
return window.NativeShell ? window.NativeShell.AppHost.deviceId() : getDeviceId();
return window.NativeShell?.AppHost?.deviceId
? window.NativeShell.AppHost.deviceId() : getDeviceId();
},
appName: function () {
return window.NativeShell ? window.NativeShell.AppHost.appName() : appName;
return window.NativeShell?.AppHost?.appName
? window.NativeShell.AppHost.appName() : appName;
},
appVersion: function () {
return window.NativeShell ? window.NativeShell.AppHost.appVersion() : appVersion;
return window.NativeShell?.AppHost?.appVersion
? window.NativeShell.AppHost.appVersion() : Package.version;
},
getPushTokenInfo: function () {
return {};

View file

@ -87,7 +87,7 @@ import '../../assets/css/scrollstyles.scss';
if (!self.closedByBack && isHistoryEnabled(dlg)) {
const state = window.history.state || {};
if (state.dialogId === hash) {
window.history.back();
appRouter.back();
}
}
@ -142,7 +142,7 @@ import '../../assets/css/scrollstyles.scss';
animateDialogOpen(dlg);
if (isHistoryEnabled(dlg)) {
appRouter.pushState({ dialogId: hash }, 'Dialog', `#${hash}`);
appRouter.show(`/dialog?dlg=${hash}`, { dialogId: hash });
window.addEventListener('popstate', onHashChange);
} else {
@ -213,7 +213,7 @@ import '../../assets/css/scrollstyles.scss';
export function close(dlg) {
if (isOpened(dlg)) {
if (isHistoryEnabled(dlg)) {
window.history.back();
appRouter.back();
} else {
closeDialog(dlg);
}
@ -379,7 +379,7 @@ import '../../assets/css/scrollstyles.scss';
dlg.setAttribute('data-lockscroll', 'true');
}
if (options.enableHistory === true) {
if (options.enableHistory !== false) {
dlg.setAttribute('data-history', 'true');
}

View file

@ -11,6 +11,7 @@ import { Events } from 'jellyfin-apiclient';
import '../../elements/emby-select/emby-select';
import '../../elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-button/emby-button';
import '../../elements/emby-textarea/emby-textarea';
import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
import template from './displaySettings.template.html';
@ -122,6 +123,10 @@ import template from './displaySettings.template.html';
context.querySelector('#chkBlurhash').checked = userSettings.enableBlurhash();
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
context.querySelector('#chkUseEpisodeImagesInNextUp').checked = userSettings.useEpisodeImagesInNextUpAndResume();
context.querySelector('#chkDisableCustomCss').checked = userSettings.disableCustomCss();
context.querySelector('#txtLocalCustomCss').value = userSettings.customCss();
context.querySelector('#selectLanguage').value = userSettings.language() || '';
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
@ -156,6 +161,10 @@ import template from './displaySettings.template.html';
userSettingsInstance.enableBlurhash(context.querySelector('#chkBlurhash').checked);
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
userSettingsInstance.useEpisodeImagesInNextUpAndResume(context.querySelector('#chkUseEpisodeImagesInNextUp').checked);
userSettingsInstance.disableCustomCss(context.querySelector('#chkDisableCustomCss').checked);
userSettingsInstance.customCss(context.querySelector('#txtLocalCustomCss').value);
if (user.Id === apiClient.getCurrentUserId()) {
skinManager.setTheme(userSettingsInstance.theme());

View file

@ -156,6 +156,19 @@
<select id="selectTheme" is="emby-select" label="${LabelTheme}"></select>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkDisableCustomCss" />
<span>${DisableCustomCss}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelDisableCustomCss}</div>
</div>
<div class="inputContainer customCssContainer">
<textarea is="emby-textarea" id="txtLocalCustomCss" label="${LabelCustomCss}" class="textarea-mono"></textarea>
<div class="fieldDescription">${LabelLocalCustomCss}</div>
</div>
<div class="selectContainer selectDashboardThemeContainer hide">
<select id="selectDashboardTheme" is="emby-select" label="${LabelDashboardTheme}"></select>
</div>
@ -225,6 +238,14 @@
<div class="fieldDescription checkboxFieldDescription">${DisplayMissingEpisodesWithinSeasonsHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldUseEpisodeImagesInNextUp">
<label>
<input type="checkbox" is="emby-checkbox" id="chkUseEpisodeImagesInNextUp" />
<span>${UseEpisodeImagesInNextUp}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${UseEpisodeImagesInNextUpHelp}</div>
</div>
<button is="emby-button" type="submit" class="raised button-submit block btnSave hide">
<span>${Save}</span>
</button>

View file

@ -32,9 +32,6 @@ import template from './filterdialog.template.html';
}
function renderFilters(context, result, query) {
if (result.Tags) {
result.Tags.length = Math.min(result.Tags.length, 50);
}
renderOptions(context, '.genreFilters', 'chkGenreFilter', result.Genres, function (i) {
const delimeter = '|';
return (delimeter + (query.Genres || '') + delimeter).includes(delimeter + i + delimeter);

View file

@ -60,6 +60,7 @@
}
.layout-tv .formDialogFooter {
position: relative;
align-items: center;
justify-content: center;
flex-wrap: wrap;

View file

@ -144,17 +144,17 @@ import ServerConnections from '../ServerConnections';
} else if (section === 'librarybuttons') {
loadlibraryButtons(elem, apiClient, user, userSettings, userViews);
} else if (section === 'resume') {
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video');
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video', userSettings);
} else if (section === 'resumeaudio') {
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio');
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio', userSettings);
} else if (section === 'activerecordings') {
loadLatestLiveTvRecordings(elem, true, apiClient);
} else if (section === 'nextup') {
loadNextUp(elem, apiClient);
loadNextUp(elem, apiClient, userSettings);
} else if (section === 'onnow' || section === 'livetv') {
return loadOnNow(elem, apiClient, user);
} else if (section === 'resumebook') {
return loadResume(elem, apiClient, 'HeaderContinueReading', 'Book');
return loadResume(elem, apiClient, 'HeaderContinueReading', 'Book', userSettings);
} else {
elem.innerHTML = '';
return Promise.resolve();
@ -374,7 +374,7 @@ import ServerConnections from '../ServerConnections';
'Video': 'videoplayback,markplayed'
};
function loadResume(elem, apiClient, headerText, mediaType) {
function loadResume(elem, apiClient, headerText, mediaType, userSettings) {
let html = '';
const dataMonitor = dataMonitorHints[mediaType] || 'markplayed';
@ -397,7 +397,7 @@ import ServerConnections from '../ServerConnections';
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getItemsToResumeFn(mediaType, apiClient.serverId());
itemsContainer.getItemsHtml = getItemsToResumeHtml;
itemsContainer.getItemsHtml = getItemsToResumeHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume(), mediaType);
itemsContainer.parentContainer = elem;
}
@ -428,25 +428,28 @@ import ServerConnections from '../ServerConnections';
};
}
function getItemsToResumeHtml(items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
defaultShape: getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
showDetailsMenu: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: false,
cardLayout: cardLayout,
showYear: true,
lines: 2
});
function getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ? getPortraitShape() : getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
showDetailsMenu: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: false,
cardLayout: cardLayout,
showYear: true,
lines: 2
});
};
}
function getOnNowFetchFn(serverId) {
@ -607,25 +610,28 @@ import ServerConnections from '../ServerConnections';
};
}
function getNextUpItemsHtml(items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
shape: getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
function getNextUpItemsHtmlFn(useEpisodeImages) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
};
}
function loadNextUp(elem, apiClient) {
function loadNextUp(elem, apiClient, userSettings) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
@ -660,7 +666,7 @@ import ServerConnections from '../ServerConnections';
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getNextUpFetchFn(apiClient.serverId());
itemsContainer.getItemsHtml = getNextUpItemsHtml;
itemsContainer.getItemsHtml = getNextUpItemsHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume());
itemsContainer.parentContainer = elem;
}

View file

@ -15,6 +15,10 @@ function setLayout(instance, layout, selectedLayout) {
}
class LayoutManager {
tv = false;
mobile = false;
desktop = false;
setLayout(layout, save) {
if (!layout || layout === 'auto') {
this.autoLayout();

View file

@ -78,14 +78,10 @@ import '../elements/emby-button/emby-button';
}
export function setTabs(view, selectedIndex, getTabsFn, getTabContainersFn, onBeforeTabChange, onTabChange, setSelectedIndex) {
ensureElements();
if (!view) {
if (tabOwnerView) {
if (!headerTabsContainer) {
headerTabsContainer = queryScope.querySelector('.headerTabs');
}
ensureElements();
document.body.classList.remove('withSectionTabs');
headerTabsContainer.innerHTML = '';
@ -99,8 +95,6 @@ import '../elements/emby-button/emby-button';
};
}
ensureElements();
const tabsContainerElem = headerTabsContainer;
if (!tabOwnerView) {
@ -178,18 +172,13 @@ import '../elements/emby-button/emby-button';
return {
tabsContainer: tabsContainerElem,
tabs: tabsContainerElem.querySelector('[is="emby-tabs"]'),
tabs: tabsElem,
replaced: true
};
}
if (!tabsElem) {
tabsElem = tabsContainerElem.querySelector('[is="emby-tabs"]');
}
tabsElem.selectedIndex(selectedIndex);
tabOwnerView = view;
return {
tabsContainer: tabsContainerElem,
tabs: tabsElem,
@ -198,12 +187,6 @@ import '../elements/emby-button/emby-button';
}
export function selectedTabIndex(index) {
const tabsContainerElem = headerTabsContainer;
if (!tabsElem) {
tabsElem = tabsContainerElem.querySelector('[is="emby-tabs"]');
}
if (index != null) {
tabsElem.selectedIndex(index);
} else {

View file

@ -330,8 +330,8 @@ import '../../elements/emby-button/emby-button';
return null;
}
export function getEndsAtFromPosition(runtimeTicks, positionTicks, includeText) {
let endDate = new Date().getTime() + ((runtimeTicks - (positionTicks || 0)) / 10000);
export function getEndsAtFromPosition(runtimeTicks, positionTicks, playbackRate, includeText) {
let endDate = new Date().getTime() + (1 / playbackRate) * ((runtimeTicks - (positionTicks || 0)) / 10000);
endDate = new Date(endDate);
const displayTime = datetime.getDisplayTime(endDate);

View file

@ -925,7 +925,9 @@ import template from './metadataEditor.template.html';
html += '</div>';
if (person.Role && person.Role !== lastType) {
html += '<div class="secondary">' + (person.Role) + '</div>';
html += '<div class="secondary">' + person.Role + '</div>';
} else {
html += '<div class="secondary">' + globalize.translate(person.Type) + '</div>';
}
html += '</button>';

View file

@ -20,6 +20,12 @@
<option value="GuestStar">${GuestStar}</option>
<option value="Producer">${Producer}</option>
<option value="Writer">${Writer}</option>
<option value="Conductor">${Conductor}</option>
<option value="Lyricist">${Lyricist}</option>
<option value="Arranger">${Arranger}</option>
<option value="Engineer">${Engineer}</option>
<option value="Mixer">${Mixer}</option>
<option value="Remixer">${Remixer}</option>
</select>
</div>

View file

@ -0,0 +1,42 @@
import React, { FunctionComponent, useState } from 'react';
import SearchFields from '../search/SearchFields';
import SearchResults from '../search/SearchResults';
import SearchSuggestions from '../search/SearchSuggestions';
import LiveTVSearchResults from '../search/LiveTVSearchResults';
type SearchProps = {
serverId?: string,
parentId?: string,
collectionType?: string
};
const SearchPage: FunctionComponent<SearchProps> = ({ serverId, parentId, collectionType }: SearchProps) => {
const [ query, setQuery ] = useState(null);
return (
<>
<SearchFields onSearch={setQuery} />
{!query &&
<SearchSuggestions
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
/>
}
<SearchResults
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
collectionType={collectionType}
query={query}
/>
<LiveTVSearchResults
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
collectionType={collectionType}
query={query}
/>
</>
);
};
export default SearchPage;

View file

@ -429,7 +429,7 @@ function getPlaybackInfo(player,
enableDirectStream,
allowVideoStreamCopy,
allowAudioStreamCopy) {
if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio') {
if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio' && !player.useServerPlaybackInfoForAudio) {
return Promise.resolve({
MediaSources: [
{
@ -1692,7 +1692,7 @@ class PlaybackManager {
if (validatePlaybackInfoResult(self, result)) {
currentMediaSource = result.MediaSources[0];
const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks);
const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks, player);
streamInfo.fullscreen = currentPlayOptions.fullscreen;
streamInfo.lastMediaInfoQuery = lastMediaInfoQuery;
@ -1960,6 +1960,7 @@ class PlaybackManager {
state.PlayState.PositionTicks = getCurrentTicks(player);
state.PlayState.PlaybackStartTimeTicks = self.playbackStartTime(player);
state.PlayState.PlaybackRate = self.getPlaybackRate(player);
state.PlayState.SubtitleStreamIndex = self.getSubtitleStreamIndex(player);
state.PlayState.AudioStreamIndex = self.getAudioStreamIndex(player);
@ -2106,7 +2107,7 @@ class PlaybackManager {
}
}
function playInternal(item, playOptions, onPlaybackStartedFn) {
function playInternal(item, playOptions, onPlaybackStartedFn, prevSource) {
if (item.IsPlaceHolder) {
loading.hide();
showPlaybackInfoErrorMessage(self, 'PlaybackErrorPlaceHolder');
@ -2131,7 +2132,7 @@ class PlaybackManager {
const mediaType = item.MediaType;
const onBitrateDetectionFailure = function () {
return playAfterBitrateDetect(getSavedMaxStreamingBitrate(ServerConnections.getApiClient(item.ServerId), mediaType), item, playOptions, onPlaybackStartedFn);
return playAfterBitrateDetect(getSavedMaxStreamingBitrate(ServerConnections.getApiClient(item.ServerId), mediaType), item, playOptions, onPlaybackStartedFn, prevSource);
};
if (!isServerItem(item) || itemHelper.isLocalItem(item)) {
@ -2144,7 +2145,7 @@ class PlaybackManager {
return apiClient.detectBitrate().then(function (bitrate) {
appSettings.maxStreamingBitrate(endpointInfo.IsInNetwork, mediaType, bitrate);
return playAfterBitrateDetect(bitrate, item, playOptions, onPlaybackStartedFn);
return playAfterBitrateDetect(bitrate, item, playOptions, onPlaybackStartedFn, prevSource);
}, onBitrateDetectionFailure);
} else {
onBitrateDetectionFailure();
@ -2153,7 +2154,7 @@ class PlaybackManager {
}, onInterceptorRejection);
}
function onInterceptorRejection() {
function cancelPlayback() {
const player = self._currentPlayer;
if (player) {
@ -2162,7 +2163,10 @@ class PlaybackManager {
}
Events.trigger(self, 'playbackcancelled');
}
function onInterceptorRejection() {
cancelPlayback();
return Promise.reject();
}
@ -2222,7 +2226,104 @@ class PlaybackManager {
});
}
function playAfterBitrateDetect(maxBitrate, item, playOptions, onPlaybackStartedFn) {
function rankStreamType(prevIndex, prevSource, mediaSource, streamType) {
if (prevIndex == -1) {
console.debug(`AutoSet ${streamType} - No Stream Set`);
if (streamType == 'Subtitle')
mediaSource.DefaultSubtitleStreamIndex = -1;
return;
}
if (!prevSource.MediaStreams || !mediaSource.MediaStreams) {
console.debug(`AutoSet ${streamType} - No MediaStreams`);
return;
}
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${streamType} - No prevStream`);
return;
}
console.debug(`AutoSet ${streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type != streamType)
continue;
if (stream.Index == prevIndex)
break;
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaSource.MediaStreams) {
if (stream.Type != streamType)
continue;
let score = 0;
if (prevStream.Codec == stream.Codec)
score += 1;
if (prevRelIndex == newRelIndex)
score += 1;
if (prevStream.Title && prevStream.Title == stream.Title)
score += 2;
if (prevStream.Language && prevStream.Language != 'und' && prevStream.Language == stream.Language)
score += 2;
console.debug(`AutoSet ${streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(`AutoSet ${streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`);
if (streamType == 'Subtitle')
mediaSource.DefaultSubtitleStreamIndex = bestStreamIndex;
if (streamType == 'Audio')
mediaSource.DefaultAudioStreamIndex = bestStreamIndex;
} else {
console.debug(`AutoSet ${streamType} - Threshold not met. Using default.`);
}
}
function autoSetNextTracks(prevSource, mediaSource) {
try {
if (!prevSource) return;
if (!mediaSource) {
console.warn('AutoSet - No mediaSource');
return;
}
if (typeof prevSource.DefaultAudioStreamIndex != 'number'
|| typeof prevSource.DefaultSubtitleStreamIndex != 'number')
return;
if (typeof mediaSource.DefaultAudioStreamIndex != 'number'
|| typeof mediaSource.DefaultSubtitleStreamIndex != 'number') {
console.warn('AutoSet - No stream indexes (but prevSource has them)');
return;
}
rankStreamType(prevSource.DefaultAudioStreamIndex, prevSource, mediaSource, 'Audio');
rankStreamType(prevSource.DefaultSubtitleStreamIndex, prevSource, mediaSource, 'Subtitle');
} catch (e) {
console.error(`AutoSet - Caught unexpected error: ${e}`);
}
}
function playAfterBitrateDetect(maxBitrate, item, playOptions, onPlaybackStartedFn, prevSource) {
const startPosition = playOptions.startPositionTicks;
const player = getPlayer(item, playOptions);
@ -2238,6 +2339,15 @@ class PlaybackManager {
promise = Promise.resolve();
}
if (!player) {
return promise.then(() => {
cancelPlayback();
loading.hide();
console.error(`No player found for the requested media: ${item.Url}`);
showPlaybackInfoErrorMessage(self, 'ErrorPlayerNotFound');
});
}
if (!isServerItem(item) || item.MediaType === 'Book') {
return promise.then(function () {
const streamInfo = createStreamInfoFromUrlItem(item);
@ -2272,7 +2382,10 @@ class PlaybackManager {
playOptions.items = null;
return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, mediaSourceId, audioStreamIndex, subtitleStreamIndex).then(function (mediaSource) {
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition);
if (userSettings.enableSetUsingLastTracks())
autoSetNextTracks(prevSource, mediaSource);
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
streamInfo.fullscreen = playOptions.fullscreen;
@ -2311,7 +2424,7 @@ class PlaybackManager {
return player.getDeviceProfile(item).then(function (deviceProfile) {
return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, options.mediaSourceId, options.audioStreamIndex, options.subtitleStreamIndex).then(function (mediaSource) {
return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition);
return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
});
});
});
@ -2337,7 +2450,7 @@ class PlaybackManager {
});
};
function createStreamInfo(apiClient, type, item, mediaSource, startPosition) {
function createStreamInfo(apiClient, type, item, mediaSource, startPosition, player) {
let mediaUrl;
let contentType;
let transcodingOffsetTicks = 0;
@ -2349,6 +2462,14 @@ class PlaybackManager {
const mediaSourceContainer = (mediaSource.Container || '').toLowerCase();
let directOptions;
if (mediaSource.MediaStreams && player.useFullSubtitleUrls) {
mediaSource.MediaStreams.forEach(stream => {
if (stream.DeliveryUrl && stream.DeliveryUrl.startsWith('/')) {
stream.DeliveryUrl = apiClient.getUrl(stream.DeliveryUrl);
}
});
}
if (type === 'Video' || type === 'Audio') {
contentType = getMimeType(type.toLowerCase(), mediaSourceContainer);
@ -2622,6 +2743,16 @@ class PlaybackManager {
return self.previousTrack(player);
};
function getPreviousSource(player) {
const prevSource = self.currentMediaSource(player);
const prevPlayerData = getPlayerData(player);
return {
...prevSource,
DefaultAudioStreamIndex: prevPlayerData.audioStreamIndex,
DefaultSubtitleStreamIndex: prevPlayerData.subtitleStreamIndex
};
}
self.nextTrack = function (player) {
player = player || self._currentPlayer;
if (player && !enableLocalPlaylistManagement(player)) {
@ -2637,7 +2768,7 @@ class PlaybackManager {
playInternal(newItemInfo.item, newItemPlayOptions, function () {
setPlaylistState(newItemInfo.item.PlaylistItemId, newItemInfo.index);
});
}, getPreviousSource(player));
}
};
@ -2658,7 +2789,7 @@ class PlaybackManager {
playInternal(newItem, newItemPlayOptions, function () {
setPlaylistState(newItem.PlaylistItemId, newIndex);
});
}, getPreviousSource(player));
}
}
};
@ -3008,6 +3139,9 @@ class PlaybackManager {
}
return promise.then(function () {
// Clear the data since we were not listening 'stopped'
getPlayerData(activePlayer).streamInfo = null;
bindStopped(activePlayer);
if (enableLocalPlaylistManagement(activePlayer)) {

View file

@ -195,6 +195,7 @@ import template from './playbackSettings.template.html';
context.querySelector('.chkPreferFmp4HlsContainer').checked = userSettings.preferFmp4HlsContainer();
context.querySelector('.chkEnableCinemaMode').checked = userSettings.enableCinemaMode();
context.querySelector('.chkEnableNextVideoOverlay').checked = userSettings.enableNextVideoInfoOverlay();
context.querySelector('.chkSetUsingLastTracks').checked = userSettings.enableSetUsingLastTracks();
context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers();
setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video');
@ -236,6 +237,7 @@ import template from './playbackSettings.template.html';
userSettingsInstance.enableCinemaMode(context.querySelector('.chkEnableCinemaMode').checked);
userSettingsInstance.enableNextVideoInfoOverlay(context.querySelector('.chkEnableNextVideoOverlay').checked);
userSettingsInstance.enableSetUsingLastTracks(context.querySelector('.chkSetUsingLastTracks').checked);
userSettingsInstance.chromecastVersion(context.querySelector('.selectChromecastVersion').value);
userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value);
userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').value);

View file

@ -82,6 +82,14 @@
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkSetUsingLastTracks" />
<span>${SetUsingLastTracks}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${SetUsingLastTracksHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldEnableNextVideoOverlay hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableNextVideoOverlay" />

View file

@ -173,6 +173,12 @@ import ServerConnections from '../ServerConnections';
value: session.TranscodingInfo.TranscodeReasons.map(translateReason).join('<br/>')
});
}
if (session.TranscodingInfo.HardwareAccelerationType) {
sessionStats.push({
label: globalize.translate('LabelHardwareEncoding'),
value: session.TranscodingInfo.HardwareAccelerationType
});
}
}
return sessionStats;

View file

@ -3,6 +3,11 @@ import globalize from '../scripts/globalize';
import loading from './loading/loading';
import appSettings from '../scripts/settings/appSettings';
import { playbackManager } from './playback/playbackmanager';
import { appHost } from '../components/apphost';
import { appRouter } from '../components/appRouter';
import * as inputManager from '../scripts/inputManager';
import toast from '../components/toast/toast';
import confirm from '../components/confirm/confirm';
/* eslint-disable indent */
@ -90,7 +95,13 @@ import { playbackManager } from './playback/playbackmanager';
events: Events,
loading,
appSettings,
playbackManager
playbackManager,
globalize,
appHost,
appRouter,
inputManager,
toast,
confirm
});
} else {
console.debug(`Loading plugin (via dynamic import): ${pluginSpec}`);

View file

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
export default (view, params, { detail }) => {
if (detail.options?.pageComponent) {
// Fetch and render the page component to the view
import(/* webpackChunkName: "[request]" */ `./pages/${detail.options.pageComponent}`)
.then(({ default: component }) => {
ReactDOM.render(React.createElement(component, params), view);
});
// Unmount component when view is destroyed
view.addEventListener('viewdestroy', () => {
ReactDOM.unmountComponentAtNode(view);
});
}
};

View file

@ -388,7 +388,7 @@
.btnPlayPause {
padding: 0;
margin: 0;
font-size: 1.7em;
font-size: 2em;
}
.nowPlayingPageImage {

View file

@ -173,6 +173,15 @@ import layoutManager from './layoutManager';
return Math.min(document.documentElement.clientHeight, document.body.clientHeight);
}
/**
* Returns attribute value.
* @param {string} attributeName - Attibute name.
* @return {string} Attibute value.
*/
getAttribute(attributeName) {
return document.body.getAttribute(attributeName);
}
/**
* Returns bounding client rect.
* @return {Rect} Bounding client rect.
@ -201,6 +210,21 @@ import layoutManager from './layoutManager';
*/
const documentScroller = new DocumentScroller();
const scrollerHints = {
x: {
nameScroll: 'scrollWidth',
nameClient: 'clientWidth',
nameStyle: 'overflowX',
nameScrollMode: 'data-scroll-mode-x'
},
y: {
nameScroll: 'scrollHeight',
nameClient: 'clientHeight',
nameStyle: 'overflowY',
nameScrollMode: 'data-scroll-mode-y'
}
};
/**
* Returns parent element that can be scrolled. If no such, returns document scroller.
*
@ -210,22 +234,28 @@ import layoutManager from './layoutManager';
*/
function getScrollableParent(element, vertical) {
if (element) {
let nameScroll = 'scrollWidth';
let nameClient = 'clientWidth';
let nameClass = 'scrollX';
if (vertical) {
nameScroll = 'scrollHeight';
nameClient = 'clientHeight';
nameClass = 'scrollY';
}
const scrollerHint = vertical ? scrollerHints.y : scrollerHints.x;
let parent = element.parentElement;
while (parent) {
// Skip 'emby-scroller' because it scrolls by itself
if (!parent.classList.contains('emby-scroller') &&
parent[nameScroll] > parent[nameClient] && parent.classList.contains(nameClass)) {
while (parent && parent !== document.body) {
const scrollMode = parent.getAttribute(scrollerHint.nameScrollMode);
// Stop on self-scrolled containers
if (scrollMode === 'custom') {
return parent;
}
const styles = window.getComputedStyle(parent);
// Stop on fixed parent
if (styles.position === 'fixed') {
return parent;
}
const overflow = styles[scrollerHint.nameStyle];
if (overflow === 'scroll' || overflow === 'auto' && parent[scrollerHint.nameScroll] > parent[scrollerHint.nameClient]) {
return parent;
}
@ -241,6 +271,8 @@ import layoutManager from './layoutManager';
* @property {number} scrollPos - Current scroll position.
* @property {number} scrollSize - Scroll size.
* @property {number} clientSize - Client size.
* @property {string} mode - Scrolling mode.
* @property {boolean} custom - Custom scrolling mode.
*/
/**
@ -257,12 +289,16 @@ import layoutManager from './layoutManager';
data.scrollPos = scroller.scrollLeft;
data.scrollSize = scroller.scrollWidth;
data.clientSize = scroller.clientWidth;
data.mode = scroller.getAttribute(scrollerHints.x.nameScrollMode);
} else {
data.scrollPos = scroller.scrollTop;
data.scrollSize = scroller.scrollHeight;
data.clientSize = scroller.clientHeight;
data.mode = scroller.getAttribute(scrollerHints.y.nameScrollMode);
}
data.custom = data.mode === 'custom';
return data;
}
@ -347,9 +383,13 @@ import layoutManager from './layoutManager';
const scrollBehavior = smooth ? 'smooth' : 'instant';
if (xScroller !== yScroller) {
scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior});
scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior});
} else {
if (xScroller) {
scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior});
}
if (yScroller) {
scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior});
}
} else if (xScroller) {
scrollToHelper(xScroller, {left: scrollX, top: scrollY, behavior: scrollBehavior});
}
}
@ -376,8 +416,8 @@ import layoutManager from './layoutManager';
* @param {number} scrollY - Vertical coordinate.
*/
function animateScroll(xScroller, scrollX, yScroller, scrollY) {
const ox = xScroller.scrollLeft;
const oy = yScroller.scrollTop;
const ox = xScroller ? xScroller.scrollLeft : scrollX;
const oy = yScroller ? yScroller.scrollTop : scrollY;
const dx = scrollX - ox;
const dy = scrollY - oy;
@ -501,30 +541,51 @@ import layoutManager from './layoutManager';
scrollCenterX = scrollCenterY = false;
}
const xScroller = getScrollableParent(element, false);
const yScroller = getScrollableParent(element, true);
const elementRect = element.getBoundingClientRect();
let xScroller = getScrollableParent(element, false);
let yScroller = getScrollableParent(element, true);
const xScrollerData = getScrollerData(xScroller, false);
const yScrollerData = getScrollerData(yScroller, true);
const xPos = getScrollerChildPos(xScroller, element, false);
const yPos = getScrollerChildPos(yScroller, element, true);
const scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
let scrollY = calcScroll(yScrollerData, yPos, elementRect.height, scrollCenterY);
// HACK: Scroll to top for top menu because it is hidden
// FIXME: Need a marker to scroll top/bottom
if (isFixed && elementRect.bottom < 0) {
scrollY = 0;
// Exit, since we have no control over scrolling in this container
if (xScroller === yScroller && (xScrollerData.custom || yScrollerData.custom)) {
return;
}
// HACK: Ensure we are at the top
// FIXME: Need a marker to scroll top/bottom
if (scrollY < minimumScrollY() && yScroller === documentScroller) {
scrollY = 0;
// Exit, since we have no control over scrolling in these containers
if (xScrollerData.custom && yScrollerData.custom) {
return;
}
const elementRect = element.getBoundingClientRect();
let scrollX = 0;
let scrollY = 0;
if (!xScrollerData.custom) {
const xPos = getScrollerChildPos(xScroller, element, false);
scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
} else {
xScroller = null;
}
if (!yScrollerData.custom) {
const yPos = getScrollerChildPos(yScroller, element, true);
scrollY = calcScroll(yScrollerData, yPos, elementRect.height, scrollCenterY);
// HACK: Scroll to top for top menu because it is hidden
// FIXME: Need a marker to scroll top/bottom
if (isFixed && elementRect.bottom < 0) {
scrollY = 0;
}
// HACK: Ensure we are at the top
// FIXME: Need a marker to scroll top/bottom
if (scrollY < minimumScrollY() && yScroller === documentScroller) {
scrollY = 0;
}
} else {
yScroller = null;
}
doScroll(xScroller, scrollX, yScroller, scrollY, smooth);

View file

@ -0,0 +1,189 @@
import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
import SearchResultsRow from './SearchResultsRow';
const CARD_OPTIONS = {
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
};
type LiveTVSearchResultsProps = {
serverId?: string;
parentId?: string;
collectionType?: string;
query?: string;
}
/*
* React component to display search result rows for live tv library search
*/
const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serverId, parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const [ movies, setMovies ] = useState([]);
const [ episodes, setEpisodes ] = useState([]);
const [ sports, setSports ] = useState([]);
const [ kids, setKids ] = useState([]);
const [ news, setNews ] = useState([]);
const [ programs, setPrograms ] = useState([]);
const [ channels, setChannels ] = useState([]);
useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
// FIXME: This query does not support Live TV filters
const fetchItems = (apiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(),
{
...getDefaultParameters(),
IncludeMedia: true,
...params
}
);
// Reset state
setMovies([]);
setEpisodes([]);
setSports([]);
setKids([]);
setNews([]);
setPrograms([]);
setChannels([]);
if (query && collectionType === 'livetv') {
// TODO: Remove type casting once we're using a properly typed API client
const apiClient = (ServerConnections as any).getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
}).then(result => setMovies(result.Items));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
}).then(result => setEpisodes(result.Items));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: true,
IsKids: false,
IsNews: false
}).then(result => setSports(result.Items));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: true,
IsNews: false
}).then(result => setKids(result.Items));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: true
}).then(result => setNews(result.Items));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
}).then(result => setPrograms(result.Items));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items));
}
}, [collectionType, parentId, query, serverId]);
return (
<div
className={classNames(
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || !(collectionType === 'livetv') }
)}
>
<SearchResultsRow
title={globalize.translate('Movies')}
items={movies}
cardOptions={{
...CARD_OPTIONS,
shape: 'overflowPortrait'
}}
/>
<SearchResultsRow
title={globalize.translate('Episodes')}
items={episodes}
cardOptions={CARD_OPTIONS}
/>
<SearchResultsRow
title={globalize.translate('Sports')}
items={sports}
cardOptions={CARD_OPTIONS}
/>
<SearchResultsRow
title={globalize.translate('Kids')}
items={kids}
cardOptions={CARD_OPTIONS}
/>
<SearchResultsRow
title={globalize.translate('News')}
items={news}
cardOptions={CARD_OPTIONS}
/>
<SearchResultsRow
title={globalize.translate('Programs')}
items={programs}
cardOptions={CARD_OPTIONS}
/>
<SearchResultsRow
title={globalize.translate('Channels')}
items={channels}
cardOptions={{ shape: 'square' }}
/>
</div>
);
};
export default LiveTVSearchResults;

View file

@ -0,0 +1,90 @@
import debounce from 'lodash-es/debounce';
import React, { FunctionComponent, useEffect, useMemo, useRef } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import globalize from '../../scripts/globalize';
import 'material-design-icons-iconfont';
import '../../elements/emby-input/emby-input';
import '../../assets/css/flexstyles.scss';
import './searchfields.scss';
import layoutManager from '../layoutManager';
import browser from '../../scripts/browser';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createInputElement = () => ({
__html: `<input
is="emby-input"
class="searchfields-txtSearch"
type="text"
data-keyboard="true"
placeholder="${globalize.translate('Search')}"
autocomplete="off"
maxlength="40"
autofocus
/>`
});
const normalizeInput = (value = '') => value.trim();
type SearchFieldsProps = {
onSearch?: () => void
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const SearchFields: FunctionComponent<SearchFieldsProps> = ({ onSearch = () => {} }: SearchFieldsProps) => {
const element = useRef(null);
const getSearchInput = () => element?.current?.querySelector('.searchfields-txtSearch');
const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]);
useEffect(() => {
getSearchInput()?.addEventListener('input', e => {
debouncedOnSearch(normalizeInput(e.target?.value));
});
getSearchInput()?.focus();
return () => {
debouncedOnSearch.cancel();
};
}, [debouncedOnSearch]);
const onAlphaPicked = e => {
const value = e.detail.value;
const searchInput = getSearchInput();
if (value === 'backspace') {
const currentValue = searchInput.value;
searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : '';
} else {
searchInput.value += value;
}
searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true }));
};
return (
<div
className='padded-left padded-right searchFields'
ref={element}
>
<div className='searchFieldsInner flex align-items-center justify-content-center'>
<span className='searchfields-icon material-icons search' />
<div
className='inputContainer flex-grow'
style={{ marginBottom: 0 }}
dangerouslySetInnerHTML={createInputElement()}
/>
</div>
{layoutManager.tv && !browser.tv &&
<AlphaPicker onAlphaPicked={onAlphaPicked} />
}
</div>
);
};
export default SearchFields;

View file

@ -0,0 +1,268 @@
import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
import SearchResultsRow from './SearchResultsRow';
type SearchResultsProps = {
serverId?: string;
parentId?: string;
collectionType?: string;
query?: string;
}
/*
* React component to display search result rows for global search and non-live tv library search
*/
const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId, parentId, collectionType, query }: SearchResultsProps) => {
const [ movies, setMovies ] = useState([]);
const [ shows, setShows ] = useState([]);
const [ episodes, setEpisodes ] = useState([]);
const [ videos, setVideos ] = useState([]);
const [ programs, setPrograms ] = useState([]);
const [ channels, setChannels ] = useState([]);
const [ playlists, setPlaylists ] = useState([]);
const [ artists, setArtists ] = useState([]);
const [ albums, setAlbums ] = useState([]);
const [ songs, setSongs ] = useState([]);
const [ photoAlbums, setPhotoAlbums ] = useState([]);
const [ photos, setPhotos ] = useState([]);
const [ audioBooks, setAudioBooks ] = useState([]);
const [ books, setBooks ] = useState([]);
const [ people, setPeople ] = useState([]);
useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
const fetchArtists = (apiClient, params = {}) => apiClient?.getArtists(
apiClient?.getCurrentUserId(),
{
...getDefaultParameters(),
IncludeArtists: true,
...params
}
);
const fetchItems = (apiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(),
{
...getDefaultParameters(),
IncludeMedia: true,
...params
}
);
const fetchPeople = (apiClient, params = {}) => apiClient?.getPeople(
apiClient?.getCurrentUserId(),
{
...getDefaultParameters(),
IncludePeople: true,
...params
}
);
const isMovies = () => collectionType === 'movies';
const isMusic = () => collectionType === 'music';
const isTVShows = () => collectionType === 'tvshows' || collectionType === 'tv';
// Reset state
setMovies([]);
setShows([]);
setEpisodes([]);
setVideos([]);
setPrograms([]);
setChannels([]);
setPlaylists([]);
setArtists([]);
setAlbums([]);
setSongs([]);
setPhotoAlbums([]);
setPhotos([]);
setAudioBooks([]);
setBooks([]);
setPeople([]);
if (query) {
// TODO: Remove type casting once we're using a properly typed API client
const apiClient = (ServerConnections as any).getApiClient(serverId);
// Movie libraries
if (!collectionType || isMovies()) {
// Movies row
fetchItems(apiClient, { IncludeItemTypes: 'Movie' })
.then(result => setMovies(result.Items));
}
// TV Show libraries
if (!collectionType || isTVShows()) {
// Shows row
fetchItems(apiClient, { IncludeItemTypes: 'Series' })
.then(result => setShows(result.Items));
// Episodes row
fetchItems(apiClient, { IncludeItemTypes: 'Episode' })
.then(result => setEpisodes(result.Items));
}
// People are included for Movies and TV Shows
if (!collectionType || isMovies() || isTVShows()) {
// People row
fetchPeople(apiClient).then(result => setPeople(result.Items));
}
// Music libraries
if (!collectionType || isMusic()) {
// Playlists row
fetchItems(apiClient, { IncludeItemTypes: 'Playlist' })
.then(results => setPlaylists(results.Items));
// Artists row
fetchArtists(apiClient).then(result => setArtists(result.Items));
// Albums row
fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' })
.then(result => setAlbums(result.Items));
// Songs row
fetchItems(apiClient, { IncludeItemTypes: 'Audio' })
.then(result => setSongs(result.Items));
}
// Other libraries do not support in-library search currently
if (!collectionType) {
// Videos row
fetchItems(apiClient, {
MediaTypes: 'Video',
ExcludeItemTypes: 'Movie,Episode,TvChannel'
}).then(result => setVideos(result.Items));
// Programs row
fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' })
.then(result => setPrograms(result.Items));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items));
// Photo Albums row
fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' })
.then(results => setPhotoAlbums(results.Items));
// Photos row
fetchItems(apiClient, { IncludeItemTypes: 'Photo' })
.then(results => setPhotos(results.Items));
// Audio Books row
fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' })
.then(results => setAudioBooks(results.Items));
// Books row
fetchItems(apiClient, { IncludeItemTypes: 'Book' })
.then(results => setBooks(results.Items));
}
}
}, [collectionType, parentId, query, serverId]);
return (
<div
className={classNames(
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || collectionType === 'livetv' }
)}
>
<SearchResultsRow
title={globalize.translate('Movies')}
items={movies}
cardOptions={{ showYear: true }}
/>
<SearchResultsRow
title={globalize.translate('Shows')}
items={shows}
cardOptions={{ showYear: true }}
/>
<SearchResultsRow
title={globalize.translate('Episodes')}
items={episodes}
cardOptions={{
coverImage: true,
showParentTitle: true
}}
/>
<SearchResultsRow
title={globalize.translate('HeaderVideos')}
items={videos}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('Programs')}
items={programs}
cardOptions={{
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
}}
/>
<SearchResultsRow
title={globalize.translate('Channels')}
items={channels}
cardOptions={{ shape: 'square' }}
/>
<SearchResultsRow
title={globalize.translate('Playlists')}
items={playlists}
/>
<SearchResultsRow
title={globalize.translate('Artists')}
items={artists}
cardOptions={{ coverImage: true }}
/>
<SearchResultsRow
title={globalize.translate('Albums')}
items={albums}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('Songs')}
items={songs}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('HeaderPhotoAlbums')}
items={photoAlbums}
/>
<SearchResultsRow
title={globalize.translate('Photos')}
items={photos}
/>
<SearchResultsRow
title={globalize.translate('HeaderAudioBooks')}
items={audioBooks}
/>
<SearchResultsRow
title={globalize.translate('Books')}
items={books}
/>
<SearchResultsRow
title={globalize.translate('People')}
items={people}
cardOptions={{ coverImage: true }}
/>
</div>
);
};
export default SearchResults;

View file

@ -0,0 +1,50 @@
import React, { FunctionComponent, useEffect, useRef } from 'react';
import cardBuilder from '../cardbuilder/cardBuilder';
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createScroller = ({ title = '' }) => ({
__html: `<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${title}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>`
});
type SearchResultsRowProps = {
title?: string;
items?: Array<any>; // TODO: Should be Array<BaseItemDto> once we have a typed API client
cardOptions?: Record<string, any>;
}
const SearchResultsRow: FunctionComponent<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }: SearchResultsRowProps) => {
const element = useRef(null);
useEffect(() => {
cardBuilder.buildCards(items, {
itemsContainer: element.current?.querySelector('.itemsContainer'),
parentContainer: element.current,
shape: 'autooverflow',
scalable: true,
showTitle: true,
overlayText: false,
centerText: true,
allowBottomPadding: false,
...cardOptions
});
}, [cardOptions, items]);
return (
<div
ref={element}
className='verticalSection'
dangerouslySetInnerHTML={createScroller({ title })}
/>
);
};
export default SearchResultsRow;

View file

@ -0,0 +1,71 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
import { appRouter } from '../appRouter';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
import '../../elements/emby-button/emby-button';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createSuggestionLink = ({name, href}) => ({
__html: `<a
is='emby-linkbutton'
class='button-link'
style='display: inline-block; padding: 0.5em 1em;'
href='${href}'
>${name}</a>`
});
type SearchSuggestionsProps = {
serverId?: string;
parentId?: string;
}
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ serverId, parentId }: SearchSuggestionsProps) => {
const [ suggestions, setSuggestions ] = useState([]);
useEffect(() => {
// TODO: Remove type casting once we're using a properly typed API client
const apiClient = (ServerConnections as any).getApiClient(serverId);
apiClient.getItems(apiClient.getCurrentUserId(), {
SortBy: 'IsFavoriteOrLiked,Random',
IncludeItemTypes: 'Movie,Series,MusicArtist',
Limit: 20,
Recursive: true,
ImageTypeLimit: 0,
EnableImages: false,
ParentId: parentId,
EnableTotalRecordCount: false
}).then(result => setSuggestions(result.Items));
}, [parentId, serverId]);
return (
<div
className='verticalSection searchSuggestions'
style={{ textAlign: 'center' }}
>
<div>
<h2 className='sectionTitle padded-left padded-right'>
{globalize.translate('Suggestions')}
</h2>
</div>
<div className='searchSuggestionsList padded-left padded-right'>
{suggestions.map(item => (
<div
key={`suggestion-${item.Id}`}
dangerouslySetInnerHTML={createSuggestionLink({
name: item.Name,
href: appRouter.getRouteUrl(item)
})}
/>
))}
</div>
</div>
);
};
export default SearchSuggestions;

View file

@ -1,115 +0,0 @@
import layoutManager from '../layoutManager';
import globalize from '../../scripts/globalize';
import { Events } from 'jellyfin-apiclient';
import browser from '../../scripts/browser';
import AlphaPicker from '../alphaPicker/alphaPicker';
import '../../elements/emby-input/emby-input';
import '../../assets/css/flexstyles.scss';
import 'material-design-icons-iconfont';
import './searchfields.scss';
import template from './searchfields.template.html';
/* eslint-disable indent */
function onSearchTimeout() {
const instance = this;
let value = instance.nextSearchValue;
value = (value || '').trim();
Events.trigger(instance, 'search', [value]);
}
function triggerSearch(instance, value) {
if (instance.searchTimeout) {
clearTimeout(instance.searchTimeout);
}
instance.nextSearchValue = value;
instance.searchTimeout = setTimeout(onSearchTimeout.bind(instance), 400);
}
function onAlphaValueClicked(e) {
const value = e.detail.value;
const searchFieldsInstance = this;
const txtSearch = searchFieldsInstance.options.element.querySelector('.searchfields-txtSearch');
if (value === 'backspace') {
const val = txtSearch.value;
txtSearch.value = val.length ? val.substring(0, val.length - 1) : '';
} else {
txtSearch.value += value;
}
txtSearch.dispatchEvent(new CustomEvent('input', {
bubbles: true
}));
}
function initAlphaPicker(alphaPickerElement, instance) {
instance.alphaPicker = new AlphaPicker({
element: alphaPickerElement,
mode: 'keyboard'
});
alphaPickerElement.addEventListener('alphavalueclicked', onAlphaValueClicked.bind(instance));
}
function onSearchInput(e) {
const value = e.target.value;
const searchFieldsInstance = this;
triggerSearch(searchFieldsInstance, value);
}
function embed(elem, instance) {
elem.innerHTML = globalize.translateHtml(template, 'core');
elem.classList.add('searchFields');
const txtSearch = elem.querySelector('.searchfields-txtSearch');
if (layoutManager.tv && !browser.tv) {
const alphaPickerElement = elem.querySelector('.alphaPicker');
elem.querySelector('.alphaPicker').classList.remove('hide');
initAlphaPicker(alphaPickerElement, instance);
}
txtSearch.addEventListener('input', onSearchInput.bind(instance));
instance.focus();
}
class SearchFields {
constructor(options) {
this.options = options;
embed(options.element, this);
}
focus() {
this.options.element.querySelector('.searchfields-txtSearch').focus();
}
destroy() {
const options = this.options;
if (options) {
options.element.classList.remove('searchFields');
}
this.options = null;
const alphaPicker = this.alphaPicker;
if (alphaPicker) {
alphaPicker.destroy();
}
this.alphaPicker = null;
const searchTimeout = this.searchTimeout;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
this.searchTimeout = null;
this.nextSearchValue = null;
}
}
export default SearchFields;
/* eslint-enable indent */

View file

@ -1,7 +0,0 @@
<div class="searchFieldsInner flex align-items-center justify-content-center">
<span class="searchfields-icon material-icons search"></span>
<div class="inputContainer flex-grow" style="margin-bottom: 0;">
<input is="emby-input" class="searchfields-txtSearch" type="text" data-keyboard="true" placeholder="${Search}" autocomplete="off" maxlength="40" autofocus />
</div>
</div>
<div class="alphaPicker align-items-center hide"></div>

View file

@ -1,624 +0,0 @@
import layoutManager from '../layoutManager';
import globalize from '../../scripts/globalize';
import cardBuilder from '../cardbuilder/cardBuilder';
import { appRouter } from '../appRouter';
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../elements/emby-button/emby-button';
import ServerConnections from '../ServerConnections';
import template from './searchresults.template.html';
/* eslint-disable indent */
function loadSuggestions(instance, context, apiClient) {
const options = {
SortBy: 'IsFavoriteOrLiked,Random',
IncludeItemTypes: 'Movie,Series,MusicArtist',
Limit: 20,
Recursive: true,
ImageTypeLimit: 0,
EnableImages: false,
ParentId: instance.options.parentId,
EnableTotalRecordCount: false
};
apiClient.getItems(apiClient.getCurrentUserId(), options).then(function (result) {
if (instance.mode !== 'suggestions') {
result.Items = [];
}
const html = result.Items.map(function (i) {
const href = appRouter.getRouteUrl(i);
let itemHtml = '<div><a is="emby-linkbutton" class="button-link" style="display:inline-block;padding:.5em 1em;" href="' + href + '">';
itemHtml += i.Name;
itemHtml += '</a></div>';
return itemHtml;
}).join('');
const searchSuggestions = context.querySelector('.searchSuggestions');
searchSuggestions.querySelector('.searchSuggestionsList').innerHTML = html;
if (result.Items.length) {
searchSuggestions.classList.remove('hide');
}
});
}
function getSearchHints(instance, apiClient, query) {
if (!query.searchTerm) {
return Promise.resolve({
SearchHints: []
});
}
let allowSearch = true;
const queryIncludeItemTypes = query.IncludeItemTypes;
if (instance.options.collectionType === 'tvshows') {
if (query.IncludeArtists) {
allowSearch = false;
} else if (queryIncludeItemTypes === 'Movie' ||
queryIncludeItemTypes === 'LiveTvProgram' ||
queryIncludeItemTypes === 'MusicAlbum' ||
queryIncludeItemTypes === 'Audio' ||
queryIncludeItemTypes === 'Book' ||
queryIncludeItemTypes === 'AudioBook' ||
queryIncludeItemTypes === 'Playlist' ||
queryIncludeItemTypes === 'PhotoAlbum' ||
query.MediaTypes === 'Video' ||
query.MediaTypes === 'Photo') {
allowSearch = false;
}
} else if (instance.options.collectionType === 'movies') {
if (query.IncludeArtists) {
allowSearch = false;
} else if (queryIncludeItemTypes === 'Series' ||
queryIncludeItemTypes === 'Episode' ||
queryIncludeItemTypes === 'LiveTvProgram' ||
queryIncludeItemTypes === 'MusicAlbum' ||
queryIncludeItemTypes === 'Audio' ||
queryIncludeItemTypes === 'Book' ||
queryIncludeItemTypes === 'AudioBook' ||
queryIncludeItemTypes === 'Playlist' ||
queryIncludeItemTypes === 'PhotoAlbum' ||
query.MediaTypes === 'Video' ||
query.MediaTypes === 'Photo') {
allowSearch = false;
}
} else if (instance.options.collectionType === 'music') {
if (query.People) {
allowSearch = false;
} else if (queryIncludeItemTypes === 'Series' ||
queryIncludeItemTypes === 'Episode' ||
queryIncludeItemTypes === 'LiveTvProgram' ||
queryIncludeItemTypes === 'Movie') {
allowSearch = false;
}
} else if (instance.options.collectionType === 'livetv') {
if (query.IncludeArtists || query.IncludePeople) {
allowSearch = false;
} else if (queryIncludeItemTypes === 'Series' ||
queryIncludeItemTypes === 'Episode' ||
queryIncludeItemTypes === 'MusicAlbum' ||
queryIncludeItemTypes === 'Audio' ||
queryIncludeItemTypes === 'Book' ||
queryIncludeItemTypes === 'AudioBook' ||
queryIncludeItemTypes === 'PhotoAlbum' ||
queryIncludeItemTypes === 'Movie' ||
query.MediaTypes === 'Video' ||
query.MediaTypes === 'Photo') {
allowSearch = false;
}
}
if (queryIncludeItemTypes === 'NullType') {
allowSearch = false;
}
if (!allowSearch) {
return Promise.resolve({
SearchHints: []
});
}
// Convert the search hint query to a regular item query
if (apiClient.isMinServerVersion('3.4.1.31')) {
query.Fields = 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount';
query.Recursive = true;
query.EnableTotalRecordCount = false;
query.ImageTypeLimit = 1;
let methodName = 'getItems';
if (!query.IncludeMedia) {
if (query.IncludePeople) {
methodName = 'getPeople';
} else if (query.IncludeArtists) {
methodName = 'getArtists';
}
}
return apiClient[methodName](apiClient.getCurrentUserId(), query);
}
query.UserId = apiClient.getCurrentUserId();
return apiClient.getSearchHints(query);
}
function search(instance, apiClient, context, value) {
if (value || layoutManager.tv) {
instance.mode = 'search';
context.querySelector('.searchSuggestions').classList.add('hide');
} else {
instance.mode = 'suggestions';
loadSuggestions(instance, context, apiClient);
}
if (instance.options.collectionType === 'livetv') {
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true,
IsKids: false,
IsNews: false
}, context, '.movieResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowPortrait' : 'portrait'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
} else {
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Movie'
}, context, '.movieResults', {
showTitle: true,
overlayText: false,
centerText: true,
showYear: true
});
}
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Series'
}, context, '.seriesResults', {
showTitle: true,
overlayText: false,
centerText: true,
showYear: true
});
if (instance.options.collectionType === 'livetv') {
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'LiveTvProgram',
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
}, context, '.episodeResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
} else {
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Episode'
}, context, '.episodeResults', {
coverImage: true,
showTitle: true,
showParentTitle: true
});
}
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
// NullType to hide
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
IsSports: true
}, context, '.sportsResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
// NullType to hide
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
IsKids: true
}, context, '.kidsResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
// NullType to hide
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
IsNews: true
}, context, '.newsResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'LiveTvProgram',
IsMovie: instance.options.collectionType === 'livetv' ? false : null,
IsSeries: instance.options.collectionType === 'livetv' ? false : null,
IsSports: instance.options.collectionType === 'livetv' ? false : null,
IsKids: instance.options.collectionType === 'livetv' ? false : null,
IsNews: instance.options.collectionType === 'livetv' ? false : null
}, context, '.programResults', {
preferThumb: true,
inheritThumb: false,
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
showParentTitleOrTitle: true,
showTitle: false,
centerText: true,
coverImage: true,
overlayText: false,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
MediaTypes: 'Video',
ExcludeItemTypes: 'Movie,Episode'
}, context, '.videoResults', {
showParentTitle: true,
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: true,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}, context, '.peopleResults', {
coverImage: true,
showTitle: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: true
}, context, '.artistResults', {
coverImage: true,
showTitle: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'MusicAlbum'
}, context, '.albumResults', {
showParentTitle: true,
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Audio'
}, context, '.songResults', {
showParentTitle: true,
showTitle: true,
overlayText: false,
centerText: true,
overlayPlayButton: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
MediaTypes: 'Photo'
}, context, '.photoResults', {
showParentTitle: false,
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'PhotoAlbum'
}, context, '.photoAlbumResults', {
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Book'
}, context, '.bookResults', {
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'AudioBook'
}, context, '.audioBookResults', {
showTitle: true,
overlayText: false,
centerText: true
});
searchType(instance, apiClient, {
searchTerm: value,
IncludePeople: false,
IncludeMedia: true,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false,
IncludeItemTypes: 'Playlist'
}, context, '.playlistResults', {
showTitle: true,
overlayText: false,
centerText: true
});
}
function searchType(instance, apiClient, query, context, section, cardOptions) {
query.Limit = enableScrollX() ? 24 : 16;
query.ParentId = instance.options.parentId;
getSearchHints(instance, apiClient, query).then(function (result) {
populateResults(result, context, section, cardOptions);
});
}
function populateResults(result, context, section, cardOptions) {
section = context.querySelector(section);
const items = result.Items || result.SearchHints;
const itemsContainer = section.querySelector('.itemsContainer');
cardBuilder.buildCards(items, Object.assign({
itemsContainer: itemsContainer,
parentContainer: section,
shape: enableScrollX() ? 'autooverflow' : 'auto',
scalable: true,
overlayText: false,
centerText: true,
allowBottomPadding: !enableScrollX()
}, cardOptions || {}));
}
function enableScrollX() {
return true;
}
function replaceAll(originalString, strReplace, strWith) {
const reg = new RegExp(strReplace, 'ig');
return originalString.replace(reg, strWith);
}
function embed(elem, instance) {
let workingTemplate = template;
if (!enableScrollX()) {
workingTemplate = replaceAll(workingTemplate, 'data-horizontal="true"', 'data-horizontal="false"');
workingTemplate = replaceAll(workingTemplate, 'itemsContainer scrollSlider', 'itemsContainer scrollSlider vertical-wrap');
}
const html = globalize.translateHtml(workingTemplate, 'core');
elem.innerHTML = html;
elem.classList.add('searchResults');
instance.search('');
}
class SearchResults {
constructor(options) {
this.options = options;
embed(options.element, this);
}
search(value) {
const apiClient = ServerConnections.getApiClient(this.options.serverId);
search(this, apiClient, this.options.element, value);
}
destroy() {
const options = this.options;
if (options) {
options.element.classList.remove('searchFields');
}
this.options = null;
}
}
export default SearchResults;
/* eslint-enable indent */

View file

@ -1,145 +0,0 @@
<div class="hide verticalSection searchSuggestions" style="text-align:center;">
<div>
<h2 class="sectionTitle padded-left padded-right">${Suggestions}</h2>
</div>
<div class="searchSuggestionsList padded-left padded-right">
</div>
</div>
<div class="hide verticalSection movieResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Movies}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection seriesResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Shows}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection episodeResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Episodes}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection sportsResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Sports}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection kidsResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Kids}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection newsResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${News}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection programResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Programs}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection videoResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Videos}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection playlistResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Playlists}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection artistResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Artists}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection albumResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Albums}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection songResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Songs}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection photoAlbumResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${HeaderPhotoAlbums}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection photoResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Photos}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection audioBookResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${HeaderAudioBooks}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection bookResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Books}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>
<div class="hide verticalSection peopleResults">
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${People}</h2>
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
</div>
</div>

View file

@ -100,6 +100,18 @@ class Controller {
});
}
/**
* Clears the playlist of a SyncPlay group.
* @param {Array} clearPlayingItem Whether to remove the playing item as well.
*/
clearPlaylist(clearPlayingItem = false) {
const apiClient = this.manager.getApiClient();
apiClient.requestSyncPlayRemoveFromPlaylist({
ClearPlaylist: true,
ClearPlayingItem: clearPlayingItem
});
}
/**
* Removes items from SyncPlay group playlist.
* @param {Array} playlistItemIds The items to remove.

View file

@ -78,8 +78,7 @@ export function getItemsForPlayback(apiClient, query) {
return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) {
return {
Items: [item],
TotalRecordCount: 1
Items: [item]
};
});
} else {

View file

@ -47,12 +47,8 @@ class Manager {
* @param {Object} apiClient The ApiClient.
*/
init(apiClient) {
if (!apiClient) {
throw new Error('ApiClient is null!');
}
// Set ApiClient.
this.apiClient = apiClient;
this.updateApiClient(apiClient);
// Get default player wrapper.
this.playerWrapper = this.playerFactory.getDefaultWrapper(this);
@ -73,6 +69,18 @@ class Manager {
});
}
/**
* Update active ApiClient.
* @param {Object} apiClient The ApiClient.
*/
updateApiClient(apiClient) {
if (!apiClient) {
throw new Error('ApiClient is null!');
}
this.apiClient = apiClient;
}
/**
* Gets the time sync core.
* @returns {TimeSyncCore} The time sync core.

View file

@ -4,7 +4,9 @@
*/
import { Events } from 'jellyfin-apiclient';
import { toBoolean, toFloat } from '../../../scripts/stringUtils';
import * as Helper from './Helper';
import { getSetting } from './Settings';
/**
* Class that manages the playback of SyncPlay.
@ -25,6 +27,8 @@ class PlaybackCore {
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
this.scheduledCommandTimeout = null;
this.syncTimeout = null;
this.loadPreferences();
}
/**
@ -35,26 +39,35 @@ class PlaybackCore {
this.manager = syncPlayManager;
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
Events.on(this.manager, 'settings-update', () => {
this.loadPreferences();
});
}
/**
* Loads preferences from saved settings.
*/
loadPreferences() {
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
this.minDelaySpeedToSync = 60.0;
this.minDelaySpeedToSync = toFloat(getSetting('minDelaySpeedToSync'), 60.0);
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
this.maxDelaySpeedToSync = 3000.0;
this.maxDelaySpeedToSync = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0);
// Time during which the playback is sped up, in milliseconds.
this.speedToSyncDuration = 1000.0;
this.speedToSyncDuration = toFloat(getSetting('speedToSyncDuration'), 1000.0);
// Minimum required delay for SkipToSync to kick in, in milliseconds.
this.minDelaySkipToSync = 400.0;
this.minDelaySkipToSync = toFloat(getSetting('minDelaySkipToSync'), 400.0);
// Whether SpeedToSync should be used.
this.useSpeedToSync = true;
this.useSpeedToSync = toBoolean(getSetting('useSpeedToSync'), true);
// Whether SkipToSync should be used.
this.useSkipToSync = true;
this.useSkipToSync = toBoolean(getSetting('useSkipToSync'), true);
// Whether sync correction during playback is active.
this.enableSyncCorrection = true;
this.enableSyncCorrection = toBoolean(getSetting('enableSyncCorrection'), true);
}
/**
@ -118,9 +131,11 @@ class PlaybackCore {
* Sends a buffering request to the server.
* @param {boolean} isBuffering Whether this client is buffering or not.
*/
sendBufferingRequest(isBuffering = true) {
async sendBufferingRequest(isBuffering = true) {
const playerWrapper = this.manager.getPlayerWrapper();
const currentPosition = playerWrapper.currentTime();
const currentPosition = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime());
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
@ -155,7 +170,7 @@ class PlaybackCore {
* Applies a command and checks the playback state if a duplicate command is received.
* @param {Object} command The playback command.
*/
applyCommand(command) {
async applyCommand(command) {
// Check if duplicate.
if (this.lastCommand &&
this.lastCommand.When.getTime() === command.When.getTime() &&
@ -177,7 +192,9 @@ class PlaybackCore {
} else {
// Check if playback state matches requested command.
const playerWrapper = this.manager.getPlayerWrapper();
const currentPositionTicks = Math.round(playerWrapper.currentTime() * Helper.TicksPerMillisecond);
const currentPositionTicks = Math.round((playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();
switch (command.Command) {
@ -255,14 +272,16 @@ class PlaybackCore {
* @param {Date} playAtTime The server's UTC time at which to resume playback.
* @param {number} positionTicks The PositionTicks from where to resume.
*/
scheduleUnpause(playAtTime, positionTicks) {
async scheduleUnpause(playAtTime, positionTicks) {
this.clearScheduledCommand();
const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0;
const currentTime = new Date();
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
const playerWrapper = this.manager.getPlayerWrapper();
const currentPositionTicks = playerWrapper.currentTime() * Helper.TicksPerMillisecond;
const currentPositionTicks = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond;
if (playAtTimeLocal > currentTime) {
const playTimeout = playAtTimeLocal - currentTime;
@ -520,7 +539,9 @@ class PlaybackCore {
// Diff might be caused by the player internally starting the playback.
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond;
// Notify update for playback sync.
this.playbackDiffMillis = diffMillis;
Events.trigger(this.manager, 'playback-diff', [this.playbackDiffMillis]);
// Avoid overloading the browser.
const elapsed = currentTime - this.lastSyncTime;

View file

@ -167,14 +167,16 @@ class QueueCore {
* @param {string} origin The origin of the wait call, used for debug.
*/
scheduleReadyRequestOnPlaybackStart(apiClient, origin) {
Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(() => {
Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(async () => {
console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.');
const playerWrapper = this.manager.getPlayerWrapper();
playerWrapper.localPause();
const currentTime = new Date();
const now = this.manager.timeSyncCore.localDateToRemote(currentTime);
const currentPosition = playerWrapper.currentTime();
const currentPosition = (playerWrapper.currentTimeAsync
? await playerWrapper.currentTimeAsync()
: playerWrapper.currentTime());
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
const isPlaying = playerWrapper.isPlaying();

View file

@ -0,0 +1,28 @@
/**
* Module that manages SyncPlay settings.
* @module components/syncPlay/core/Settings
*/
import appSettings from '../../../scripts/settings/appSettings';
/**
* Prefix used when saving SyncPlay settings.
*/
const PREFIX = 'syncPlay';
/**
* Gets the value of a setting.
* @param {string} name The name of the setting.
* @returns {string} The value.
*/
export function getSetting(name) {
return appSettings.get(name, PREFIX);
}
/**
* Sets the value of a setting. Triggers an update if the new value differs from the old one.
* @param {string} name The name of the setting.
* @param {Object} value The value of the setting.
*/
export function setSetting(name, value) {
return appSettings.set(name, value, PREFIX);
}

View file

@ -44,13 +44,15 @@ class PlayerFactory {
return this.getDefaultWrapper(syncPlayManager);
}
console.debug('SyncPlay WrapperFactory getWrapper:', player.id);
const Wrapper = this.wrappers[player.id];
const playerId = player.syncPlayWrapAs || player.id;
console.debug('SyncPlay WrapperFactory getWrapper:', playerId);
const Wrapper = this.wrappers[playerId];
if (Wrapper) {
return new Wrapper(player, syncPlayManager);
}
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${player.id}, using default wrapper.`);
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${playerId}, using default wrapper.`);
return this.getDefaultWrapper(syncPlayManager);
}

View file

@ -4,8 +4,21 @@
*/
import { Events } from 'jellyfin-apiclient';
import appSettings from '../../../../scripts/settings/appSettings';
import { toFloat } from '../../../../scripts/stringUtils';
import { getSetting } from '../Settings';
import TimeSyncServer from './TimeSyncServer';
/**
* Utility function to offset a given date by a given amount of milliseconds.
* @param {Date} date The date.
* @param {number} offset The offset, in milliseconds.
* @returns {Date} The offset date.
*/
function offsetDate(date, offset) {
return new Date(date.getTime() + offset);
}
/**
* Class that manages time syncing with several devices.
*/
@ -13,6 +26,9 @@ class TimeSyncCore {
constructor() {
this.manager = null;
this.timeSyncServer = null;
this.timeSyncDeviceId = getSetting('timeSyncDevice') || 'server';
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
}
/**
@ -31,6 +47,12 @@ class TimeSyncCore {
Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]);
});
Events.on(appSettings, 'change', function (e, name) {
if (name === 'extraTimeOffset') {
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
}
});
}
/**
@ -54,7 +76,8 @@ class TimeSyncCore {
* @returns {Date} Local time.
*/
remoteDateToLocal(remote) {
return this.timeSyncServer.remoteDateToLocal(remote);
const date = this.timeSyncServer.remoteDateToLocal(remote);
return offsetDate(date, -this.extraTimeOffset);
}
/**
@ -63,15 +86,16 @@ class TimeSyncCore {
* @returns {Date} Server time.
*/
localDateToRemote(local) {
return this.timeSyncServer.localDateToRemote(local);
const date = this.timeSyncServer.localDateToRemote(local);
return offsetDate(date, this.extraTimeOffset);
}
/**
* Gets time offset that should be used for time syncing, in milliseconds.
* Gets time offset that should be used for time syncing, in milliseconds. Takes into account server and active device selected for syncing.
* @returns {number} The time offset.
*/
getTimeOffset() {
return this.timeSyncServer.getTimeOffset();
return this.timeSyncServer.getTimeOffset() + this.extraTimeOffset;
}
}

View file

@ -1,5 +1,6 @@
import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../core';
import SyncPlaySettingsEditor from './settings/SettingsEditor';
import loading from '../../loading/loading';
import toast from '../../toast/toast';
import actionsheet from '../../actionSheet/actionSheet';
@ -62,7 +63,6 @@ class GroupSelectionMenu {
title: globalize.translate('HeaderSyncPlaySelectGroup'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
@ -77,7 +77,9 @@ class GroupSelectionMenu {
});
}
}).catch((error) => {
console.error('SyncPlay: unexpected error listing groups:', error);
if (error) {
console.error('SyncPlay: unexpected error listing groups:', error);
}
});
loading.hide();
@ -119,6 +121,14 @@ class GroupSelectionMenu {
});
}
menuItems.push({
name: globalize.translate('Settings'),
icon: 'video_settings',
id: 'settings',
selected: false,
secondaryText: globalize.translate('LabelSyncPlaySettingsDescription')
});
menuItems.push({
name: globalize.translate('LabelSyncPlayLeaveGroup'),
icon: 'meeting_room',
@ -131,7 +141,6 @@ class GroupSelectionMenu {
title: groupInfo.GroupName,
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
@ -142,9 +151,19 @@ class GroupSelectionMenu {
SyncPlay.Manager.haltGroupPlayback(apiClient);
} else if (id == 'leave-group') {
apiClient.leaveSyncPlayGroup();
} else if (id == 'settings') {
new SyncPlaySettingsEditor(apiClient, SyncPlay.Manager.getTimeSyncCore(), { groupInfo: groupInfo })
.embed()
.catch(error => {
if (error) {
console.error('Error creating SyncPlay settings editor', error);
}
});
}
}).catch((error) => {
console.error('SyncPlay: unexpected error showing group menu:', error);
if (error) {
console.error('SyncPlay: unexpected error showing group menu:', error);
}
});
loading.hide();

View file

@ -1,3 +1,5 @@
import { appHost } from '../../apphost';
/**
* Creates an audio element that plays a silent sound.
* @returns {HTMLMediaElement} The audio element.
@ -33,6 +35,10 @@ class PlaybackPermissionManager {
* @returns {Promise} Promise that resolves succesfully if playback permission is allowed.
*/
check () {
if (appHost.supports('htmlaudioautoplay')) {
return Promise.resolve(true);
}
return new Promise((resolve, reject) => {
const media = createTestMediaElement();
media.play().then(() => {

View file

@ -17,6 +17,16 @@ class HtmlVideoPlayer extends NoActivePlayer {
this.isPlayerActive = false;
this.savedPlaybackRate = 1.0;
this.minBufferingThresholdMillis = 3000;
if (player.currentTimeAsync) {
/**
* Gets current playback position.
* @returns {Promise<number>} The player position, in milliseconds.
*/
this.currentTimeAsync = () => {
return this.player.currentTimeAsync();
};
}
}
/**

View file

@ -45,6 +45,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
playbackManager._localPlay = playbackManager.play;
playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem;
playbackManager._localClearQueue = playbackManager.clearQueue;
playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist;
playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem;
playbackManager._localQueue = playbackManager.queue;
@ -62,6 +63,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
playbackManager.play = this.playRequest;
playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest;
playbackManager.clearQueue = this.clearQueueRequest;
playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest;
playbackManager.movePlaylistItem = this.movePlaylistItemRequest;
playbackManager.queue = this.queueRequest;
@ -93,6 +95,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
playbackManager.play = playbackManager._localPlay;
playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem;
playbackManager.clearQueue = this._localClearQueue;
playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist;
playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem;
playbackManager.queue = playbackManager._localQueue;
@ -247,6 +250,14 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
controller.setCurrentPlaylistItem(playlistItemId);
}
/**
* Overrides PlaybackManager's clearQueue method.
*/
clearQueueRequest(clearPlayingItem) {
const controller = syncPlayManager.getController();
controller.clearPlaylist(clearPlayingItem);
}
/**
* Overrides PlaybackManager's removeFromPlaylist method.
*/

View file

@ -0,0 +1,147 @@
/**
* Module that displays an editor for changing SyncPlay settings.
* @module components/syncPlay/settings/SettingsEditor
*/
import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../../core';
import { getSetting, setSetting } from '../../core/Settings';
import dialogHelper from '../../../dialogHelper/dialogHelper';
import layoutManager from '../../../layoutManager';
import loading from '../../../loading/loading';
import toast from '../../../toast/toast';
import globalize from '../../../../scripts/globalize';
import { toBoolean, toFloat } from '../../../../scripts/stringUtils';
import 'material-design-icons-iconfont';
import '../../../../elements/emby-input/emby-input';
import '../../../../elements/emby-select/emby-select';
import '../../../../elements/emby-button/emby-button';
import '../../../../elements/emby-button/paper-icon-button-light';
import '../../../../elements/emby-checkbox/emby-checkbox';
import '../../../listview/listview.scss';
import '../../../formdialog.scss';
function centerFocus(elem, horiz, on) {
import('../../../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
/**
* Class that displays an editor for changing SyncPlay settings.
*/
class SettingsEditor {
constructor(apiClient, timeSyncCore, options = {}) {
this.apiClient = apiClient;
this.timeSyncCore = timeSyncCore;
this.options = options;
}
async embed() {
const dialogOptions = {
removeOnClose: true,
scrollY: true
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
this.context = dialogHelper.createDialog(dialogOptions);
this.context.classList.add('formDialog');
const { default: editorTemplate } = await import('./editor.html');
this.context.innerHTML = globalize.translateHtml(editorTemplate, 'core');
// Set callbacks for form submission
this.context.querySelector('form').addEventListener('submit', (event) => {
// Disable default form submission
if (event) {
event.preventDefault();
}
return false;
});
this.context.querySelector('.btnSave').addEventListener('click', () => {
this.onSubmit();
});
this.context.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(this.context);
});
await this.initEditor();
if (layoutManager.tv) {
centerFocus(this.context.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(this.context).then(() => {
if (layoutManager.tv) {
centerFocus(this.context.querySelector('.formDialogContent'), false, false);
}
if (this.context.submitted) {
return Promise.resolve();
}
return Promise.reject();
});
}
async initEditor() {
const { context } = this;
context.querySelector('#txtExtraTimeOffset').value = toFloat(getSetting('extraTimeOffset'), 0.0);
context.querySelector('#chkSyncCorrection').checked = toBoolean(getSetting('enableSyncCorrection'), true);
context.querySelector('#txtMinDelaySpeedToSync').value = toFloat(getSetting('minDelaySpeedToSync'), 60.0);
context.querySelector('#txtMaxDelaySpeedToSync').value = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0);
context.querySelector('#txtSpeedToSyncDuration').value = toFloat(getSetting('speedToSyncDuration'), 1000.0);
context.querySelector('#txtMinDelaySkipToSync').value = toFloat(getSetting('minDelaySkipToSync'), 400.0);
context.querySelector('#chkSpeedToSync').checked = toBoolean(getSetting('useSpeedToSync'), true);
context.querySelector('#chkSkipToSync').checked = toBoolean(getSetting('useSkipToSync'), true);
}
onSubmit() {
this.save();
dialogHelper.close(this.context);
}
async save() {
loading.show();
await this.saveToAppSettings();
loading.hide();
toast(globalize.translate('SettingsSaved'));
Events.trigger(this, 'saved');
}
async saveToAppSettings() {
const { context } = this;
const extraTimeOffset = context.querySelector('#txtExtraTimeOffset').value;
const syncCorrection = context.querySelector('#chkSyncCorrection').checked;
const minDelaySpeedToSync = context.querySelector('#txtMinDelaySpeedToSync').value;
const maxDelaySpeedToSync = context.querySelector('#txtMaxDelaySpeedToSync').value;
const speedToSyncDuration = context.querySelector('#txtSpeedToSyncDuration').value;
const minDelaySkipToSync = context.querySelector('#txtMinDelaySkipToSync').value;
const useSpeedToSync = context.querySelector('#chkSpeedToSync').checked;
const useSkipToSync = context.querySelector('#chkSkipToSync').checked;
setSetting('extraTimeOffset', extraTimeOffset);
setSetting('enableSyncCorrection', syncCorrection);
setSetting('minDelaySpeedToSync', minDelaySpeedToSync);
setSetting('maxDelaySpeedToSync', maxDelaySpeedToSync);
setSetting('speedToSyncDuration', speedToSyncDuration);
setSetting('minDelaySkipToSync', minDelaySkipToSync);
setSetting('useSpeedToSync', useSpeedToSync);
setSetting('useSkipToSync', useSkipToSync);
Events.trigger(SyncPlay.Manager, 'settings-update');
}
}
export default SettingsEditor;

View file

@ -0,0 +1,75 @@
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
<span class="material-icons arrow_back"></span>
</button>
<h3 class="formDialogHeaderTitle">${HeaderSyncPlaySettings}</h3>
</div>
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner dialog-content-centered">
<form style="margin: auto;">
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
<!-- Sync Correction Setting -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSyncCorrection" />
<span>${LabelSyncPlaySettingsSyncCorrection}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSyncCorrectionHelp}</div>
</div>
<!-- SpeedToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSpeedToSync" />
<span>${LabelSyncPlaySettingsSpeedToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
</div>
<!-- SkipToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSkipToSync" />
<span>${LabelSyncPlaySettingsSkipToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSkipToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
</div>
<!-- Time Settings -->
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
</div>
</form>
<div class="formDialogFooter" id="footer">
<button is="emby-button" type="submit" class="raised button-submit block btnSave formDialogFooterItem">
<span id="saveButtonText">${Save}</span>
</button>
</div>
</div>
</div>

View file

@ -94,13 +94,13 @@ import '../../assets/css/flexstyles.scss';
}
}
function onStartNowClick() {
async function onStartNowClick() {
const options = this.options;
if (options) {
const player = options.player;
this.hide();
await this.hide();
playbackManager.nextTrack(player);
}
@ -139,7 +139,7 @@ import '../../assets/css/flexstyles.scss';
Events.trigger(instance, 'hide');
}
function hideComingUpNext() {
async function hideComingUpNext() {
const instance = this;
clearCountdownTextTimeout(this);
@ -159,17 +159,21 @@ import '../../assets/css/flexstyles.scss';
return;
}
// trigger a reflow to force it to animate again
void elem.offsetWidth;
elem.classList.add('upNextDialog-hidden');
const fn = onHideAnimationComplete.bind(instance);
instance._onHideAnimationComplete = fn;
dom.addEventListener(elem, transitionEndEventName, fn, {
once: true
const transitionEvent = await new Promise((resolve) => {
dom.addEventListener(elem, transitionEndEventName, resolve, {
once: true
});
// trigger a reflow to force it to animate again
void elem.offsetWidth;
elem.classList.add('upNextDialog-hidden');
});
instance._onHideAnimationComplete(transitionEvent);
}
function getTimeRemainingMs(instance) {
@ -226,8 +230,8 @@ class UpNextDialog {
startComingUpNextHideTimer(this);
}
hide() {
hideComingUpNext.call(this);
async hide() {
await hideComingUpNext.bind(this)();
}
destroy() {
hideComingUpNext.call(this);

View file

@ -21,9 +21,9 @@ viewContainer.setOnBeforeChange(function (newView, isRestored, options) {
newView.initComplete = true;
if (typeof options.controllerFactory === 'function') {
new options.controllerFactory(newView, eventDetail.detail.params);
new options.controllerFactory(newView, eventDetail.detail.params, eventDetail);
} else if (options.controllerFactory && typeof options.controllerFactory.default === 'function') {
new options.controllerFactory.default(newView, eventDetail.detail.params);
new options.controllerFactory.default(newView, eventDetail.detail.params, eventDetail);
}
if (!options.controllerFactory || dispatchPageEvents) {

View file

@ -23,6 +23,7 @@
"id": "wmc"
}
],
"menuLinks": [],
"servers": [],
"plugins": [
"playAccessValidation/plugin",

View file

@ -134,6 +134,7 @@
<option value="reinhard">Reinhard</option>
<option value="hable">Hable</option>
<option value="mobius">Mobius</option>
<option value="bt2390">BT.2390</option>
</select>
<div class="fieldDescription">
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="http://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl" target="_blank">${TonemappingAlgorithmHelp}</a>

View file

@ -49,6 +49,19 @@
</div>
</div>
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${QuickConnect}</h2>
</div>
</div>
<div class="checkboxList paperList" style="padding:.5em 1em;">
<label>
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
<span>${EnableQuickConnect}</span>
</label>
</div>
<div class="verticalSection">
<h2>${HeaderBranding}</h2>
<div class="inputContainer">

View file

@ -14,6 +14,7 @@ import alert from '../../components/alert';
function loadPage(page, config, languageOptions, systemInfo) {
page.querySelector('#txtServerName').value = systemInfo.ServerName;
page.querySelector('#txtCachePath').value = systemInfo.CachePath || '';
page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true;
$('#txtMetadataPath', page).val(systemInfo.InternalMetadataPath || '');
$('#txtMetadataNetworkPath', page).val(systemInfo.MetadataNetworkPath || '');
$('#selectLocalizationLanguage', page).html(languageOptions.map(function (language) {
@ -33,6 +34,7 @@ import alert from '../../components/alert';
config.CachePath = form.querySelector('#txtCachePath').value;
config.MetadataPath = $('#txtMetadataPath', form).val();
config.MetadataNetworkPath = $('#txtMetadataNetworkPath', form).val();
config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked;
ApiClient.updateServerConfiguration(config).then(function() {
ApiClient.getNamedConfiguration(brandingConfigKey).then(function(brandingConfig) {
brandingConfig.LoginDisclaimer = form.querySelector('#txtLoginDisclaimer').value;

View file

@ -1,6 +1,31 @@
<div id="logPage" data-role="page" class="page type-interior">
<div>
<div class="content-primary">
<form class="logsForm">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabLogs}</h2>
</div>
</div>
<div class="verticalSection">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSlowResponseWarning" />
<span>${LabelSlowResponseEnabled}</span>
</label>
</div>
<div class="inputContainer">
<input is="emby-input" type="number" id="txtSlowResponseWarning" label="${LabelSlowResponseTime}" />
</div>
</div>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
</div>
</form>
<div class="serverLogs readOnlyContent">
</div>
</div>

View file

@ -1,12 +1,32 @@
import datetime from '../../scripts/datetime';
import loading from '../../components/loading/loading';
import globalize from '../../scripts/globalize';
import '../../elements/emby-button/emby-button';
import '../../components/listview/listview.scss';
import '../../assets/css/flexstyles.scss';
import Dashboard from '../../scripts/clientUtils';
import alert from '../../components/alert';
/* eslint-disable indent */
function onSubmit() {
loading.show();
const form = this;
ApiClient.getServerConfiguration().then(function (config) {
config.EnableSlowResponseWarning = form.querySelector('#chkSlowResponseWarning').checked;
config.SlowResponseThresholdMs = form.querySelector('#txtSlowResponseWarning').value;
ApiClient.updateServerConfiguration(config).then(function() {
Dashboard.processServerConfigurationUpdateResult();
}, function () {
alert(globalize.translate('ErrorDefault'));
Dashboard.processServerConfigurationUpdateResult();
});
});
return false;
}
export default function(view) {
view.querySelector('.logsForm').addEventListener('submit', onSubmit);
view.addEventListener('viewbeforeshow', function() {
loading.show();
const apiClient = ApiClient;
@ -32,8 +52,14 @@ import '../../assets/css/flexstyles.scss';
}).join('');
html += '</div>';
view.querySelector('.serverLogs').innerHTML = html;
loading.hide();
});
apiClient.getServerConfiguration().then(function (config) {
view.querySelector('#chkSlowResponseWarning').checked = config.EnableSlowResponseWarning;
view.querySelector('#txtSlowResponseWarning').value = config.SlowResponseThresholdMs;
});
loading.hide();
});
}

View file

@ -1,4 +1,6 @@
import 'jquery';
import marked from 'marked';
import DOMPurify from 'dompurify';
import loading from '../../../../components/loading/loading';
import globalize from '../../../../scripts/globalize';
import '../../../../elements/emby-button/emby-button';
@ -13,7 +15,7 @@ function populateHistory(packageInfo, page) {
for (let i = 0; i < length; i++) {
const version = packageInfo.versions[i];
html += '<h2 style="margin:.5em 0;">' + version.version + '</h2>';
html += '<div style="margin-bottom:1.5em;">' + version.changelog + '</div>';
html += '<div style="margin-bottom:1.5em;">' + DOMPurify.sanitize(marked(version.changelog)) + '</div>';
}
$('#revisionHistory', page).html(html);

View file

@ -1,24 +0,0 @@
<div id="quickConnectPage" data-role="page" class="page type-interior advancedConfigurationPage">
<div class="content-primary">
<form class="quickConnectSettings">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${QuickConnect}</h2>
</div>
</div>
<div>${LabelCurrentStatus}<span id="quickConnectStatus" style="padding:0 0.4em;"></span></div>
<div class="checkboxList paperList" style="padding:.5em 1em;">
<label>
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
<span>${EnableQuickConnect}</span>
</label>
</div>
<button is="emby-button" id="btnQuickConnectSubmit" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
</form>
</div>
</div>

View file

@ -1,58 +0,0 @@
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import globalize from '../../scripts/globalize';
const unavailable = 'Unavailable';
const available = 'Available';
const active = 'Active';
let page;
export default function(view) {
view.addEventListener('viewshow', function () {
page = this;
loading.show();
page.querySelector('#btnQuickConnectSubmit').onclick = onSubmit;
updatePage();
});
}
function loadPage(status) {
const check = status === available || status === active;
page.querySelector('#quickConnectStatus').textContent = status.toLocaleLowerCase();
page.querySelector('#chkQuickConnectAvailable').checked = check;
loading.hide();
}
function onSubmit() {
loading.show();
const newStatus = page.querySelector('#chkQuickConnectAvailable').checked ? available : unavailable;
const url = ApiClient.getUrl('/QuickConnect/Available?Status=' + newStatus);
ApiClient.ajax({
type: 'POST',
url: url
}, true).then(() => {
toast(globalize.translate('SettingsSaved'));
setTimeout(updatePage, 500);
return true;
}).catch((e) => {
console.error('Unable to set quick connect status. error:', e);
});
loading.hide();
return false;
}
function updatePage() {
ApiClient.getQuickConnect('Status').then((response) => {
loadPage(response);
return true;
}).catch((e) => {
console.error('Unable to get quick connect status. error:', e);
});
}

View file

@ -1,15 +1,12 @@
<div id="itemDetailPage" data-role="page" class="page libraryPage itemDetailPage noSecondaryNavPage selfBackdropPage" data-backbutton="true">
<div id="itemBackdrop" class="itemBackdrop">
</div>
<div id="itemBackdrop" class="itemBackdrop"></div>
<div class="detailLogo"></div>
<div class="detailPageWrapperContainer padded-bottom-page">
<div class="detailPagePrimaryContainer padded-left padded-right">
<div class="primaryImageWrapper hide">
<img id="primaryImage" />
</div>
<div class="infoWrapper infoText">
<div class="infoWrapper">
<div class="detailImageContainer padded-left"></div>
<div class="nameContainer"></div>
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
@ -90,29 +87,11 @@
</div>
</div>
<div class="detailPageSecondaryContainer">
<div class="detailImageContainer padded-left"></div>
<div class="detailPageContent">
<div class="detailPagePrimaryContent padded-right">
<div class="detailSection">
<div class="itemMiscInfo nativeName hide"></div>
<div class="itemDetailsGroup">
<div class="detailsGroupItem genresGroup hide">
<div class="genresLabel label"></div>
<div class="genres content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem directorsGroup hide">
<div class="directorsLabel label"></div>
<div class="directors content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem writersGroup hide">
<div class="writersLabel label"></div>
<div class="writers content focuscontainer-x"></div>
</div>
</div>
<form class="trackSelections hide focuscontainer-x">
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
@ -129,6 +108,7 @@
</form>
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
<div class="detailSectionContent">
<div class="itemLastPlayed hide"></div>
@ -147,6 +127,23 @@
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
<div class="seriesRecordingEditor"></div>
</div>
<div class="itemDetailsGroup">
<div class="detailsGroupItem genresGroup hide">
<div class="genresLabel label"></div>
<div class="genres content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem directorsGroup hide">
<div class="directorsLabel label"></div>
<div class="directors content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem writersGroup hide">
<div class="writersLabel label"></div>
<div class="writers content focuscontainer-x"></div>
</div>
</div>
</div>
</div>

View file

@ -367,6 +367,14 @@ function reloadPlayButtons(page, item) {
hideAll(page, 'btnShuffle');
}
const btnResume = page.querySelector('.mainDetailButtons .btnResume');
const btnPlay = page.querySelector('.mainDetailButtons .btnPlay');
if (layoutManager.tv && !btnResume.classList.contains('hide')) {
btnResume.classList.add('fab');
} else if (layoutManager.tv && btnResume.classList.contains('hide')) {
btnPlay.classList.add('fab');
}
return canPlay;
}
@ -552,14 +560,20 @@ function renderBackdrop(item) {
}
function renderDetailPageBackdrop(page, item, apiClient) {
// Details banner is disabled in user settings
if (!userSettings.detailsBanner()) {
return false;
}
// Disable item backdrop for books and people because they only have primary images
if (item.Type === 'Person' || item.Type === 'Book') {
return false;
}
let imgUrl;
let hasbackdrop = false;
const itemBackdropElement = page.querySelector('#itemBackdrop');
if (layoutManager.mobile || !userSettings.detailsBanner()) {
return false;
}
if (item.BackdropImageTags && item.BackdropImageTags.length) {
imgUrl = apiClient.getScaledImageUrl(item.Id, {
type: 'Backdrop',
@ -593,24 +607,6 @@ function renderDetailPageBackdrop(page, item, apiClient) {
return hasbackdrop;
}
function renderPrimaryImage(page, item, apiClient) {
if (item?.ImageTags?.Primary) {
const imageUrl = apiClient.getScaledImageUrl(item.Id, {
type: 'Primary',
maxWidth: dom.getScreenWidth(),
tag: item.ImageTags.Primary
});
const imageElem = page.querySelector('#primaryImage');
imageElem.src = imageUrl;
imageElem.alt = item.Name;
if (item.PrimaryImageAspectRatio === 1) {
imageElem.classList.add('aspect-square');
}
page.querySelector('.primaryImageWrapper')?.classList.remove('hide');
}
}
function reloadFromItem(instance, page, params, item, user) {
const apiClient = ServerConnections.getApiClient(item.ServerId);
@ -623,9 +619,7 @@ function reloadFromItem(instance, page, params, item, user) {
renderLogo(page, item, apiClient);
renderDetailPageBackdrop(page, item, apiClient);
}
if (layoutManager.mobile) {
renderPrimaryImage(page, item, apiClient);
}
renderBackdrop(item);
// Render the main information for the item
@ -812,8 +806,8 @@ function renderDetailImage(elem, item, imageLoader) {
overlayText: false,
transition: false,
disableIndicators: true,
overlayPlayButton: true,
action: 'play',
overlayPlayButton: layoutManager.mobile ? false : true,
action: layoutManager.mobile ? 'none' : 'play',
width: dom.getWindowSize().innerWidth * 0.25
});
@ -1216,11 +1210,9 @@ function renderMoreFromArtist(view, item, apiClient) {
};
if (item.Type === 'MusicArtist') {
query.ContributingArtistIds = item.Id;
} else if (apiClient.isMinServerVersion('3.4.1.18')) {
query.AlbumArtistIds = item.AlbumArtists[0].Id;
query.AlbumArtistIds = item.Id;
} else {
query.ArtistIds = item.AlbumArtists[0].Id;
query.AlbumArtistIds = item.AlbumArtists[0].Id;
}
apiClient.getItems(apiClient.getCurrentUserId(), query).then(function (result) {
@ -2063,16 +2055,6 @@ export default function (view, params) {
function init() {
const apiClient = getApiClient();
const btnResume = view.querySelector('.mainDetailButtons .btnResume');
const btnPlay = view.querySelector('.mainDetailButtons .btnPlay');
if (layoutManager.tv && !btnResume.classList.contains('hide')) {
btnResume.classList.add('fab');
btnResume.classList.add('detailFloatingButton');
} else if (layoutManager.tv && btnResume.classList.contains('hide')) {
btnPlay.classList.add('fab');
btnPlay.classList.add('detailFloatingButton');
}
view.querySelectorAll('.btnPlay');
bindAll(view, '.btnPlay', 'click', onPlayClick);
bindAll(view, '.btnResume', 'click', onPlayClick);

View file

@ -1115,7 +1115,11 @@ class ItemsView {
let imageType = userSettings.get(basekey + '-imageType');
if (!imageType && params.type === 'nextup') {
imageType = 'thumb';
if (userSettings.useEpisodeImagesInNextUpAndResume()) {
imageType = 'primary';
} else {
imageType = 'thumb';
}
}
return {

View file

@ -61,7 +61,7 @@
<div class="pageTabContent flexPageTabContent absolutePageTabContent" id="guideTab" data-index="1" style="width:auto;padding-top:0; padding-bottom: 0!important;">
</div>
<div class="pageTabContent" id="channelsTab" data-index="2">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnFilter sectionTitleButton" title="${Filter}"><span class="material-icons filter_list"></span></button>
</div>

View file

@ -1,7 +1,7 @@
<div id="moviesPage" data-role="page" data-dom-cache="true" class="page libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs" data-backdroptype="movie">
<div class="pageTabContent" id="moviesTab" data-index="0">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
@ -13,7 +13,7 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
@ -44,7 +44,7 @@
</div>
</div>
<div class="pageTabContent" id="trailersTab" data-index="2">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
@ -55,24 +55,24 @@
<div is="emby-itemscontainer" class="itemsContainer vertical-wrap padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
<div class="pageTabContent" id="favoritesTab" data-index="3">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
</div>
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
<div class="pageTabContent" id="collectionsTab" data-index="4">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
@ -81,7 +81,7 @@
<div is="emby-itemscontainer" class="itemsContainer vertical-wrap centered padded-left padded-right" style="text-align:center;">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>

View file

@ -9,7 +9,7 @@
}
</style>
<div class="pageTabContent pageTabContent" id="albumsTab" data-index="0">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnPlayAll musicglobalButton" title="${HeaderPlayAll}"><span class="material-icons play_arrow"></span></button>
<button is="paper-icon-button-light" class="btnShuffle musicglobalButton" title="${Shuffle}"><span class="material-icons shuffle"></span></button>
@ -23,7 +23,7 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
@ -54,7 +54,7 @@
<div class="favoriteSections verticalSection"></div>
</div>
<div class="pageTabContent" id="albumArtistsTab" data-index="2">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
@ -65,12 +65,12 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
<div class="pageTabContent" id="artistsTab" data-index="3">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
@ -81,7 +81,7 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
@ -90,7 +90,7 @@
<div is="emby-itemscontainer" id="items" class="itemsContainer padded-left padded-right padded-top vertical-wrap centered"></div>
</div>
<div class="pageTabContent" id="songsTab" data-index="5">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
@ -98,7 +98,7 @@
<div is="emby-itemscontainer" id="items" class="itemsContainer vertical-list" style="max-width:67.5em;margin: 0 auto;"></div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>

View file

@ -35,6 +35,10 @@
<span class="xlargePaperIconButton material-icons skip_previous"></span>
</button>
<button is="paper-icon-button-light" class="btnPreviousChapter autoSize hide" title="${PreviousChapter}">
<span class="xlargePaperIconButton material-icons undo"></span>
</button>
<button is="paper-icon-button-light" class="btnRewind" title="${Rewind} (j)">
<span class="xlargePaperIconButton material-icons fast_rewind"></span>
</button>
@ -47,6 +51,10 @@
<span class="xlargePaperIconButton material-icons fast_forward"></span>
</button>
<button is="paper-icon-button-light" class="btnNextChapter autoSize hide" title="${NextChapter}">
<span class="xlargePaperIconButton material-icons redo"></span>
</button>
<button is="paper-icon-button-light" class="btnNextTrack autoSize hide" title="${NextTrack}">
<span class="xlargePaperIconButton material-icons skip_next"></span>
</button>

View file

@ -1,5 +1,6 @@
import { playbackManager } from '../../../components/playback/playbackmanager';
import SyncPlay from '../../../components/syncPlay/core';
import browser from '../../../scripts/browser';
import dom from '../../../scripts/dom';
import inputManager from '../../../scripts/inputManager';
import mouseManager from '../../../scripts/mouseManager';
@ -180,6 +181,14 @@ import { appRouter } from '../../../components/appRouter';
} else {
view.querySelector('.btnAudio').classList.add('hide');
}
if (currentItem.Chapters.length > 1) {
view.querySelector('.btnPreviousChapter').classList.remove('hide');
view.querySelector('.btnNextChapter').classList.remove('hide');
} else {
view.querySelector('.btnPreviousChapter').classList.add('hide');
view.querySelector('.btnNextChapter').classList.add('hide');
}
}
function setTitle(item, parentName) {
@ -312,8 +321,8 @@ import { appRouter } from '../../../components/appRouter';
function onPointerMove(e) {
if ((e.pointerType || (layoutManager.mobile ? 'touch' : 'mouse')) === 'mouse') {
const eventX = e.screenX || 0;
const eventY = e.screenY || 0;
const eventX = e.screenX || e.clientX || 0;
const eventY = e.screenY || e.clientY || 0;
const obj = lastPointerMoveData;
if (!obj) {
@ -544,7 +553,7 @@ import { appRouter } from '../../../components/appRouter';
const player = this;
currentRuntimeTicks = playbackManager.duration(player);
const currentTime = playbackManager.currentTime(player) * 10000;
updateTimeDisplay(currentTime, currentRuntimeTicks, playbackManager.playbackStartTime(player), playbackManager.getBufferedRanges(player));
updateTimeDisplay(currentTime, currentRuntimeTicks, playbackManager.playbackStartTime(player), playbackManager.getPlaybackRate(player), playbackManager.getBufferedRanges(player));
const item = currentItem;
refreshProgramInfoIfNeeded(player, item);
showComingUpNextIfNeeded(player, item, currentTime, currentRuntimeTicks);
@ -639,7 +648,7 @@ import { appRouter } from '../../../components/appRouter';
btnRewind.disabled = !playState.CanSeek;
const nowPlayingItem = state.NowPlayingItem || {};
playbackStartTimeTicks = playState.PlaybackStartTimeTicks;
updateTimeDisplay(playState.PositionTicks, nowPlayingItem.RunTimeTicks, playState.PlaybackStartTimeTicks, playState.BufferedRanges || []);
updateTimeDisplay(playState.PositionTicks, nowPlayingItem.RunTimeTicks, playState.PlaybackStartTimeTicks, playState.PlaybackRate, playState.BufferedRanges || []);
updateNowPlayingInfo(player, state);
if (state.MediaSource && state.MediaSource.SupportsTranscoding && supportedCommands.indexOf('SetMaxStreamingBitrate') !== -1) {
@ -681,7 +690,7 @@ import { appRouter } from '../../../components/appRouter';
return (currentTimeMs - programStartDateMs) / programRuntimeMs * 100;
}
function updateTimeDisplay(positionTicks, runtimeTicks, playbackStartTimeTicks, bufferedRanges) {
function updateTimeDisplay(positionTicks, runtimeTicks, playbackStartTimeTicks, playbackRate, bufferedRanges) {
if (enableProgressByTimeOfDay) {
if (nowPlayingPositionSlider && !nowPlayingPositionSlider.dragging) {
if (programStartDateMs && programEndDateMs) {
@ -716,8 +725,8 @@ import { appRouter } from '../../../components/appRouter';
nowPlayingPositionSlider.value = 0;
}
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording') {
endsAtText.innerHTML = '&nbsp;&nbsp;&nbsp;&nbsp;' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, true);
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording' && playbackRate !== null) {
endsAtText.innerHTML = '&nbsp;&nbsp;&nbsp;&nbsp;' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, playbackRate, true);
} else {
endsAtText.innerHTML = '';
}
@ -987,14 +996,30 @@ import { appRouter } from '../../../components/appRouter';
*/
let clickedElement;
function onClickCapture(e) {
// Firefox/Edge emits `click` even if `preventDefault` was used on `keydown`
// Ignore 'click' if another element was originally clicked
if (!e.target.contains(clickedElement)) {
e.preventDefault();
e.stopPropagation();
return false;
}
}
function onKeyDown(e) {
clickedElement = e.target;
const key = keyboardnavigation.getKeyName(e);
const isKeyModified = e.ctrlKey || e.altKey || e.metaKey;
if (!currentVisibleMenu && e.keyCode === 32) {
playbackManager.playPause(currentPlayer);
if (e.keyCode === 32) {
if (e.target.tagName !== 'BUTTON' || !layoutManager.tv) {
playbackManager.playPause(currentPlayer);
e.preventDefault();
e.stopPropagation();
// Trick Firefox with a null element to skip next click
clickedElement = null;
}
showOsd();
return;
}
@ -1304,6 +1329,9 @@ import { appRouter } from '../../../components/appRouter';
capture: true,
passive: true
});
if (browser.firefox || browser.edge) {
dom.addEventListener(document, 'click', onClickCapture, { capture: true });
}
} catch (e) {
appRouter.goHome();
}
@ -1342,6 +1370,9 @@ import { appRouter } from '../../../components/appRouter';
capture: true,
passive: true
});
if (browser.firefox || browser.edge) {
dom.removeEventListener(document, 'click', onClickCapture, { capture: true });
}
stopOsdHideTimer();
headerElement.classList.remove('osdHeader');
headerElement.classList.remove('osdHeader-hidden');
@ -1490,11 +1521,14 @@ import { appRouter } from '../../../components/appRouter';
view.querySelector('.btnPreviousTrack').addEventListener('click', function () {
playbackManager.previousTrack(currentPlayer);
});
view.querySelector('.btnPreviousChapter').addEventListener('click', function () {
playbackManager.previousChapter(currentPlayer);
});
view.querySelector('.btnPause').addEventListener('click', function () {
// Ignore 'click' if another element was originally clicked (Firefox/Edge issue)
if (this.contains(clickedElement)) {
playbackManager.playPause(currentPlayer);
}
playbackManager.playPause(currentPlayer);
});
view.querySelector('.btnNextChapter').addEventListener('click', function () {
playbackManager.nextChapter(currentPlayer);
});
view.querySelector('.btnNextTrack').addEventListener('click', function () {
playbackManager.nextTrack(currentPlayer);

View file

@ -1,4 +1,2 @@
<div id="searchPage" data-role="page" class="page libraryPage allLibraryPage noSecondaryNavPage" data-title="${Search}" data-backbutton="true">
<div class="padded-left padded-right searchFields"></div>
<div class="searchResults padded-bottom-page padded-top"></div>
</div>

View file

@ -1,36 +0,0 @@
import SearchFields from '../components/search/searchfields';
import SearchResults from '../components/search/searchresults';
import { Events } from 'jellyfin-apiclient';
export default function (view, params) {
function onSearch(e, value) {
self.searchResults.search(value);
}
const self = this;
view.addEventListener('viewshow', function () {
if (!self.searchFields) {
self.searchFields = new SearchFields({
element: view.querySelector('.searchFields')
});
self.searchResults = new SearchResults({
element: view.querySelector('.searchResults'),
serverId: params.serverId || ApiClient.serverId(),
parentId: params.parentId,
collectionType: params.collectionType
});
Events.on(self.searchFields, 'search', onSearch);
}
});
view.addEventListener('viewdestroy', function () {
if (self.searchFields) {
self.searchFields.destroy();
self.searchFields = null;
}
if (self.searchResults) {
self.searchResults.destroy();
self.searchResults = null;
}
});
}

View file

@ -76,7 +76,7 @@ import cardBuilder from '../../../components/cardbuilder/cardBuilder';
dialogHelper.close(dlg);
}
const result = await apiClient.quickConnect(data.Authentication);
const result = await apiClient.quickConnect(data.Secret);
onLoginSuccessful(result.User.Id, result.AccessToken, apiClient);
}, function (e) {
clearInterval(interval);
@ -260,9 +260,9 @@ import cardBuilder from '../../../components/cardbuilder/cardBuilder';
const apiClient = getApiClient();
apiClient.getQuickConnect('Status')
.then(status => {
if (status !== 'Unavailable') {
apiClient.getQuickConnect('Enabled')
.then(enabled => {
if (enabled === true) {
view.querySelector('.btnQuick').classList.remove('hide');
}
})

View file

@ -1,7 +1,7 @@
<div id="tvRecommendedPage" data-dom-cache="true" data-role="page" class="page libraryPage backdropPage pageWithAbsoluteTabs withTabs" data-backdroptype="series">
<div class="pageTabContent" id="seriesTab" data-index="0">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
@ -11,7 +11,7 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right"></div>
<div class="alphaPicker alphaPicker-fixed alphaPicker-vertical"></div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>
@ -55,7 +55,7 @@
<div is="emby-itemscontainer" id="items" class="itemsContainer padded-left padded-right padded-top vertical-wrap" style="text-align: center;"></div>
</div>
<div class="pageTabContent" id="episodesTab" data-index="5">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
@ -63,7 +63,7 @@
</div>
<div is="emby-itemscontainer" class="itemsContainer vertical-wrap padded-left padded-right">
</div>
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
<div class="paging"></div>
</div>
</div>

View file

@ -119,6 +119,7 @@ import autoFocuser from '../../components/autoFocuser';
cardBuilder.buildCards(result.Items, {
itemsContainer: container,
preferThumb: true,
inheritThumb: !userSettings.useEpisodeImagesInNextUpAndResume(),
shape: getThumbShape(),
scalable: true,
overlayPlayButton: true,
@ -197,6 +198,7 @@ import autoFocuser from '../../components/autoFocuser';
parentContainer: section,
itemsContainer: container,
preferThumb: true,
inheritThumb: !userSettings.useEpisodeImagesInNextUpAndResume(),
shape: 'backdrop',
scalable: true,
showTitle: true,

View file

@ -0,0 +1,24 @@
<div id="controlsPreferencesPage" data-role="page" class="page libraryPage userPreferencesPage noSecondaryNavPage" data-title="${Controls}" data-menubutton="true">
<div class="padded-left padded-right padded-bottom-page">
<form style="margin: 0 auto;">
<div class="verticalSection verticalSection-extrabottompadding">
<h2 class="sectionTitle">
${Controls}
</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableGamepad" />
<span>${LabelEnableGamepad}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${EnableGamepadHelp}</div>
<div class="fieldDescription checkboxFieldDescription">${LabelPleaseRestart}</div>
</div>
</div>
<button is="emby-button" type="submit" class="raised button-submit block btnSave hide">
<span>${Save}</span>
</button>
</form>
</div>
</div>

View file

@ -0,0 +1,28 @@
import { Events } from 'jellyfin-apiclient';
import toast from '../../../components/toast/toast';
import globalize from '../../../scripts/globalize';
import appSettings from '../../../scripts/settings/appSettings';
export default function (view) {
function submit(e) {
appSettings.enableGamepad(view.querySelector('.chkEnableGamepad').checked);
toast(globalize.translate('SettingsSaved'));
Events.trigger(view, 'saved');
e?.preventDefault();
return false;
}
view.addEventListener('viewshow', function () {
view.querySelector('.chkEnableGamepad').checked = appSettings.enableGamepad();
view.querySelector('form').addEventListener('submit', submit);
view.querySelector('.btnSave').classList.remove('hide');
import('../../../components/autoFocuser').then(({default: autoFocuser}) => {
autoFocuser.autoFocus(view);
});
});
}

View file

@ -12,6 +12,15 @@
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkQuickConnectPreferences listItem-border hide">
<div class="listItem">
<em class="material-icons listItemIcon listItemIcon-transparent">tap_and_play</em>
<div class="listItemBody">
<div class="listItemBodyText">${QuickConnect}</div>
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkDisplayPreferences listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent tv"></span>
@ -48,16 +57,6 @@
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkQuickConnectPreferences listItem-border hide">
<div class="listItem">
<em class="material-icons listItemIcon listItemIcon-transparent">tap_and_play</em>
<div class="listItemBody">
<div class="listItemBodyText">${QuickConnect}</div>
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="clientSettings listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent devices_other"></span>
@ -66,6 +65,15 @@
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkControlsPreferences listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent keyboard"></span>
<div class="listItemBody">
<div class="listItemBodyText">${Controls}</div>
</div>
</div>
</a>
</div>
<div class="adminSection verticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle" style="padding-left:.25em;">${HeaderAdmin}</h2>

View file

@ -28,6 +28,7 @@ export default function (view, params) {
page.querySelector('.lnkPlaybackPreferences').setAttribute('href', '#!/mypreferencesplayback.html?userId=' + userId);
page.querySelector('.lnkSubtitlePreferences').setAttribute('href', '#!/mypreferencessubtitles.html?userId=' + userId);
page.querySelector('.lnkQuickConnectPreferences').setAttribute('href', '#!/mypreferencesquickconnect.html');
page.querySelector('.lnkControlsPreferences').setAttribute('href', '#!/mypreferencescontrols.html?userId=' + userId);
const supportsClientSettings = appHost.supports('clientsettings');
page.querySelector('.clientSettings').classList.toggle('hide', !supportsClientSettings);
@ -35,16 +36,17 @@ export default function (view, params) {
const supportsMultiServer = appHost.supports('multiserver');
page.querySelector('.selectServer').classList.toggle('hide', !supportsMultiServer);
ApiClient.getQuickConnect('Status')
.then(status => {
if (status !== 'Unavailable') {
page.querySelector('.lnkControlsPreferences').classList.toggle('hide', layoutManager.mobile);
ApiClient.getQuickConnect('Enabled')
.then(enabled => {
if (enabled === true) {
page.querySelector('.lnkQuickConnectPreferences').classList.remove('hide');
}
})
.catch(() => {
console.debug('Failed to get QuickConnect status');
});
ApiClient.getUser(userId).then(function (user) {
page.querySelector('.headerUsername').innerHTML = user.Name;
if (user.Policy.IsAdministrator && !layoutManager.tv) {
@ -56,6 +58,7 @@ export default function (view, params) {
if (params.userId && params.userId !== Dashboard.getCurrentUserId) {
page.querySelector('.userSection').classList.add('hide');
page.querySelector('.adminSection').classList.add('hide');
page.querySelector('.lnkControlsPreferences').classList.add('hide');
}
import('../../../components/autoFocuser').then(({default: autoFocuser}) => {

View file

@ -1,6 +1,5 @@
import globalize from '../../../scripts/globalize';
import toast from '../../../components/toast/toast';
import Dashboard from '../../../scripts/clientUtils';
export const authorize = (code) => {
const url = ApiClient.getUrl('/QuickConnect/Authorize?Code=' + code);
@ -16,22 +15,3 @@ export const authorize = (code) => {
// prevent bubbling
return false;
};
export const activate = () => {
const url = ApiClient.getUrl('/QuickConnect/Activate');
return ApiClient.ajax({
type: 'POST',
url: url
}).then(() => {
toast(globalize.translate('QuickConnectActivationSuccessful'));
return true;
}).catch((e) => {
console.error('Error activating quick connect. Error:', e);
Dashboard.alert({
title: globalize.translate('HeaderError'),
message: globalize.translate('DefaultErrorMessage')
});
throw e;
});
};

View file

@ -1,19 +1,15 @@
<div id="quickConnectPreferencesPage" data-role="page" class="page libraryPage userPreferencesPage noSecondaryNavPage" data-title="${QuickConnect}" data-backbutton="true" style="margin: 0 auto; max-width: 54em">
<div class="settingsContainer padded-left padded-right padded-bottom-page">
<button is="emby-button" id="btnQuickConnectActivate" type="button" class="raised button-submit block">
<span>${ButtonActivate}</span>
</button>
<form class="quickConnectSettingsContainer">
<div style="margin-bottom: 1em">
${QuickConnectDescription}
</div>
<form class="quickConnectSettingsContainer">
<div class="verticalSection">
<h2 class="sectionTitle">${QuickConnect}</h2>
<div>${QuickConnectDescription}</div>
<br />
<div class="inputContainer">
<input is="emby-input" type="number" min="0" max="999999" required id="txtQuickConnectCode" label="${LabelQuickConnectCode}" autocomplete="off" />
</div>
<button id="btnQuickConnectAuthorize" is="emby-button" type="submit" class="raised button-submit block">
<span>${Authorize}</span>
</button>
</form>
</div>
</div>
</form>
</div>

View file

@ -1,4 +1,4 @@
import { activate, authorize } from './helper';
import { authorize } from './helper';
import globalize from '../../../scripts/globalize';
import toast from '../../../components/toast/toast';
@ -6,52 +6,16 @@ export default function (view) {
view.addEventListener('viewshow', function () {
const codeElement = view.querySelector('#txtQuickConnectCode');
view.querySelector('#btnQuickConnectActivate').addEventListener('click', () => {
activate().then(() => {
renderPage();
});
});
view.querySelector('.quickConnectSettingsContainer').addEventListener('submit', (e) => {
e.preventDefault();
view.querySelector('#btnQuickConnectAuthorize').addEventListener('click', () => {
if (!codeElement.validity.valid) {
toast(globalize.translate('QuickConnectInvalidCode'));
return;
}
const code = codeElement.value;
authorize(code);
authorize(codeElement.value);
});
view.querySelector('.quickConnectSettingsContainer').addEventListener('submit', (e) => {
e.preventDefault();
});
renderPage();
});
function renderPage(forceActive = false) {
ApiClient.getQuickConnect('Status').then((status) => {
const btn = view.querySelector('#btnQuickConnectActivate');
const container = view.querySelector('.quickConnectSettingsContainer');
// The activation button should only be visible when quick connect is unavailable (with the text replaced with an error) or when it is available (so it can be activated)
// The authorization container is only usable when quick connect is active, so it should be hidden otherwise
container.style.display = 'none';
if (status === 'Unavailable') {
btn.textContent = globalize.translate('QuickConnectNotAvailable');
btn.disabled = true;
btn.classList.remove('button-submit');
btn.classList.add('button');
} else if (status === 'Active' || forceActive) {
container.style.display = '';
btn.style.display = 'none';
}
return true;
}).catch((e) => {
throw e;
});
}
}

View file

@ -3,6 +3,7 @@ import dom from '../../scripts/dom';
import scroller from '../../libraries/scroller';
import browser from '../../scripts/browser';
import focusManager from '../../components/focusManager';
import layoutManager from '../../components/layoutManager';
import './emby-tabs.scss';
import '../../assets/css/scrollstyles.scss';
@ -100,6 +101,14 @@ import '../../assets/css/scrollstyles.scss';
}
}
function onFocusIn(e) {
const tabs = this;
const tabButton = dom.parentWithClass(e.target, buttonClass);
if (tabButton && tabs.scroller) {
tabs.scroller.toCenter(tabButton, false);
}
}
function onFocusOut(e) {
const parentContainer = e.target.parentNode;
const previousFocus = parentContainer.querySelector('.lastFocused');
@ -155,10 +164,14 @@ import '../../assets/css/scrollstyles.scss';
passive: true
});
if (layoutManager.tv) {
dom.addEventListener(this, 'focusin', onFocusIn, { passive: true });
}
dom.addEventListener(this, 'focusout', onFocusOut);
};
EmbyTabs.focus = function onFocusIn() {
EmbyTabs.focus = function () {
const selectedTab = this.querySelector('.' + activeButtonClass);
const lastFocused = this.querySelector('.lastFocused');
@ -210,6 +223,10 @@ import '../../assets/css/scrollstyles.scss';
dom.removeEventListener(this, 'click', onClick, {
passive: true
});
if (layoutManager.tv) {
dom.removeEventListener(this, 'focusin', onFocusIn, { passive: true });
}
};
function getSelectedTabButton(elem) {

View file

@ -56,7 +56,6 @@ import '../emby-input/emby-input';
textarea.style.height = 'auto';
newHeight = textarea.scrollHeight/* - offset*/;
}
$('.customCssContainer').css('height', newHeight + 'px');
textarea.style.height = newHeight + 'px';
}

5
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export declare global {
interface Window {
ApiClient: any;
}
}

View file

@ -41,7 +41,7 @@
<!-- iPhone Xs -->
<link href="assets/splash/iphonexsmax_splash.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" rel="apple-touch-startup-image" />
<link href="assets/splash/iphonexsmax_splashl.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" rel="apple-touch-startup-image" />
<link href="assets/splash/iphonexsmax_splash_l.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" rel="apple-touch-startup-image" />
<!-- iPad -->
<link href="assets/splash/ipad_splash.png" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" rel="apple-touch-startup-image" />

View file

@ -630,6 +630,8 @@ const scrollerFactory = function (frame, options) {
//passive: true
});
scrollSource.removeAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`);
// Reset initialized status and return the instance
self.initialized = 0;
return self;
@ -751,6 +753,8 @@ const scrollerFactory = function (frame, options) {
}
}
scrollSource.setAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`, 'custom');
if (transform || layoutManager.tv) {
// This can prevent others from being able to listen to mouse events
dom.addEventListener(dragSourceElement, 'mousedown', dragInitSlidee, {

View file

@ -5,6 +5,7 @@ import loading from '../../components/loading/loading';
import keyboardnavigation from '../../scripts/keyboardNavigation';
import dialogHelper from '../../components/dialogHelper/dialogHelper';
import ServerConnections from '../../components/ServerConnections';
import * as Screenfull from 'screenfull';
import TableOfContents from './tableOfContents';
import dom from '../../scripts/dom';
import { translateHtml } from '../../scripts/globalize';
@ -151,6 +152,7 @@ export class BookPlayer {
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('#btnBookplayerExit').addEventListener('click', this.onDialogClosed, {once: true});
elem.querySelector('#btnBookplayerToc').addEventListener('click', this.openTableOfContents);
elem.querySelector('#btnBookplayerFullscreen').addEventListener('click', this.toggleFullscreen);
elem.querySelector('#btnBookplayerPrev')?.addEventListener('click', this.previous);
elem.querySelector('#btnBookplayerNext')?.addEventListener('click', this.next);
}
@ -170,6 +172,7 @@ export class BookPlayer {
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('#btnBookplayerExit').removeEventListener('click', this.onDialogClosed);
elem.querySelector('#btnBookplayerToc').removeEventListener('click', this.openTableOfContents);
elem.querySelector('#btnBookplayerFullscreen').removeEventListener('click', this.toggleFullscreen);
elem.querySelector('#btnBookplayerPrev')?.removeEventListener('click', this.previous);
elem.querySelector('#btnBookplayerNext')?.removeEventListener('click', this.next);
}
@ -191,6 +194,15 @@ export class BookPlayer {
}
}
toggleFullscreen() {
if (Screenfull.isEnabled) {
const icon = document.querySelector('#btnBookplayerFullscreen .material-icons');
icon.classList.remove(Screenfull.isFullscreen ? 'fullscreen_exit' : 'fullscreen');
icon.classList.add(Screenfull.isFullscreen ? 'fullscreen' : 'fullscreen_exit');
Screenfull.toggle();
}
}
previous(e) {
e?.preventDefault();
if (this.rendition) {
@ -246,15 +258,23 @@ export class BookPlayer {
const serverId = item.ServerId;
const apiClient = ServerConnections.getApiClient(serverId);
if (!Screenfull.isEnabled) {
document.getElementById('btnBookplayerFullscreen').display = 'none';
}
return new Promise((resolve, reject) => {
import('epubjs').then(({default: epubjs}) => {
const downloadHref = apiClient.getItemDownloadUrl(item.Id);
const book = epubjs(downloadHref, {openAs: 'epub'});
// We need to calculate the height of the window beforehand because using 100% is not accurate when the dialog is opening.
// In addition we don't render to the full height so that we have space for the top buttons.
const clientHeight = document.body.clientHeight;
const renderHeight = clientHeight - (clientHeight * 0.0425);
const rendition = book.renderTo('bookPlayerContainer', {
width: '100%',
// Calculate the height of the window because using 100% is not accurate when the dialog is opening
height: document.body.clientHeight,
height: renderHeight,
// TODO: Add option for scrolled-doc
flow: 'paginated'
});

View file

@ -11,8 +11,6 @@
.topButtons {
z-index: 1002;
position: absolute;
top: 0;
width: 100%;
color: #000;
opacity: 0.7;

View file

@ -11,6 +11,9 @@
<button is="paper-icon-button-light" id="btnBookplayerExit" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon close"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerFullscreen" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon fullscreen"></span>
</button>
</div>
<div id="bookPlayerContainer" class="bookPlayerContainer"></div>

View file

@ -457,14 +457,14 @@ class HtmlAudioPlayer {
setVolume(val) {
const mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.volume = val / 100;
mediaElement.volume = Math.pow(val / 100, 3);
}
}
getVolume() {
const mediaElement = this._mediaElement;
if (mediaElement) {
return Math.min(Math.round(mediaElement.volume * 100), 100);
return Math.min(Math.round(Math.pow(mediaElement.volume, 1 / 3) * 100), 100);
}
}

View file

@ -385,7 +385,6 @@ function tryRemoveElement(elem) {
return new Promise((resolve, reject) => {
requireHlsPlayer(async () => {
let maxBufferLength = 30;
let maxMaxBufferLength = 600;
// Some browsers cannot handle huge fragments in high bitrate.
// This issue usually happens when using HWA encoders with a high bitrate setting.
@ -393,7 +392,6 @@ function tryRemoveElement(elem) {
// https://github.com/video-dev/hls.js/issues/876
if ((browser.chrome || browser.edgeChromium || browser.firefox) && playbackManager.getMaxStreamingBitrate(this) >= 25000000) {
maxBufferLength = 6;
maxMaxBufferLength = 6;
}
const includeCorsCredentials = await getIncludeCorsCredentials();
@ -401,7 +399,6 @@ function tryRemoveElement(elem) {
const hls = new Hls({
manifestLoadingTimeOut: 20000,
maxBufferLength: maxBufferLength,
maxMaxBufferLength: maxMaxBufferLength,
xhrSetup(xhr) {
xhr.withCredentials = includeCorsCredentials;
}
@ -1708,14 +1705,14 @@ function tryRemoveElement(elem) {
setVolume(val) {
const mediaElement = this.#mediaElement;
if (mediaElement) {
mediaElement.volume = val / 100;
mediaElement.volume = Math.pow(val / 100, 3);
}
}
getVolume() {
const mediaElement = this.#mediaElement;
if (mediaElement) {
return Math.min(Math.round(mediaElement.volume * 100), 100);
return Math.min(Math.round(Math.pow(mediaElement.volume, 1 / 3) * 100), 100);
}
}

View file

@ -203,7 +203,7 @@ export class PdfPlayer {
const percentageTicks = options.startPositionTicks / 10000;
if (percentageTicks !== 0) {
this.loadPage(percentageTicks);
this.loadPage(percentageTicks + 1);
this.progress = percentageTicks;
} else {
this.loadPage(1);

View file

@ -667,12 +667,13 @@ import browser from './browser';
}
}
if (canPlayVp8) {
if (webmAudioCodecs.length && webmVideoCodecs.length) {
profile.TranscodingProfiles.push({
Container: 'webm',
Type: 'Video',
AudioCodec: 'vorbis',
VideoCodec: 'vpx',
AudioCodec: webmAudioCodecs.join(','),
// TODO: Remove workaround when servers migrate away from 'vpx' for transcoding profiles.
VideoCodec: (canPlayVp8 ? webmVideoCodecs.concat('vpx') : webmVideoCodecs).join(','),
Context: 'Streaming',
Protocol: 'http',
// If audio transcoding is needed, limit channels to number of physical audio channels

View file

@ -26,8 +26,13 @@ export async function serverAddress() {
// Use servers specified in config.json
const urls = await webSettings.getServers();
// Otherwise use computed base URL
if (urls.length == 0) {
if (urls.length === 0) {
// Don't use app URL as server URL
if (window.NativeShell) {
return Promise.resolve();
}
// Otherwise use computed base URL
const index = window.location.href.toLowerCase().lastIndexOf('/web');
if (index != -1) {
urls.push(window.location.href.substring(0, index));

View file

@ -13,6 +13,7 @@
case 'Kodi':
return baseUrl + 'kodi.svg';
case 'Jellyfin Android':
case 'AndroidTV':
case 'Android TV':
return baseUrl + 'android.svg';
case 'Jellyfin Web':

View file

@ -5,6 +5,7 @@
import inputManager from './inputManager';
import layoutManager from '../components/layoutManager';
import appSettings from './settings/appSettings';
/**
* Key name mapping.
@ -160,7 +161,7 @@ function attachGamepadScript() {
}
// No need to check for gamepads manually at load time, the eventhandler will be fired for that
if (navigator.getGamepads) { /* eslint-disable-line compat/compat */
if (navigator.getGamepads && appSettings.enableGamepad()) { /* eslint-disable-line compat/compat */
window.addEventListener('gamepadconnected', attachGamepadScript);
}

View file

@ -6,11 +6,11 @@ import viewManager from '../components/viewManager/viewManager';
import { appRouter } from '../components/appRouter';
import { appHost } from '../components/apphost';
import { playbackManager } from '../components/playback/playbackmanager';
import SyncPlay from '../components/syncPlay/core';
import groupSelectionMenu from '../components/syncPlay/ui/groupSelectionMenu';
import browser from './browser';
import globalize from './globalize';
import imageHelper from './imagehelper';
import { getMenuLinks } from '../scripts/settings/webSettings';
import '../elements/emby-button/paper-icon-button-light';
import 'material-design-icons-iconfont';
import '../assets/css/scrollstyles.scss';
@ -32,7 +32,7 @@ import Headroom from 'headroom.js';
html += '</div>';
html += '<div class="headerRight">';
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="headerSyncButton syncButton headerButton headerButtonRight hide"><span class="material-icons groups"></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 type="button" is="paper-icon-button-light" class="headerButton headerButtonRight headerSearchButton hide"><span class="material-icons search"></span></button>';
@ -134,7 +134,7 @@ import Headroom from 'headroom.js';
const policy = user.Policy ? user.Policy : user.localUser.Policy;
const apiClient = getCurrentApiClient();
if (headerSyncButton && policy && policy.SyncPlayAccess !== 'None' && apiClient.isMinServerVersion('10.6.0')) {
if (headerSyncButton && policy?.SyncPlayAccess !== 'None' && apiClient.isMinServerVersion('10.6.0')) {
headerSyncButton.classList.remove('hide');
}
} else {
@ -233,26 +233,6 @@ import Headroom from 'headroom.js';
groupSelectionMenu.show(btn);
}
function onSyncPlayEnabled(event, enabled) {
const 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) {
const 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) {
return appRouter.getRouteUrl(item, {
context: context
@ -294,9 +274,11 @@ import Headroom from 'headroom.js';
html += '<div style="height:.5em;"></div>';
html += '<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder" href="#!/home.html"><span class="material-icons navMenuOptionIcon home"></span><span class="navMenuOptionText">' + globalize.translate('Home') + '</span></a>';
// placeholder for custom menu links
html += '<div class="customMenuOptions"></div>';
// libraries are added here
html += '<div class="libraryMenuOptions">';
html += '</div>';
html += '<div class="libraryMenuOptions"></div>';
if (user.localUser && user.localUser.Policy.IsAdministrator) {
html += '<div class="adminMenuOptions">';
@ -430,12 +412,6 @@ import Headroom from 'headroom.js';
pageIds: ['devicesPage', 'devicePage'],
icon: 'devices'
});
links.push({
name: globalize.translate('QuickConnect'),
href: '#!/quickConnect.html',
pageIds: ['quickConnectPage'],
icon: 'tap_and_play'
});
links.push({
name: globalize.translate('HeaderActivity'),
href: '#!/serveractivity.html',
@ -659,6 +635,32 @@ import Headroom from 'headroom.js';
const userId = Dashboard.getCurrentUserId();
const apiClient = getCurrentApiClient();
const customMenuOptions = document.querySelector('.customMenuOptions');
if (customMenuOptions) {
getMenuLinks().then(links => {
links.forEach(link => {
const option = document.createElement('a');
option.setAttribute('is', 'emby-linkbutton');
option.className = 'navMenuOption lnkMediaFolder';
option.rel = 'noopener noreferrer';
option.target = '_blank';
option.href = link.url;
const icon = document.createElement('span');
icon.className = `material-icons navMenuOptionIcon ${link.icon || 'link'}`;
option.appendChild(icon);
const label = document.createElement('span');
label.className = 'navMenuOptionText';
label.textContent = link.name;
option.appendChild(label);
customMenuOptions.appendChild(option);
});
});
}
const libraryMenuOptions = document.querySelector('.libraryMenuOptions');
if (libraryMenuOptions) {
@ -1023,9 +1025,6 @@ import Headroom from 'headroom.js';
Events.on(playbackManager, 'playerchange', updateCastIcon);
Events.on(SyncPlay.Manager, 'enabled', onSyncPlayEnabled);
Events.on(SyncPlay.Manager, 'syncing', onSyncPlaySyncing);
loadNavDrawer();
const LibraryMenu = {

View file

@ -54,8 +54,8 @@ import dom from '../scripts/dom';
let lastPointerMoveData;
function onPointerMove(e) {
const eventX = e.screenX;
const eventY = e.screenY;
const eventX = e.screenX || e.clientX;
const eventY = e.screenY || e.clientY;
// if coord don't exist how could it move
if (typeof eventX === 'undefined' && typeof eventY === 'undefined') {

View file

@ -189,7 +189,7 @@ export default function (view) {
reloadItems();
});
view.querySelector('.btnNewPlaylist').addEventListener('click', function () {
import('playlistEditor').then(({default: playlistEditor}) => {
import('../components/playlisteditor/playlisteditor').then(({default: playlistEditor}) => {
const serverId = ApiClient.serverInfo().Id;
new playlistEditor({
items: [],

View file

@ -84,6 +84,13 @@ import { appRouter } from '../components/appRouter';
controller: 'user/profile/index'
});
defineRoute({
alias: '/mypreferencescontrols.html',
path: 'user/controls/index.html',
autoFocus: false,
controller: 'user/controls/index'
});
defineRoute({
alias: '/mypreferencesdisplay.html',
path: 'user/display/index.html',
@ -304,7 +311,7 @@ import { appRouter } from '../components/appRouter';
defineRoute({
alias: '/search.html',
path: 'search.html',
controller: 'searchpage'
pageComponent: 'SearchPage'
});
defineRoute({
@ -553,6 +560,11 @@ import { appRouter } from '../components/appRouter';
serverRequest: true
});
defineRoute({
path: '/dialog',
dummyRoute: true
});
defineRoute({
path: '',
isDefaultRoute: true,

View file

@ -103,6 +103,8 @@ function centerOnFocusVertical(e) {
export const centerFocus = {
on: function (element, horizontal) {
element.setAttribute(`data-scroll-mode-${horizontal ? 'x' : 'y'}`, 'custom');
if (horizontal) {
dom.addEventListener(element, 'focus', centerOnFocusHorizontal, {
capture: true,
@ -116,6 +118,8 @@ export const centerFocus = {
}
},
off: function (element, horizontal) {
element.removeAttribute(`data-scroll-mode-${horizontal ? 'x' : 'y'}`);
if (horizontal) {
dom.removeEventListener(element, 'focus', centerOnFocusHorizontal, {
capture: true,

View file

@ -18,6 +18,19 @@ class AppSettings {
return this.get('enableAutoLogin') !== 'false';
}
/**
* Get or set 'Enable Gamepad' state.
* @param {boolean|undefined} val - Flag to enable 'Enable Gamepad' or undefined.
* @return {boolean} 'Enable Gamepad' state.
*/
enableGamepad(val) {
if (val !== undefined) {
return this.set('enableGamepad', val.toString());
}
return this.get('enableGamepad') === 'true';
}
enableSystemExternalPlayers(val) {
if (val !== undefined) {
this.set('enableSystemExternalPlayers', val.toString());

View file

@ -169,6 +169,19 @@ export class UserSettings {
return val !== 'false';
}
/**
* Get or set 'SetUsingLastTracks' state.
* @param {boolean|undefined} val - Flag to enable 'SetUsingLastTracks' or undefined.
* @return {boolean} 'SetUsingLastTracks' state.
*/
enableSetUsingLastTracks(val) {
if (val !== undefined) {
return this.set('enableSetUsingLastTracks', val.toString());
}
return this.get('enableSetUsingLastTracks', false) !== 'false';
}
/**
* Get or set 'Theme Songs' state.
* @param {boolean|undefined} val - Flag to enable 'Theme Songs' or undefined.
@ -239,6 +252,32 @@ export class UserSettings {
return val === 'true';
}
/**
* Get or set 'disableCustomCss' state.
* @param {boolean|undefined} val - Flag to enable 'disableCustomCss' or undefined.
* @return {boolean} 'disableCustomCss' state.
*/
disableCustomCss(val) {
if (val !== undefined) {
return this.set('disableCustomCss', val.toString(), false);
}
return this.get('disableCustomCss', false) === 'true';
}
/**
* Get or set customCss.
* @param {string|undefined} val - Language.
* @return {string} Language.
*/
customCss(val) {
if (val !== undefined) {
return this.set('customCss', val.toString(), false);
}
return this.get('customCss', false);
}
/**
* Get or set 'Details Banner' state.
* @param {boolean|undefined} val - Flag to enable 'Details Banner' or undefined.
@ -253,6 +292,20 @@ export class UserSettings {
return val !== 'false';
}
/**
* Get or set 'Use Episode Images in Next Up and Continue Watching' state.
* @param {string|boolean|undefined} val - Flag to enable 'Use Episode Images in Next Up and Continue Watching' or undefined.
* @return {boolean} 'Use Episode Images in Next Up' state.
*/
useEpisodeImagesInNextUpAndResume(val) {
if (val !== undefined) {
return this.set('useEpisodeImagesInNextUpAndResume', val.toString(), true);
}
val = this.get('useEpisodeImagesInNextUpAndResume', true);
return val === 'true';
}
/**
* Get or set language.
* @param {string|undefined} val - Language.
@ -488,12 +541,14 @@ export const allowedAudioChannels = currentSettings.allowedAudioChannels.bind(cu
export const preferFmp4HlsContainer = currentSettings.preferFmp4HlsContainer.bind(currentSettings);
export const enableCinemaMode = currentSettings.enableCinemaMode.bind(currentSettings);
export const enableNextVideoInfoOverlay = currentSettings.enableNextVideoInfoOverlay.bind(currentSettings);
export const enableSetUsingLastTracks = currentSettings.enableSetUsingLastTracks.bind(currentSettings);
export const enableThemeSongs = currentSettings.enableThemeSongs.bind(currentSettings);
export const enableThemeVideos = currentSettings.enableThemeVideos.bind(currentSettings);
export const enableFastFadein = currentSettings.enableFastFadein.bind(currentSettings);
export const enableBlurhash = currentSettings.enableBlurhash.bind(currentSettings);
export const enableBackdrops = currentSettings.enableBackdrops.bind(currentSettings);
export const detailsBanner = currentSettings.detailsBanner.bind(currentSettings);
export const useEpisodeImagesInNextUpAndResume = currentSettings.useEpisodeImagesInNextUpAndResume.bind(currentSettings);
export const language = currentSettings.language.bind(currentSettings);
export const dateTimeLocale = currentSettings.dateTimeLocale.bind(currentSettings);
export const chromecastVersion = currentSettings.chromecastVersion.bind(currentSettings);
@ -511,3 +566,5 @@ export const getSubtitleAppearanceSettings = currentSettings.getSubtitleAppearan
export const setSubtitleAppearanceSettings = currentSettings.setSubtitleAppearanceSettings.bind(currentSettings);
export const setFilter = currentSettings.setFilter.bind(currentSettings);
export const getFilter = currentSettings.getFilter.bind(currentSettings);
export const customCss = currentSettings.customCss.bind(currentSettings);
export const disableCustomCss = currentSettings.disableCustomCss.bind(currentSettings);

Some files were not shown because too many files have changed in this diff Show more