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

Merge pull request #5200 from nicknsy/trickplay-new

Add trickplay support
This commit is contained in:
Bill Thornton 2024-03-24 02:24:01 -04:00 committed by GitHub
commit cb98a5cce0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 545 additions and 9 deletions

View file

@ -21,7 +21,8 @@ const LIBRARY_PATHS = [
const PLAYBACK_PATHS = [ const PLAYBACK_PATHS = [
'/dashboard/playback/transcoding', '/dashboard/playback/transcoding',
'/dashboard/playback/resume', '/dashboard/playback/resume',
'/dashboard/playback/streaming' '/dashboard/playback/streaming',
'/dashboard/playback/trickplay'
]; ];
const ServerDrawerSection = () => { const ServerDrawerSection = () => {
@ -108,6 +109,9 @@ const ServerDrawerSection = () => {
<ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabStreaming')} /> <ListItemText inset primary={globalize.translate('TabStreaming')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/dashboard/playback/trickplay' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Trickplay')} />
</ListItemLink>
</List> </List>
</Collapse> </Collapse>
</List> </List>

View file

@ -9,5 +9,6 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'users/add', type: AsyncRouteType.Dashboard }, { path: 'users/add', type: AsyncRouteType.Dashboard },
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard }, { path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
{ path: 'users/password', type: AsyncRouteType.Dashboard }, { path: 'users/password', type: AsyncRouteType.Dashboard },
{ path: 'users/profile', type: AsyncRouteType.Dashboard } { path: 'users/profile', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
]; ];

View file

@ -0,0 +1,305 @@
import type { ProcessPriorityClass, ServerConfiguration, TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client';
import React, { type FunctionComponent, useCallback, useEffect, useRef } from 'react';
import globalize from '../../../../scripts/globalize';
import Page from '../../../../components/Page';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import InputElement from '../../../../elements/InputElement';
import LinkTrickplayAcceleration from '../../../../components/dashboard/playback/trickplay/LinkTrickplayAcceleration';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import ServerConnections from '../../../../components/ServerConnections';
function onSaveComplete() {
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const PlaybackTrickplay: FunctionComponent = () => {
const element = useRef<HTMLDivElement>(null);
const loadConfig = useCallback((config) => {
const page = element.current;
const options = config.TrickplayOptions;
if (!page) {
console.error('Unexpected null reference');
return;
}
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options.EnableHwAcceleration;
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = options.ScanBehavior;
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = options.ProcessPriority;
(page.querySelector('#txtInterval') as HTMLInputElement).value = options.Interval;
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options.WidthResolutions.join(',');
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options.TileWidth;
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options.TileHeight;
(page.querySelector('#txtQscale') as HTMLInputElement).value = options.Qscale;
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options.JpegQuality;
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options.ProcessThreads;
loading.hide();
}, []);
const loadData = useCallback(() => {
loading.show();
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
loadConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
}, [loadConfig]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
const saveConfig = (config: ServerConfiguration) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
if (!config.TrickplayOptions) {
throw new Error('Unexpected null TrickplayOptions');
}
const options = config.TrickplayOptions;
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
apiClient.updateServerConfiguration(config).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[PlaybackTrickplay] failed to update config', err);
});
};
const onSubmit = (e: Event) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
loading.show();
apiClient.getServerConfiguration().then(function (config) {
saveConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
e.preventDefault();
e.stopPropagation();
return false;
};
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
loadData();
}, [loadData]);
const optionScanBehavior = () => {
let content = '';
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
return content;
};
const optionProcessPriority = () => {
let content = '';
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
return content;
};
return (
<Page
id='trickplayConfigurationPage'
className='mainAnimatedPage type-interior playbackConfigurationPage'
>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={globalize.translate('Trickplay')}
isLinkVisible={false}
/>
</div>
<form className='trickplayConfigurationForm'>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwAcceleration'
title='LabelTrickplayAccel'
/>
<div className='fieldDescription checkboxFieldDescription'>
<LinkTrickplayAcceleration
title='LabelTrickplayAccelHelp'
href='#/dashboard/playback/transcoding'
className='button-link'
/>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectScanBehavior'>
<SelectElement
id='selectScanBehavior'
label='LabelScanBehavior'
>
{optionScanBehavior()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelScanBehaviorHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectProcessPriority'>
<SelectElement
id='selectProcessPriority'
label='LabelProcessPriority'
>
{optionProcessPriority()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelProcessPriorityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtInterval'
label='LabelImageInterval'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelImageIntervalHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='text'
id='txtWidthResolutions'
label='LabelWidthResolutions'
options={'required pattern="[0-9,]*"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelWidthResolutionsHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileWidth'
label='LabelTileWidth'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileWidthHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileHeight'
label='LabelTileHeight'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileHeightHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtJpegQuality'
label='LabelJpegQuality'
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelJpegQualityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtQscale'
label='LabelQscale'
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelQscaleHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtProcessThreads'
label='LabelTrickplayThreads'
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayThreadsHelp')}
</div>
</div>
</div>
<div>
<ButtonElement
type='submit'
className='raised button-submit block'
title='Save'
/>
</div>
</form>
</div>
</Page>
);
};
export default PlaybackTrickplay;

View file

@ -0,0 +1,33 @@
import React, { type FunctionComponent } from 'react';
import globalize from '../../../../scripts/globalize';
type IProps = {
title?: string;
className?: string;
href?: string;
};
const createLinkElement = ({ className, title, href }: IProps) => ({
__html: `<a
is="emby-linkbutton"
rel="noopener noreferrer"
class="${className}"
href="${href}"
>
${title}
</a>`
});
const LinkTrickplayAcceleration: FunctionComponent<IProps> = ({ className, title, href }: IProps) => {
return (
<div
dangerouslySetInnerHTML={createLinkElement({
className,
title: globalize.translate(title),
href
})}
/>
);
};
export default LinkTrickplayAcceleration;

View file

@ -391,8 +391,10 @@ export function setContentType(parent, contentType) {
} }
if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') { if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') {
parent.querySelector('.trickplaySettingsSection').classList.add('hide');
parent.querySelector('.chapterSettingsSection').classList.add('hide'); parent.querySelector('.chapterSettingsSection').classList.add('hide');
} else { } else {
parent.querySelector('.trickplaySettingsSection').classList.remove('hide');
parent.querySelector('.chapterSettingsSection').classList.remove('hide'); parent.querySelector('.chapterSettingsSection').classList.remove('hide');
} }
@ -517,6 +519,8 @@ export function getLibraryOptions(parent) {
EnablePhotos: parent.querySelector('.chkEnablePhotos').checked, EnablePhotos: parent.querySelector('.chkEnablePhotos').checked,
EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked, EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked,
EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').checked, EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').checked,
ExtractTrickplayImagesDuringLibraryScan: parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked,
EnableTrickplayImageExtraction: parent.querySelector('.chkExtractTrickplayImages').checked,
UseReplayGainTags: parent.querySelector('.chkUseReplayGainTags').checked, UseReplayGainTags: parent.querySelector('.chkUseReplayGainTags').checked,
ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked, ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked,
EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked, EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked,
@ -580,6 +584,8 @@ export function setLibraryOptions(parent, options) {
parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos; parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos;
parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor; parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor;
parent.querySelector('.chkEnableLUFSScan').checked = options.EnableLUFSScan; parent.querySelector('.chkEnableLUFSScan').checked = options.EnableLUFSScan;
parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked = options.ExtractTrickplayImagesDuringLibraryScan;
parent.querySelector('.chkExtractTrickplayImages').checked = options.EnableTrickplayImageExtraction;
parent.querySelector('.chkUseReplayGainTags').checked = options.UseReplayGainTags; parent.querySelector('.chkUseReplayGainTags').checked = options.UseReplayGainTags;
parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan; parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan;
parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction; parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction;

View file

@ -112,6 +112,25 @@
<div class="fieldDescription checkboxFieldDescription">${OptionAutomaticallyGroupSeriesHelp}</div> <div class="fieldDescription checkboxFieldDescription">${OptionAutomaticallyGroupSeriesHelp}</div>
</div> </div>
<div class="trickplaySettingsSection hide">
<h2>${Trickplay}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayImages">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayImages" />
<span>${OptionExtractTrickplayImage}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${ExtractTrickplayImagesHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayDuringLibraryScan advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayDuringLibraryScan" />
<span>${LabelExtractTrickplayDuringLibraryScan}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelExtractTrickplayDuringLibraryScanHelp}</div>
</div>
</div>
<div class="chapterSettingsSection hide"> <div class="chapterSettingsSection hide">
<h2>${HeaderChapterImages}</h2> <h2>${HeaderChapterImages}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractChapterImages"> <div class="checkboxContainer checkboxContainer-withDescription fldExtractChapterImages">

View file

@ -125,7 +125,7 @@ function getItemsForPlayback(serverId, query) {
} else { } else {
query.Limit = query.Limit || 300; query.Limit = query.Limit || 300;
} }
query.Fields = 'Chapters'; query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual'; query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false; query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false; query.CollapseBoxSetItems = false;
@ -1858,7 +1858,7 @@ class PlaybackManager {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
const originalResults = episodesResult.Items; const originalResults = episodesResult.Items;
const isSeries = firstItem.Type === 'Series'; const isSeries = firstItem.Type === 'Series';
@ -1940,7 +1940,7 @@ class PlaybackManager {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
let foundItem = false; let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) { episodesResult.Items = episodesResult.Items.filter(function (e) {

View file

@ -135,6 +135,12 @@
<span>${AllowAv1Encoding}</span> <span>${AllowAv1Encoding}</span>
</label> </label>
</div> </div>
<div class="checkboxList">
<label>
<input type="checkbox" is="emby-checkbox" id="chkAllowMjpegEncoding" />
<span>${AllowMjpegEncoding}</span>
</label>
</div>
</div> </div>
<div class="vppTonemappingOptions hide"> <div class="vppTonemappingOptions hide">

View file

@ -19,6 +19,7 @@ function loadPage(page, config, systemInfo) {
page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding; page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding;
page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding; page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding;
page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding; page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding;
page.querySelector('#chkAllowMjpegEncoding').checked = config.AllowMjpegEncoding;
$('#selectVideoDecoder', page).val(config.HardwareAccelerationType); $('#selectVideoDecoder', page).val(config.HardwareAccelerationType);
$('#selectThreadCount', page).val(config.EncodingThreadCount); $('#selectThreadCount', page).val(config.EncodingThreadCount);
page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr; page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr;
@ -127,6 +128,7 @@ function onSubmit() {
config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked; config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked;
config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked; config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked;
config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked; config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked;
config.AllowMjpegEncoding = form.querySelector('#chkAllowMjpegEncoding').checked;
ApiClient.updateNamedConfiguration('encoding', config).then(function () { ApiClient.updateNamedConfiguration('encoding', config).then(function () {
updateEncoder(form); updateEncoder(form);
}, function () { }, function () {
@ -177,6 +179,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }

View file

@ -39,6 +39,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }
@ -52,4 +55,3 @@ $(document).on('pageinit', '#playbackConfigurationPage', function () {
loadPage(page, config); loadPage(page, config);
}); });
}); });

View file

@ -30,6 +30,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }

View file

@ -146,6 +146,26 @@ export default function (view) {
btnUserRating.classList.add('hide'); btnUserRating.classList.add('hide');
btnUserRating.setItem(null); btnUserRating.setItem(null);
} }
// Update trickplay data
trickplayResolution = null;
const mediaSourceId = currentPlayer.streamInfo.mediaSource.Id;
const trickplayResolutions = item.Trickplay?.[mediaSourceId];
if (trickplayResolutions) {
// Prefer highest resolution <= 20% of total screen resolution width
let bestWidth;
const maxWidth = window.screen.width * window.devicePixelRatio * 0.2;
for (const [, info] of Object.entries(trickplayResolutions)) {
if (!bestWidth
|| (info.Width < bestWidth && bestWidth > maxWidth) // Objects not guaranteed to be sorted in any order, first width might be > maxWidth.
|| (info.Width > bestWidth && info.Width <= maxWidth)) {
bestWidth = info.Width;
}
}
if (bestWidth) trickplayResolution = trickplayResolutions[bestWidth];
}
} }
function getDisplayTimeWithoutAmPm(date, showSeconds) { function getDisplayTimeWithoutAmPm(date, showSeconds) {
@ -1356,6 +1376,81 @@ export default function (view) {
resetIdle(); resetIdle();
} }
function updateTrickplayBubbleHtml(apiClient, trickplayInfo, item, mediaSourceId, bubble, positionTicks) {
let doFullUpdate = false;
let chapterThumbContainer = bubble.querySelector('.chapterThumbContainer');
let chapterThumb;
let chapterThumbText;
// Create bubble elements if they don't already exist
if (chapterThumbContainer) {
chapterThumb = chapterThumbContainer.querySelector('.chapterThumb');
chapterThumbText = chapterThumbContainer.querySelector('.chapterThumbText');
} else {
doFullUpdate = true;
chapterThumbContainer = document.createElement('div');
chapterThumbContainer.classList.add('chapterThumbContainer');
chapterThumbContainer.style.overflow = 'hidden';
const chapterThumbWrapper = document.createElement('div');
chapterThumbWrapper.classList.add('chapterThumbWrapper');
chapterThumbWrapper.style.overflow = 'hidden';
chapterThumbWrapper.style.position = 'relative';
chapterThumbWrapper.style.width = trickplayInfo.Width + 'px';
chapterThumbWrapper.style.height = trickplayInfo.Height + 'px';
chapterThumbContainer.appendChild(chapterThumbWrapper);
chapterThumb = document.createElement('img');
chapterThumb.classList.add('chapterThumb');
chapterThumb.style.position = 'absolute';
chapterThumb.style.width = 'unset';
chapterThumb.style.minWidth = 'unset';
chapterThumb.style.height = 'unset';
chapterThumb.style.minHeight = 'unset';
chapterThumbWrapper.appendChild(chapterThumb);
const chapterThumbTextContainer = document.createElement('div');
chapterThumbTextContainer.classList.add('chapterThumbTextContainer');
chapterThumbContainer.appendChild(chapterThumbTextContainer);
chapterThumbText = document.createElement('h2');
chapterThumbText.classList.add('chapterThumbText');
chapterThumbTextContainer.appendChild(chapterThumbText);
}
// Update trickplay values
const currentTimeMs = positionTicks / 10_000;
const currentTile = Math.floor(currentTimeMs / trickplayInfo.Interval);
const tileSize = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
const tileOffset = currentTile % tileSize;
const index = Math.floor(currentTile / tileSize);
const tileOffsetX = tileOffset % trickplayInfo.TileWidth;
const tileOffsetY = Math.floor(tileOffset / trickplayInfo.TileWidth);
const offsetX = -(tileOffsetX * trickplayInfo.Width);
const offsetY = -(tileOffsetY * trickplayInfo.Height);
const imgSrc = apiClient.getUrl('Videos/' + item.Id + '/Trickplay/' + trickplayInfo.Width + '/' + index + '.jpg', {
api_key: apiClient.accessToken(),
MediaSourceId: mediaSourceId
});
if (chapterThumb.src != imgSrc) chapterThumb.src = imgSrc;
chapterThumb.style.left = offsetX + 'px';
chapterThumb.style.top = offsetY + 'px';
chapterThumbText.textContent = datetime.getDisplayRunningTime(positionTicks);
// Set bubble innerHTML if container isn't part of DOM
if (doFullUpdate) {
bubble.innerHTML = chapterThumbContainer.outerHTML;
}
return true;
}
function getImgUrl(item, chapter, index, maxWidth, apiClient) { function getImgUrl(item, chapter, index, maxWidth, apiClient) {
if (chapter.ImageTag) { if (chapter.ImageTag) {
return apiClient.getScaledImageUrl(item.Id, { return apiClient.getScaledImageUrl(item.Id, {
@ -1455,6 +1550,7 @@ export default function (view) {
let programEndDateMs = 0; let programEndDateMs = 0;
let playbackStartTimeTicks = 0; let playbackStartTimeTicks = 0;
let subtitleSyncOverlay; let subtitleSyncOverlay;
let trickplayResolution = null;
const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider'); const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider');
const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer'); const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer');
const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider'); const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider');
@ -1681,6 +1777,25 @@ export default function (view) {
} }
}); });
nowPlayingPositionSlider.updateBubbleHtml = function(bubble, value) {
showOsd();
const item = currentItem;
const ticks = currentRuntimeTicks * value / 100;
if (trickplayResolution && item?.Trickplay) {
return updateTrickplayBubbleHtml(
ServerConnections.getApiClient(item.ServerId),
trickplayResolution,
item,
currentPlayer.streamInfo.mediaSource.Id,
bubble,
ticks);
}
return false;
};
nowPlayingPositionSlider.getBubbleHtml = function (value) { nowPlayingPositionSlider.getBubbleHtml = function (value) {
showOsd(); showOsd();
if (enableProgressByTimeOfDay) { if (enableProgressByTimeOfDay) {

View file

@ -161,6 +161,10 @@ function updateBubble(range, percent, value, bubble) {
let html; let html;
if (range.updateBubbleHtml?.(bubble, value)) {
return;
}
if (range.getBubbleHtml) { if (range.getBubbleHtml) {
html = range.getBubbleHtml(percent, value); html = range.getBubbleHtml(percent, value);
} else { } else {

View file

@ -84,7 +84,7 @@ export function getItemsForPlayback(apiClient, query) {
}); });
} else { } else {
query.Limit = query.Limit || 300; query.Limit = query.Limit || 300;
query.Fields = 'Chapters'; query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual'; query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false; query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false; query.CollapseBoxSetItems = false;
@ -200,7 +200,7 @@ export function translateItemsForPlayback(apiClient, items, options) {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
let foundItem = false; let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) { episodesResult.Items = episodesResult.Items.filter(function (e) {

View file

@ -1625,5 +1625,38 @@
"MachineTranslated": "Machine Translated", "MachineTranslated": "Machine Translated",
"ForeignPartsOnly": "Forced/Foreign parts only", "ForeignPartsOnly": "Forced/Foreign parts only",
"HearingImpairedShort": "HI/SDH", "HearingImpairedShort": "HI/SDH",
"LabelIsHearingImpaired": "For hearing impaired (SDH)" "LabelIsHearingImpaired": "For hearing impaired (SDH)",
"AllowMjpegEncoding": "Allow encoding in MJPEG format (used during trickplay generation)",
"Trickplay": "Trickplay",
"LabelTrickplayAccel": "Enable hardware acceleration",
"LabelTrickplayAccelHelp": "Make sure to enable 'Allow MJPEG Encoding' in Transcoding if your hardware supports it.",
"NonBlockingScan": "Non Blocking - queues generation, then returns",
"BlockingScan": "Blocking - queues generation, blocks scan until complete",
"LabelScanBehavior": "Scan Behavior",
"LabelScanBehaviorHelp": "The default behavior is non blocking, which will add media to the library before trickplay generation is done. Blocking will ensure trickplay files are generated before media is added to the library, but will make scans significantly longer.",
"PriorityHigh": "High",
"PriorityAboveNormal": "Above Normal",
"PriorityNormal": "Normal",
"PriorityBelowNormal": "Below Normal",
"PriorityIdle": "Idle",
"LabelProcessPriority": "Process Priority",
"LabelProcessPriorityHelp": "Setting this lower or higher will determine how the CPU prioritizes the ffmpeg trickplay generation process in relation to other processes. If you notice slowdown while generating trickplay images but don't want to fully stop their generation, try lowering this as well as the thread count.",
"LabelImageInterval": "Image Interval",
"LabelImageIntervalHelp": "Interval of time (ms) between each new trickplay image.",
"LabelWidthResolutions": "Width Resolutions",
"LabelWidthResolutionsHelp": "Comma separated list of the widths (px) that trickplay images will be generated at. All images should generate proportionally to the source, so a width of 320 on a 16:9 video ends up around 320x180.",
"LabelTileWidth": "Tile Width",
"LabelTileWidthHelp": "Maximum number of images per tile in the X direction.",
"LabelTileHeight": "Tile Height",
"LabelTileHeightHelp": "Maximum number of images per tile in the Y direction.",
"LabelJpegQuality": "JPEG Quality",
"LabelJpegQualityHelp": "The JPEG compression quality for trickplay images.",
"LabelQscale": "Qscale",
"LabelQscaleHelp": "The quality scale of images output by ffmpeg, with 2 being the highest quality and 31 being the lowest.",
"LabelTrickplayThreads": "FFmpeg Threads",
"LabelTrickplayThreadsHelp": "The number of threads to pass to the '-threads' argument of ffmpeg.",
"OptionExtractTrickplayImage": "Enable trickplay image extraction",
"ExtractTrickplayImagesHelp": "Trickplay images are similar to chapter images, except they span the entire length of the content and are used to show a preview when scrubbing through videos.",
"LabelExtractTrickplayDuringLibraryScan": "Extract trickplay images during the library scan",
"LabelExtractTrickplayDuringLibraryScanHelp": "Generate trickplay images when videos are imported during the library scan. Otherwise, they will be extracted during the trickplay images scheduled task. If generation is set to non-blocking this will not affect the time a library scan takes to complete."
} }