New feature: Skin Manager, also minor fixes and improvements

This commit is contained in:
HighImKevin committed 2025-10-29 01:45:44 -04:00
1 parent 0ffc967d8d
commit 629f524b4c
24 files changed
+6166 -4013

No files matched your search

+40 -94
View File
@@ -11,7 +11,6 @@
const CONFIG = {
supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum'],
minWidth: 768, // Only show on screens wider than 768px
headerHeight: 80 // Position below .skinHeader
};
// State management
@@ -59,15 +58,26 @@
elements: [],
item: item
};
// Don't append .html for server versions 10.11 and above
const urlSuffix = ApiClient._serverVersion.split('.')[1] > 10 ? '' : '.html'
let queryParams = '';
if (item.ParentId) {
queryParams = `?topParentId=${item.ParentId}&serverId=${ApiClient.serverId()}`;
}
else if (ApiClient.serverId()) {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
if (item.Type === 'Movie') {
structure.elements = [
{ text: 'Movies', url: '#/movies.html', clickable: true },
{ text: 'Movies', url: `#/movies${urlSuffix}${queryParams}`, clickable: true },
{ text: item.Name, url: null, clickable: false }
];
} else if (item.Type === 'Series') {
structure.elements = [
{ text: 'Shows', url: '#/tv.html', clickable: true },
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true },
{ text: item.Name, url: null, clickable: false },
{ text: 'All Seasons', url: null, clickable: true, popover: true }
];
@@ -77,8 +87,8 @@
const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
structure.elements = [
{ text: 'Shows', url: '#/tv.html', clickable: true },
{ text: seriesName, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true },
{ text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }
];
} else if (item.Type === 'Episode') {
@@ -91,21 +101,21 @@
const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`;
structure.elements = [
{ text: 'Shows', url: '#/tv.html', clickable: true },
{ text: seriesName, url: `#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true },
{ text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: seasonName, url: null, clickable: true, popover: true },
{ text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false }
];
} else if (item.Type === 'MusicArtist') {
structure.elements = [
{ text: 'Music', url: '#/music.html', clickable: true },
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true },
{ text: item.Name, url: null, clickable: false },
{ text: 'All Albums', url: null, clickable: true, popover: true }
];
} else if (item.Type === 'MusicAlbum') {
structure.elements = [
{ text: 'Music', url: '#/music.html', clickable: true },
{ text: item.AlbumArtist, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true },
{ text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: item.Name, url: null, clickable: true, popover: true }
];
}
@@ -233,7 +243,7 @@
if (elementDef.url) {
// Direct link
element.addEventListener('click', () => {
window.location.hash = elementDef.url;
window.location.href = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
@@ -262,7 +272,7 @@
if (elementDef.url) {
// Direct link
newElement.addEventListener('click', () => {
window.location.hash = elementDef.url;
window.location.href = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
@@ -281,7 +291,7 @@
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, null, (selectedSeason) => {
window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
@@ -291,7 +301,7 @@
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(albums, null, (selectedAlbum) => {
window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
@@ -304,7 +314,7 @@
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, item, (selectedSeason) => {
window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
@@ -314,14 +324,14 @@
const seasons = await getSeasonsIfNeeded(item.SeriesId);
if (seasons.length === 1) {
element.addEventListener('click', () => {
window.location.hash = `#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
const currentSeason = seasons.find(season => season.Id === item.ParentId);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, currentSeason, (selectedSeason) => {
window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
@@ -331,13 +341,13 @@
const albums = await getAlbumsIfNeeded(item.ParentId);
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.hash = `#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(albums, item, (selectedAlbum) => {
window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
@@ -354,52 +364,6 @@
console.error(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
}
function debugBreadcrumbLinks() {
if (!breadcrumbContainer) return;
const children = Array.from(breadcrumbContainer.children);
log('Breadcrumb structure:');
children.forEach((child, index) => {
if (child.style.cursor === 'pointer') {
log(` Element ${index}: "${child.textContent}" (clickable)`);
} else {
log(` Element ${index}: "${child.textContent}" (static)`);
}
});
}
function ensureSeriesNameIsClickable(seriesId) {
if (!breadcrumbContainer) return;
const children = Array.from(breadcrumbContainer.children);
// Find the series name element (should be the 3rd element: Shows / Series Name / ...)
if (children.length >= 3) {
const seriesElement = children[2]; // Shows (0) / Series Name (2) / ...
// Check if it's already clickable
if (seriesElement.style.cursor !== 'pointer') {
log('Making series name clickable:', seriesElement.textContent);
// Make it clickable
seriesElement.style.cursor = 'pointer';
// Add click handler
seriesElement.onclick = () => {
window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`;
};
log('Series name is now clickable and links to series page:', seriesId);
} else {
// Update existing click handler to ensure it points to the series page
seriesElement.onclick = () => {
window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`;
};
log('Updated series name click handler to point to series page:', seriesId);
}
}
}
function isWideScreen() {
return window.innerWidth > CONFIG.minWidth;
}
@@ -491,12 +455,7 @@
}
closePopover();
}
function closePopover() {
if (activePopover) {
activePopover.remove();
@@ -643,22 +602,6 @@
}
}
// Breadcrumb generation
function createBreadcrumbElement(text, url = null, isClickable = true) {
const element = document.createElement('span');
element.className = 'kefinTweaks-breadcrumb-element';
element.textContent = text;
if (url && isClickable) {
element.style.cursor = 'pointer';
element.addEventListener('click', () => {
window.location.hash = url;
});
}
return element;
}
function createSeparator() {
const separator = document.createElement('span');
separator.className = 'kefinTweaks-breadcrumb-separator';
@@ -747,23 +690,26 @@
breadcrumbContainer = createBreadcrumbContainer();
}
// Clear existing content
clearBreadcrumbs();
// Add all elements
// Build all elements first
const elements = [];
for (let i = 0; i < targetStructure.elements.length; i++) {
const elementDef = targetStructure.elements[i];
const element = await createBreadcrumbElementFromStructure(elementDef, targetStructure.item);
breadcrumbContainer.appendChild(element);
elements.push(element);
// Add separator after each element except the last
if (i < targetStructure.elements.length - 1) {
const separator = createSeparator();
breadcrumbContainer.appendChild(separator);
elements.push(separator);
}
}
// Replace the innerHTML entirely to avoid any duplicate appending issues
breadcrumbContainer.innerHTML = '';
// Append all elements at once
elements.forEach(el => breadcrumbContainer.appendChild(el));
log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
}
+119 -298
View File
@@ -10,10 +10,12 @@
/**
* Main entry point function to build a Jellyfin card
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed card element
*/
buildCard: function(item) {
return createJellyfinCardElement(item);
buildCard: function(item, overflowCard = false, cardFormat = null) {
return createJellyfinCardElement(item, overflowCard, cardFormat);
},
/**
@@ -21,11 +23,12 @@
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
* @param {number} sectionIndex - Index for debugging
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
renderCards: function(items, title, viewMoreUrl = null, sectionIndex = 0) {
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
},
/**
@@ -33,15 +36,16 @@
* @param {Array} itemIds - Array of Jellyfin item IDs
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {number} sectionIndex - Index for debugging
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {Promise<HTMLElement>} - The constructed scrollable container
*/
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, sectionIndex = 0) {
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
try {
@@ -51,10 +55,10 @@
);
LOG(`Fetched ${items.length} items for section: ${title}`);
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
} catch (error) {
console.error('[CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
}
};
@@ -62,27 +66,49 @@
/**
* Creates a Jellyfin card element from an item
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed card element
*/
function createJellyfinCardElement(item) {
function createJellyfinCardElement(item, overflowCard = false, cardFormat = null) {
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
// Determine card type based on item type
// Determine card type based on cardFormat override or item type
let cardClass, padderClass, imageParams;
if (item.Type === 'Episode' || item.Type === 'TvChannel') {
cardClass = 'overflowBackdropCard';
padderClass = 'cardPadder-overflowBackdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
cardClass = 'overflowSquareCard';
padderClass = 'cardPadder-overflowSquare';
imageParams = 'fillHeight=297&fillWidth=297';
if (cardFormat) {
// Use specified cardFormat
if (cardFormat === 'backdrop' || cardFormat === 'thumb') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (cardFormat === 'square') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// portrait (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
} else {
// Default poster style for Movies, Series, etc.
cardClass = 'overflowPortraitCard';
padderClass = 'cardPadder-overflowPortrait';
imageParams = 'fillHeight=446&fillWidth=297';
// Use item type to determine card type
if (item.Type === 'Episode' || item.Type === 'TvChannel') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// Default poster style for Movies, Series, etc.
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
}
// Create the main card container
@@ -140,13 +166,49 @@
// Card image container
const cardImageContainer = document.createElement('a');
cardImageContainer.href = `#/details?id=${item.Id}&serverId=${serverId}`;
cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast';
cardImageContainer.setAttribute('data-action', 'link');
cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
// Set background image or icon
if (item.ImageTags?.Primary) {
// Force specific image if card format is specified
if (cardFormat === 'backdrop') {
let imageUrl = '';
if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (cardFormat === 'square') {
if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
const imageUrl = item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
const imageUrl = item.PrimaryImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
} else if (cardFormat === 'portrait') {
if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) {
const imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
const imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
} else if (cardFormat === 'thumb') {
let imageUrl = '';
if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ImageTags?.Primary) {
let imageUrl;
if (item.IsJellyseerr) {
// Jellyseerr items use external image URLs
@@ -208,7 +270,7 @@
// Overlay link
const overlayLink = document.createElement('a');
overlayLink.href = `#/details?id=${item.Id}&serverId=${serverId}`;
overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
overlayLink.className = 'cardImageContainer';
// Play button
@@ -275,7 +337,7 @@
if (item.Type === 'Episode') {
// Episodes: Series name as primary, episode title as secondary
const seriesLink = document.createElement('a');
seriesLink.href = `#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`;
seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
seriesLink.setAttribute('data-id', item.SeriesId || item.Id);
seriesLink.setAttribute('data-serverid', serverId);
@@ -295,7 +357,7 @@
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
@@ -313,7 +375,7 @@
} else {
// Default: Item name as primary, year as secondary
const titleLink = document.createElement('a');
titleLink.href = `#/details?id=${item.Id}&serverId=${serverId}`;
titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
titleLink.className = 'itemAction textActionButton';
titleLink.setAttribute('data-id', item.Id);
titleLink.setAttribute('data-serverid', serverId);
@@ -352,7 +414,7 @@
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
@@ -387,10 +449,11 @@
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {number} sectionIndex - Index for debugging
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
function createScrollableContainer(items, title, viewMoreUrl = null, sectionIndex = 0) {
function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
// Create the main vertical section container
@@ -398,8 +461,8 @@
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Create section title
const sectionTitle = document.createElement('h2');
sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
if (viewMoreUrl) {
// Create clickable title with chevron icon
@@ -417,7 +480,8 @@
titleLink.href = viewMoreUrl;
}
const titleText = document.createElement('span');
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
const chevronIcon = document.createElement('span');
@@ -426,57 +490,23 @@
titleLink.appendChild(titleText);
titleLink.appendChild(chevronIcon);
sectionTitle.appendChild(titleLink);
sectionTitleContainer.appendChild(titleLink);
} else {
// Regular non-clickable title
sectionTitle.textContent = title;
// Regular non-clickable title
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
sectionTitleContainer.appendChild(titleText);
}
// Create "Show All" button (will be added after items are created)
const showAllButton = document.createElement('button');
showAllButton.type = 'button';
showAllButton.className = 'show-all-button';
showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer;';
showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer; color: var(--main-text, #fff) !important; margin-bottom: .35em; align-self: center;';
showAllButton.textContent = 'Show All';
showAllButton.title = 'Show all items in a grid layout';
// Create scroll buttons container
const scrollButtons = document.createElement('div');
//scrollButtons.setAttribute('is', 'emby-scrollbuttons');
scrollButtons.className = 'emby-scrollbuttons custom-scrollbuttons padded-right';
// Previous button
const prevButton = document.createElement('button');
prevButton.type = 'button';
prevButton.setAttribute('is', 'paper-icon-button-light');
prevButton.setAttribute('data-ripple', 'false');
prevButton.setAttribute('data-direction', 'left');
prevButton.title = 'Previous';
prevButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
prevButton.disabled = true;
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons chevron_left';
prevIcon.setAttribute('aria-hidden', 'true');
prevButton.appendChild(prevIcon);
// Next button
const nextButton = document.createElement('button');
nextButton.type = 'button';
nextButton.setAttribute('is', 'paper-icon-button-light');
nextButton.setAttribute('data-ripple', 'false');
nextButton.setAttribute('data-direction', 'right');
nextButton.title = 'Next';
nextButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
const nextIcon = document.createElement('span');
nextIcon.className = 'material-icons chevron_right';
nextIcon.setAttribute('aria-hidden', 'true');
nextButton.appendChild(nextIcon);
scrollButtons.appendChild(prevButton);
scrollButtons.appendChild(nextButton);
// Create scroller container
const scroller = document.createElement('div');
scroller.setAttribute('is', 'emby-scroller');
@@ -491,13 +521,10 @@
itemsContainer.setAttribute('is', 'emby-itemscontainer');
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
itemsContainer.style.whiteSpace = 'nowrap';
itemsContainer.style.willChange = 'transform';
itemsContainer.style.transition = 'transform 270ms ease-out';
itemsContainer.style.transform = 'translateX(0px)';
// Add items to container
items.forEach((item, index) => {
const card = createJellyfinCardElement(item);
const card = createJellyfinCardElement(item, overflowCard, cardFormat);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
@@ -506,7 +533,8 @@
// Add "Show All" button to title if there are more than 20 items
if (items.length > 20) {
sectionTitle.appendChild(showAllButton);
sectionTitleContainer.style.display = 'flex';
sectionTitleContainer.appendChild(showAllButton);
}
// Toggle between scroll and grid view
@@ -514,241 +542,34 @@
const originalItemsContainerStyle = itemsContainer.style.cssText;
showAllButton.addEventListener('click', () => {
// Find the scroll buttons for this container
const scrollerContainer = showAllButton.closest('.emby-scroller-container');
const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null;
if (isShowingAll) {
// Switch back to scroll view
itemsContainer.style.cssText = originalItemsContainerStyle;
scrollButtons.style.display = '';
if (scrollButtons) scrollButtons.style.display = '';
showAllButton.textContent = 'Show All';
showAllButton.title = 'Show all items in a grid layout';
isShowingAll = false;
} else {
// Switch to grid view
itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;';
scrollButtons.style.display = 'none';
if (scrollButtons) scrollButtons.style.display = 'none';
showAllButton.textContent = 'Show Less';
showAllButton.title = 'Show items in scrollable layout';
isShowingAll = true;
}
});
// Scroll functionality - calculate dynamically when needed
let scrollPosition = 0;
// Calculate scroll values dynamically based on current DOM state
const calculateScrollValues = () => {
const firstCard = itemsContainer.querySelector('.card');
if (!firstCard) {
LOG(`[SCROLL ERROR] No cards found in scroller ${sectionIndex}`);
return { cardWidth: 212, visibleCards: 1, scrollStep: 1, maxScroll: 0 };
}
const cardRect = firstCard.getBoundingClientRect();
const computedStyle = window.getComputedStyle(firstCard);
const marginRight = parseFloat(computedStyle.marginRight) || 0;
const cardWidth = cardRect.width + marginRight; // Card width + gap
const visibleCards = Math.floor(scroller.offsetWidth / cardWidth);
const scrollStep = Math.max(1, visibleCards); // Scroll by the number of fully visible cards
const maxScroll = Math.max(0, items.length - visibleCards);
LOG(`[SCROLL CALC] Scroller ${sectionIndex}: containerWidth=${scroller.offsetWidth}, cardWidth=${cardWidth}, visibleCards=${visibleCards}, scrollStep=${scrollStep}, totalItems=${items.length}, maxScroll=${maxScroll}`);
return { cardWidth, visibleCards, scrollStep, maxScroll };
};
const updateScrollButtons = () => {
const { maxScroll } = calculateScrollValues();
prevButton.disabled = scrollPosition <= 0;
nextButton.disabled = scrollPosition >= maxScroll;
};
const scrollTo = (position, smooth = true) => {
const { cardWidth, maxScroll } = calculateScrollValues();
scrollPosition = Math.max(0, Math.min(position, maxScroll));
if (smooth) {
itemsContainer.style.transition = 'transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
} else {
itemsContainer.style.transition = 'none';
}
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
updateScrollButtons();
if (smooth) {
setTimeout(() => {
itemsContainer.style.transition = 'transform 270ms ease-out';
}, 500);
}
};
// Check if buttons already have event listeners (from Jellyfin's default handlers)
const hasExistingListeners = () => {
// Check if the buttons have any event listeners attached
// We'll look for the presence of Jellyfin's default scroll behavior
const existingButtons = document.querySelectorAll('.emby-scrollbuttons-button.paper-icon-button-light');
return existingButtons.length > 0 && existingButtons[0].onclick !== null;
};
// Native horizontal scrolling with momentum and snap
let touchStartX = 0;
let touchStartY = 0;
let touchCurrentX = 0;
let isDragging = false;
let startScrollPosition = 0;
let currentScrollOffset = 0;
let lastTouchTime = 0;
let velocity = 0;
let lastTouchX = 0;
let animationFrame = null;
let momentumAnimation = null;
const handleTouchStart = (e) => {
if (e.touches.length !== 1) return;
// Cancel any ongoing momentum animation
if (momentumAnimation) {
cancelAnimationFrame(momentumAnimation);
momentumAnimation = null;
}
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
lastTouchX = touchStartX;
lastTouchTime = Date.now();
isDragging = true;
startScrollPosition = scrollPosition;
currentScrollOffset = 0;
velocity = 0;
// Disable smooth transitions during drag
itemsContainer.style.transition = 'none';
// Don't prevent default here - let touch move determine if we should handle it
};
const handleTouchMove = (e) => {
if (!isDragging || e.touches.length !== 1) return;
touchCurrentX = e.touches[0].clientX;
const deltaX = touchStartX - touchCurrentX;
const deltaY = touchStartY - e.touches[0].clientY;
// Only process horizontal swipes
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
e.preventDefault(); // Prevent default scrolling behavior
// Calculate velocity for momentum
const currentTime = Date.now();
const timeDelta = currentTime - lastTouchTime;
if (timeDelta > 0) {
velocity = (lastTouchX - touchCurrentX) / timeDelta;
}
lastTouchX = touchCurrentX;
lastTouchTime = currentTime;
// Update scroll position in real-time
const { cardWidth } = calculateScrollValues();
currentScrollOffset = deltaX / cardWidth;
const newPosition = startScrollPosition + currentScrollOffset;
// Apply the transform immediately
itemsContainer.style.transform = `translateX(-${newPosition * cardWidth}px)`;
}
};
const handleTouchEnd = (e) => {
if (!isDragging) return;
isDragging = false;
// Calculate final position with momentum
const { cardWidth, maxScroll } = calculateScrollValues();
let finalPosition = startScrollPosition + currentScrollOffset;
// Apply momentum if velocity is significant
if (Math.abs(velocity) > 0.1) {
const momentumDistance = velocity * 200; // Adjust multiplier for momentum strength
finalPosition += momentumDistance / cardWidth;
}
// Clamp to bounds
finalPosition = Math.max(0, Math.min(finalPosition, maxScroll));
// Snap to nearest card
const snapPosition = Math.round(finalPosition);
finalPosition = Math.max(0, Math.min(snapPosition, maxScroll));
// Update scroll position and animate to final position
scrollPosition = finalPosition;
// Re-enable smooth transitions
itemsContainer.style.transition = 'transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
// Update button states
updateScrollButtons();
LOG(`[TOUCH END] Scroller ${sectionIndex}: velocity=${velocity.toFixed(3)}, finalPosition=${finalPosition}, snapPosition=${snapPosition}`);
// Reset velocity
velocity = 0;
currentScrollOffset = 0;
};
// Add touch event listeners to the scroller
scroller.addEventListener('touchstart', handleTouchStart, { passive: false });
scroller.addEventListener('touchmove', handleTouchMove, { passive: false });
scroller.addEventListener('touchend', handleTouchEnd, { passive: false });
scroller.addEventListener('touchcancel', handleTouchEnd, { passive: false });
// Only attach our event listeners if there are no existing ones
if (true) {
prevButton.addEventListener('click', () => {
const { scrollStep, maxScroll } = calculateScrollValues();
const newPosition = Math.max(0, scrollPosition - scrollStep);
LOG(`[PREV CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
scrollTo(newPosition, true);
});
nextButton.addEventListener('click', () => {
const { scrollStep, maxScroll } = calculateScrollValues();
const newPosition = Math.min(maxScroll, scrollPosition + scrollStep);
LOG(`[NEXT CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
scrollTo(newPosition, true);
});
}
// Initial state
updateScrollButtons();
// Assemble the section
verticalSection.appendChild(sectionTitle);
verticalSection.appendChild(scrollButtons);
verticalSection.appendChild(sectionTitleContainer);
verticalSection.appendChild(scroller);
return verticalSection;
}
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
const style = document.createElement('style');
style.textContent = `
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
/* Ensure smooth scrolling on mobile */
@media (max-width: 768px) {
.emby-scroller.custom-scroller {
scroll-behavior: smooth !important;
}
.custom-scroller-container .emby-scrollbuttons {
display: none;
}
}
`;
document.head.appendChild(style);
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
+2 -1
View File
@@ -20,7 +20,8 @@
console.log("[DashboardButtonFix]: No history to go back. Redirecting to home page.");
// Changes this to the page you'd like to return to by default
Dashboard.navigate('/home.html');
const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html';
Dashboard.navigate(`/${homeUrl}`);
}
}, true);
}
+26 -78
View File
@@ -10,9 +10,9 @@
const WARN = (...args) => console.warn('[HeaderTabs]', ...args);
const ERR = (...args) => console.error('[HeaderTabs]', ...args);
LOG('Script loaded - JS Injector mode');
LOG('Initializing...');
const SUPPORTED_PAGES = ['home.html', 'tv.html', 'movies.html', 'music.html', 'livetv.html'];
const SUPPORTED_PAGES = ['home', 'home.html', 'tv', 'tv.html', 'movies', 'movies.html', 'music', 'music.html', 'livetv', 'livetv.html'];
// Get current page from URL hash
function getCurrentPage() {
@@ -87,10 +87,12 @@
newHash = `${currentHash}`;
}
}
const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
// Manually notify handlers for Header Tab navigation because it doesn't trigger normal Jellyfin navigation
window.KefinTweaksUtils.notifyHandlers(currentPage, document);
// Replace history entry instead of adding new one
const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
window.history.replaceState(null, '', newUrl);
LOG('Updated URL with tab:', tabIndex, newUrl);
}
@@ -139,7 +141,7 @@
// Patch for media bar because it usually reacts slowly and hides later
// Hide media bar slideshow if we aren't specifically on the home page first tab
const currentPage = getCurrentPage();
if (currentPage !== 'home.html' || currentTabFromUrl !== 0) {
if (currentPage !== 'home' && currentPage !== 'home.html' || currentTabFromUrl !== 0) {
const mediaBarSlideshow = document.getElementById('slides-container');
if (mediaBarSlideshow) {
mediaBarSlideshow.style.display = 'none';
@@ -149,6 +151,23 @@
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
const activeButton = headerTabs.querySelector('.emby-tab-button-active');
const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active');
const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null;
// Remove is-active class from the active tab content if it's not the current tab
if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl) {
activeTabContent.classList.remove('is-active');
}
// Add is-active class to the current tab content if it's not already active
if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl) {
const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`);
if (targetContent) {
targetContent.classList.add('is-active');
LOG('Set active tab content:', currentTabFromUrl);
}
}
if (activeButton && activeButton.getAttribute('data-index') === currentTabFromUrl.toString()) {
return;
}
@@ -168,23 +187,6 @@
correctButton.classList.add('emby-tab-button-active');
LOG('Set active tab button to index:', currentTabFromUrl);
}
const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active');
const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null;
// Remove is-active class from the active tab content if it's not the current tab
if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl) {
activeTabContent.classList.remove('is-active');
}
// Add is-active class to the current tab content if it's not already active
if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl) {
const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`);
if (targetContent) {
targetContent.classList.add('is-active');
LOG('Set active tab content:', currentTabFromUrl);
}
}
}
// Add click listeners to tab buttons
@@ -203,6 +205,7 @@
}
}
// TODO - Replace the MutationObservers with a less taxing alternative
// Setup observer to watch for emby-tab-button additions
function setupHeaderTabsObserver() {
const headerTabs = document.querySelector('.headerTabs');
@@ -331,63 +334,8 @@
// Check on hash changes
window.addEventListener('hashchange', checkPageChange);
// Fallback: check URL periodically but less frequently
/* urlCheckInterval = setInterval(() => {
if (window.location.href !== lastUrl) {
checkPageChange();
}
}, 500); */
// Check periodically in case we miss something
//setInterval(checkPageChange, 1000);
}
// Setup home navigation click handler with retry logic
/* function setupHomeNavClickHandler() {
const homeNavOption = document.querySelector('.mainDrawer .navMenuOption[href="#/home.html"]');
if (homeNavOption) {
homeNavOption.addEventListener('click', function(event) {
const headerTabs = document.querySelector('.headerTabs');
if (headerTabs) {
const firstTab = headerTabs.querySelector('.emby-tab-button[data-index="0"]');
if (firstTab) {
LOG('Triggering click on first homepage tab');
firstTab.click();
} else {
WARN('First homepage tab not found');
}
} else {
WARN('Header tabs not found');
}
});
LOG('Added click listener to home navigation option');
return true; // Success
} else {
WARN('Home navigation option not found, will retry...');
return false; // Failed, needs retry
}
}
// Retry setup until successful
function retryHomeNavSetup() {
const maxRetries = 50; // Try for up to 25 seconds (50 * 500ms)
let retryCount = 0;
const retryInterval = setInterval(() => {
if (setupHomeNavClickHandler()) {
clearInterval(retryInterval);
LOG('Home navigation click handler successfully attached');
} else {
retryCount++;
if (retryCount >= maxRetries) {
clearInterval(retryInterval);
ERR('Failed to attach home navigation click handler after maximum retries');
}
}
}, 500);
} */
// Register onViewPage handler to sync active tab state
if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) {
window.KefinTweaksUtils.onViewPage((view, element) => {
@@ -405,7 +353,7 @@
// Initialize
setupPageMonitor();
//retryHomeNavSetup();
setupHeaderTabsObserver();
LOG('Header tabs functionality initialized');
+17
View File
@@ -6,12 +6,20 @@
display: flex;
flex-direction: column;
}
.sections.homeSectionsContainer>div {
order: 100;
}
.sections.homeSectionsContainer>div:has(a.homeLibraryButton),
.sections.homeSectionsContainer>div:has([data-type="CollectionFolder"]) {
order: 1;
}
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
order: 2;
}
.sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) {
order: 3;
}
@@ -27,4 +35,13 @@
background-image: url('/files/KefinTweaks/pages/images/halloween.jpg');
background-position: 0px -250px;
background-size: auto 102%;
}
.home-screen h2.sectionTitle {
margin-bottom: .35em;
margin-top: 0;
}
.sectionTitleContainer.sectionTitleContainer-cards.padded-left > .sectionTitle.sectionTitle-cards {
display: flex !important;
}
+2783 -3103
View File
File diff suppressed because it is too large. Load diff
+25 -1
View File
@@ -7,8 +7,10 @@
const WARN = (...args) => console.warn('[InfiniteScroll]', ...args);
const ERR = (...args) => console.error('[InfiniteScroll]', ...args);
LOG('Initializing...');
// Supported pages (same as headerTabs)
const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html'];
const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html', 'tv', 'movies', 'music'];
// Media type configurations - Read from centralized config or use defaults
const MEDIA_CONFIGS = {
@@ -22,6 +24,26 @@
context: 'tvshows',
sortBy: 'SortName,ProductionYear'
},
'music.html': {
includeItemTypes: 'Music',
context: 'music',
sortBy: 'SortName,ProductionYear'
},
'movies': {
includeItemTypes: 'Movie',
context: 'movies',
sortBy: 'SortName,ProductionYear'
},
'tv': {
includeItemTypes: 'Series',
context: 'tvshows',
sortBy: 'SortName,ProductionYear'
},
'music': {
includeItemTypes: 'Music',
context: 'music',
sortBy: 'SortName,ProductionYear'
}
};
let userId = null;
@@ -813,4 +835,6 @@
// Cleanup on page unload
window.addEventListener('beforeunload', cleanup);
LOG('Initialized successfully');
})();
+107 -36
View File
@@ -4,9 +4,12 @@
(function() {
'use strict';
console.log('[KefinTweaks Injector] Initializing...');
// Configuration: Read from centralized config or use defaults
const ENABLED_SCRIPTS = window.KefinTweaksConfig?.scripts || {
// Configuration: Start with defaults, then merge user config
// This allows new scripts to work out of the box without requiring config updates
const ENABLED_SCRIPTS = {
// UI and functional enhancements
watchlist: true, // Watchlist functionality
homeScreen: true, // Custom home screen sections
@@ -20,10 +23,14 @@
infiniteScroll: true, // Infinite scroll functionality
removeContinue: true, // Remove from continue watching functionality
subtitleSearch: true, // Subtitle search functionality
playlist: true // Playlist view page modifications
playlist: true, // Playlist view page modifications
skinManager: true, // Skin selection and management
// Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
// are automatically enabled when needed by other scripts
// Merge user config on top of defaults (allows users to override defaults)
...window.KefinTweaksConfig?.scripts
};
// Script definitions with dependencies and metadata
@@ -35,6 +42,20 @@
dependencies: [],
description: 'Common utilities for page view management and MutationObserver conversion'
},
{
name: 'userConfig',
script: 'userConfig.js',
css: null,
dependencies: [],
description: 'User-specific configuration for skins and other settings'
},
{
name: 'skinManager',
script: 'skinManager.js',
css: null,
dependencies: ['utils', 'userConfig'],
description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading'
},
{
name: 'cardBuilder',
script: 'cardBuilder.js',
@@ -153,7 +174,7 @@
css: null,
dependencies: ['cardBuilder', 'utils'],
description: 'Modifies playlist view page behavior to navigate to item details instead of playing, and adds play button to playlist items'
}
},
];
// Auto-enable dependencies for enabled scripts
@@ -243,7 +264,7 @@
function loadScript(filename) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
const existingScript = document.querySelector(`script[src*="${filename}"]`);
const existingScript = document.querySelector(`script[src="${getScriptRoot()}${filename}"]`);
if (existingScript) {
console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`);
resolve();
@@ -268,33 +289,55 @@
});
}
// Load a script and its dependencies
async function loadScriptWithDependencies(scriptDef) {
try {
// Load dependencies first - only if they are enabled
for (const depName of scriptDef.dependencies) {
const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName);
if (depScript && ENABLED_SCRIPTS[depName]) {
await loadScriptWithDependencies(depScript);
} else if (depScript && !ENABLED_SCRIPTS[depName]) {
console.warn(`[KefinTweaks Injector] Dependency '${depName}' is disabled, skipping for '${scriptDef.name}'`);
}
}
// Load CSS if specified
if (scriptDef.css) {
await loadCSS(scriptDef.css);
}
// Load the script
await loadScript(scriptDef.script);
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
} catch (error) {
console.error(`[KefinTweaks Injector] Error loading ${scriptDef.name}:`, error);
throw error;
// Recursively collect all dependencies for a given script
function collectAllDependencies(scriptDef, collected = new Set(), visited = new Set()) {
// Avoid infinite recursion
if (visited.has(scriptDef.name)) {
return collected;
}
visited.add(scriptDef.name);
// For each dependency
for (const depName of scriptDef.dependencies) {
// Only process enabled dependencies
if (!ENABLED_SCRIPTS[depName]) {
continue;
}
const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName);
if (!depScript) {
continue;
}
// Add to collected if we haven't already
if (!collected.has(depScript.name)) {
collected.add(depScript.name);
// Recursively collect this dependency's dependencies
collectAllDependencies(depScript, collected, visited);
}
}
return collected;
}
// Load a single script (assumes dependencies are already loaded)
async function loadScriptSync(scriptDef) {
// Check if already loaded
const isAlreadyLoaded = document.querySelector(`script[src*="${scriptDef.script}"]`);
if (isAlreadyLoaded) {
console.log(`[KefinTweaks Injector] Script already loaded: ${scriptDef.name}`);
return;
}
// Load CSS if specified
if (scriptDef.css) {
await loadCSS(scriptDef.css);
}
// Load the script
await loadScript(scriptDef.script);
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
}
// Main initialization function
@@ -307,17 +350,45 @@
return;
}
// Get enabled scripts in dependency order
// Get enabled scripts
const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]);
console.log(`[KefinTweaks Injector] Loading ${enabledScripts.length} scripts:`,
enabledScripts.map(s => s.name));
// Step 1: Collect all dependencies from all enabled scripts
const allDependencyNames = new Set();
for (const script of enabledScripts) {
const deps = collectAllDependencies(script);
deps.forEach(dep => allDependencyNames.add(dep));
}
// Load all enabled scripts
const loadPromises = enabledScripts.map(script => loadScriptWithDependencies(script));
// Step 2: Separate dependencies from non-dependencies
const dependencyScripts = SCRIPT_DEFINITIONS.filter(script =>
allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name]
);
const nonDependencyScripts = enabledScripts.filter(script =>
!allDependencyNames.has(script.name)
);
console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`,
dependencyScripts.map(s => s.name));
console.log(`[KefinTweaks Injector] Found ${nonDependencyScripts.length} non-dependency scripts:`,
nonDependencyScripts.map(s => s.name));
try {
// Step 3: Load all dependencies first
console.log('[KefinTweaks Injector] Loading dependencies first...');
console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name));
let dependencyLoadPromises = [];
dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script)));
await Promise.all(dependencyLoadPromises);
// Step 4: Load non-dependencies in parallel (their dependencies are already loaded)
let loadPromises = [];
console.log('[KefinTweaks Injector] Loading non-dependencies...');
loadPromises.push(...nonDependencyScripts.map(script => loadScriptSync(script)));
await Promise.all(loadPromises);
console.log('[KefinTweaks Injector] All scripts loaded successfully!');
// Dispatch custom event when all scripts are loaded
+71 -3
View File
@@ -68,19 +68,22 @@
breadcrumbs: true, // Breadcrumb navigation for Movies, Series, Seasons, Episodes, Music
// Branding and external integrations
updoot: false, // Integration with Jellyfin Updoot upvote functionality
updoot: false, // EXPERIMENTAL: Integration with Jellyfin Updoot upvote functionality - Needs more work
// REQUIRES: ExclusiveElsewhere requires JellyfinEnhanced plugin https://github.com/n00bcodr/Jellyfin-Enhanced
exclusiveElsewhere: false, // Custom branding when items aren't available on streaming services
// Performance and stability improvements
backdropLeakFix: true, // Fixes memory leaks from backdrop images when tab isn't focused
dashboardButtonFix: true, // Proper back button behavior on dashboard
infiniteScroll: true, // Smooth infinite scrolling for large media libraries
infiniteScroll: true, // Smooth infinite scrolling. Supports Movie and TV libraries. Music to be added in the future.
// Playback and media enhancements
removeContinue: true, // Add "Remove from Continue Watching" functionality
subtitleSearch: true, // Search and download subtitles directly from video OSD
playlist: true // Enhanced playlist view with better navigation
playlist: true, // Enhanced playlist view with better navigation
// UI customization
skinManager: true // Skin selection and management for display preferences
},
// ============================================================================
@@ -158,6 +161,71 @@
enableJellyseerr: false // Enable Jellyseerr integration for request functionality
},
// ============================================================================
// SKIN CONFIGURATION
// ============================================================================
// Configure additional skins available in the skin manager
// These will be merged with skins from userConfig.js
// Each skin can have a single URL (string) or multiple URLs (array) for complex themes
// Each skin can also have colorSchemes array for color variations specific to that skin
// Each skin should have an author field indicating who created the skin
skins: [
// Add additional skins here that will be available to all users
// These are example skins - replace with your own or remove if not needed
/* Example of adding more skins:
{
name: 'Custom Theme',
author: 'username',
url: 'https://cdn.jsdelivr.net/gh/username/custom-theme.css',
colorSchemes: []
},
{
name: 'Multi-file Theme',
author: 'username',
url: [
'https://cdn.jsdelivr.net/gh/username/theme-base.css',
'https://cdn.jsdelivr.net/gh/username/theme-extensions.css'
],
colorSchemes: [
{
name: 'Dark Blue',
url: 'https://cdn.jsdelivr.net/gh/username/theme-dark-blue.css'
},
{
name: 'Light Green',
url: 'https://cdn.jsdelivr.net/gh/username/theme-light-green.css'
}
]
},
*/
],
// Default skin to use when no skin is selected
// This overrides the default selection from userConfig.js
defaultSkin: null, // Set to skin name (e.g., 'Dark Theme') or null to use userConfig default
// ============================================================================
// THEME CONFIGURATION
// ============================================================================
// Configure additional themes available in the theme manager
// These will be merged with themes from userConfig.js
// Each theme should have a name and URL pointing to a CSS file
themes: [
// Add additional themes here that will be available to all users
// These are example themes - replace with your own or remove if not needed
/* Example of adding more themes:
{
name: 'Custom Dark Theme',
url: 'https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css'
},
{
name: 'Custom Light Theme',
url: 'https://cdn.jsdelivr.net/gh/username/custom-light-theme.css'
},
*/
],
// ============================================================================
// CUSTOM MENU LINKS CONFIGURATION
// ============================================================================
+96 -52
View File
@@ -11,7 +11,7 @@
const WARN = (...args) => console.warn('[Playlist]', ...args);
const ERR = (...args) => console.error('[Playlist]', ...args);
LOG('Script loaded - JS Injector mode');
LOG('Initializing...');
let currentPlaylistId = null;
@@ -25,9 +25,13 @@
return false;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
return false;
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return false;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
@@ -90,16 +94,24 @@
return;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return;
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
LOG(`Found ${playlistItems.length} playlist items`);
playlistItems.forEach((item, index) => {
if (item.dataset.customPlaylistButton === 'true') {
LOG(`Playlist item ${index} already has a custom button, skipping`);
return;
}
const playlistItemId = item.getAttribute('data-playlistitemid');
const serverId = ApiClient.serverId();
@@ -114,31 +126,38 @@
// Add new click handler to navigate to details page
item.addEventListener('click', (e) => {
// Check if the click target is within the listViewUserDataButtons container
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (buttonsContainer && buttonsContainer.contains(e.target)) {
LOG(`Click on button detected, not navigating for item: ${playlistItemId}`);
return; // Don't navigate when clicking on buttons
}
e.preventDefault();
e.stopPropagation();
const detailsUrl = `#/details?id=${playlistItemId}&serverId=${serverId}`;
LOG(`Navigating to: ${detailsUrl}`);
// Use Jellyfin's navigation method
if (window.Emby && window.Emby.Page) {
window.Emby.Page.show(detailsUrl);
} else {
// Fallback to direct navigation
window.location.hash = detailsUrl;
}
handlePlaylistItemClick(e, item, playlistItemId, serverId);
});
item.dataset.customPlaylistButton = 'true';
LOG(`Modified click handler for playlist item: ${playlistItemId}`);
});
}
function handlePlaylistItemClick(e, item, playlistItemId, serverId) {
// Check if the click target is within the listViewUserDataButtons container
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (buttonsContainer && buttonsContainer.contains(e.target)) {
LOG(`Click on button detected, not navigating for item: ${playlistItemId}`);
return; // Don't navigate when clicking on buttons
}
e.preventDefault();
e.stopPropagation();
const detailsUrl = `/details?id=${playlistItemId}&serverId=${serverId}`;
LOG(`Navigating to: ${detailsUrl}`);
// Use Jellyfin's navigation method
if (Dashboard) {
Dashboard.navigate(detailsUrl);
} else {
// Fallback to direct navigation
const fullDetailsUrl = `${ApiClient.serverAddress()}/web/#${detailsUrl}`;
window.location.href = fullDetailsUrl;
}
}
/**
* Adds play button to each playlist item's listViewUserDataButtons container
@@ -150,10 +169,13 @@
return;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return;
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found for play buttons');
return;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
@@ -161,7 +183,6 @@
playlistItems.forEach((item, index) => {
const playlistItemId = item.getAttribute('data-playlistitemid');
const serverId = ApiClient.serverId();
if (!playlistItemId) {
WARN(`Playlist item ${index} has no data-playlistitemid`);
@@ -215,12 +236,54 @@
LOG(`Modifying playlist page for item: ${itemId}`);
currentPlaylistId = itemId;
// Wait a bit for the page to fully load
setTimeout(() => {
// Poll for page elements to be ready (every 100ms for up to 10 seconds)
const pollInterval = 100;
const maxAttempts = 100; // 100ms * 100 = 10 seconds
let attempts = 0;
const pollForElements = () => {
attempts++;
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('Library page not found after 10 seconds');
}
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('Children items container not found after 10 seconds');
}
return;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
if (playlistItems.length === 0) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('No playlist items found after 10 seconds');
}
return;
}
// Elements found, proceed with modifications
modifyPlaylistItemClicks();
addPlayButtons();
LOG('Playlist page modifications complete');
}, 500);
};
pollForElements();
}
/**
@@ -286,24 +349,5 @@
// Initialize the hook when the script loads
initializePlaylistHook();
// Also check current page on script load in case we're already on a playlist page
const currentUrl = window.location.hash;
if (currentUrl.includes('#/details')) {
const urlParams = new URLSearchParams(currentUrl.split('?')[1]);
const itemId = urlParams.get('id');
if (itemId) {
LOG(`Script loaded on details page with item ID: ${itemId}`);
isPlaylistPage(itemId).then(isPlaylist => {
if (isPlaylist) {
LOG(`Already on playlist page: ${itemId}`);
modifyPlaylistPage(itemId);
}
}).catch(error => {
ERR('Error checking current playlist page:', error);
});
}
}
console.log('[Playlist] Module loaded and ready');
})();
+3 -3
View File
@@ -12,8 +12,8 @@
const LOG = (...args) => console.log('[RemoveContinue]', ...args);
const WARN = (...args) => console.warn('[RemoveContinue]', ...args);
const ERROR = (...args) => console.error('[RemoveContinue]', ...args);
LOG('Remove continue watching functionality initialized');
LOG('Initializing...');
// Add CSS for positioning the remove button in top right corner
const style = document.createElement('style');
@@ -291,5 +291,5 @@
processExistingOverlayContainers();
};
LOG('Remove continue watching functionality loaded successfully');
LOG('Initialized successfully');
})();
+12
View File
@@ -47,4 +47,16 @@ div#searchPage>.jellyseerr-section {
div#smart-search-results {
padding-bottom: 0 !important;
padding-right: 0 !important;
}
label[for="searchTextInput"] {
display: none;
}
.searchFieldsInner>.searchfields-icon {
display: none;
}
.input-wrapper {
position: relative;
}
+45 -68
View File
@@ -4,8 +4,8 @@
const LOG = (...args) => console.log('[SmartSearch]', ...args);
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
const ERR = (...args) => console.error('[SmartSearch]', ...args);
LOG('script load start');
LOG('Initializing...');
// Configuration
const CONFIG = window.KefinTweaksConfig?.search || {
@@ -14,7 +14,7 @@
// Track if we should block XHR requests (only for direct URL navigation with query params)
let shouldBlockXHR = false;
let hasBlockedInitialSearch = false;
//let hasBlockedInitialSearch = false;
// Override XHR to block automatic search requests from direct URL navigation
const OriginalXHR = window.XMLHttpRequest;
@@ -23,17 +23,15 @@
const originalOpen = xhr.open;
const originalSend = xhr.send;
xhr.open = function(method, url, ...args) {
LOG('XHR request:', method, url);
xhr.open = function(method, url, ...args) {
// Check if this is a search request we want to block
// Only block if we're in smart search mode and haven't blocked yet
const isSearchRequest = url.includes('/Items?') &&
(url.includes('searchTerm=') || url.includes('query='));
if (isSearchRequest && shouldBlockXHR && !hasBlockedInitialSearch) {
if (isSearchRequest && shouldBlockXHR) {
LOG('Blocking initial automatic search XHR request:', url);
hasBlockedInitialSearch = true;
//hasBlockedInitialSearch = true;
// Disable blocking after this first request
shouldBlockXHR = false;
LOG('XHR blocking disabled after initial search');
@@ -68,10 +66,11 @@
(function() {
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
if (window.location.hash.includes('#/search') && window.location.hash.includes('query=')) {
const hashQuery = window.location.hash.split('query=')[1];
if (hashQuery) {
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' ');
}
}
@@ -79,7 +78,7 @@
LOG('Current pathname:', window.location.pathname);
LOG('Current hash:', window.location.hash);
if (queryFromUrl && (window.location.pathname.includes('search.html') || window.location.hash.includes('search'))) {
if (queryFromUrl && window.location.pathname.includes('#/search')) {
LOG('URL query detected, enabling XHR blocking for automatic search prevention');
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
}
@@ -127,19 +126,6 @@
}
}
// Function to wait for jellyseerrAPI to be available
async function waitForJellyseerrAPI(maxAttempts = 50, interval = 200) {
for (let i = 0; i < maxAttempts; i++) {
if (window.JellyfinEnhanced?.jellyseerrAPI) {
LOG('jellyseerrAPI found after', i * interval, 'ms');
return true;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
WARN('jellyseerrAPI not found after', maxAttempts * interval, 'ms');
return false;
}
// styles (dedupe by id)
function addCustomStyles() {
if (document.getElementById('smart-search-styles')) return;
@@ -147,18 +133,17 @@
const style = document.createElement('style');
style.id = 'smart-search-styles';
style.textContent = `
.smart-search-wrapper { margin: 12px 0 20px 0; }
.smart-search-wrapper { margin: 20px 0; }
.smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; }
.smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; }
.smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; }
.smart-search-btn.active { background:var(--btnSubmitColor); }
.smart-search-btn.active { background:#673AB7; }
.smart-search-results { margin-top:12px; }
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
/* Smart search mode - hide search icon */
.smart-search-mode .searchfields-icon {
top: 37px;
top: 50%;
transform: translateY(-50%);
position: absolute;
z-index: 1;
right: 0;
@@ -172,14 +157,15 @@
function updateSearchUrl(searchTerm, searchType = 'videos') {
try {
// Jellyfin uses hash-based routing, so we need to update the hash
const baseHash = '#/search.html';
const urlSuffix = ApiClient._serverVersion.split('.')[1] > 10 ? '' : '.html';
const baseHash = `#/search${urlSuffix}`;
const params = new URLSearchParams();
const trimmed = searchTerm.trim();
if (searchTerm && searchTerm.trim()) {
params.set('query', searchTerm.trim());
if (trimmed) {
params.set('query', trimmed);
}
params.set('type', searchType);
const newHash = `${baseHash}?${params.toString()}`;
window.history.replaceState({}, '', window.location.pathname + newHash);
LOG('Updated search URL hash:', newHash);
@@ -197,7 +183,7 @@
userId,
limit: '100',
recursive: 'true',
searchTerm,
searchTerm: searchTerm.trim(),
fields: 'PrimaryImageAspectRatio,CanDelete',
imageTypeLimit: '1',
enableTotalRecordCount: 'false',
@@ -413,11 +399,11 @@
// Define the order to display sections
const sectionOrder = ['Movie', 'Series', 'Episode', 'Person', 'MusicAlbum', 'Audio', 'Artist', 'Playlist', 'Book', 'AudioBook', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
sectionOrder.forEach((itemType, index) => {
sectionOrder.forEach((itemType) => {
if (results.groupedItems[itemType] && results.groupedItems[itemType].length > 0) {
const items = results.groupedItems[itemType];
const title = getTypeDisplayName(itemType);
const section = window.cardBuilder.renderCards(items, title, null, index);
const section = window.cardBuilder.renderCards(items, title, null);
frag.appendChild(section);
totalShown += items.length;
}
@@ -439,10 +425,11 @@
// Handle URL query parameter on page load
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
if (window.location.hash.includes('#/search') && window.location.hash.includes('query=')) {
const hashQuery = window.location.hash.split('query=')[1];
if (hashQuery) {
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' ');
}
}
LOG('initSmartSearch - URL query:', queryFromUrl);
@@ -462,6 +449,11 @@
wrapper.id = 'smart-search-wrapper';
wrapper.className = 'smart-search-wrapper';
// Create the search icon element
const searchIcon = document.createElement('span');
searchIcon.className = 'searchfields-icon material-icons search';
searchIcon.setAttribute('aria-hidden', 'true');
// Replace the original input with our smart search input
const input = originalInput.cloneNode(true);
input.id = 'searchTextInput'; // Use the same ID as original
@@ -509,7 +501,14 @@
stats.id = 'smart-search-stats';
stats.className = 'smart-search-stats';
wrapper.appendChild(input);
// Create input wrapper
const inputWrapper = document.createElement('div');
inputWrapper.className = 'input-wrapper';
inputWrapper.appendChild(searchIcon);
inputWrapper.appendChild(input);
wrapper.appendChild(inputWrapper);
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
@@ -538,7 +537,10 @@
smartCoreBtn.classList.remove('active');
smartMusicBtn.classList.remove('active');
smartBooksBtn.classList.remove('active');
smartRequestBtn.classList.remove('active');
if (smartRequestBtn) {
smartRequestBtn.classList.remove('active');
}
// Add active class to the selected button
if (activeType === 'all') {
@@ -549,7 +551,7 @@
smartMusicBtn.classList.add('active');
} else if (activeType === 'books') {
smartBooksBtn.classList.add('active');
} else if (activeType === 'request') {
} else if (activeType === 'request' && smartRequestBtn) {
smartRequestBtn.classList.add('active');
}
}
@@ -698,31 +700,6 @@
setMode(true);
}
function init() {
LOG('init() called');
LOG('Pathname:', window.location.pathname);
LOG('Hash:', window.location.hash);
if (window.location.pathname.includes('search.html') || window.location.hash.includes('search')) {
LOG('Search page detected');
// Check for URL query parameter and prevent default search early
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
const hashQuery = window.location.hash.split('query=')[1];
if (hashQuery) {
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
}
}
LOG('URL query in init:', queryFromUrl);
LOG('Calling initSmartSearch');
initSmartSearch();
}
}
// Initialize search hook using utils
function initializeSearchHook() {
if (!window.KefinTweaksUtils) {
@@ -753,14 +730,14 @@
}
// Initialize the hook when the script loads
initializeSearchHook();
//initializeSearchHook();
if (document.readyState==='loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
initializeSearchHook();
});
} else {
init();
initializeSearchHook();
}
LOG('script load end');
LOG('Initialized successfully');
})();
+1505
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
#indexPage, #moviesPage, #tvRecommendedPage, #musicRecommendedPage {
overflow-y: visible !important;
}
+78
View File
@@ -0,0 +1,78 @@
.layout-desktop .streaming-lookup-container {
position: absolute;
bottom: 100%;
z-index: 2;
margin: 0 !important;
}
.layout-desktop .nextUpSection.verticalSection.detailVerticalSection {
display: flex;
flex-direction: column;
align-items: end;
outline: none;
border: var(--defaultBorder);
border-radius: var(--smallRadius);
background-color: var(--lighterGradientPointAlpha);
}
.layout-desktop .nextUpSection .card.overflowBackdropCard {
width: auto !important;
}
.layout-desktop .nextUpSection h2.sectionTitle.sectionTitle-cards {
border-bottom: var(--defaultBorder);
width: 100%;
margin: 0;
text-align: center;
}
.layout-desktop .nextUpItems {
margin-left: 0 !important;
}
.layout-desktop .nextUpSection .cardScalable, .layout-desktop .nextUpSection .cardText {
width: 18em;
}
.layout-desktop .nextUpSection .cardBox {
}
.layout-desktop .nextUpSection .cardOverlayContainer {
opacity: 1;
}
.layout-desktop .nextUpSection .cardBox {
margin-top: 0;
}
.layout-desktop h3.parentName.musicParentName {
margin-bottom: 2rem;
}
.layout-desktop .nextUpSection .cardBox {
display: flex;
flex-direction: column;
align-items: center;
}
.layout-desktop .nextUpSection {
position: absolute;
right: 2em;
bottom: 18vh;
z-index: 10;
}
.layout-desktop .nextUpSection h2.sectionTitle {
margin-left: 2em;
margin-bottom: -0.6em;
}
.layout-desktop .nextUpSection .card.overflowBackdropCard {
width: 18vw !important;
}
#slides-container {
top: 2em !important;
}
.slide .watchlist-button:not(:hover) {
background: transparent;
}
+14
View File
@@ -0,0 +1,14 @@
/* Media bar slides container */
div#slides-container {
margin-left: 250px !important;
width: calc(100% - 250px) !important;
}
/* body.home-screen .emby-tab-button {
display: flex;
}
body.home-screen .emby-tabs-slider {
display: flex;
}
body.home-screen .emby-tab-button > div {
align-self: normal;
} */
+29
View File
@@ -0,0 +1,29 @@
.kefinTweaks-breadcrumbs-wrapper {
margin-left: 180px;
color: var(--theme-text-colour);
border-radius: var(--theme-roundness);
box-shadow: inset 0 1px var(--theme-menu-shadow-colour);
background-color: var(--theme-menu-background-colour);
backdrop-filter: blur(var(--theme-blur)) !important;
-webkit-backdrop-filter: blur(var(--theme-blur)) !important;
-moz-backdrop-filter: blur(var(--theme-blur)) !important;
-o-backdrop-filter: blur(var(--theme-blur)) !important;
-ms-backdrop-filter: blur(var(--theme-blur)) !important;
height: 100%;
display: flex !important;
align-items: center;
}
.watchlist-icon .detailButton-content:before {
content: 'Watchlist';
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: var(--theme-menu-background-colour);
}
/* Media bar slides container */
div#slides-container {
margin-left: 250px !important;
width: calc(100% - 250px) !important;
}
+14
View File
@@ -0,0 +1,14 @@
/* Media bar slides container */
div#slides-container {
margin-left: 250px !important;
width: calc(100% - 250px) !important;
}
body.home-screen .emby-tab-button {
display: flex;
}
body.home-screen .emby-tabs-slider {
display: flex;
}
body.home-screen .emby-tab-button > div {
align-self: normal;
}
+3 -2
View File
@@ -10,7 +10,7 @@
const WARN = (...args) => console.warn('[SubtitleSearch]', ...args);
const ERR = (...args) => console.error('[SubtitleSearch]', ...args);
LOG('Script loaded - Subtitle Search functionality');
LOG('Initializing...');
// State management
let isInitialized = false;
@@ -1259,5 +1259,6 @@
window.setSubtitleStreamIndex = setSubtitleStreamIndex;
window.listenForSubtitleStreamChange = listenForSubtitleStreamChange;
window.triggerPlaybackProgressEvent = triggerPlaybackProgressEvent;
LOG('Initialized successfully');
})();
+306
View File
@@ -0,0 +1,306 @@
// KefinTweaks User Configuration
// User-specific settings and configurations for KefinTweaks features
// This file contains settings that users can customize for their specific needs
(function() {
'use strict';
// ============================================================================
// USER CONFIGURATION
// ============================================================================
const USER_CONFIG = {
// ============================================================================
// SKIN CONFIGURATION
// ============================================================================
// Configure available skins for the display settings page
// These skins will be merged with skins from the main KefinTweaks config
// User-defined skins take priority over main config skins (no duplicates by name)
// Each skin can have a single URL (string) or multiple URLs (array) for complex themes
// Each skin can also have colorSchemes array for color variations specific to that skin
// Each skin should have an author field indicating who created the skin
skins: [
{
name: 'Default',
author: 'Jellyfin',
url: null, // null means no additional CSS (default Jellyfin theme)
colorSchemes: []
},
{
name: 'ElegantFin',
author: 'lscambo13',
url: [
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css',
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-latest-min.css',
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFLow-RoundCastCrew.css',
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/episodelist/episodes_grid.css',
`${KefinTweaksConfig.scriptRoot}skins/elegantKefin.css`
],
colorSchemes: []
},
{
name: 'Jellypane',
author: 'tedhinklater',
url: 'https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane.css',
colorSchemes: []
},
{
name: 'Jellyfish',
author: 'n00bcodr',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css',
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Banana',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/banana.css'
},
{
name: 'Coal',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coal.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coral.css'
},
{
name: 'Grass',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/grass.css'
},
{
name: 'Jellyblue',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyblue.css'
},
{
name: 'Jellyflix',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyflix.css'
},
{
name: 'Lavender',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/lavender.css'
},
{
name: 'Mint',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/mint.css'
},
{
name: 'Ocean',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/ocean.css'
},
{
name: 'Peach',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/peach.css'
},
{
name: 'Watermelon',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/watermelon.css'
}
]
},
{
name: 'Glassmorphism',
author: 'alexyle',
url: [
'https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css',
],
colorSchemes: []
},
{
name: 'Zombie',
author: 'MakD',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css',
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Amazon Prime',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/amazon-prime.css'
},
{
name: 'Apple TV',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/apple-tv.css'
},
{
name: 'Disney',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/disney.css'
},
{
name: 'HBO Max',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hbo-max.css'
},
{
name: 'Hulu',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hulu.css'
},
{
name: 'Netflix',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/netflix.css'
},
{
name: 'YouTube',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/youtube.css'
}
]
},
{
name: 'Jamfin',
author: 'JamsRepos',
url: [
'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css',
`${KefinTweaksConfig.scriptRoot}skins/jamfin-kefin.css`
],
colorSchemes: []
},
{
name: 'Scyfin',
author: 'loof2736',
url: [
'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css',
`${KefinTweaksConfig.scriptRoot}skins/scyfin-kefin.css`
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Seafoam',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css'
},
{
name: 'Snow',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css'
}
]
},
{
name: 'Catppuccin',
author: 'Catppuccin',
url: 'https://jellyfin.catppuccin.com/theme.css',
colorSchemes: [
{
name: 'Latte',
url: 'https://jellyfin.catppuccin.com/catppuccin-latte.css'
},
{
name: 'Frappé',
url: 'https://jellyfin.catppuccin.com/catppuccin-frappe.css'
},
{
name: 'Macchiato',
url: 'https://jellyfin.catppuccin.com/catppuccin-macchiato.css'
},
{
name: 'Mocha',
url: 'https://jellyfin.catppuccin.com/catppuccin-mocha.css'
}
]
},
{
name: 'Flow',
author: 'LitCastVlog',
url: [
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-main.css',
`${KefinTweaksConfig.scriptRoot}skins/flow-kefin.css`
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Orange',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Orange.css'
},
{
name: 'Pink',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Pink.css'
},
{
name: 'Rainbow',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Rainbow.css'
},
{
name: 'Red',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Red.css'
},
{
name: 'White',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-White.css'
},
{
name: 'Blue',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Blue.css'
},
{
name: 'Seafoam',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css'
},
{
name: 'Snow',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css'
}
]
},
{
name: 'Monochromic',
author: 'CTalvio',
url: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
],
colorSchemes: []
},
{
name: 'Kaleidochromic',
author: 'CTalvio',
url: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
],
colorSchemes: []
},
{
name: 'Novachromic',
author: 'CTalvio',
url: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
],
colorSchemes: []
}
// Add more skins here as needed
],
// ============================================================================
// THEME CONFIGURATION
// ============================================================================
// Configure available themes for color variations
// These themes will be added as options to the existing select#selectTheme dropdown
// When a custom theme is selected, the cssTheme link href will be updated to the custom URL
// Each theme should have a name and URL pointing to a CSS file
themes: [
]
};
// ============================================================================
// CONFIGURATION EXPOSURE
// ============================================================================
// Make user configuration available to scripts
window.KefinTweaksUserConfig = USER_CONFIG;
console.log('[KefinTweaks UserConfig] User configuration loaded. Available at window.KefinTweaksUserConfig');
})();
+7 -18
View File
@@ -10,7 +10,7 @@
const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args);
LOG('Utils script loaded');
LOG('Initializing');
// Store the original onViewShow function
let originalOnViewShow = null;
@@ -142,26 +142,14 @@
* @returns {string|null} Current view name or null if not available
*/
function getCurrentView() {
// Try to get view from URL hash (primary method)
const hash = window.location.hash;
if (hash) {
// Extract page name from hash patterns like #/home.html, #/search.html, etc.
const pageMatch = hash.match(/#\/([^\/\?]+)\.html/);
// Extract page name from hash - match part after #/ and before ? or /
// This handles both .html pages (old) and pages without .html (Jellyfin 10.11+)
const pageMatch = hash.match(/#\/([^\/\?]+)/);
if (pageMatch) {
return pageMatch[1];
}
// Fallback: look for /view/ pattern
const viewMatch = hash.match(/\/view\/([^\/\?]+)/);
if (viewMatch) {
return viewMatch[1];
}
}
// Try to get view from active page element (fallback)
const activePage = document.querySelector('.page:not(.hide)');
if (activePage) {
return activePage.getAttribute('data-page') || activePage.className.split(' ').find(cls => cls.includes('Page'));
}
return null;
@@ -287,12 +275,13 @@
// Expose utilities to global scope
window.KefinTweaksUtils = {
onViewPage,
notifyHandlers,
getCurrentView,
getHandlerCount,
clearHandlers,
addCustomMenuLink
};
LOG('KefinTweaks Utils loaded and available at window.KefinTweaksUtils');
LOG('Initialized successfully');
LOG('Available at window.KefinTweaksUtils');
})();
+127 -40
View File
@@ -9,6 +9,7 @@
/* Watchlist Tab Styling */
.sections.watchlist {
padding-bottom: 20px;
margin-top: 20px;
}
.sections.watchlist .watchlist-tabs {
@@ -35,6 +36,7 @@
}
.sections.watchlist .watchlist-tabs button:hover {
color: rgba(200, 200, 200, 0.9);
color: rgba(200, 200, 200, 0.9);
background: rgba(255, 255, 255, 0.05);
}
@@ -43,6 +45,9 @@
color: rgba(200, 200, 200, 1);
border-bottom-color: #ffffff;
background: rgba(200, 200, 200, 0.1);
color: rgba(200, 200, 200, 1);
border-bottom-color: #ffffff;
background: rgba(200, 200, 200, 0.1);
}
/* Tab content styling - hide content by default, show only active */
@@ -133,12 +138,15 @@
gap: 16px;
}
.progress-tab-header, .movie-history-header {
.progress-tab-header, .movie-history-header, .watchlist-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 0 10px 10px;
max-width: 1200px;
margin: 0 auto;
}
.progress-title {
@@ -242,22 +250,50 @@
gap: 8px;
}
.progress-header-stats-container, .movie-history-stats-container {
.progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container {
display: flex;
gap: 10px;
}
.progress-header-stats {
.progress-header-stats, .watchlist-header-stats, .movie-history-stats {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.1);
padding: 6px 12px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
}
.watchlist-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.layout-toggle-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.layout-toggle-btn:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.1);
}
.layout-toggle-btn .material-icons {
font-size: 20px;
}
.progress-stats-separator {
color: rgba(255, 255, 255, 0.5);
font-weight: 300;
@@ -485,8 +521,20 @@
display: none;
}
.progress-tab-header h2 {
display: none;
.tab-header {
display: flex;
flex-direction: column;
padding: 10px;
}
.progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container {
flex-wrap: wrap;
justify-content: center;
}
.tab-header h2 {
padding: 0;
margin: 0;
}
}
@@ -718,17 +766,6 @@
display: none;
}
.movie-history-stats {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.1);
padding: 6px 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
}
.movie-history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -972,19 +1009,7 @@
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.movie-history-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
align-items: center;
padding: 20px;
}
.movie-history-header h2 {
display: none;
}
.movie-poster {
aspect-ratio: 16 / 9;
}
@@ -1623,15 +1648,6 @@ div[data-tab="watchlist"] .loading-message {
color: rgba(255, 255, 255, 0.7);
}
.episode-count-info {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
text-align: center;
}
.episode-count-info .episode-count {
font-weight: 600;
font-size: 16px;
@@ -1639,6 +1655,15 @@ div[data-tab="watchlist"] .loading-message {
}
.episode-count-info {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
text-align: center;
display: flex;
gap: 10px;
justify-content: center;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
@@ -1809,4 +1834,66 @@ div[data-tab="watchlist"] .loading-message {
.watchlist-button[data-active="true"], .watchlist-icon[data-active="true"] {
color: rgb(34, 197, 94) !important;
}
}
.watchlist-button[data-active="true"] span, .watchlist-icon[data-active="true"] span {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48;
}
.slide .watchlist-button::before {
content: 'bookmark';
font-family: 'Material Icons';
font-weight: 600;
display: inline-block;
font-size: 22px;
vertical-align: middle;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;
}
.slide .watchlist-button {
border-radius: 50%;
height: 50px;
width: 50px;
border: none;
cursor: pointer;
transition: color 0.2s;
-webkit-tap-highlight-color: #fff0;
}
.card:has(button[data-played="true"]) .watchlist-button {
display: none;
}
/* List View Styling */
[data-layout='List'] .cardScalable {
display: none;
}
[data-layout='List'] .itemsContainer {
display: flex;
flex-direction: column;
}
[data-layout='List'] .verticalSection.emby-scroller-container {
max-width: 1200px;
margin: 0 auto;
}
[data-layout='List'] .cardBox {
display: flex;
width: 100%;
}
[data-layout='List'] .card {
width: 100% !important;
cursor: initial;
}
[data-layout='List'] .cardText-first {
flex: 1;
text-align: left !important;
}
[data-layout='List'] .cardText-secondary {
font-size: 100%;
padding-top: .24em;
}
+734 -216
View File
File diff suppressed because it is too large. Load diff