Compare commits

...

53 Commits

Author SHA1 Message Date
CodeDevMLH
1ae59f5da5 Update manifest.json for release v1.6.1.17 [skip ci] 2026-02-13 13:49:23 +00:00
CodeDevMLH
92eaf91173 Bump version to 1.6.1.17
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 1m2s
2026-02-13 14:48:24 +01:00
CodeDevMLH
e7410ec22a Fix slideshow preloading logic to prevent redundant slide creation 2026-02-13 14:48:07 +01:00
CodeDevMLH
bb43d1e679 Update manifest.json for release v1.6.1.16 [skip ci] 2026-02-13 02:53:20 +00:00
CodeDevMLH
b6609d23a2 Bump version to 1.6.1.16
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 51s
2026-02-13 03:52:27 +01:00
CodeDevMLH
9d4cbf37d3 Refactor slideshow state management and improve video playback logic 2026-02-13 03:52:07 +01:00
CodeDevMLH
b5e63ef3b7 Update manifest.json for release v1.6.1.15 [skip ci] 2026-02-13 02:41:24 +00:00
CodeDevMLH
22f9906188 Bump version to 1.6.1.15
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-13 03:40:35 +01:00
CodeDevMLH
ae54ab41a8 Refactor video playback logic to simplify conditions for active slides 2026-02-13 03:40:23 +01:00
CodeDevMLH
9663ab78d2 Update manifest.json for release v1.6.1.14 [skip ci] 2026-02-13 02:23:29 +00:00
CodeDevMLH
f633e4273f Bump version to 1.6.1.14
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-13 03:22:39 +01:00
CodeDevMLH
c0895fd8d7 Fix slideshow current slide index update logic 2026-02-13 03:22:21 +01:00
CodeDevMLH
002ccdb08b Enhance label styling and adjust layout in configuration form 2026-02-13 03:11:53 +01:00
CodeDevMLH
7cb03410ee Update manifest.json for release v1.6.1.13 [skip ci] 2026-02-13 02:02:19 +00:00
CodeDevMLH
17c8681e93 Bump version to 1.6.1.13 in project files and manifest
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-13 03:01:29 +01:00
CodeDevMLH
3a4c663c0e Enhance configuration layout and improve seasonal section handling 2026-02-13 03:01:17 +01:00
CodeDevMLH
3385196611 Update manifest.json for release v1.6.1.12 [skip ci] 2026-02-13 01:41:56 +00:00
CodeDevMLH
2538556f7c Bump version to 1.6.1.12
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 51s
2026-02-13 02:41:06 +01:00
CodeDevMLH
550ebed942 Update manifest.json for release v1.6.1.11 [skip ci] 2026-02-13 01:19:07 +00:00
CodeDevMLH
21d55711d4 Enhance configuration layout for season name input and update select appearance
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 51s
2026-02-13 02:18:18 +01:00
CodeDevMLH
81a0d375be Update manifest.json for release v1.6.1.11 [skip ci] 2026-02-13 01:12:47 +00:00
CodeDevMLH
23cbc0a85a Bump version to 1.6.1.11
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-13 02:11:58 +01:00
CodeDevMLH
2de066cca8 Merge branch 'main' of ssh://git.mahom03-spacecloud.de:44322/CodeDevMLH/jellyfin-plugin-media-bar-enhanced 2026-02-13 02:11:46 +01:00
CodeDevMLH
138e50ff15 Enhance configuration layout and add disablePictureInPicture option for video playback 2026-02-13 02:11:20 +01:00
CodeDevMLH
bf72dc08a3 Update manifest.json for release v1.6.1.10 [skip ci] 2026-02-13 00:41:36 +00:00
CodeDevMLH
65a63b4aa0 Bump version to 1.6.1.10
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-13 01:40:48 +01:00
CodeDevMLH
a1df756c56 Enhance seasonal section input fields with improved layout and descriptions 2026-02-13 01:40:33 +01:00
CodeDevMLH
f2d383ec61 Refactor video playback logic in SlideshowManager for improved fallback handling 2026-02-13 01:36:26 +01:00
CodeDevMLH
b85f52d8d3 Update manifest.json for release v1.6.1.9 [skip ci] 2026-02-13 00:16:40 +00:00
CodeDevMLH
ad18acb011 Bump version to 1.6.1.9
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 53s
2026-02-13 01:15:47 +01:00
CodeDevMLH
2ae147ac01 Add seasonal content support and enhance custom media ID handling 2026-02-13 01:15:33 +01:00
CodeDevMLH
9896044988 Update manifest.json for release v1.6.1.8 [skip ci] 2026-02-12 23:51:47 +00:00
CodeDevMLH
93e91e2e60 Bump version to 1.6.1.8 in project file and manifest
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 58s
2026-02-13 00:50:52 +01:00
CodeDevMLH
b613b028d0 Add configuration options for custom ID limits 2026-02-13 00:50:34 +01:00
CodeDevMLH
9906784845 Enhance visibility change handling to manage video playback more effectively when tab visibility changes 2026-02-12 21:35:29 +01:00
CodeDevMLH
009a3c4720 Update manifest.json for release v1.6.1.7 [skip ci] 2026-02-12 15:57:17 +00:00
CodeDevMLH
595056230a Bump version to 1.6.1.7 in project file and manifest
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 59s
2026-02-12 16:54:36 +01:00
CodeDevMLH
b18060dfd7 Refactor visibility observer initialization and remove debounce logic 2026-02-12 16:54:20 +01:00
CodeDevMLH
ebb2af9d24 Update manifest.json for release v1.6.1.6 [skip ci] 2026-02-12 02:03:10 +00:00
CodeDevMLH
743af20b8e Bump version to 1.6.1.6
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 53s
2026-02-12 03:02:17 +01:00
CodeDevMLH
9844b186d7 Enhance focus management in TV mode by delaying focus call after iframe removal 2026-02-12 03:02:10 +01:00
CodeDevMLH
104b76aa41 Update manifest.json for release v1.6.1.5 [skip ci] 2026-02-12 01:51:53 +00:00
CodeDevMLH
7493c8fa93 Bump version to 1.6.1.5
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-12 02:51:04 +01:00
CodeDevMLH
77c03157a1 Enhance slide pruning logic to restore focus in TV mode after pruning slides 2026-02-12 02:50:50 +01:00
CodeDevMLH
a7929e1ff6 Update manifest.json for release v1.6.1.4 [skip ci] 2026-02-12 01:29:37 +00:00
CodeDevMLH
c78e07de62 Bump version to 1.6.1.4
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 50s
2026-02-12 02:28:47 +01:00
CodeDevMLH
a90f805ea8 Refactor visibility management in slideshow and video playback; optimize state updates and debounce observer for improved performance. 2026-02-12 02:28:32 +01:00
CodeDevMLH
ccba1857e1 Update manifest.json for release v1.6.1.3 [skip ci] 2026-02-12 00:43:53 +00:00
CodeDevMLH
ff56c9370b Bump version to 1.6.1.3
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 49s
2026-02-12 01:43:05 +01:00
CodeDevMLH
162600c43f Enhance TV mode support by preventing iframe focus stealing and ensuring container focus continuity; refactor video playback management for improved clarity and functionality. 2026-02-12 01:42:22 +01:00
CodeDevMLH
a21549af47 Update manifest.json for release v1.6.1.2 [skip ci] 2026-02-11 23:50:41 +00:00
CodeDevMLH
1b319ade40 Merge branch 'main' of ssh://git.mahom03-spacecloud.de:44322/CodeDevMLH/jellyfin-plugin-media-bar-enhanced
All checks were successful
Auto Release Plugin / build-and-release (push) Successful in 52s
2026-02-12 00:49:49 +01:00
CodeDevMLH
75757d67e7 Adjust backdrop container positioning for TV layout 2026-02-11 20:15:17 +01:00
6 changed files with 611 additions and 376 deletions

View File

@@ -34,8 +34,10 @@ namespace Jellyfin.Plugin.MediaBarEnhanced.Configuration
public bool EnableCustomMediaIds { get; set; } = true; public bool EnableCustomMediaIds { get; set; } = true;
public string PreferredVideoQuality { get; set; } = "Auto"; public string PreferredVideoQuality { get; set; } = "Auto";
public bool EnableSeasonalContent { get; set; } = false; public bool EnableSeasonalContent { get; set; } = false;
public string SeasonalSections { get; set; } = "[]";
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
public bool EnableClientSideSettings { get; set; } = false; public bool EnableClientSideSettings { get; set; } = false;
public bool ApplyLimitsToCustomIds { get; set; } = false;
public string SortBy { get; set; } = "Random"; public string SortBy { get; set; } = "Random";
public string SortOrder { get; set; } = "Ascending"; public string SortOrder { get; set; } = "Ascending";
} }

View File

@@ -94,6 +94,7 @@
<!-- CUSTOM CONTENT TAB --> <!-- CUSTOM CONTENT TAB -->
<div id="custom" class="tab-content" style="display:none;"> <div id="custom" class="tab-content" style="display:none;">
<!-- Default Custom Media IDs -->
<h2 class="sectionTitle">Custom Media IDs</h2> <h2 class="sectionTitle">Custom Media IDs</h2>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label> <label>
@@ -101,61 +102,74 @@
name="EnableCustomMediaIds" /> name="EnableCustomMediaIds" />
<span>Enable Custom Media IDs</span> <span>Enable Custom Media IDs</span>
</label> </label>
<div class="fieldDescription">If enabled, the slideshow will try to show the items listed <div class="fieldDescription">If enabled, the slideshow will show the items listed
below. If the list is empty, default behavior (random items) is used. Disable this below as the default content. If the list is empty, random items from your library are used.</div>
to temporarily ignore your custom list without deleting it.</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="ApplyLimitsToCustomIds"
name="ApplyLimitsToCustomIds" />
<span>Apply Limits to Custom IDs</span>
</label>
<div class="fieldDescription">If enabled, the Max Items limit (Advanced &rarr; Content Limits) will also apply to Custom Media IDs and Collections. By default, custom lists are not limited.</div>
</div>
<div id="customMediaIdsContainer">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CustomMediaIds">Default Media/Collection/Playlist
IDs
(Newline or Comma separated)</label>
<textarea class="emby-textarea" is="emby-textarea" id="CustomMediaIds" name="CustomMediaIds"
style="width: 100%; height: 150px; font-family: monospace;"></textarea>
<div class="fieldDescription">Enter the IDs of the items you want to show in the slideshow as
your default content.
You can separate them by new line or comma.
<br><br>
<b>Manual Trailer/Video Override:</b> You can specify a YouTube URL <b>OR</b> a Jellyfin Item ID (e.g. for a Theme Video) for an item by adding it in
brackets: <br> <code>e.g. ID DESCRIPTION [https://youtu.be/...]</code> or <code>ID [JellyfinItemId] DESCRIPTION</code>.
<br>
Methods:
<ul>
<li><b>YouTube URL:</b> Play a remote trailer from YouTube.</li>
<li><b>Jellyfin Item ID (GUID):</b> Play the video of another library item (e.g. a Theme Video or Backdrop Video) using the native player.</li>
</ul>
You can also add a description after the ID using any separator like space, pipe
(|) or dash (-): <br>e.g. <code>ID DESCRIPTION</code> or <code>ID | DESCRIPTION</code>
<br><br>
<b>Note:</b> If using a <b>Collection Name</b> (instead of an ID) combined with a description, you <b>MUST</b> use
the pipe (|) separator.
<br>
<b>Note:</b> The separator <b>MUST NOT</b> be a hex character (0-9, a-f).
</div>
<p>You can find the IDs of your items in the URL of the item page in the web interface.<br>
Example:
<code>https://your-jellyfin-url/web/#/details?id=<b style="color:red;">your-item-id</b>&serverId=your-server-id</code><br><br>
You can also insert a name of a collection or playlist to fetch the IDs of all items in
it (will take the first hit.<br><b>Note:</b> there is currently no feedback if the name
resolution succeeded, you will have to look if the bar displays the correct items).
</p>
</div>
</div>
<!-- Seasonal Content -->
<h2 class="sectionTitle" style="margin-top: 2em;">Seasonal Content</h2>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label> <label>
<input is="emby-checkbox" type="checkbox" id="EnableSeasonalContent" <input is="emby-checkbox" type="checkbox" id="EnableSeasonalContent"
name="EnableSeasonalContent" /> name="EnableSeasonalContent" />
<span>Enable Seasonal Content Mode</span> <span>Enable Seasonal Content</span>
</label> </label>
<div class="fieldDescription">Enable this to define time-based lists in the field below. <div class="fieldDescription">When enabled, seasonal sections below will override the default list
</div> during their active date ranges. If no season matches the current date, the default Custom Media IDs above are used as fallback.</div>
</div> </div>
<div class="inputContainer"> <div id="seasonalContentContainer" style="display: none;">
<label class="inputLabel inputLabelUnfocused" for="CustomMediaIds">Media/Collection/Playlist <div id="seasonalSectionsList"></div>
IDs <button is="emby-button" type="button" id="addSeasonBtn" class="raised emby-button"
(Newline or Comma separated)</label> style="margin-top: 1em; display: inline-flex; align-items: center; gap: 0.4em;">
<textarea is="emby-textarea" id="CustomMediaIds" name="CustomMediaIds" <i class="material-icons" style="font-size: 24px;">add</i>
style="width: 100%; height: 150px; font-family: monospace;"></textarea> <span>Add Season</span>
<div class="fieldDescription" id="customMediaIdsDesc">Enter the IDs of the items you want to show in the slideshow. </button>
You can separate them by new line or comma.
<br><br>
<b>Manual Trailer/Video Override:</b> You can specify a YouTube URL <b>OR</b> a Jellyfin Item ID (e.g. for a Theme Video) for an item by adding it in
brackets: <br> <code>e.g. ID DESCRIPTION [https://youtu.be/...]</code> or <code>ID [Method] DESCRIPTION</code>.
<br>
Methods:
<ul>
<li><b>YouTube URL:</b> Play a remote trailer from YouTube.</li>
<li><b>Jellyfin Item ID (GUID):</b> Play the video of another library item (e.g. a Theme Video or Backdrop Video) using the native player.</li>
</ul>
<br>
You can also add a description after the ID using any separator like space, pipe
(|) or dash (-): <br>e.g. <code>ID DESCRIPTION</code> or <code>ID | DESCRIPTION</code>
<br><br>
<b>Note:</b> If using a <b>Collection Name</b> (instead of an ID) combined with a description, you <b>MUST</b> use
the pipe (|) separator.
<br>
<b>Note:</b> The separator <b>MUST NOT</b> be a hex character (0-9, a-f).</div>
<div class="fieldDescription" id="seasonalMediaIdsDesc" style="display: none;">
<b>Seasonal Mode Enabled:</b> Define lines with date ranges (Format: DD.MM-DD.MM |
<i>name</i> | <i>IDs</i>).<br>
Example:<br>
<code>20.10-31.10 | Halloween | ID1, ID2 [https://youtu.be/...]</code><br>
<code>01.12-26.12 | Christmas | ID3, ID4</code><br>
<i>Only lines matching the current date will be used. If no line matches, it will try to
fetch the list.txt or use random items.</i>
</div>
<p>You can find the IDs of your items in the URL of the item page in the web interface.<br>
Example:
<code>https://your-jellyfin-url/web/#/details?id=<b style="color:red;">your-item-id</b>&serverId=your-server-id</code><br><br>
You can also insert a name of a collection or playlist to fetch the IDs of all items in
it (will take the first hit.<br><b>Note:</b> there is currently no feedback if the name
resolution succeeded, you will have to look if the bar displays the correct items.).
</p>
</div> </div>
<input type="hidden" id="SeasonalSections" name="SeasonalSections" value="[]" />
</div> </div>
<!-- ADVANCED TAB --> <!-- ADVANCED TAB -->
@@ -188,7 +202,7 @@
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="PreferredVideoQuality">Preferred YouTube Quality</label> <label class="selectLabel" for="PreferredVideoQuality">Preferred YouTube Quality</label>
<select is="emby-select" id="PreferredVideoQuality" name="PreferredVideoQuality" class="selectLayout emby-select-withcolor emby-select"> <select is="emby-select" id="PreferredVideoQuality" name="PreferredVideoQuality" class="selectLayout emby-select-withcolor emby-select" style="width: 100%; -webkit-appearance: menulist; appearance: menulist;">
<option value="Auto">Auto (Smart)</option> <option value="Auto">Auto (Smart)</option>
<option value="Maximum">Maximum (4K+)</option> <option value="Maximum">Maximum (4K+)</option>
<option value="1080p">1080p</option> <option value="1080p">1080p</option>
@@ -290,7 +304,7 @@
<h2 class="sectionTitle">Content Sorting</h2> <h2 class="sectionTitle">Content Sorting</h2>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="SortBy">Sort By</label> <label class="selectLabel" for="SortBy">Sort By</label>
<select is="emby-select" id="SortBy" name="SortBy" class="selectLayout emby-select-withcolor emby-select"> <select is="emby-select" id="SortBy" name="SortBy" class="selectLayout emby-select-withcolor emby-select" style="width: 100%; -webkit-appearance: menulist; appearance: menulist;">
<option value="Random">Random</option> <option value="Random">Random</option>
<option value="Original">Original (Custom List Order)</option> <option value="Original">Original (Custom List Order)</option>
<option value="PremiereDate">Premiere Date</option> <option value="PremiereDate">Premiere Date</option>
@@ -304,7 +318,7 @@
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="SortOrder">Sort Order</label> <label class="selectLabel" for="SortOrder">Sort Order</label>
<select is="emby-select" id="SortOrder" name="SortOrder" class="selectLayout emby-select-withcolor emby-select"> <select is="emby-select" id="SortOrder" name="SortOrder" class="selectLayout emby-select-withcolor emby-select" style="width: 100%; -webkit-appearance: menulist; appearance: menulist;">
<option value="Ascending">Ascending</option> <option value="Ascending">Ascending</option>
<option value="Descending">Descending</option> <option value="Descending">Descending</option>
</select> </select>
@@ -411,7 +425,7 @@
'ShowTrailerButton', 'AlwaysShowArrows', 'EnableKeyboardControls', 'ShowTrailerButton', 'AlwaysShowArrows', 'EnableKeyboardControls',
'EnableCustomMediaIds', 'CustomMediaIds', 'EnableLoadingScreen', 'EnableCustomMediaIds', 'CustomMediaIds', 'EnableLoadingScreen',
'EnableSeasonalContent', 'EnableClientSideSettings', 'SortBy', 'SortOrder', 'EnableSeasonalContent', 'EnableClientSideSettings', 'SortBy', 'SortOrder',
'PreferLocalTrailers' 'PreferLocalTrailers', 'ApplyLimitsToCustomIds', 'SeasonalSections'
]; ];
keys.forEach(function (key) { keys.forEach(function (key) {
@@ -425,24 +439,38 @@
} }
}); });
// Handle Seasonal UI logic // Render Seasonal Sections
var seasonalCheckbox = page.querySelector('#EnableSeasonalContent'); try {
var normalDesc = page.querySelector('#customMediaIdsDesc'); var sections = JSON.parse(config.SeasonalSections || "[]");
var seasonalDesc = page.querySelector('#seasonalMediaIdsDesc'); MediaBarEnhancedConfigurationPage.renderSeasonalSections(page, sections);
} catch (e) {
console.error("Error parsing SeasonalSections", e);
}
function updateDesc() { // Handle Seasonal UI visibility
if (seasonalCheckbox && seasonalCheckbox.checked) { var seasonalCheckbox = page.querySelector('#EnableSeasonalContent');
if (normalDesc) normalDesc.style.display = 'none'; var seasonalContainer = page.querySelector('#seasonalContentContainer');
if (seasonalDesc) seasonalDesc.style.display = 'block';
} else { function updateSeasonalVisibility() {
if (normalDesc) normalDesc.style.display = 'block'; if (seasonalContainer) {
if (seasonalDesc) seasonalDesc.style.display = 'none'; seasonalContainer.style.display = seasonalCheckbox && seasonalCheckbox.checked ? 'block' : 'none';
} }
} }
if (seasonalCheckbox) { if (seasonalCheckbox) {
seasonalCheckbox.addEventListener('change', updateDesc); seasonalCheckbox.addEventListener('change', updateSeasonalVisibility);
updateDesc(); updateSeasonalVisibility();
}
// Add Season Button
var addSeasonBtn = page.querySelector('#addSeasonBtn');
if (addSeasonBtn) {
// Remove existing listeners to avoid duplicates if re-attached
var newBtn = addSeasonBtn.cloneNode(true);
addSeasonBtn.parentNode.replaceChild(newBtn, addSeasonBtn);
newBtn.addEventListener('click', function() {
MediaBarEnhancedConfigurationPage.addSeasonalSection(page);
});
} }
// Handle Prefer Local Trailers visibility // Handle Prefer Local Trailers visibility
@@ -468,6 +496,11 @@
saveConfiguration: function (page) { saveConfiguration: function (page) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
var sections = MediaBarEnhancedConfigurationPage.getSeasonalSectionsFromUI(page);
var sectionsJson = JSON.stringify(sections);
var seasonalInput = page.querySelector('#SeasonalSections');
if (seasonalInput) seasonalInput.value = sectionsJson;
var config = {}; var config = {};
var keys = [ var keys = [
'IsEnabled', 'ShuffleInterval', 'RetryInterval', 'MinSwipeDistance', 'IsEnabled', 'ShuffleInterval', 'RetryInterval', 'MinSwipeDistance',
@@ -478,7 +511,7 @@
'ShowTrailerButton', 'AlwaysShowArrows', 'EnableKeyboardControls', 'ShowTrailerButton', 'AlwaysShowArrows', 'EnableKeyboardControls',
'EnableCustomMediaIds', 'CustomMediaIds', 'EnableLoadingScreen', 'EnableCustomMediaIds', 'CustomMediaIds', 'EnableLoadingScreen',
'EnableSeasonalContent', 'EnableClientSideSettings', 'SortBy', 'SortOrder', 'EnableSeasonalContent', 'EnableClientSideSettings', 'SortBy', 'SortOrder',
'PreferLocalTrailers' 'PreferLocalTrailers', 'ApplyLimitsToCustomIds', 'SeasonalSections'
]; ];
keys.forEach(function (key) { keys.forEach(function (key) {
@@ -495,6 +528,105 @@
ApiClient.updatePluginConfiguration(MediaBarEnhancedConfigurationPage.pluginId, config).then(function (result) { ApiClient.updatePluginConfiguration(MediaBarEnhancedConfigurationPage.pluginId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
},
renderSeasonalSections: function(page, sections) {
var container = page.querySelector('#seasonalSectionsList');
if (!container) return;
container.innerHTML = '';
sections.forEach(function(section, index) {
MediaBarEnhancedConfigurationPage.createSectionElement(container, section, index + 1);
});
},
addSeasonalSection: function(page) {
var container = page.querySelector('#seasonalSectionsList');
if (!container) return;
var index = container.children.length + 1;
MediaBarEnhancedConfigurationPage.createSectionElement(container, {
Name: 'New Season',
StartDay: 1, StartMonth: 1,
EndDay: 1, EndMonth: 1,
MediaIds: ''
}, index);
},
createSectionElement: function(container, data, index) {
var div = document.createElement('div');
div.className = 'seasonal-section';
div.style.cssText = 'background: rgba(0,0,0,0.2); padding: 1em; margin-bottom: 1em; border-radius: 4px; border: 1px solid rgba(255,255,255,0.1);';
var days = [];
for(var i=1; i<=31; i++) days.push(i);
var months = [
{v:1, n:'Jan'}, {v:2, n:'Feb'}, {v:3, n:'Mar'}, {v:4, n:'Apr'},
{v:5, n:'May'}, {v:6, n:'Jun'}, {v:7, n:'Jul'}, {v:8, n:'Aug'},
{v:9, n:'Sep'}, {v:10, n:'Oct'}, {v:11, n:'Nov'}, {v:12, n:'Dec'}
];
function mkSelect(val, opts, cls) {
var h = '<select class="emby-select emby-select-withcolor ' + cls + '" style="width: auto; display: inline-block; margin-right: 5px; -webkit-appearance: menulist; appearance: menulist;">';
opts.forEach(function(o) {
var v = o.v || o;
var n = o.n || o;
h += '<option value="'+v+'" ' + (v == val ? 'selected' : '') + '>' + n + '</option>';
});
h += '</select>';
return h;
}
var labelText = 'Season list #' + (index || 1);
div.innerHTML =
'<div class="inputContainer" style="margin-bottom: 0.5em;">' +
' <label class="inputLabel" style="font-size: 1.2em; font-weight: bold; margin-bottom:0.5em; display:block;">' + labelText + '</label>' +
' <div style="display: flex; align-items: center;">' +
' <div style="flex-grow:1;">' +
' <input is="emby-input" type="text" class="emby-input section-name" value="' + (data.Name || '') + '" />' +
' </div>' +
' <button type="button" class="raised emby-button remove-section" style="background: #a94442; min-width: unset; margin-left: 1em;">Remove</button>' +
' </div>' +
' <div class="fieldDescription">Name of the season</div>' +
'</div>' +
'<div class="inputContainer" style="margin-bottom: 1em;">' +
' <label class="inputLabel" style="margin-bottom:0.5em; display:block;">Active Period</label>' +
' <div style="display: flex; align-items: center; flex-wrap: wrap; gap: 0.5em;">' +
' <span>From:</span>' +
mkSelect(data.StartDay, days, 'start-day') +
mkSelect(data.StartMonth, months, 'start-month') +
' <span style="margin-left: 1em;">To:</span>' +
mkSelect(data.EndDay, days, 'end-day') +
mkSelect(data.EndMonth, months, 'end-month') +
' </div>' +
' <div class="fieldDescription">Date range (inclusive) when this content is active.</div>' +
'</div>' +
'<div class="inputContainer">' +
' <label class="inputLabel" style="margin-bottom:0.5em; display:block;">Media IDs</label>' +
' <textarea is="emby-textarea" class="emby-textarea section-ids" style="width: 100%; height: 80px; font-family: monospace;">' + (data.MediaIds || '') + '</textarea>' +
' <div class="fieldDescription">Comma-separated list of Movie/Series/Collection IDs to show during this season.</div>' +
'</div>';
div.querySelector('.remove-section').addEventListener('click', function() {
div.remove();
});
container.appendChild(div);
},
getSeasonalSectionsFromUI: function(page) {
var sections = [];
var els = page.querySelectorAll('.seasonal-section');
els.forEach(function(el) {
sections.push({
Name: el.querySelector('.section-name').value,
StartDay: parseInt(el.querySelector('.start-day').value),
StartMonth: parseInt(el.querySelector('.start-month').value),
EndDay: parseInt(el.querySelector('.end-day').value),
EndMonth: parseInt(el.querySelector('.end-month').value),
MediaIds: el.querySelector('.section-ids').value
});
});
return sections;
} }
}; };

View File

@@ -12,7 +12,7 @@
<!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> --> <!-- <TreatWarningsAsErrors>false</TreatWarningsAsErrors> -->
<Title>Jellyfin Media Bar Enhanced Plugin</Title> <Title>Jellyfin Media Bar Enhanced Plugin</Title>
<Authors>CodeDevMLH</Authors> <Authors>CodeDevMLH</Authors>
<Version>1.6.1.2</Version> <Version>1.6.1.17</Version>
<RepositoryUrl>https://github.com/CodeDevMLH/jellyfin-plugin-media-bar-enhanced</RepositoryUrl> <RepositoryUrl>https://github.com/CodeDevMLH/jellyfin-plugin-media-bar-enhanced</RepositoryUrl>
</PropertyGroup> </PropertyGroup>

View File

@@ -175,6 +175,7 @@
overflow: hidden; overflow: hidden;
margin: 0 auto; margin: 0 auto;
pointer-events: auto; pointer-events: auto;
outline: none;
} }
#slides-container[style*="display: none"], #slides-container[style*="display: none"],
@@ -996,13 +997,6 @@
margin: 0; margin: 0;
} }
.layout-tv .video-backdrop { .layout-tv .backdrop-container{
mask-image: linear-gradient(to top, top: -5%;
#fff0 2%,
rgb(0 0 0 / 0.5) 6%,
#000000 8%);
-webkit-mask-image: linear-gradient(to top,
#fff0 2%,
rgb(0 0 0 / 0.5) 6%,
#000000 8%);
} }

View File

@@ -57,6 +57,8 @@ const CONFIG = {
enableClientSideSettings: false, enableClientSideSettings: false,
sortBy: "Random", sortBy: "Random",
sortOrder: "Ascending", sortOrder: "Ascending",
applyLimitsToCustomIds: false,
seasonalSections: "[]",
}; };
// State management // State management
@@ -611,7 +613,8 @@ const SlideUtils = {
if (!container) { if (!container) {
container = this.createElement("div", { container = this.createElement("div", {
id: "slides-container", id: "slides-container",
className: "noautofocus" className: "noautofocus",
tabIndex: "-1"
}); });
document.body.appendChild(container); document.body.appendChild(container);
} }
@@ -1316,7 +1319,7 @@ const ApiUtils = {
async fetchCollectionItems(collectionId) { async fetchCollectionItems(collectionId) {
try { try {
const response = await fetch( const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items?ParentId=${collectionId}&Recursive=true&IncludeItemTypes=Movie,Series&fields=Id&userId=${STATE.jellyfinData.userId}`, `${STATE.jellyfinData.serverAddress}/Items?ParentId=${collectionId}&Recursive=true&IncludeItemTypes=Movie,Series&fields=Id,Type&userId=${STATE.jellyfinData.userId}`,
{ {
headers: this.getAuthHeaders(), headers: this.getAuthHeaders(),
} }
@@ -1330,7 +1333,7 @@ const ApiUtils = {
const data = await response.json(); const data = await response.json();
const items = data.Items || []; const items = data.Items || [];
console.log(`Resolved collection ${collectionId} to ${items.length} items`); console.log(`Resolved collection ${collectionId} to ${items.length} items`);
return items.map(i => i.Id); return items.map(i => ({ Id: i.Id, Type: i.Type }));
} catch (error) { } catch (error) {
console.error(`Error fetching collection items for ${collectionId}:`, error); console.error(`Error fetching collection items for ${collectionId}:`, error);
return []; return [];
@@ -1432,16 +1435,19 @@ const VisibilityObserver = {
// If a full screen video player is active, hide slideshow and stop playback // If a full screen video player is active, hide slideshow and stop playback
if ((videoPlayer && !videoPlayer.classList.contains('hide')) || (trailerPlayer && !trailerPlayer.classList.contains('hide'))) { if ((videoPlayer && !videoPlayer.classList.contains('hide')) || (trailerPlayer && !trailerPlayer.classList.contains('hide'))) {
const container = document.getElementById("slides-container"); if (this._lastVisibleState !== 'player-active') {
if (container) { this._lastVisibleState = 'player-active';
container.style.display = "none"; const container = document.getElementById("slides-container");
container.style.visibility = "hidden"; if (container) {
container.style.pointerEvents = "none"; container.style.display = "none";
container.style.visibility = "hidden";
container.style.pointerEvents = "none";
}
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
} }
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
return; return;
} }
@@ -1456,20 +1462,27 @@ const VisibilityObserver = {
activeTab && activeTab &&
activeTab.getAttribute("data-index") === "0"; activeTab.getAttribute("data-index") === "0";
container.style.display = isVisible ? "block" : "none"; const newState = isVisible ? 'visible' : 'hidden';
container.style.visibility = isVisible ? "visible" : "hidden";
container.style.pointerEvents = isVisible ? "auto" : "none"; // Only update DOM and trigger actions when state actually changes
if (this._lastVisibleState !== newState) {
this._lastVisibleState = newState;
container.style.display = isVisible ? "block" : "none";
container.style.visibility = isVisible ? "visible" : "hidden";
container.style.pointerEvents = isVisible ? "auto" : "none";
if (isVisible) { if (isVisible) {
if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) {
STATE.slideshow.slideInterval.start(); STATE.slideshow.slideInterval.start();
SlideshowManager.resumeActivePlayback(); SlideshowManager.resumeActivePlayback();
}
} else {
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
} }
} else {
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
} }
}, },
@@ -1477,7 +1490,13 @@ const VisibilityObserver = {
* Initializes visibility observer * Initializes visibility observer
*/ */
init() { init() {
// MARK: Mark
const observer = new MutationObserver(() => this.updateVisibility()); const observer = new MutationObserver(() => this.updateVisibility());
// let debounceTimer = null;
// const observer = new MutationObserver(() => {
// if (debounceTimer) clearTimeout(debounceTimer);
// debounceTimer = setTimeout(() => this.updateVisibility(), 250);
// });
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
document.body.addEventListener("click", () => this.updateVisibility()); document.body.addEventListener("click", () => this.updateVisibility());
@@ -1696,6 +1715,13 @@ const SlideCreator = {
playerVars: playerVars, playerVars: playerVars,
events: { events: {
'onReady': (event) => { 'onReady': (event) => {
// Prevent iframe from stealing focus (critical for TV mode)
const iframe = event.target.getIframe();
if (iframe) {
iframe.setAttribute('tabindex', '-1');
iframe.setAttribute('inert', '');
}
// Store start/end time and videoId for later use // Store start/end time and videoId for later use
event.target._startTime = playerVars.start || 0; event.target._startTime = playerVars.start || 0;
event.target._endTime = playerVars.end || undefined; event.target._endTime = playerVars.end || undefined;
@@ -1717,14 +1743,15 @@ const SlideCreator = {
const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer');
if (slide && slide.classList.contains('active') && !document.hidden && (!isVideoPlayerOpen || isVideoPlayerOpen.classList.contains('hide'))) { if (slide && slide.classList.contains('active') && !document.hidden && (!isVideoPlayerOpen || isVideoPlayerOpen.classList.contains('hide'))) {
const currentIndex = STATE.slideshow.currentSlideIndex; // MARK: mark
const currentItemId = STATE.slideshow.itemIds[currentIndex]; // const currentIndex = STATE.slideshow.currentSlideIndex;
if (currentItemId !== itemId) { // const currentItemId = STATE.slideshow.itemIds[currentIndex];
console.log(`Slide ${itemId} is no longer active (current: ${currentItemId}), aborting playback.`); // if (currentItemId !== itemId) {
event.target.mute(); // Mute just in case // console.log(`Slide ${itemId} is no longer active (current: ${currentItemId}), aborting playback.`);
return; // event.target.mute(); // Mute just in case
} // return;
event.target.playVideo(); // }
event.target.playVideo();
// Check if it actually started playing after a short delay (handling autoplay blocks) // Check if it actually started playing after a short delay (handling autoplay blocks)
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -1757,14 +1784,11 @@ const SlideCreator = {
}, },
'onStateChange': (event) => { 'onStateChange': (event) => {
if (event.data === YT.PlayerState.ENDED) { if (event.data === YT.PlayerState.ENDED) {
if (CONFIG.waitForTrailerToEnd) { SlideshowManager.nextSlide();
SlideshowManager.nextSlide();
} else {
event.target.playVideo(); // Loop if not waiting for end if trailer is shorter than slide duration
}
} }
}, },
'onError': () => { 'onError': (event) => {
console.warn(`YouTube player error ${event.data} for video ${videoId}`);
// Fallback to next slide on error // Fallback to next slide on error
if (CONFIG.waitForTrailerToEnd) { if (CONFIG.waitForTrailerToEnd) {
SlideshowManager.nextSlide(); SlideshowManager.nextSlide();
@@ -1785,6 +1809,7 @@ const SlideCreator = {
autoplay: false, autoplay: false,
preload: "auto", preload: "auto",
loop: false, loop: false,
disablePictureInPicture: true,
style: "object-fit: cover; object-position: center center; width: 100%; height: 100%; position: absolute; top: 0; left: 0; pointer-events: none;" style: "object-fit: cover; object-position: center center; width: 100%; height: 100%; position: absolute; top: 0; left: 0; pointer-events: none;"
}; };
@@ -1802,10 +1827,13 @@ const SlideCreator = {
backdrop.addEventListener('play', (event) => { backdrop.addEventListener('play', (event) => {
const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`);
const currentIndex = STATE.slideshow.currentSlideIndex; // MARK: mark
const currentItemId = STATE.slideshow.itemIds[currentIndex]; // const currentIndex = STATE.slideshow.currentSlideIndex;
// const currentItemId = STATE.slideshow.itemIds[currentIndex];
if (!slide || !slide.classList.contains('active') || currentItemId !== itemId) { // if (!slide || !slide.classList.contains('active') || currentItemId !== itemId) {
if (!slide || !slide.classList.contains('active')) {
console.log(`Local video ${itemId} started playing but is not active, pausing.`); console.log(`Local video ${itemId} started playing but is not active, pausing.`);
event.target.pause(); event.target.pause();
event.target.currentTime = 0; event.target.currentTime = 0;
@@ -1818,9 +1846,7 @@ const SlideCreator = {
}); });
backdrop.addEventListener('ended', () => { backdrop.addEventListener('ended', () => {
if (CONFIG.waitForTrailerToEnd) { SlideshowManager.nextSlide();
SlideshowManager.nextSlide();
}
}); });
backdrop.addEventListener('error', () => { backdrop.addEventListener('error', () => {
@@ -2292,110 +2318,28 @@ const SlideshowManager = {
} }
currentSlide.classList.add("active"); currentSlide.classList.add("active");
if (focusSelector) { // Restore focus for TV mode navigation continuity
requestAnimationFrame(() => {
if (focusSelector) {
const target = currentSlide.querySelector(focusSelector); const target = currentSlide.querySelector(focusSelector);
if (target) { if (target) {
requestAnimationFrame(() => { target.focus();
target.focus(); return;
});
} }
} }
// Always ensure container has focus in TV mode to keep keyboard navigation working
// Manage Video Playback: Stop others, Play current const isTvMode = (window.layoutManager && window.layoutManager.tv) ||
document.documentElement.classList.contains('layout-tv') ||
// 1. Pause all other YouTube players document.body.classList.contains('layout-tv');
if (STATE.slideshow.videoPlayers) { if (isTvMode) {
Object.keys(STATE.slideshow.videoPlayers).forEach(id => { container.focus({ preventScroll: true });
if (id !== currentItemId) {
const p = STATE.slideshow.videoPlayers[id];
if (p) {
try {
if (typeof p.pauseVideo === 'function') {
p.pauseVideo();
} else if (p.tagName === 'VIDEO') {
p.pause();
}
} catch (e) { console.warn("Error pausing player", id, e); }
}
}
});
}
// 2. Pause all other HTML5 videos e.g. local trailers
document.querySelectorAll('video').forEach(video => {
const slideParent = video.closest('.slide');
if (slideParent && slideParent.dataset.itemId !== currentItemId) {
try {
video.pause();
} catch (e) {}
} }
}); });
// 3. Play and Reset current video // Manage Video Playback: Stop others, Play current
const videoBackdrop = currentSlide.querySelector('.video-backdrop'); this.pauseOtherVideos(currentItemId);
this.playCurrentVideo(currentSlide, currentItemId);
// Update mute button visibility
const muteButton = document.querySelector('.mute-button');
if (muteButton) {
const hasVideo = !!videoBackdrop;
muteButton.style.display = hasVideo ? 'block' : 'none';
}
if (videoBackdrop) {
if (videoBackdrop.tagName === 'VIDEO') {
videoBackdrop.currentTime = 0;
videoBackdrop.muted = STATE.slideshow.isMuted;
if (!STATE.slideshow.isMuted) {
videoBackdrop.volume = 0.4;
}
videoBackdrop.play().catch(e => {
// Check if it actually started playing after a short delay (handling autoplay blocks)
setTimeout(() => {
if (videoBackdrop.paused) {
console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`);
videoBackdrop.muted = true;
videoBackdrop.play().catch(err => console.error("Muted fallback failed", err));
}
}, 1000);
});
} else if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) {
const player = STATE.slideshow.videoPlayers[currentItemId];
if (player && typeof player.loadVideoById === 'function' && player._videoId) {
// Use loadVideoById to enforce start and end times
player.loadVideoById({
videoId: player._videoId,
startSeconds: player._startTime || 0,
endSeconds: player._endTime
});
if (STATE.slideshow.isMuted) {
player.mute();
} else {
player.unMute();
player.setVolume(40);
}
// Check if playback successfully started, otherwise fallback to muted
setTimeout(() => {
if (player.getPlayerState &&
player.getPlayerState() !== YT.PlayerState.PLAYING &&
player.getPlayerState() !== YT.PlayerState.BUFFERING) {
console.log("YouTube loadVideoById didn't start playback, retrying muted...");
player.mute();
player.playVideo();
}
}, 1000);
} else if (player && typeof player.seekTo === 'function') {
// Fallback if loadVideoById is not available or videoId missing
const startTime = player._startTime || 0;
player.seekTo(startTime);
player.playVideo();
}
}
}
const enableAnimations = MediaBarEnhancedSettingsManager.getSetting('slideAnimations', CONFIG.slideAnimationEnabled); const enableAnimations = MediaBarEnhancedSettingsManager.getSetting('slideAnimations', CONFIG.slideAnimationEnabled);
@@ -2404,7 +2348,8 @@ const SlideshowManager = {
if (backdrop && !backdrop.classList.contains("video-backdrop")) { if (backdrop && !backdrop.classList.contains("video-backdrop")) {
backdrop.classList.add("animate"); backdrop.classList.add("animate");
} }
currentSlide.querySelector(".logo").classList.add("animate"); const logo = currentSlide.querySelector(".logo");
if (logo) logo.classList.add("animate");
} }
STATE.slideshow.currentSlideIndex = index; STATE.slideshow.currentSlideIndex = index;
@@ -2492,6 +2437,8 @@ const SlideshowManager = {
// Preload next slides // Preload next slides
for (let i = 1; i <= preloadCount; i++) { for (let i = 1; i <= preloadCount; i++) {
const nextIndex = (currentIndex + i) % totalItems; const nextIndex = (currentIndex + i) % totalItems;
if (nextIndex === currentIndex) break;
const itemId = STATE.slideshow.itemIds[nextIndex]; const itemId = STATE.slideshow.itemIds[nextIndex];
SlideCreator.createSlideForItemId(itemId); SlideCreator.createSlideForItemId(itemId);
} }
@@ -2499,6 +2446,8 @@ const SlideshowManager = {
// Preload previous slides // Preload previous slides
for (let i = 1; i <= preloadCount; i++) { for (let i = 1; i <= preloadCount; i++) {
const prevIndex = (currentIndex - i + totalItems) % totalItems; const prevIndex = (currentIndex - i + totalItems) % totalItems;
if (prevIndex === currentIndex) break;
const prevItemId = STATE.slideshow.itemIds[prevIndex]; const prevItemId = STATE.slideshow.itemIds[prevIndex];
SlideCreator.createSlideForItemId(prevItemId); SlideCreator.createSlideForItemId(prevItemId);
} }
@@ -2529,6 +2478,7 @@ const SlideshowManager = {
pruneSlideCache() { pruneSlideCache() {
const currentIndex = STATE.slideshow.currentSlideIndex; const currentIndex = STATE.slideshow.currentSlideIndex;
const keepRange = 5; const keepRange = 5;
let prunedAny = false;
Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => { Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => {
const index = STATE.slideshow.itemIds.indexOf(itemId); const index = STATE.slideshow.itemIds.indexOf(itemId);
@@ -2560,10 +2510,27 @@ const SlideshowManager = {
if (slide) slide.remove(); if (slide) slide.remove();
delete STATE.slideshow.createdSlides[itemId]; delete STATE.slideshow.createdSlides[itemId];
prunedAny = true;
console.log(`Pruned slide ${itemId} at distance ${distance} from view`); console.log(`Pruned slide ${itemId} at distance ${distance} from view`);
} }
}); });
// After pruning, restore focus to container in TV mode
if (prunedAny) {
const isTvMode = (window.layoutManager && window.layoutManager.tv) ||
document.documentElement.classList.contains('layout-tv') ||
document.body.classList.contains('layout-tv');
if (isTvMode) {
// Use setTimeout to execute AFTER Jellyfin's focus manager processes the iframe removal
setTimeout(() => {
const container = document.getElementById("slides-container");
if (container && container.style.display !== 'none') {
container.focus({ preventScroll: true });
}
}, 0);
}
}
}, },
toggleMute() { toggleMute() {
@@ -2682,6 +2649,116 @@ const SlideshowManager = {
} }
}, },
/**
* Pauses all video players except the one with the given item ID
* @param {string} excludeItemId - Item ID to exclude from pausing
*/
pauseOtherVideos(excludeItemId) {
// Pause YouTube players
if (STATE.slideshow.videoPlayers) {
Object.keys(STATE.slideshow.videoPlayers).forEach(id => {
if (id !== excludeItemId) {
const p = STATE.slideshow.videoPlayers[id];
if (p) {
try {
if (typeof p.pauseVideo === 'function') {
p.pauseVideo();
if (typeof p.mute === 'function') {
p.mute();
}
}
else if (p.tagName === 'VIDEO') {
p.pause();
p.muted = true;
}
} catch (e) { console.warn("Error pausing player", id, e); }
}
}
});
}
// Pause HTML5 videos
document.querySelectorAll('video').forEach(video => {
const slideParent = video.closest('.slide');
if (slideParent && slideParent.dataset.itemId !== excludeItemId) {
try {
video.pause();
video.muted = true;
} catch (e) {}
}
});
},
/**
* Plays the video backdrop on the given slide and updates mute button visibility
* @param {Element} slide - The slide DOM element
* @param {string} itemId - The item ID of the slide
* @returns {boolean} Whether a video was found and playback attempted
*/
playCurrentVideo(slide, itemId) {
const videoBackdrop = slide.querySelector('.video-backdrop');
// Update mute button visibility
const muteButton = document.querySelector('.mute-button');
if (muteButton) {
muteButton.style.display = videoBackdrop ? 'block' : 'none';
}
if (!videoBackdrop) return false;
if (videoBackdrop.tagName === 'VIDEO') {
videoBackdrop.currentTime = 0;
videoBackdrop.muted = STATE.slideshow.isMuted;
if (!STATE.slideshow.isMuted) videoBackdrop.volume = 0.4;
videoBackdrop.play().catch(() => {
setTimeout(() => {
if (videoBackdrop.paused) {
console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`);
videoBackdrop.muted = true;
videoBackdrop.play().catch(err => console.error("Muted fallback failed", err));
}
}, 1000);
});
return true;
}
// YouTube player
const player = STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[itemId];
if (player && typeof player.loadVideoById === 'function' && player._videoId) {
player.loadVideoById({
videoId: player._videoId,
startSeconds: player._startTime || 0,
endSeconds: player._endTime
});
if (STATE.slideshow.isMuted) {
player.mute();
} else {
player.unMute();
player.setVolume(40);
}
setTimeout(() => {
if (player.getPlayerState &&
player.getPlayerState() !== YT.PlayerState.PLAYING &&
player.getPlayerState() !== YT.PlayerState.BUFFERING) {
console.log("YouTube loadVideoById didn't start playback, retrying muted...");
player.mute();
player.playVideo();
}
}, 1000);
return true;
} else if (player && typeof player.seekTo === 'function') {
// Fallback if loadVideoById is not available or videoId missing but player object exists
const startTime = player._startTime || 0;
player.seekTo(startTime);
player.playVideo();
return true;
}
return false;
},
/** /**
* Stops all video playback (YouTube and HTML5) * Stops all video playback (YouTube and HTML5)
* Used when navigating away from the home screen * Used when navigating away from the home screen
@@ -2711,14 +2788,16 @@ const SlideshowManager = {
}); });
} }
// 2. Pause all HTML5 videos // 2. Stop and mute all HTML5 videos
const container = document.getElementById("slides-container"); const container = document.getElementById("slides-container");
if (container) { if (container) {
container.querySelectorAll('video').forEach(video => { container.querySelectorAll('video').forEach(video => {
try { try {
video.pause(); video.pause();
video.muted = true;
video.currentTime = 0;
} catch (e) { } catch (e) {
console.warn("Error pausing HTML5 video:", e); console.warn("Error stopping HTML5 video:", e);
} }
}); });
} }
@@ -2736,18 +2815,24 @@ const SlideshowManager = {
const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`); const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`);
if (!currentSlide) return; if (!currentSlide) return;
// 1. Try YouTube Player // YouTube player: just resume, don't reload
const ytPlayer = STATE.slideshow.videoPlayers[currentItemId]; const ytPlayer = STATE.slideshow.videoPlayers?.[currentItemId];
if (ytPlayer && typeof ytPlayer.playVideo === 'function') { if (ytPlayer && typeof ytPlayer.playVideo === 'function') {
if (STATE.slideshow.isMuted) {
if (typeof ytPlayer.mute === 'function') ytPlayer.mute();
} else {
if (typeof ytPlayer.unMute === 'function') ytPlayer.unMute();
if (typeof ytPlayer.setVolume === 'function') ytPlayer.setVolume(40);
}
ytPlayer.playVideo(); ytPlayer.playVideo();
return;
} }
// 2. Try HTML5 Video // HTML5 video: just resume, don't reset currentTime
const html5Video = currentSlide.querySelector('video'); const html5Video = currentSlide.querySelector('video.video-backdrop');
if (html5Video) { if (html5Video) {
if (STATE.slideshow.isMuted) { html5Video.muted = STATE.slideshow.isMuted;
html5Video.muted = true; if (!STATE.slideshow.isMuted) html5Video.volume = 0.4;
}
html5Video.play().catch(e => console.warn("Error resuming HTML5 video:", e)); html5Video.play().catch(e => console.warn("Error resuming HTML5 video:", e));
} }
}, },
@@ -2822,7 +2907,7 @@ const SlideshowManager = {
// Determine if we should handle navigation keys (Arrows, Space, M) // Determine if we should handle navigation keys (Arrows, Space, M)
// TV Mode: Strict focus required (must be on slideshow) // TV Mode: Strict focus required (must be on slideshow)
// Desktop Mode: Loose focus allowed (slideshow OR body/nothing focused) // Desktop Mode: Loose focus allowed (slideshow OR body/nothing focused)
const canControlSlideshow = isTvMode ? hasDirectFocus : (hasDirectFocus || isBodyFocused); let canControlSlideshow = isTvMode ? hasDirectFocus : (hasDirectFocus || isBodyFocused);
// Check for Input Fields (always ignore typing) // Check for Input Fields (always ignore typing)
const isInputElement = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable); const isInputElement = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable);
@@ -2895,22 +2980,96 @@ const SlideshowManager = {
}, },
/** /**
* Parses custom media IDs, handling seasonal content if enabled * Parses custom media IDs, handling seasonal content if enabled.
* If Seasonal Content is enabled:
* - Check if any defined season matches the current date.
* - If match: Return IDs from that season.
* - If NO match: Fall back to Default Custom IDs.
* If Custom Media IDs are enabled (and no seasonal match):
* - Return Default Custom IDs.
* If no Custom Media IDs are enabled:
* - Return empty array (triggering random fallback).
* @returns {string[]} Array of media IDs * @returns {string[]} Array of media IDs
*/ */
parseCustomIds() { parseCustomIds() {
if (!CONFIG.enableSeasonalContent) { let idsString = CONFIG.customMediaIds;
return CONFIG.customMediaIds let usingSeasonal = false;
.split(/[\n,]/).map((line) => {
if (CONFIG.enableSeasonalContent) {
try {
const sections = JSON.parse(CONFIG.seasonalSections || "[]");
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // 1-12
const currentDay = currentDate.getDate(); // 1-31
for (const section of sections) {
const startDay = parseInt(section.StartDay);
const startMonth = parseInt(section.StartMonth);
const endDay = parseInt(section.EndDay);
const endMonth = parseInt(section.EndMonth);
let isInRange = false;
if (startMonth === endMonth) {
if (currentMonth === startMonth && currentDay >= startDay && currentDay <= endDay) {
isInRange = true;
}
} else if (startMonth < endMonth) {
// Normal range
if (
(currentMonth > startMonth && currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
} else {
// Wrap around year
if (
(currentMonth > startMonth || currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
}
if (isInRange) {
console.log(`Seasonal match found: ${section.Name}`);
idsString = section.MediaIds;
usingSeasonal = true;
break; // Use first matching season
}
}
} catch (e) {
console.error("Error parsing seasonal sections in JS:", e);
}
}
// If NOT using seasonal content (disabled or no match),
// Custom IDs are disabled, return empty to skip to random
if (!usingSeasonal && !CONFIG.enableCustomMediaIds) {
return [];
}
// Parse the resulting string (either seasonal or default)
if (!idsString) return [];
return idsString
.split(/[\n,]/)
.map((line) => {
const urlMatch = line.match(/\[(.*?)\]/); const urlMatch = line.match(/\[(.*?)\]/);
let id = line; let id = line;
if (urlMatch) { if (urlMatch) {
const url = urlMatch[1]; const url = urlMatch[1];
// Remove the [url] part from the ID string for parsing
id = line.replace(/\[.*?\]/, '').trim(); id = line.replace(/\[.*?\]/, '').trim();
// Attempt to extract GUID if present
const guidMatch = id.match(/([0-9a-f]{32})/i); const guidMatch = id.match(/([0-9a-f]{32})/i);
if (guidMatch) { if (guidMatch) {
id = guidMatch[1]; id = guidMatch[1];
} else { } else {
// Fallback: split by pipe if used
id = id.split('|')[0].trim(); id = id.split('|')[0].trim();
} }
STATE.slideshow.customTrailerUrls[id] = url; STATE.slideshow.customTrailerUrls[id] = url;
@@ -2919,83 +3078,6 @@ const SlideshowManager = {
}) })
.map((id) => id.trim()) .map((id) => id.trim())
.filter((id) => id); .filter((id) => id);
} else {
return this.parseSeasonalIds();
}
},
/**
* Parses custom media IDs, handling seasonal content if enabled
* @returns {string[]} Array of media IDs
*/
parseSeasonalIds() {
console.log("Using Seasonal Content Mode");
const lines = CONFIG.customMediaIds.split('\n');
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // 1-12
const currentDay = currentDate.getDate(); // 1-31
const rawIds = [];
for (const line of lines) {
const match = line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|.*\|(.*)$/) ||
line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|(.*)$/);
if (match) {
const startDay = parseInt(match[1]);
const startMonth = parseInt(match[2]);
const endDay = parseInt(match[3]);
const endMonth = parseInt(match[4]);
const idsPart = match[5];
let isInRange = false;
if (startMonth === endMonth) {
if (currentMonth === startMonth && currentDay >= startDay && currentDay <= endDay) {
isInRange = true;
}
} else if (startMonth < endMonth) {
// Normal range spanning months (e.g. 15.06 - 15.08)
if (
(currentMonth > startMonth && currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
} else {
// Wrap around year (e.g. 01.12 - 15.01)
if (
(currentMonth > startMonth || currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
}
if (isInRange) {
console.log(`Seasonal match found: ${line}`);
const ids = idsPart.split(/[,]/).map(line => {
const urlMatch = line.match(/\[(.*?)\]/);
let id = line;
if (urlMatch) {
const url = urlMatch[1];
id = line.replace(/\[.*?\]/, '').trim();
const guidMatch = id.match(/([0-9a-f]{32})/i);
if (guidMatch) {
id = guidMatch[1];
} else {
id = id.split('|')[0].trim();
}
STATE.slideshow.customTrailerUrls[id] = url;
}
return id.trim();
}).filter(id => id);
rawIds.push(...ids);
}
}
}
return rawIds;
}, },
/** /**
@@ -3037,10 +3119,10 @@ const SlideshowManager = {
const children = await ApiUtils.fetchCollectionItems(id); const children = await ApiUtils.fetchCollectionItems(id);
finalIds.push(...children); finalIds.push(...children);
} else if (item) { } else if (item) {
finalIds.push(id); finalIds.push({ Id: item.Id, Type: item.Type });
} }
} catch (e) { } catch (e) {
console.warn(`Error resolving item ${id}:`, e); console.warn(`Error resolving item ${rawId}:`, e);
} }
} }
return finalIds; return finalIds;
@@ -3055,10 +3137,41 @@ const SlideshowManager = {
let itemIds = []; let itemIds = [];
// 1. Try Custom Media/Collection IDs from Config & seasonal content // 1. Try Custom Media/Collection IDs from Config & seasonal content
if (CONFIG.enableCustomMediaIds && CONFIG.customMediaIds) { if (CONFIG.enableCustomMediaIds || CONFIG.enableSeasonalContent) {
console.log("Using Custom Media IDs from configuration"); console.log("Using Custom Media IDs from configuration");
const rawIds = this.parseCustomIds(); const rawIds = this.parseCustomIds();
itemIds = await this.resolveCollectionsAndItems(rawIds); const resolvedItems = await this.resolveCollectionsAndItems(rawIds);
// Apply max items limit to custom IDs if enabled
if (CONFIG.applyLimitsToCustomIds) {
let movieCount = 0;
let showCount = 0;
let keptItems = [];
for (const item of resolvedItems) {
if (keptItems.length >= CONFIG.maxItems) break;
if (item.Type === 'Movie') {
if (movieCount < CONFIG.maxMovies) {
movieCount++;
keptItems.push(item);
}
} else if (item.Type === 'Series' || item.Type === 'Season' || item.Type === 'Episode') {
// Count Seasons/Episodes as TV Shows
if (showCount < CONFIG.maxTvShows) {
showCount++;
keptItems.push(item);
}
} else {
// Other types: count towards total only
keptItems.push(item);
}
}
itemIds = keptItems.map(i => i.Id);
console.log(`Applied limits to custom IDs: ${itemIds.length} items (Movies: ${movieCount}, Shows: ${showCount})`);
} else {
itemIds = resolvedItems.map(i => i.Id);
}
} }
// 2. Try Avatar List (list.txt) // 2. Try Avatar List (list.txt)
@@ -3438,75 +3551,69 @@ const MediaBarEnhancedSettingsManager = {
* Initialize page visibility handling to pause when tab is inactive * Initialize page visibility handling to pause when tab is inactive
*/ */
const initPageVisibilityHandler = () => { const initPageVisibilityHandler = () => {
let wasVideoPlayingBeforeHide = false;
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer:not(.hide)') ||
document.querySelector('.youtubePlayerContainer:not(.hide)');
if (document.hidden) { if (document.hidden) {
console.log("Tab inactive - pausing slideshow and videos"); // Stop slide timer
wasVideoPlayingBeforeHide = STATE.slideshow.isVideoPlaying;
if (STATE.slideshow.slideInterval) { if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop(); STATE.slideshow.slideInterval.stop();
} }
// Pause active video if playing if (isVideoPlayerOpen) {
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; // Jellyfin video is playing --> full stop to free all resources
if (currentItemId) { console.log("Tab inactive and Jellyfin player active - stopping all playback");
// YouTube SlideshowManager.stopAllPlayback();
if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { } else {
const player = STATE.slideshow.videoPlayers[currentItemId]; // Simple tab switch: stop all others, pause only the current
if (typeof player.pauseVideo === "function") { console.log("Tab inactive. Pausing current video, stopping others");
try { const currentItemId = STATE.slideshow.itemIds?.[STATE.slideshow.currentSlideIndex];
player.pauseVideo();
STATE.slideshow.isVideoPlaying = false; // Stop all players except the current one
} catch (e) { if (STATE.slideshow.videoPlayers) {
console.warn("Error pausing video on tab hide:", e); Object.keys(STATE.slideshow.videoPlayers).forEach(id => {
if (id === currentItemId) return;
const p = STATE.slideshow.videoPlayers[id];
if (p) {
try {
if (typeof p.stopVideo === 'function') {
p.stopVideo();
if (typeof p.clearVideo === 'function') p.clearVideo();
} else if (p.tagName === 'VIDEO') {
p.pause();
p.muted = true;
p.currentTime = 0;
}
} catch (e) { console.warn("Error stopping background player", id, e); }
} }
} else if (player.tagName === 'VIDEO') { // HTML5 Video });
player.pause(); }
STATE.slideshow.isVideoPlaying = false;
// Pause only the current video
if (currentItemId) {
const player = STATE.slideshow.videoPlayers?.[currentItemId];
if (player) {
try {
if (typeof player.pauseVideo === 'function') {
player.pauseVideo();
} else if (player.tagName === 'VIDEO') {
player.pause();
}
} catch (e) { console.warn("Error pausing video on tab hide:", e); }
} }
} }
} }
} else { } else {
console.log("Tab active - resuming slideshow"); console.log("Tab active. Resuming slideshow");
if (!STATE.slideshow.isPaused) { const isOnHome = window.location.hash === "#/home.html" || window.location.hash === "#/home";
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (isOnHome && !STATE.slideshow.isPaused && !isVideoPlayerOpen) {
if (wasVideoPlayingBeforeHide && currentItemId && STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { SlideshowManager.resumeActivePlayback();
const player = STATE.slideshow.videoPlayers[currentItemId];
if (STATE.slideshow.slideInterval && !CONFIG.waitForTrailerToEnd) {
// YouTube STATE.slideshow.slideInterval.start();
if (typeof player.playVideo === "function") {
try {
player.playVideo();
STATE.slideshow.isVideoPlaying = true;
} catch (e) {
console.warn("Error resuming video on tab show:", e);
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.start();
}
}
} else if (player.tagName === 'VIDEO') { // HTML5 Video
try {
player.play().catch(e => console.warn("Error resuming HTML5 video:", e));
STATE.slideshow.isVideoPlaying = true;
} catch(e) { console.warn(e); }
}
} else {
// No video was playing, just restart interval
const activeSlide = document.querySelector('.slide.active');
const hasVideo = activeSlide && activeSlide.querySelector('.video-backdrop');
if (CONFIG.waitForTrailerToEnd && hasVideo) {
// Don't restart interval if waiting for trailer
} else {
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.start();
}
}
} }
wasVideoPlayingBeforeHide = false;
} }
} }
}); });

View File

@@ -9,12 +9,12 @@
"imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/raw/branch/main/logo.png", "imageUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/raw/branch/main/logo.png",
"versions": [ "versions": [
{ {
"version": "1.6.1.2", "version": "1.6.1.17",
"changelog": "- fix tv mode issue", "changelog": "- fix tv mode issue\n- refactor video playback management",
"targetAbi": "10.11.0.0", "targetAbi": "10.11.0.0",
"sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.6.1.2/Jellyfin.Plugin.MediaBarEnhanced.zip", "sourceUrl": "https://git.mahom03-spacecloud.de/CodeDevMLH/jellyfin-plugin-media-bar-enhanced/releases/download/v1.6.1.17/Jellyfin.Plugin.MediaBarEnhanced.zip",
"checksum": "186c08c80091c24270fda41f0908e951", "checksum": "4f0f1b0bfcb073ae72c6a5ba3b991e65",
"timestamp": "2026-02-11T19:13:24Z" "timestamp": "2026-02-13T13:49:22Z"
}, },
{ {
"version": "1.6.0.2", "version": "1.6.0.2",