= ({ className, title, href }: IProps) => {
+ return (
+
+ );
+};
+
+export default LinkTrickplayAcceleration;
diff --git a/src/components/libraryoptionseditor/libraryoptionseditor.js b/src/components/libraryoptionseditor/libraryoptionseditor.js
index 0b9ce0b967..3ae5e3a0b0 100644
--- a/src/components/libraryoptionseditor/libraryoptionseditor.js
+++ b/src/components/libraryoptionseditor/libraryoptionseditor.js
@@ -391,8 +391,10 @@ export function setContentType(parent, contentType) {
}
if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') {
+ parent.querySelector('.trickplaySettingsSection').classList.add('hide');
parent.querySelector('.chapterSettingsSection').classList.add('hide');
} else {
+ parent.querySelector('.trickplaySettingsSection').classList.remove('hide');
parent.querySelector('.chapterSettingsSection').classList.remove('hide');
}
@@ -517,6 +519,8 @@ export function getLibraryOptions(parent) {
EnablePhotos: parent.querySelector('.chkEnablePhotos').checked,
EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked,
EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').checked,
+ ExtractTrickplayImagesDuringLibraryScan: parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked,
+ EnableTrickplayImageExtraction: parent.querySelector('.chkExtractTrickplayImages').checked,
UseReplayGainTags: parent.querySelector('.chkUseReplayGainTags').checked,
ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked,
EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked,
@@ -580,6 +584,8 @@ export function setLibraryOptions(parent, options) {
parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos;
parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor;
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('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan;
parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction;
diff --git a/src/components/libraryoptionseditor/libraryoptionseditor.template.html b/src/components/libraryoptionseditor/libraryoptionseditor.template.html
index bf3bd7aaa8..6a50d57d71 100644
--- a/src/components/libraryoptionseditor/libraryoptionseditor.template.html
+++ b/src/components/libraryoptionseditor/libraryoptionseditor.template.html
@@ -112,6 +112,25 @@
${OptionAutomaticallyGroupSeriesHelp}
+
+
${Trickplay}
+
+
+
+
+
diff --git a/src/controllers/dashboard/encodingsettings.js b/src/controllers/dashboard/encodingsettings.js
index fe6dfad8d4..6e254ced46 100644
--- a/src/controllers/dashboard/encodingsettings.js
+++ b/src/controllers/dashboard/encodingsettings.js
@@ -19,6 +19,7 @@ function loadPage(page, config, systemInfo) {
page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding;
page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding;
page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding;
+ page.querySelector('#chkAllowMjpegEncoding').checked = config.AllowMjpegEncoding;
$('#selectVideoDecoder', page).val(config.HardwareAccelerationType);
$('#selectThreadCount', page).val(config.EncodingThreadCount);
page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr;
@@ -127,6 +128,7 @@ function onSubmit() {
config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked;
config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked;
config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked;
+ config.AllowMjpegEncoding = form.querySelector('#chkAllowMjpegEncoding').checked;
ApiClient.updateNamedConfiguration('encoding', config).then(function () {
updateEncoder(form);
}, function () {
@@ -177,6 +179,9 @@ function getTabs() {
}, {
href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming')
+ }, {
+ href: '#/dashboard/playback/trickplay',
+ name: globalize.translate('Trickplay')
}];
}
diff --git a/src/controllers/dashboard/playback.js b/src/controllers/dashboard/playback.js
index 6b4985df34..f0d35f02ee 100644
--- a/src/controllers/dashboard/playback.js
+++ b/src/controllers/dashboard/playback.js
@@ -39,6 +39,9 @@ function getTabs() {
}, {
href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming')
+ }, {
+ href: '#/dashboard/playback/trickplay',
+ name: globalize.translate('Trickplay')
}];
}
@@ -52,4 +55,3 @@ $(document).on('pageinit', '#playbackConfigurationPage', function () {
loadPage(page, config);
});
});
-
diff --git a/src/controllers/dashboard/streaming.js b/src/controllers/dashboard/streaming.js
index c02a5cdbde..d6ca058743 100644
--- a/src/controllers/dashboard/streaming.js
+++ b/src/controllers/dashboard/streaming.js
@@ -30,6 +30,9 @@ function getTabs() {
}, {
href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming')
+ }, {
+ href: '#/dashboard/playback/trickplay',
+ name: globalize.translate('Trickplay')
}];
}
diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js
index 2cb099856b..deac178c73 100644
--- a/src/controllers/playback/video/index.js
+++ b/src/controllers/playback/video/index.js
@@ -146,6 +146,26 @@ export default function (view) {
btnUserRating.classList.add('hide');
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) {
@@ -1356,6 +1376,81 @@ export default function (view) {
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) {
if (chapter.ImageTag) {
return apiClient.getScaledImageUrl(item.Id, {
@@ -1455,6 +1550,7 @@ export default function (view) {
let programEndDateMs = 0;
let playbackStartTimeTicks = 0;
let subtitleSyncOverlay;
+ let trickplayResolution = null;
const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider');
const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer');
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) {
showOsd();
if (enableProgressByTimeOfDay) {
diff --git a/src/elements/emby-slider/emby-slider.js b/src/elements/emby-slider/emby-slider.js
index ddbe9c596c..86a52c0787 100644
--- a/src/elements/emby-slider/emby-slider.js
+++ b/src/elements/emby-slider/emby-slider.js
@@ -161,6 +161,10 @@ function updateBubble(range, percent, value, bubble) {
let html;
+ if (range.updateBubbleHtml?.(bubble, value)) {
+ return;
+ }
+
if (range.getBubbleHtml) {
html = range.getBubbleHtml(percent, value);
} else {
diff --git a/src/plugins/syncPlay/core/Helper.js b/src/plugins/syncPlay/core/Helper.js
index 47f25578a3..1489f1e156 100644
--- a/src/plugins/syncPlay/core/Helper.js
+++ b/src/plugins/syncPlay/core/Helper.js
@@ -84,7 +84,7 @@ export function getItemsForPlayback(apiClient, query) {
});
} else {
query.Limit = query.Limit || 300;
- query.Fields = 'Chapters';
+ query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false;
@@ -200,7 +200,7 @@ export function translateItemsForPlayback(apiClient, items, options) {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
- Fields: 'Chapters'
+ Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) {
let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) {
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index e5f7fb0fa5..999f646346 100644
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -1625,5 +1625,38 @@
"MachineTranslated": "Machine Translated",
"ForeignPartsOnly": "Forced/Foreign parts only",
"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."
}