mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' of https://github.com/jellyfin/jellyfin-web into fix-accese
# Conflicts: # src/scripts/site.js
This commit is contained in:
commit
8e724d3119
168 changed files with 12834 additions and 5898 deletions
37
src/components/alphaPicker/AlphaPickerComponent.tsx
Normal file
37
src/components/alphaPicker/AlphaPickerComponent.tsx
Normal 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;
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -60,6 +60,7 @@
|
|||
}
|
||||
|
||||
.layout-tv .formDialogFooter {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>';
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
42
src/components/pages/SearchPage.tsx
Normal file
42
src/components/pages/SearchPage.tsx
Normal 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;
|
|
@ -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)) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`);
|
||||
|
|
17
src/components/reactControllerFactory.js
Normal file
17
src/components/reactControllerFactory.js
Normal 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);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -388,7 +388,7 @@
|
|||
.btnPlayPause {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 1.7em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.nowPlayingPageImage {
|
||||
|
|
|
@ -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);
|
||||
|
|
189
src/components/search/LiveTVSearchResults.tsx
Normal file
189
src/components/search/LiveTVSearchResults.tsx
Normal 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;
|
90
src/components/search/SearchFields.tsx
Normal file
90
src/components/search/SearchFields.tsx
Normal 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;
|
268
src/components/search/SearchResults.tsx
Normal file
268
src/components/search/SearchResults.tsx
Normal 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;
|
50
src/components/search/SearchResultsRow.tsx
Normal file
50
src/components/search/SearchResultsRow.tsx
Normal 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;
|
71
src/components/search/SearchSuggestions.tsx
Normal file
71
src/components/search/SearchSuggestions.tsx
Normal 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;
|
|
@ -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 */
|
|
@ -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>
|
|
@ -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 */
|
|
@ -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>
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
28
src/components/syncPlay/core/Settings.js
Normal file
28
src/components/syncPlay/core/Settings.js
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
147
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal file
147
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal 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;
|
75
src/components/syncPlay/ui/settings/editor.html
Normal file
75
src/components/syncPlay/ui/settings/editor.html
Normal 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>
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue