KefinTweaks 1.0 Initial Release

This commit is contained in:
HighImKevin
2025-10-21 13:27:43 -04:00
parent 76d29b6de9
commit c74d7fb10c
45 changed files with 16374 additions and 795 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 ranaldsgift
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+165
View File
@@ -0,0 +1,165 @@
# KefinTweaks for Jellyfin
KefinTweaks is a comprehensive collection of enhancements and customizations for Jellyfin, focused on, but not limited to, filling gaps in functionality based on the community's most desired [Feature Requests](https://features.jellyfin.org/?view=most-wanted). While working on these scripts to meet my personal needs, I noticed that there was a lot of overlap between the functionality I desire and many of the requested features by the community so I thought it would be a good idea to share this for anyone who may be interested. There is no configuration required, but if you wish to customize some of the features, there are options to do so.
## Features Overview
KefinTweaks provides a modular system of enhancements that can be individually enabled or disabled based on your needs. Each script is designed to work independently while sharing common utilities and dependencies.
### 📋 Core Features
#### **Watchlist Page**
- **Watchlist**: Add movies, series, seasons, and episodes to your watchlist with intelligent caching
<img src="pages/images/watchlist.png" alt="watchlist" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Series Progress**: An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched.
<img src="pages/images/progress.png" alt="progress" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Movie History**: An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites.
<img src="pages/images/history.png" alt="history" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Statistics**: A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched.
<img src="pages/images/statistics.png" alt="statistics" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
- **Auto-Remove**: Automatically removes watched items from your watchlist when playback completes.
- **Real-time Updates**: Cached watchlist updates when toggling watchlist status from card overlays
#### **Enhanced Home Screen**
- **Custom Sections**: Add playlist or collection-based sections to your home screen
- **New & Trending**: Creates sections for movies and episodes released in the last 7 days (trending sections incomplete)
- **Infinite Discovery Sections**: Load discovery sections based on items you've watched and favorited, as well as from genres or top people in your library
- **Seasonal Content**: Seasonally-themed sections (Halloween, Christmas, etc.)
- **Watchlist Integration**: Dedicated watchlist section on home screen
<img src="pages/images/homescreen.png" alt="homescreen" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
#### **Enhanced Search**
- **Performance Improvements**: Defaults to searching in Movies/TV/People as most searches are for these items. Options to search specific categories or all categories like the default Jellyfin search functionality.
- **Jellyseerr Support**: Zero-config support for Jellyseerr results if [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) is installed
- **Meilisearch Support**: Zero-config support for searching with the [Meilisearch](https://github.com/arnesacnussem/jellyfin-plugin-meilisearch) plugin
<img src="pages/images/search.png" alt="search" style="max-width: 800px; margin: 0 auto; display: block;"/><br>
### 🎨 UI Enhancements
#### **Watchlist Support**
- **Watchlist Toggle**: Watchlist toggle button added to all item card overlays
<img src="pages/images/watchlistoverlay.png" alt="watchlistoverlay" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
#### **Navigation Improvements**
- **Header Tab Enhancements**: Improved tab navigation and functionality, supports linking to specific tabs
- **Breadcrumb Navigation**: Clear navigation paths for Movies, Series, Seasons, Episodes, Music Artists, and Albums
<img src="pages/images/breadcrumbs.png" alt="breadcrumbs" style="max-width: 500px; margin: 0 auto; display: block;"/><br>
- **Custom Menu Integration**: Add custom menu links to the side navigation drawer menu
<img src="pages/images/custommenu.png" alt="custommenu" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Subtitle Search**: Search and download subtitles directly from the video OSD
<img src="pages/images/subtitlesearch.png" alt="subtitlesearch" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Remove Continue Watching**: Adds a card overlay button to remove items from Continue Watching for all resumable items
<img src="pages/images/continuewatching.png" alt="continuewatching" style="max-width: 300px; margin: 0 auto; display: block;"/><br>
- **Playlist Screen Improvement**: Updates the default playlist functionality to include a play button to start playback and make clicking an item go to the item detail page
### 🔧 System Improvements
#### **Performance & Stability**
- **Background Leak Fix**: Resolves an issue that causes backgrounds to be added to the DOM endlesslly when a tab is not focused and Backdrop images are enabled
- **Infinite Scroll**: Adds infinite scrolling to the Movies and TV library pages. Loads batches of 100 items at a time and supports filtering and sorting.
- **LocalStorage Caching**: Local caching for data related to Watchlist, Series Progress, Movie History and Top People
- **Dashboard Button Fix**: Fix to handle navigating back from the dashboard page to the homescreen instead of the "new tab" page of a browser
#### **Branding & Customization**
- **Exclusive Elsewhere**: Custom branding for items which aren't available on any external streaming providers. Requires [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced).
- **Ratings and Comments**: Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script
## 📦 Installation
### Prerequisites
- Jellyfin 10.10.7 or later
- [JS Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) plugin installed and configured
- [Custom Tabs](https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs) plugin for watchlist functionality
- [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) plugin for Jellyseerr search functionality and ExclusiveElsewhere
### Setup Instructions
0. **Install Prerequisites listed above**
1. **Add a new script to your JS Injector Plugin**
2. **Copy this entire contents of kefinTweaks.js into the new script**
**Optionally update the "scriptRoot" in the KefinTweaks script if you wish to customize and host these scripts yourself.**
3. **Ensure the script is enabled**
4. **Save your changes**
<img src="pages/images/injector.png" alt="injector" style="max-width: 600px; margin: 0 auto; display: block;"/><br>
5. **Required for Watchlist: Add a new tab to your Custom Tabs plugin with the following content**
```html
<div class="sections watchlist"></div>
```
<img src="pages/images/customtab.png" alt="customtab" style="max-width: 600px; margin: 0 auto; display: block;"/><br>
## 🛠️ Script Details
### Core Scripts
| Script | Description |
|--------|-------------|
| `utils.js` | Page view management and common utilities |
| `cardBuilder.js` | Enhanced card building functionality |
| `localStorageCache.js` | Caching layer with TTL management |
| `modal.js` | Generic modal system for dialogs |
### Feature Scripts
| Script | Description |
|--------|-------------|
| `watchlist.js` | Complete watchlist management system |
| `homeScreen.js` | Custom home screen sections |
| `search.js` | Enhanced search functionality |
| `headerTabs.js` | Header tab improvements |
| `customMenu.js` | Custom menu link handling |
| `exclusiveElsewhere.js` | Custom branding for unavailable content |
| `updoot.js` | Upvote functionality integration |
| `backdropLeakFix.js` | Memory leak fixes |
| `dashboardButtonFix.js` | Dashboard button behavior fix |
| `infiniteScroll.js` | Infinite scroll functionality |
| `removeContinue.js` | Remove continue watching functionality |
| `subtitleSearch.js` | Subtitle search in video OSD |
| `breadcrumbs.js` | Breadcrumb navigation |
| `playlist.js` | Playlist view enhancements |
<br>
## Auto-Inject Dependencies
KefinTweaks automatically enables required dependencies when you enable a script that needs them. For example:
- Enabling `watchlist` automatically enables `cardBuilder`, `localStorageCache`, and `modal`
This ensures all scripts have their required dependencies without manual configuration.
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- **Jellyfin Team**: For the amazing media server platform
- **[n00bcodr](https://github.com/n00bcodr)**: For JellyfinEnhanced, the JS Injector Plugin and moral support
- **[IAmParadox27](https://github.com/IAmParadox27)**: For the Custom Tabs Plugin
- **[BobHasNoSoul](https://github.com/BobHasNoSoul)**: For the jellyfin-updoot functionality
- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming enviornment to ask questions and learn
## 📞 Support
- **Issues**: You can report bugs and request features on GitHub Issues, or visit the [Jellyfin Community Discord](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ) to DM me (username: HighImKevin)
- **Documentation**: This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time
## 🔮 Roadmap
### Planned Features
- **Jellyfin 10.11 Support**: Full compatibility with upcoming Jellyfin versions
- **UI Configuration**: Allow admins to customize the configuration through the UI instead of the JS script
### Version History
- **v1.0.0**: Initial release for Jellyfin 10.10.7
+104
View File
@@ -0,0 +1,104 @@
/* KefinTweaks Breadcrumbs Styles */
.kefinTweaks-breadcrumbs-wrapper {
margin-left: -.5em;
transition: background 0.2s ease;
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: rgba(0, 0, 0, 0.5);
border-radius: 0.5em;
}
.kefinTweaks-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
font-family: inherit;
pointer-events: auto;
position: relative;
padding: 8px 12px;
border-radius: 4px;
word-wrap: break-word;
display: inline-block;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
/* Responsive behavior */
@media (max-width: 768px) {
.kefinTweaks-breadcrumbs-wrapper {
display: none !important;
}
}
/* Popover styles for seasons and albums */
.kefinTweaks-popover {
position: relative;
background-color: rgba(29, 38, 53, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 0;
min-width: 200px;
max-height: 300px;
overflow-y: auto;
z-index: 1001;
display: block;
margin-top: 8px;
}
.kefinTweaks-popover-item {
padding: 8px 16px 8px 24px;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item:last-child {
border-bottom: none;
}
.kefinTweaks-popover-item.selected {
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
.kefinTweaks-popover-item.selected::before {
content: '>';
position: absolute;
left: 8px;
top: calc(50% - 1px);
transform: translateY(-50%);
font-weight: bold;
}
.skinHeader {
contain: none;
}
.headerLeft {
overflow: visible;
}
+821
View File
@@ -0,0 +1,821 @@
// KefinTweaks Breadcrumbs
// Adds breadcrumb navigation to item detail pages
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum
(function() {
'use strict';
console.log('[KefinTweaks Breadcrumbs] Initializing...');
// Configuration
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
let currentBreadcrumbs = null;
let breadcrumbContainer = null;
let activePopover = null;
let currentItemId = null;
// Track parent IDs to avoid unnecessary API calls
let lastSeasonsParentId = null;
let lastAlbumsParentId = null;
let cachedSeasonsData = null;
let cachedAlbumsData = null;
// Smart API call functions that check parent ID
async function getSeasonsIfNeeded(parentId) {
if (lastSeasonsParentId === parentId && cachedSeasonsData) {
log('Using existing seasons data for parent:', parentId);
return cachedSeasonsData;
}
log('Fetching seasons for parent:', parentId);
const seasons = await getSeasons(parentId);
lastSeasonsParentId = parentId;
cachedSeasonsData = seasons;
return seasons;
}
async function getAlbumsIfNeeded(parentId) {
if (lastAlbumsParentId === parentId && cachedAlbumsData) {
log('Using existing albums data for parent:', parentId);
return cachedAlbumsData;
}
log('Fetching albums for parent:', parentId);
const albums = await getAlbums(parentId);
lastAlbumsParentId = parentId;
cachedAlbumsData = albums;
return albums;
}
// Simplified breadcrumb structure definition
async function getTargetBreadcrumbStructure(item) {
const structure = {
elements: [],
item: item
};
if (item.Type === 'Movie') {
structure.elements = [
{ text: 'Movies', url: '#/movies.html', clickable: true },
{ text: item.Name, url: null, clickable: false }
];
} else if (item.Type === 'Series') {
structure.elements = [
{ text: 'Shows', url: '#/tv.html', clickable: true },
{ text: item.Name, url: null, clickable: false },
{ text: 'All Seasons', url: null, clickable: true, popover: true }
];
} else if (item.Type === 'Season') {
// Get series details for the series name
const seriesDetails = await getItemDetails(item.ParentId);
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: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }
];
} else if (item.Type === 'Episode') {
// Get series details for the series name
const seriesDetails = await getItemDetails(item.SeriesId);
const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
// Get season details for the season name
const seasonDetails = await getItemDetails(item.ParentId);
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: 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: 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: item.Name, url: null, clickable: true, popover: true }
];
}
return structure;
}
// Compare existing breadcrumbs with target structure
function compareBreadcrumbStructures(existing, target) {
const differences = [];
// Check if we have the right number of elements
const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
child.classList.contains('kefinTweaks-breadcrumb-element')
);
log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length);
// Compare each element
for (let i = 0; i < Math.max(existingElements.length, target.elements.length); i++) {
const existingElement = existingElements[i];
const targetElement = target.elements[i];
if (!existingElement && targetElement) {
// Need to add new element
differences.push({ action: 'add', index: i, element: targetElement });
} else if (existingElement && !targetElement) {
// Need to remove element
differences.push({ action: 'remove', index: i });
} else if (existingElement && targetElement) {
// Compare existing vs target
const existingText = existingElement.textContent;
const existingClickable = existingElement.style.cursor === 'pointer';
const targetClickable = targetElement.clickable;
// Check if popover functionality needs to be updated
const needsPopoverUpdate = targetElement.popover && (
existingText !== targetElement.text ||
existingClickable !== targetClickable ||
// Force update for "All Seasons" and "All Albums" elements since the underlying data changes
targetElement.text === 'All Seasons' ||
targetElement.text === 'All Albums'
);
if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
differences.push({ action: 'update', index: i, element: targetElement });
}
}
}
return differences;
}
// Apply breadcrumb differences intelligently
async function applyBreadcrumbDifferences(differences, targetStructure) {
if (differences.length === 0) {
log('No breadcrumb differences found, keeping existing structure');
return;
}
log('Applying breadcrumb differences:', differences.length, 'changes needed');
// Get existing elements
const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
child.classList.contains('kefinTweaks-breadcrumb-element')
);
// Process differences in reverse order to maintain indices
for (let i = differences.length - 1; i >= 0; i--) {
const diff = differences[i];
if (diff.action === 'add') {
// Add new element at the specified index
const newElement = await createBreadcrumbElementFromStructure(diff.element, targetStructure.item);
const separator = createSeparator();
if (diff.index === 0) {
breadcrumbContainer.insertBefore(newElement, breadcrumbContainer.firstChild);
breadcrumbContainer.insertBefore(separator, newElement.nextSibling);
} else {
const insertAfter = existingElements[diff.index - 1];
if (insertAfter) {
breadcrumbContainer.insertBefore(separator, insertAfter.nextSibling);
breadcrumbContainer.insertBefore(newElement, separator.nextSibling);
} else {
breadcrumbContainer.appendChild(separator);
breadcrumbContainer.appendChild(newElement);
}
}
log('Added new breadcrumb element:', diff.element.text);
} else if (diff.action === 'remove') {
// Remove element and its separator
const elementToRemove = existingElements[diff.index];
if (elementToRemove) {
const separatorToRemove = elementToRemove.previousSibling;
if (separatorToRemove && separatorToRemove.classList.contains('kefinTweaks-breadcrumb-separator')) {
breadcrumbContainer.removeChild(separatorToRemove);
}
breadcrumbContainer.removeChild(elementToRemove);
log('Removed breadcrumb element at index:', diff.index);
}
} else if (diff.action === 'update') {
// Update existing element
const elementToUpdate = existingElements[diff.index];
if (elementToUpdate) {
await updateBreadcrumbElement(elementToUpdate, diff.element, targetStructure.item);
log('Updated breadcrumb element:', diff.element.text);
}
}
}
}
// Create breadcrumb element from structure definition
async function createBreadcrumbElementFromStructure(elementDef, item) {
const element = document.createElement('span');
element.className = 'kefinTweaks-breadcrumb-element';
element.textContent = elementDef.text;
if (elementDef.clickable) {
element.style.cursor = 'pointer';
if (elementDef.url) {
// Direct link
element.addEventListener('click', () => {
window.location.hash = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
await setupPopoverForElement(element, elementDef, item);
}
} else {
element.style.cursor = 'default';
}
return element;
}
// Update existing breadcrumb element
async function updateBreadcrumbElement(element, elementDef, item) {
// Update text content
element.textContent = elementDef.text;
// Remove old event listeners by cloning
const newElement = element.cloneNode(true);
element.parentNode.replaceChild(newElement, element);
// Set cursor style
if (elementDef.clickable) {
newElement.style.cursor = 'pointer';
if (elementDef.url) {
// Direct link
newElement.addEventListener('click', () => {
window.location.hash = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
await setupPopoverForElement(newElement, elementDef, item);
}
} else {
newElement.style.cursor = 'default';
}
}
// Setup popover functionality for an element
async function setupPopoverForElement(element, elementDef, item) {
if (elementDef.text === 'All Seasons') {
// Series page - always show popover
const seasons = await getSeasonsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, null, (selectedSeason) => {
window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (elementDef.text === 'All Albums') {
// Music Artist page - always show popover
const albums = await getAlbumsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(albums, null, (selectedAlbum) => {
window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (item.Type === 'Season') {
// Season page - check if single season
const seasons = await getSeasonsIfNeeded(item.ParentId);
if (seasons.length === 1) {
element.style.cursor = 'default';
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, item, (selectedSeason) => {
window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
} else if (item.Type === 'Episode') {
// Episode page - check if single season
const seasons = await getSeasonsIfNeeded(item.SeriesId);
if (seasons.length === 1) {
element.addEventListener('click', () => {
window.location.hash = `#/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()}`;
});
showPopover(element, popover);
});
}
} else if (item.Type === 'MusicAlbum') {
// Music album page - check if single album
const albums = await getAlbumsIfNeeded(item.ParentId);
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.hash = `#/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()}`;
});
showPopover(element, popover);
});
}
}
}
// Utility functions
function log(message, ...args) {
console.log(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
}
function error(message, ...args) {
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;
}
function getItemIdFromPath(path) {
const match = path.match(/[?&]id=([^&]+)/);
return match ? match[1] : null;
}
function padNumber(num, length = 2) {
return num.toString().padStart(length, '0');
}
// DOM manipulation
function createBreadcrumbContainer() {
if (breadcrumbContainer) {
return breadcrumbContainer;
}
// Find the header left container
const headerLeft = document.querySelector('.skinHeader .headerLeft');
if (!headerLeft) {
error('Could not find .skinHeader .headerLeft element');
return null;
}
// Create main wrapper
const wrapper = document.createElement('div');
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.style.cssText = `
display: none;
`;
// Create breadcrumb container (for background styling)
breadcrumbContainer = document.createElement('div');
breadcrumbContainer.id = 'kefinTweaks-breadcrumbs';
breadcrumbContainer.className = 'kefinTweaks-breadcrumbs';
// Create popover container
const popoverContainer = document.createElement('div');
popoverContainer.id = 'kefinTweaks-popover-container';
popoverContainer.className = 'kefinTweaks-popover-container';
popoverContainer.style.cssText = `
position: absolute;
top: calc(100% - 15px);
left: 0;
z-index: 1001;
`;
wrapper.appendChild(breadcrumbContainer);
wrapper.appendChild(popoverContainer);
headerLeft.appendChild(wrapper);
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
return breadcrumbContainer;
}
function showBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
const wideScreen = isWideScreen();
// Race condition protection: verify we're still on a supported page
const currentPath = Emby.Page.lastPath || '';
const isSupportedPage = currentPath.startsWith('/details?');
log('showBreadcrumbs called - wrapper exists:', !!wrapper, 'isWideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage, 'currentPath:', currentPath);
if (wrapper && wideScreen && isSupportedPage) {
wrapper.style.display = 'block';
log('Breadcrumbs shown successfully');
} else {
log('Breadcrumbs not shown - wrapper:', !!wrapper, 'wideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage);
}
}
function hideBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
if (wrapper) {
wrapper.style.display = 'none';
}
}
function clearBreadcrumbs() {
if (breadcrumbContainer) {
breadcrumbContainer.innerHTML = '';
currentBreadcrumbs = null;
currentItemId = null;
}
closePopover();
}
function closePopover() {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
}
function createPopover(items, currentItem, onItemClick) {
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover';
popover.style.display = 'block'; // Ensure it's visible
log('Creating popover with', items.length, 'items');
let selectedItemElement = null;
items.forEach(item => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item';
if (currentItem && item.Id === currentItem.Id) {
itemElement.classList.add('selected');
selectedItemElement = itemElement; // Store reference to selected item
}
itemElement.textContent = item.Name || `Season ${item.IndexNumber}`;
itemElement.addEventListener('click', () => {
log('Popover item clicked:', item.Name);
onItemClick(item);
closePopover();
});
popover.appendChild(itemElement);
});
// Add auto-scroll functionality
if (selectedItemElement) {
// Use setTimeout to ensure the popover is rendered before scrolling
setTimeout(() => {
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest'
});
log('Auto-scrolled to selected item:', selectedItemElement.textContent);
}, 10);
}
return popover;
}
function showPopover(triggerElement, popover) {
closePopover();
activePopover = popover;
log('Showing popover for element:', triggerElement.textContent);
// Add popover to the dedicated popover container
const popoverContainer = document.getElementById('kefinTweaks-popover-container');
if (popoverContainer) {
// Calculate the left offset to align with the trigger element
// Since breadcrumbs are now in the header, we need to calculate relative to the page
const triggerRect = triggerElement.getBoundingClientRect();
const popoverContainerRect = popoverContainer.getBoundingClientRect();
// Calculate offset from popover container to trigger element
const leftOffset = triggerRect.left - popoverContainerRect.left - 22;
popover.style.left = `${leftOffset}px`;
popoverContainer.appendChild(popover);
log('Popover added to popover container with left offset:', leftOffset);
} else {
log('Popover container not found, falling back to trigger element');
triggerElement.style.position = 'relative';
triggerElement.appendChild(popover);
}
// Close popover when clicking outside
const closeHandler = (e) => {
if (!popover.contains(e.target) && !triggerElement.contains(e.target)) {
closePopover();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
}, 100);
}
// API functions
async function getItemDetails(itemId) {
try {
const currentUserId = ApiClient.getCurrentUserId();
if (!currentUserId) {
throw new Error('No user ID found');
}
const url = ApiClient.getUrl(`Users/${currentUserId}/Items/${itemId}`);
const response = await ApiClient.getJSON(url);
log('Retrieved item details:', response.Name, response.Type);
return response;
} catch (err) {
error('Failed to get item details:', err);
return null;
}
}
async function getSeasons(seriesId) {
try {
const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`, {
userId: ApiClient.getCurrentUserId(),
imageTypeLimit: 1
});
const response = await ApiClient.getJSON(url);
log('Retrieved seasons:', response.Items?.length || 0);
return response.Items || [];
} catch (err) {
error('Failed to get seasons:', err);
return [];
}
}
async function getAlbums(artistId) {
try {
const url = ApiClient.getUrl('Items', {
SortOrder: 'Descending',
IncludeItemTypes: 'MusicAlbum',
Recursive: true,
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: artistId,
SortBy: 'PremiereDate,ProductionYear,Sortname'
});
const response = await ApiClient.getJSON(url);
log('Retrieved albums:', response.Items?.length || 0);
return response.Items || [];
} catch (err) {
error('Failed to get albums:', err);
return [];
}
}
// 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';
separator.textContent = ' / ';
separator.style.margin = '0 8px';
return separator;
}
// Page change handler
// Simplified page change handler
async function handlePageChange() {
try {
// Close any active popovers first
closePopover();
const currentPath = Emby.Page.lastPath;
log('Page changed to:', currentPath);
// Check if we're on a details page
if (!currentPath || !currentPath.startsWith('/details?')) {
log('Not a details page, hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Extract item ID
const itemId = getItemIdFromPath(currentPath);
if (!itemId) {
log('No item ID found in path, hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Get item details
const item = await getItemDetails(itemId);
if (!item) {
log('Failed to get item details, hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Check if item type is supported
if (!CONFIG.supportedTypes.includes(item.Type)) {
log('Item type not supported:', item.Type, 'hiding and clearing breadcrumbs');
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Determine target breadcrumb structure
const targetStructure = await getTargetBreadcrumbStructure(item);
log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text));
// Check if we need to create breadcrumbs from scratch
if (!breadcrumbContainer || !currentBreadcrumbs) {
log('Creating breadcrumbs from scratch');
await createBreadcrumbsFromScratch(targetStructure);
} else {
// Compare existing with target and apply differences
const differences = compareBreadcrumbStructures(currentBreadcrumbs, targetStructure);
await applyBreadcrumbDifferences(differences, targetStructure);
}
// Update state
currentBreadcrumbs = targetStructure;
currentItemId = item.Id;
log('About to show breadcrumbs...');
showBreadcrumbs();
log('Breadcrumbs updated successfully');
} catch (err) {
error('Error handling page change:', err);
clearBreadcrumbs();
hideBreadcrumbs();
}
}
// Create breadcrumbs from scratch
async function createBreadcrumbsFromScratch(targetStructure) {
// Create container if it doesn't exist
if (!breadcrumbContainer) {
breadcrumbContainer = createBreadcrumbContainer();
}
// Clear existing content
clearBreadcrumbs();
// Add all 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);
// Add separator after each element except the last
if (i < targetStructure.elements.length - 1) {
const separator = createSeparator();
breadcrumbContainer.appendChild(separator);
}
}
log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
}
// Window resize handler
function handleResize() {
if (currentBreadcrumbs && breadcrumbContainer) {
if (isWideScreen()) {
showBreadcrumbs();
} else {
hideBreadcrumbs();
}
}
}
// Initialize breadcrumbs
function initialize() {
log('Initializing breadcrumbs...');
// Use utils for page change detection
if (window.KefinTweaksUtils) {
log('Registering breadcrumb handler with KefinTweaksUtils');
// Register handler for all pages (breadcrumbs can appear on any detail page)
window.KefinTweaksUtils.onViewPage((view, element) => {
try {
// Run our custom code
handlePageChange();
} catch (err) {
error('Breadcrumb page change handler failed:', err);
}
}, {
immediate: true,
pages: [] // Empty array means all pages
});
} else {
error('KefinTweaksUtils not available, breadcrumbs may not work correctly');
}
// Add resize listener
window.addEventListener('resize', handleResize);
// Initial check
handlePageChange();
log('Breadcrumbs initialized successfully');
}
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();
+107 -27
View File
@@ -20,7 +20,7 @@
* Renders a scrollable container with cards
* @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 {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
* @param {number} sectionIndex - Index for debugging
* @returns {HTMLElement} - The constructed scrollable container
*/
@@ -158,6 +158,21 @@
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ImageTags?.Thumb) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if ((item.Type === 'Episode') && item.ParentThumbImageTag) {
const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
// No image - add icon as inner element
const iconSpan = document.createElement('span');
@@ -210,12 +225,47 @@
// Button container for additional overlay buttons (watchlist, etc.)
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
// Watched button
const watchedButton = document.createElement('button');
watchedButton.setAttribute('is', 'emby-playstatebutton');
watchedButton.type = 'button';
watchedButton.setAttribute('data-action', 'none');
watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
watchedButton.setAttribute('data-id', item.Id);
watchedButton.setAttribute('data-serverid', serverId);
watchedButton.setAttribute('data-itemtype', item.Type);
watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
watchedButton.title = 'Mark played';
// Add play button to button container
buttonContainer.appendChild(playButton);
const watchedIcon = document.createElement('span');
watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
watchedIcon.setAttribute('aria-hidden', 'true');
watchedButton.appendChild(watchedIcon);
buttonContainer.appendChild(watchedButton);
// Favorite button
const favoriteButton = document.createElement('button');
favoriteButton.setAttribute('is', 'emby-ratingbutton');
favoriteButton.type = 'button';
favoriteButton.setAttribute('data-action', 'none');
favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
favoriteButton.setAttribute('data-id', item.Id);
favoriteButton.setAttribute('data-serverid', serverId);
favoriteButton.setAttribute('data-itemtype', item.Type);
favoriteButton.setAttribute('data-likes', '');
favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
favoriteButton.title = 'Add to favorites';
const favoriteIcon = document.createElement('span');
favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
favoriteIcon.setAttribute('aria-hidden', 'true');
favoriteButton.appendChild(favoriteIcon);
buttonContainer.appendChild(favoriteButton);
// Assemble overlay
cardOverlayContainer.appendChild(overlayLink);
cardOverlayContainer.appendChild(playButton);
cardOverlayContainer.appendChild(buttonContainer);
// Card text container - different structure for episodes
@@ -301,6 +351,7 @@
const secondaryText = document.createElement('div');
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.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
@@ -310,8 +361,8 @@
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = item.Name || 'Unknown Episode';
episodeLink.textContent = item.Name || 'Unknown Episode';
episodeLink.title = episodeName;
episodeLink.textContent = episodeName;
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
@@ -344,7 +395,7 @@
// Create the main vertical section container
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container';
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Create section title
const sectionTitle = document.createElement('h2');
@@ -353,10 +404,19 @@
if (viewMoreUrl) {
// Create clickable title with chevron icon
const titleLink = document.createElement('a');
titleLink.href = viewMoreUrl;
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
// Handle both URL and function
if (typeof viewMoreUrl === 'function') {
titleLink.addEventListener('click', (e) => {
e.preventDefault();
viewMoreUrl();
});
} else {
titleLink.href = viewMoreUrl;
}
const titleText = document.createElement('span');
titleText.textContent = title;
@@ -372,6 +432,14 @@
sectionTitle.textContent = title;
}
// 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.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');
@@ -436,6 +504,33 @@
scroller.appendChild(itemsContainer);
// Add "Show All" button to title if there are more than 20 items
if (items.length > 20) {
sectionTitle.appendChild(showAllButton);
}
// Toggle between scroll and grid view
let isShowingAll = false;
const originalItemsContainerStyle = itemsContainer.style.cssText;
showAllButton.addEventListener('click', () => {
if (isShowingAll) {
// Switch back to scroll view
itemsContainer.style.cssText = originalItemsContainerStyle;
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';
showAllButton.textContent = 'Show Less';
showAllButton.title = 'Show items in scrollable layout';
isShowingAll = true;
}
});
// Scroll functionality - calculate dynamically when needed
let scrollPosition = 0;
@@ -638,33 +733,18 @@
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
const style = document.createElement('style');
style.textContent = `
.smart-search-results .emby-scrollbuttons {
display: none;
}
.layout-desktop .smart-search-results .emby-scrollbuttons:not(.custom-scrollbuttons) {
display: block !important;
}
style.textContent = `
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
.emby-scroller.custom-scroller {
-webkit-overflow-scrolling: touch !important;
touch-action: pan-x !important;
overscroll-behavior-x: contain !important;
}
/* Improve touch responsiveness */
.emby-scroller.custom-scroller .scrollSlider {
touch-action: pan-x !important;
-webkit-user-select: none !important;
user-select: none !important;
}
/* 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);
+112
View File
@@ -0,0 +1,112 @@
// Custom Menu Links Script
// Loads custom menu links from configuration and adds them to the custom menu
// Uses utils.addCustomMenuLink to add each configured link
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[CustomMenuLinks]', ...args);
const WARN = (...args) => console.warn('[CustomMenuLinks]', ...args);
const ERR = (...args) => console.error('[CustomMenuLinks]', ...args);
LOG('Script loaded - JS Injector mode');
// Get custom menu links configuration
function getCustomMenuLinksConfig() {
return window.KefinTweaksConfig?.customMenuLinks || [];
}
// Initialize custom menu links
async function initializeCustomMenuLinks() {
const customMenuLinks = getCustomMenuLinksConfig();
if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
LOG('No custom menu links configured');
return;
}
LOG(`Loading ${customMenuLinks.length} custom menu links`);
// Check if utils is available
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
ERR('KefinTweaksUtils.addCustomMenuLink not available');
return;
}
// Add each custom menu link
const addPromises = customMenuLinks.map(async (linkConfig, index) => {
try {
// Validate link configuration
if (!linkConfig.name || !linkConfig.url) {
WARN(`Custom menu link at index ${index} is missing required properties (name, url)`);
return false;
}
const {
name,
icon = 'link', // Default icon
url,
openInNewTab = false
} = linkConfig;
LOG(`Adding custom menu link: ${name}`);
const success = await window.KefinTweaksUtils.addCustomMenuLink(
name,
icon,
url,
openInNewTab
);
if (success) {
LOG(`Successfully added custom menu link: ${name}`);
} else {
WARN(`Failed to add custom menu link: ${name}`);
}
return success;
} catch (error) {
ERR(`Error adding custom menu link at index ${index}:`, error);
return false;
}
});
// Wait for all links to be processed
const results = await Promise.all(addPromises);
const successCount = results.filter(Boolean).length;
LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`);
}
// Wait for utils to be available and then initialize
function waitForUtilsAndInitialize() {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
LOG('Utils available, initializing custom menu links');
initializeCustomMenuLinks();
return;
}
LOG('Waiting for KefinTweaksUtils to be available');
// Poll for utils availability
const checkInterval = setInterval(() => {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
clearInterval(checkInterval);
LOG('Utils available, initializing custom menu links');
initializeCustomMenuLinks();
}
}, 100);
// Fallback timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
WARN('KefinTweaksUtils not available after 10 seconds');
}, 10000);
}
// Start initialization
waitForUtilsAndInitialize();
LOG('Custom menu links functionality initialized');
})();
+9 -2
View File
@@ -26,18 +26,25 @@
LOG('Script loaded - JS Injector mode');
// Get configuration from global config
const config = window.KefinTweaksConfig?.exclusiveElsewhere || {
hideServerName: false // Default fallback
};
const observer = new MutationObserver(() => {
const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div');
if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) {
return;
}
const serverName = config.hideServerName ? '' : ApiClient.serverName();
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
if (link && !link.classList.contains('exclusive')) {
LOG('Modifying elsewhere link to show exclusive branding');
link.innerHTML = "Only available on Jellyfin";
link.innerHTML = `Only available on ${serverName}`;
link.classList.add("exclusive");
link.title = "Jellyfin Exclusive";
link.title = "Exclusive";
link.disable = true;
}
});
+115 -11
View File
@@ -23,7 +23,6 @@
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
LOG('Current page detected:', pageFromHash);
return pageFromHash;
}
}
@@ -60,10 +59,18 @@
let newHash = currentHash;
if (currentHash.includes('?')) {
// Replace existing tab parameter
// Replace existing tab parameter and clear watchlist-specific params
const [hashPath, hashSearch] = currentHash.split('?');
const params = new URLSearchParams(hashSearch);
// Clear watchlist-specific parameters when switching header tabs
params.delete('pageTab');
params.delete('page');
params.delete('sort');
params.delete('sortDirection');
params.delete('movieSort');
params.delete('movieSortDirection');
if (tabIndex === 0) {
params.delete('tab');
} else {
@@ -76,6 +83,8 @@
// Add tab parameter
if (tabIndex !== 0) {
newHash = `${currentHash}?tab=${tabIndex}`;
} else {
newHash = `${currentHash}`;
}
}
@@ -115,6 +124,7 @@
}
}
window.scrollTo(0, 0);
LOG('Tab clicked:', currentPage, tabIndex);
}
}
@@ -125,6 +135,17 @@
if (!headerTabs) return;
const currentTabFromUrl = getCurrentTabFromUrl();
// 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) {
const mediaBarSlideshow = document.getElementById('slides-container');
if (mediaBarSlideshow) {
mediaBarSlideshow.style.display = 'none';
}
}
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
const activeButton = headerTabs.querySelector('.emby-tab-button-active');
@@ -146,13 +167,23 @@
if (correctButton) {
correctButton.classList.add('emby-tab-button-active');
LOG('Set active tab button to index:', currentTabFromUrl);
} else {
// If no correct button found, set the first one (index 0) as active
/* const firstButton = buttons[0];
if (firstButton) {
firstButton.classList.add('emby-tab-button-active');
LOG('No matching tab found, set first button as 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);
}
}
}
@@ -274,11 +305,16 @@
// Monitor for page changes
function setupPageMonitor() {
let lastPage = null;
let lastUrl = window.location.href;
function checkPageChange() {
const newPage = getCurrentPage();
if (newPage !== lastPage) {
const currentUrl = window.location.href;
// Check if page changed OR if URL changed (including query params)
if (newPage !== lastPage || currentUrl !== lastUrl) {
lastPage = newPage;
lastUrl = currentUrl;
if (newPage) {
LOG('Page change detected:', newPage);
// Setup observers when page changes
@@ -296,12 +332,80 @@
// 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);
//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) => {
LOG('onViewPage handler triggered for view:', view);
// Sync active tab state when page view changes
syncActiveTabState();
}, {
pages: SUPPORTED_PAGES, // Only trigger for supported pages
immediate: false
});
LOG('Registered onViewPage handler for syncActiveTabState');
} else {
WARN('KefinTweaksUtils.onViewPage not available');
}
// Initialize
setupPageMonitor();
//retryHomeNavSetup();
LOG('Header tabs functionality initialized');
+24 -4
View File
@@ -1,13 +1,33 @@
/* This will put the Continue Watching and Next Up sections at the top of the home screen
You can order your custom sections between those and the rest of the sections by using 3-99 for the section order
If you want your custom section to appear after the normal sections, use 101+ for the section order */
Discovery sections are 101-110 if they are enabled
If you want your custom section to appear after all other sections, use 111+ for the section order */
.sections.homeSectionsContainer {
display: flex;
flex-direction: column;
}
.sections.homeSectionsContainer>div {
order: 100;
}
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
order: 1;
order: 2;
}
.sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) {
order: 2;
order: 3;
}
/* Halloween theme */
.home-screen.halloween-theme #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: -25%;
background-image: url('/files/KefinTweaks/pages/images/halloween.jpg');
background-position: 0px -250px;
background-size: auto 102%;
}
+3183 -27
View File
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
(function () {
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY = 1000;
// Common logging functions
const LOG = (...args) => console.log('[InfiniteScroll]', ...args);
const WARN = (...args) => console.warn('[InfiniteScroll]', ...args);
const ERR = (...args) => console.error('[InfiniteScroll]', ...args);
// Supported pages (same as headerTabs)
const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html'];
// Media type configurations - Read from centralized config or use defaults
const MEDIA_CONFIGS = {
'movies.html': {
includeItemTypes: 'Movie',
context: 'movies',
sortBy: 'SortName,ProductionYear'
},
'tv.html': {
includeItemTypes: 'Series',
context: 'tvshows',
sortBy: 'SortName,ProductionYear'
},
};
let userId = null;
const limit = 100;
let currentPage = null;
let currentTab = 0;
let startIndex = 0;
let loading = false;
let hasMore = true;
let totalRecordCount = null; // Track total items available from API
let retryCount = 0;
// scrollState removed - not needed
let scrollTimeout = null;
let isScrollTriggered = false; // Prevent multiple triggers
let isRequestInProgress = false; // Blocking flag to prevent multiple simultaneous requests
let hasInitialized = false; // Prevent multiple initializations
let isInitializing = false; // Prevent concurrent initializations
// Create loading indicator element
function createLoadingIndicator() {
const loadingDiv = document.createElement('div');
loadingDiv.className = 'infinite-scroll-loading';
loadingDiv.id = 'infinite-scroll-loading-indicator';
loadingDiv.innerHTML = `
<div class="spinner"></div>
<span>Loading more content...</span>
`;
return loadingDiv;
}
// Show loading indicator
function showLoadingIndicator() {
let loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
if (!loadingIndicator) {
loadingIndicator = createLoadingIndicator();
// Find the container and append the loading indicator as a sibling
const container = getContainer();
if (container && container.parentNode) {
container.parentNode.insertBefore(loadingIndicator, container.nextSibling);
} else {
// Fallback: append to body
document.body.appendChild(loadingIndicator);
}
}
// Show with animation
setTimeout(() => {
loadingIndicator.classList.add('show');
}, 10);
LOG('Loading indicator shown');
}
// Hide loading indicator
function hideLoadingIndicator() {
const loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('show');
// Remove from DOM after animation
setTimeout(() => {
if (loadingIndicator.parentNode) {
loadingIndicator.parentNode.removeChild(loadingIndicator);
}
}, 300);
LOG('Loading indicator hidden');
}
}
// Add CSS to hide paging controls when infinite scroll is active
function addInfiniteScrollCSS() {
// Check if infinite scroll is enabled in configuration
const isEnabled = window.KefinTweaksConfig?.scripts?.infiniteScroll !== false;
if (!isEnabled) {
LOG('Infinite scroll disabled in configuration, skipping CSS addition');
return;
}
const styleId = 'infinite-scroll-paging-hide';
// Check if style already exists
if (document.getElementById(styleId)) {
return;
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
#moviesTab .listPaging>div,
#seriesTab .listPaging>div {
display: none !important;
}
/* Infinite scroll loading indicator */
.infinite-scroll-loading {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
font-size: 14px;
color: #666;
opacity: 0;
transition: opacity 0.3s ease;
}
.infinite-scroll-loading.show {
opacity: 1;
}
.infinite-scroll-loading .spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: infinite-scroll-spin 1s linear infinite;
margin-right: 10px;
}
@keyframes infinite-scroll-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
LOG('Added CSS to hide paging controls for infinite scroll');
}
// Wait for ApiClient to be available
function waitForApiClient() {
return new Promise((resolve) => {
if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) {
userId = ApiClient._serverInfo.UserId;
LOG('ApiClient ready, userId:', userId);
resolve();
} else {
LOG('Waiting for ApiClient...');
setTimeout(() => waitForApiClient().then(resolve), 100);
}
});
}
// Wait for cardBuilder to be available
function waitForCardBuilder() {
return new Promise((resolve) => {
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) {
LOG('cardBuilder ready');
resolve();
} else {
LOG('Waiting for cardBuilder...');
setTimeout(() => waitForCardBuilder().then(resolve), 100);
}
});
}
// Page detection function (same as headerTabs)
function getCurrentPage() {
const hash = window.location.hash;
const hashMatches = hash.match(/#\/([^?]+)/g);
if (!hashMatches || hashMatches.length === 0) {
LOG('No hash matches found for:', hash);
return null;
}
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
LOG('Found supported page:', pageFromHash);
return pageFromHash;
}
}
return null;
}
// Get current tab index from URL
function getCurrentTab() {
const hash = window.location.hash;
let searchParams = '';
if (hash.includes('?')) {
searchParams = hash.split('?')[1];
} else {
searchParams = window.location.search.substring(1);
}
const urlParams = new URLSearchParams(searchParams);
const tabParam = urlParams.get('tab');
return tabParam ? parseInt(tabParam, 10) : 0;
}
// State persistence functions - REMOVED
// We don't need to store scroll state as it can cause issues with infinite scroll behavior
function getParentId() {
const match = window.location.hash.match(/topParentId=([^&]+)/);
return match ? match[1] : null;
}
// Get current sort settings from Jellyfin's localStorage keys
function getCurrentSortSettings() {
const userId = ApiClient.getCurrentUserId();
const parentId = getParentId();
const page = getCurrentPage();
if (!userId || !parentId || !page) {
LOG('Missing required IDs for sort detection:', { userId, parentId, page });
return null;
}
// Map page to media type
const mediaTypeMap = {
'movies.html': 'movies',
'tv.html': 'series'
};
const mediaType = mediaTypeMap[page];
if (!mediaType) {
LOG('Unsupported page for sort detection:', page);
return null;
}
// Build localStorage keys
const storageKey = `${userId}-${parentId}-${mediaType}`;
const filterKey = `${userId}-${parentId}-${mediaType}-filter`;
LOG('Looking for sort settings in localStorage keys:', { storageKey, filterKey });
try {
// Get main sort settings
const sortData = JSON.parse(localStorage.getItem(storageKey) || '{}');
const filterData = JSON.parse(localStorage.getItem(filterKey) || '{}');
LOG('Found sort data:', sortData);
LOG('Found filter data:', filterData);
return {
sortBy: sortData.SortBy || 'SortName,ProductionYear',
sortOrder: sortData.SortOrder || 'Ascending',
filters: filterData.Filters || '',
years: filterData.Years || '',
genres: filterData.Genres || '',
tags: filterData.Tags || '',
// Add other filter properties as needed
};
} catch (error) {
ERR('Error parsing sort settings from localStorage:', error);
return null;
}
}
// Get current alpha picker selection
function getCurrentAlphaPicker() {
const page = getCurrentPage();
if (!page) return null;
let activeTab = null;
if (page === 'tv.html') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) return null;
const alphaPickerRow = activeTab.querySelector(".alphaPickerRow");
if (!alphaPickerRow) return null;
const selectedButton = alphaPickerRow.querySelector(".alphaPickerButton-selected");
if (!selectedButton) return null;
const dataValue = selectedButton.getAttribute('data-value');
LOG('Found alpha picker selection:', dataValue);
return dataValue;
}
// Get current start index based on existing items count
function getCurrentStartIndex() {
const container = getContainer();
if (!container) return 0;
const existingItemsCount = container.children.length;
LOG('Current start index based on existing items:', existingItemsCount);
return existingItemsCount;
}
// Update pagination display to show current range
function updatePaginationDisplay() {
const page = getCurrentPage();
if (!page) return;
let activeTab = null;
if (page === 'tv.html') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) return;
const listPagings = activeTab.querySelectorAll(".listPaging");
if (!listPagings.length) return;
listPagings.forEach(listPaging => {
const span = listPaging.querySelector("span");
if (!span) return;
const container = getContainer();
if (!container) return;
const currentItemsCount = container.children.length;
const totalCount = totalRecordCount || '?';
// Update the display to show current range
span.textContent = `1-${currentItemsCount} of ${totalCount}`;
LOG('Updated pagination display:', { currentItemsCount, totalCount });
});
}
function getContainer() {
const libraryPage = document.querySelector(".libraryPage:not(.hide)");
if (!libraryPage) return null;
// Check if we're on the correct active tab
const page = getCurrentPage();
if (!page) return null;
let activeTab = null;
if (page === 'tv.html') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) {
LOG('No active tab found for page:', page);
return null;
}
const container = activeTab.querySelector(".itemsContainer");
if (!container) {
LOG('No itemsContainer found in active tab:', activeTab);
return null;
}
return container;
}
async function loadMore() {
LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered });
if (loading || !hasMore || isRequestInProgress) {
LOG('LoadMore blocked:', { loading, hasMore, isRequestInProgress });
return;
}
// Don't load more if there are no items in the container (page still loading)
const container = getContainer();
if (container && container.children.length === 0) {
LOG('LoadMore blocked: No items in container yet (page still loading)');
return;
}
LOG('Starting loadMore request...');
loading = true;
isRequestInProgress = true;
// Show loading indicator
showLoadingIndicator();
// Wait for ApiClient to be ready
if (!userId) {
LOG('Waiting for ApiClient before loading...');
await waitForApiClient();
}
// Wait for cardBuilder to be ready
await waitForCardBuilder();
const page = getCurrentPage();
const tab = getCurrentTab();
if (!page) {
WARN("No supported page detected");
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
const parentId = getParentId();
if (!parentId) {
WARN("No topParentId found in URL");
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
const mediaConfig = MEDIA_CONFIGS[page];
if (!mediaConfig) {
WARN("No media configuration found for page:", page);
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
// Double-check ApiClient is available
if (typeof ApiClient === 'undefined') {
ERR('ApiClient is not available');
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
// Get current start index based on existing items
startIndex = getCurrentStartIndex();
// Get current sort settings from localStorage
const sortSettings = getCurrentSortSettings();
let apiParams = {
SortBy: mediaConfig.sortBy, // fallback
SortOrder: "Ascending", // fallback
IncludeItemTypes: mediaConfig.includeItemTypes,
Recursive: true,
Fields: "PrimaryImageAspectRatio,MediaSourceCount",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: parentId,
StartIndex: startIndex,
Limit: limit
};
// Apply detected sort settings if available
if (sortSettings) {
LOG('Using detected sort settings:', sortSettings);
apiParams.SortBy = sortSettings.sortBy;
apiParams.SortOrder = sortSettings.sortOrder;
// Add filter parameters if they exist
if (sortSettings.filters) {
apiParams.Filters = sortSettings.filters;
}
if (sortSettings.years) {
apiParams.Years = sortSettings.years;
}
if (sortSettings.genres) {
apiParams.Genres = sortSettings.genres;
}
if (sortSettings.tags) {
apiParams.Tags = sortSettings.tags;
}
} else {
LOG('Using fallback sort settings');
}
// Add alpha picker support
const alphaPicker = getCurrentAlphaPicker();
if (alphaPicker) {
apiParams.NameStartsWith = alphaPicker;
LOG('Added alpha picker filter:', alphaPicker);
}
const url = ApiClient.getUrl("Users/" + userId + "/Items", apiParams);
LOG('Loading more items:', { page, tab, startIndex, limit, url });
try {
const result = await ApiClient.getJSON(url);
// Update total record count from API response
if (result.TotalRecordCount !== undefined) {
totalRecordCount = result.TotalRecordCount;
LOG('Updated total record count from API:', totalRecordCount);
}
if (result.Items && result.Items.length > 0) {
const container = getContainer();
if (container) {
// Check if cardBuilder is available
if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.buildCard) {
ERR('cardBuilder not available or buildCard method missing');
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
LOG('Building cards:', {
itemCount: result.Items.length,
context: mediaConfig.context,
container: container
});
// Build each card individually and append to container
result.Items.forEach((item, index) => {
const card = window.cardBuilder.buildCard(item);
card.setAttribute('data-index', startIndex + index);
container.appendChild(card);
});
LOG('Cards built successfully');
}
startIndex += result.Items.length;
// Update pagination display
updatePaginationDisplay();
// Update hasMore based on total record count
if (totalRecordCount !== null) {
hasMore = startIndex < totalRecordCount;
LOG('hasMore updated based on total count:', {
startIndex,
totalRecordCount,
hasMore
});
} else {
// Fallback to old logic if no total count available
hasMore = result.Items.length >= limit;
LOG('hasMore updated using fallback logic:', {
itemsLength: result.Items.length,
limit,
hasMore
});
}
// Scroll state persistence removed - not needed
LOG('Loaded items successfully:', {
loaded: result.Items.length,
startIndex,
totalRecordCount,
hasMore
});
} else {
hasMore = false;
LOG('No more items to load (empty result)');
}
// Reset retry count on success
retryCount = 0;
} catch (err) {
ERR("Infinite scroll load error", err);
// Retry logic
if (retryCount < RETRY_ATTEMPTS) {
retryCount++;
WARN(`Retrying loadMore (attempt ${retryCount}/${RETRY_ATTEMPTS})`);
setTimeout(() => {
loading = false;
isRequestInProgress = false;
loadMore();
}, RETRY_DELAY * retryCount);
return;
} else {
ERR("Max retry attempts reached, giving up");
hasMore = false;
}
} finally {
loading = false;
isRequestInProgress = false;
// Hide loading indicator
hideLoadingIndicator();
}
}
function setupScrollWatcher() {
// Remove any existing scroll listener
window.removeEventListener("scroll", handleScroll);
// Add throttled scroll listener
window.addEventListener("scroll", handleScroll);
LOG('Scroll watcher setup complete');
}
function handleScroll() {
// Throttle scroll events - only check every 100ms
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
checkScrollPosition();
}, 100);
}
function checkScrollPosition() {
// Don't trigger if already loading, no more items, or request in progress
if (loading || !hasMore || isScrollTriggered || isRequestInProgress) {
LOG('Scroll check blocked:', { loading, hasMore, isScrollTriggered, isRequestInProgress });
return;
}
const container = getContainer();
if (!container) {
LOG('No container found in scroll check');
return;
}
// Don't trigger if there are no items in the container (page still loading)
const existingItemsCount = container.children.length;
if (existingItemsCount === 0) {
LOG('Scroll check blocked: No items in container yet (page still loading)');
return;
}
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Calculate distance from bottom - trigger when one viewport height away
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
const triggerThreshold = windowHeight; // One viewport height from bottom
LOG('Scroll check:', {
scrollTop,
windowHeight,
documentHeight,
distanceFromBottom,
triggerThreshold,
existingItemsCount,
loading,
hasMore,
isScrollTriggered,
isRequestInProgress
});
if (distanceFromBottom <= triggerThreshold) {
isScrollTriggered = true;
LOG('Triggering loadMore from scroll (one viewport height from bottom)');
loadMore().finally(() => {
// Reset trigger flag after load completes
setTimeout(() => {
isScrollTriggered = false;
LOG('Scroll trigger flag reset');
}, 1000); // Wait 1 second before allowing next trigger
});
}
}
// Reset state when navigating to a new library or tab
function reset() {
const page = getCurrentPage();
const tab = getCurrentTab();
LOG('Resetting infinite scroll state:', { page, tab });
// startIndex will be calculated dynamically based on existing items
hasMore = true;
totalRecordCount = null;
loading = false;
retryCount = 0;
isScrollTriggered = false;
isRequestInProgress = false;
// Scroll state restoration removed - not needed
// Clear container if switching pages/tabs
if (currentPage !== page || currentTab !== tab) {
const container = getContainer();
if (container) {
//container.innerHTML = '';
LOG('[REMOVED] Cleared container for new page/tab');
}
hasInitialized = false; // Reset initialization flag for new page/tab
isInitializing = false; // Reset initializing flag for new page/tab
}
currentPage = page;
currentTab = tab;
}
// Check if we should initialize infinite scroll
function shouldInitialize() {
const page = getCurrentPage();
const container = getContainer();
if (!page) {
return false;
}
if (!container) {
LOG('No active tab container found, waiting...');
return false;
}
// Double-check that we have the correct active tab
let activeTab = null;
if (page === 'tv.html') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) {
LOG('No active tab found for page, skipping initialization:', page);
return false;
}
return true;
}
// Initialize infinite scroll
async function initialize() {
if (!shouldInitialize() || hasInitialized || isInitializing) {
LOG('Skipping initialization:', { shouldInit: shouldInitialize(), hasInitialized, isInitializing });
return;
}
LOG('Initializing infinite scroll');
isInitializing = true;
hasInitialized = true;
try {
// Add CSS to hide paging controls
addInfiniteScrollCSS();
// Wait for ApiClient to be ready
if (!userId) {
LOG('Waiting for ApiClient before initialization...');
await waitForApiClient();
}
// Wait for cardBuilder to be ready
await waitForCardBuilder();
reset();
//loadMore(); // load first page
setupScrollWatcher();
// Update pagination display with initial state
updatePaginationDisplay();
} finally {
isInitializing = false;
}
}
// Scroll state loading removed - not needed
// Wait for page to mount
const observer = new MutationObserver(() => {
if (shouldInitialize()) {
initialize().catch(err => ERR('Initialization failed:', err));
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Also try to initialize immediately if page is already loaded
if (document.readyState === 'complete') {
setTimeout(() => {
initialize().catch(err => ERR('Initial initialization failed:', err));
}, 100);
}
// Cleanup function
function cleanup() {
window.removeEventListener("scroll", handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// Remove the CSS that hides paging controls
const styleElement = document.getElementById('infinite-scroll-paging-hide');
if (styleElement) {
styleElement.remove();
LOG('Removed CSS for paging controls');
}
// Hide and remove loading indicator
hideLoadingIndicator();
LOG('Infinite scroll cleanup complete');
}
// Cleanup on page unload
window.addEventListener('beforeunload', cleanup);
})();
+102 -23
View File
@@ -1,35 +1,40 @@
// KefinTweaks Script Injector
// Dynamically loads scripts and CSS files based on user configuration
// Usage: Modify the ENABLED_SCRIPTS array below to enable/disable features
// Usage: Configuration is now managed in kefinTweaks.js - this script reads from window.KefinTweaksConfig
(function() {
'use strict';
// Configuration: Set which scripts to load
// Set to true to enable, false to disable
const ENABLED_SCRIPTS = {
// Core functionality (required for other scripts)
cardBuilder: true, // Required by watchlist, homeScreen and search
// Configuration: Read from centralized config or use defaults
const ENABLED_SCRIPTS = window.KefinTweaksConfig?.scripts || {
// UI and functional enhancements
watchlist: true, // Watchlist functionality
homeScreen: true, // Custom home screen sections
search: true, // Enhanced search functionality
headerTabs: true, // Header tab enhancements
customMenu: true, // Custom menu functionality
customMenuLinks: true, // Custom menu links functionality
exclusiveElsewhere: true, // Exclusive elsewhere branding
// Scripts with additional backend requirements
updoot: false, // Upvote functionality from https://github.com/BobHasNoSoul/jellyfin-updoot
// Utility scripts
updoot: true, // Upvote functionality
backdropLeakFix: true, // Memory leak fixes
dashboardButtonFix: true, // Dashboard button fix
infiniteScroll: true, // Infinite scroll functionality
removeContinue: true, // Remove from continue watching functionality
subtitleSearch: true, // Subtitle search functionality
playlist: true // Playlist view page modifications
// Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
// are automatically enabled when needed by other scripts
};
// Script definitions with dependencies and metadata
const SCRIPT_DEFINITIONS = [
{
name: 'utils',
script: 'utils.js',
css: null,
dependencies: [],
description: 'Common utilities for page view management and MutationObserver conversion'
},
{
name: 'cardBuilder',
script: 'cardBuilder.js',
@@ -37,25 +42,39 @@
dependencies: [],
description: 'Core card building functionality (required by other scripts)'
},
{
name: 'localStorageCache',
script: 'localStorageCache.js',
css: null,
dependencies: [],
description: 'localStorage-based caching layer with 24-hour TTL and manual refresh'
},
{
name: 'modal',
script: 'modal.js',
css: null,
dependencies: [],
description: 'Generic modal system for Jellyfin-style dialogs'
},
{
name: 'watchlist',
script: 'watchlist.js',
css: 'watchlist.css',
dependencies: ['cardBuilder'],
dependencies: ['cardBuilder', 'localStorageCache', 'modal'],
description: 'Adds watchlist functionality throughout Jellyfin interface'
},
{
name: 'homeScreen',
script: 'homeScreen.js',
css: 'homeScreen.css',
dependencies: ['cardBuilder'],
dependencies: ['cardBuilder', 'localStorageCache', 'utils'],
description: 'Adds custom home screen sections'
},
{
name: 'search',
script: 'search.js',
css: 'search.css',
dependencies: [],
dependencies: ['cardBuilder', 'utils'],
description: 'Enhanced search functionality'
},
{
@@ -66,11 +85,11 @@
description: 'Header tab improvements'
},
{
name: 'customMenu',
script: 'custom-menu.js',
name: 'customMenuLinks',
script: 'customMenuLinks.js',
css: null,
dependencies: [],
description: 'Remove target="_blank" from custom menu links'
dependencies: ['utils'],
description: 'Load and add custom menu links from configuration'
},
{
name: 'exclusiveElsewhere',
@@ -99,13 +118,73 @@
css: null,
dependencies: [],
description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to'
},
{
name: 'infiniteScroll',
script: 'infiniteScroll.js',
css: null,
dependencies: ['cardBuilder'],
description: 'Adds infinite scroll functionality to media library pages'
},
{
name: 'removeContinue',
script: 'removeContinue.js',
css: null,
dependencies: [],
description: 'Adds remove from continue watching functionality to cards with data-position-ticks'
},
{
name: 'subtitleSearch',
script: 'subtitleSearch.js',
css: 'subtitleSearch.css',
dependencies: [],
description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources'
},
{
name: 'breadcrumbs',
script: 'breadcrumbs.js',
css: 'breadcrumbNav.css',
dependencies: ['utils'],
description: 'Adds breadcrumb navigation to item detail pages for Movies, Series, Seasons, Episodes, Music Artists, and Music Albums'
},
{
name: 'playlist',
script: 'playlist.js',
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
function autoEnableDependencies() {
let hasChanges = false;
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
script.dependencies.forEach(dep => {
if (!ENABLED_SCRIPTS[dep]) {
ENABLED_SCRIPTS[dep] = true;
hasChanges = true;
console.log(`[KefinTweaks Injector] Auto-enabled dependency '${dep}' for '${script.name}'`);
}
});
}
});
return hasChanges;
}
// Configuration validation
function validateConfiguration() {
const errors = [];
// Auto-enable dependencies first
const dependenciesEnabled = autoEnableDependencies();
if (dependenciesEnabled) {
console.log('[KefinTweaks Injector] Auto-enabled required dependencies');
}
// Check dependencies - only validate if the script is enabled
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
@@ -125,9 +204,9 @@
return true;
}
// Get the root path for scripts (adjust this based on your setup)
// Get the root path for scripts from configuration
function getScriptRoot() {
return 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
return window.KefinTweaksConfig?.scriptRoot || 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
}
// Load a CSS file
+203
View File
@@ -0,0 +1,203 @@
// KefinTweaks - Central Configuration and Script Loader
// Copy this entire file into your Jellyfin JS injector plugin
// Modify the configuration below to enable/disable features and customize settings
//
// VERSION: 1.0.0 - Compatible with Jellyfin 10.10.7
//
// FEATURES OVERVIEW:
// =================
// For a detailed feature overview see README.md at https://github.com/ranaldsgift/KefinTweaks
//
// PREREQUISITES:
// ============
// - Jellyfin 10.10.7
// - JS Injector plugin https://github.com/n00bcodr/Jellyfin-JavaScript-Injector
// - Custom Tabs plugin for watchlist functionality https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
// - JellyfinEnhanced plugin for Jellyseerr search functionality and ExclusiveElsewhere https://github.com/n00bcodr/Jellyfin-Enhanced
//
// INSTALLATION:
// ============
// 0. Install required prerequisites listed above
// 1. Add a new script to your JS Injector plugin
// 2. Copy this entire file into the new script
// (Optional) Enable/disable features, or customize their configuration by editing the script below
// 3. Ensure the script is enabled
// 4. Save your changes
//
// CONFIGURATION GUIDE:
// ===================
// - Scripts: Enable/disable individual features
// - Home Screen: Configure custom sections and discovery settings
// - Exclusive Elsewhere: Only useful if you want to display an icon or logo instead of text
// - Search: Enable Jellyseerr integration adds an option to search for Jellyseerr results only
//
// Considerations for the home screen sections:
// 1. Custom sections (order 1-29) - Your playlist-based sections
// 2. New and Trending sections (order 30-49) - Newest movies and episodes
// 3. Seasonal sections (order 50+) - Holiday-themed content
// 4. Discovery sections (order 200+) - Intelligent recommendations based on your library
(function() {
'use strict';
// ============================================================================
// CENTRAL CONFIGURATION
// ============================================================================
const KEFIN_TWEAKS_CONFIG = {
// ============================================================================
// SCRIPT ROOT CONFIGURATION
// ============================================================================
// Configure where scripts are loaded from
scriptRoot: 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/',
// ============================================================================
// SCRIPT ENABLE/DISABLE FLAGS
// ============================================================================
// Toggle individual features on/off. Dependencies are automatically managed.
scripts: {
// Major feature enhancements
homeScreen: true, // Custom home screen sections and discovery engine
search: true, // Enhanced search with better UI and Jellyseerr integration
// REQUIRES: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
watchlist: true, // Complete watchlist management with progress tracking
// UI and navigation improvements
headerTabs: true, // Improved header tab functionality and behavior
customMenuLinks: true, // Load and add custom menu links from configuration
breadcrumbs: true, // Breadcrumb navigation for Movies, Series, Seasons, Episodes, Music
// Branding and external integrations
updoot: false, // Integration with Jellyfin Updoot upvote functionality
// 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
// 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
},
// ============================================================================
// HOME SCREEN CONFIGURATION
// ============================================================================
// Configure custom home screen sections and discovery features
homeScreen: {
// NEW AND TRENDING SECTIONS
// Automatically curated sections showing latest content
enableNewAndTrending: true, // Master toggle for new and trending sections
enableNewMovies: true, // Show newest movies section
enableNewEpisodes: true, // Show newest episodes from last 7 days
enableTrending: false, // Show trending sections (currently stubs, order 32-33)
// DISCOVERY SECTIONS
// Intelligent recommendations based on your library content
// Includes:
// - [Genre] Movies (random genres with sufficient movie count)
// - Directed by [Director] (top directors from your library)
// - Written by [Writer] (top writers from your library)
// - Starring [Actor] (top actors from your library)
// - Because you watched [Movie] (similar content based on watch history)
// - Because you liked [Movie] (similar content based on favorites)
enableDiscovery: true, // Enables discovery sections on the home screen
// Discovery section behavior and filtering
enableInfiniteScroll: false, // Use infinite scroll vs Load More button
minPeopleAppearances: 10, // Minimum movie appearances for people to be featured
minGenreMovieCount: 50, // Minimum movie count for genres to be included
// WATCHLIST SECTION
// Requires watchlist script to be enabled
enableWatchlist: true, // Show watchlist section on home screen
// SEASONAL SECTIONS
// Holiday-themed content sections
enableSeasonal: true, // Master toggle for seasonal sections
seasonalItemLimit: 16, // Maximum items to show in seasonal sections
// CUSTOM SECTIONS
// Add your own playlist/collection-based sections
customSections: [
// Add more custom sections here
/* Example:
{
// Optionally provide a name, otherwise the name of the playlist/collection will be used
name: 'Your Custom Section',
// The ID of the playlist/collection to display in the section, get this from Jellyfin playlist/collection URL
id: 'acc84f03475eb51602b13cde158d21ad',
// The maximum number of items to display in the section on the home screen
maxItems: 20,
// The order of the section for it to appear on the home screen
order: 5
},
*/
],
},
// ============================================================================
// EXCLUSIVE ELSEWHERE CONFIGURATION
// ============================================================================
// Configure exclusive elsewhere branding behavior
exclusiveElsewhere: {
hideServerName: false // Set to true to hide server name, useful when you want to only show a logo or icon via CSS
},
// ============================================================================
// SEARCH CONFIGURATION
// ============================================================================
// Configure search functionality
search: {
enableJellyseerr: false // Enable Jellyseerr integration for request functionality
},
// ============================================================================
// CUSTOM MENU LINKS CONFIGURATION
// ============================================================================
// Configure custom menu links to be added to the custom menu
customMenuLinks: [
// Add your custom menu links here
/* Example:
{
name: 'My Custom Link', // Display name for the menu item
icon: 'link', // Material icon name (optional, defaults to 'link')
url: '#/userpluginsettings.html?pageUrl=https://domain.com/custom-page', // URL to navigate to
openInNewTab: false // Whether to open in new tab (optional, defaults to false)
},
{
name: 'External Link',
icon: 'open_in_new',
url: 'https://example.com',
openInNewTab: true
},
*/
],
};
// ============================================================================
// CONFIGURATION EXPOSURE
// ============================================================================
// Make configuration available to individual scripts
window.KefinTweaksConfig = KEFIN_TWEAKS_CONFIG;
// ============================================================================
// SCRIPT LOADER INITIALIZATION
// ============================================================================
// Load the injector script which handles loading individual scripts based on configuration
const script = document.createElement("script");
script.src = `${KEFIN_TWEAKS_CONFIG.scriptRoot}injector.js`;
script.async = true;
document.head.appendChild(script);
console.log('[KefinTweaks] Central configuration loaded. Available at window.KefinTweaksConfig');
console.log('[KefinTweaks] Version 1.0.0 - Compatible with Jellyfin 10.10.7');
})();
+269
View File
@@ -0,0 +1,269 @@
// KefinTweaks localStorage Cache Manager
// Provides 24-hour TTL caching with manual refresh functionality
// Usage: Integrates with existing cache system to add persistence
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[LocalStorageCache]', ...args);
const WARN = (...args) => console.warn('[LocalStorageCache]', ...args);
const ERR = (...args) => console.error('[LocalStorageCache]', ...args);
// Generic localStorage cache manager with chunked storage support
class LocalStorageCache {
constructor(prefix = 'kefinTweaks_', ttl = 24 * 60 * 60 * 1000, chunkSize = 10) { // 24 hours, 10 items per chunk
this.prefix = prefix;
this.ttl = ttl;
this.chunkSize = chunkSize;
}
// Generate cache key with user context
getCacheKey(cacheName, userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
return `${this.prefix}${cacheName}_${user}`;
}
// Check if cache is valid (not expired)
isCacheValid(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
if (!cached) return false;
try {
const data = JSON.parse(cached);
const now = Date.now();
const ttl = data.ttl || this.ttl; // Use stored TTL or default
return (now - data.timestamp) < ttl;
} catch {
return false;
}
}
// Get cached data if valid
get(cacheName, userId = null) {
if (!this.isCacheValid(cacheName, userId)) {
return null;
}
const key = this.getCacheKey(cacheName, userId);
try {
const cached = localStorage.getItem(key);
const data = JSON.parse(cached);
return data.payload;
} catch {
return null;
}
}
// Store data with timestamp
set(cacheName, data, userId = null, customTtl = null) {
const key = this.getCacheKey(cacheName, userId);
const ttl = customTtl || this.ttl;
const payload = {
timestamp: Date.now(),
ttl: ttl,
payload: data
};
try {
localStorage.setItem(key, JSON.stringify(payload));
LOG(`Cached data for ${cacheName}: ${Array.isArray(data) ? data.length : 'object'} items (TTL: ${ttl / (1000 * 60 * 60)}h)`);
return true;
} catch (error) {
WARN('Failed to store data:', error);
return false;
}
}
// Clear specific cache
clear(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
localStorage.removeItem(key);
LOG(`Cleared cache for ${cacheName}`);
}
// Clear all caches for current user
clearAll(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(this.prefix) && key.endsWith(`_${user}`)
);
keys.forEach(key => localStorage.removeItem(key));
LOG(`Cleared all caches for user ${user}: ${keys.length} entries`);
}
// Get cache age in hours
getCacheAge(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
if (!cached) return null;
try {
const data = JSON.parse(cached);
return (Date.now() - data.timestamp) / (1000 * 60 * 60); // hours
} catch {
return null;
}
}
// Get cache size in bytes (approximate)
getCacheSize(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
return cached ? cached.length : 0;
}
// Get all cache names for current user
getAllCacheNames(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(this.prefix) && key.endsWith(`_${user}`)
);
return keys.map(key => key.replace(this.prefix, '').replace(`_${user}`, ''));
}
// Chunked storage methods for large datasets
setChunked(cacheName, data, userId = null) {
if (!Array.isArray(data)) {
WARN('Chunked storage requires array data');
return false;
}
if (data.length === 0) {
LOG(`No data to chunk for ${cacheName}`);
return true;
}
try {
const chunks = this.chunkArray(data, this.chunkSize);
const chunkCount = chunks.length;
LOG(`Storing ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
// Store each chunk
for (let i = 0; i < chunks.length; i++) {
const chunkKey = `${cacheName}_chunk_${i}`;
const chunkData = {
chunkIndex: i,
totalChunks: chunkCount,
data: chunks[i],
timestamp: Date.now()
};
if (!this.set(chunkKey, chunkData, userId)) {
WARN(`Failed to store chunk ${i} for ${cacheName}`);
return false;
}
}
// Store metadata
const metadata = {
totalChunks: chunkCount,
totalItems: data.length,
timestamp: Date.now(),
chunkSize: this.chunkSize
};
if (!this.set(`${cacheName}_meta`, metadata, userId)) {
WARN(`Failed to store metadata for ${cacheName}`);
return false;
}
LOG(`Successfully stored ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
return true;
} catch (error) {
ERR(`Error storing chunked data for ${cacheName}:`, error);
return false;
}
}
// Load chunked data
getChunked(cacheName, userId = null) {
try {
const meta = this.get(`${cacheName}_meta`, userId);
if (!meta) {
LOG(`No metadata found for chunked cache ${cacheName}`);
return null;
}
if (!this.isCacheValid(`${cacheName}_meta`, userId)) {
LOG(`Chunked cache ${cacheName} has expired`);
return null;
}
const allData = [];
let missingChunks = 0;
for (let i = 0; i < meta.totalChunks; i++) {
const chunk = this.get(`${cacheName}_chunk_${i}`, userId);
if (chunk && chunk.data) {
allData.push(...chunk.data);
} else {
missingChunks++;
WARN(`Missing chunk ${i} for ${cacheName}`);
}
}
if (missingChunks > 0) {
WARN(`Missing ${missingChunks} chunks for ${cacheName}, returning null`);
return null;
}
if (allData.length !== meta.totalItems) {
WARN(`Data length mismatch for ${cacheName}: expected ${meta.totalItems}, got ${allData.length}`);
return null;
}
LOG(`Successfully loaded ${allData.length} items from ${meta.totalChunks} chunks for ${cacheName}`);
return allData;
} catch (error) {
ERR(`Error loading chunked data for ${cacheName}:`, error);
return null;
}
}
// Clear chunked cache
clearChunked(cacheName, userId = null) {
try {
const meta = this.get(`${cacheName}_meta`, userId);
if (meta) {
// Clear all chunks
for (let i = 0; i < meta.totalChunks; i++) {
this.clear(`${cacheName}_chunk_${i}`, userId);
}
// Clear metadata
this.clear(`${cacheName}_meta`, userId);
LOG(`Cleared chunked cache ${cacheName} (${meta.totalChunks} chunks)`);
} else {
LOG(`No chunked cache found for ${cacheName}`);
}
} catch (error) {
ERR(`Error clearing chunked cache ${cacheName}:`, error);
}
}
// Utility method to chunk arrays
chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
// Check if a cache is chunked
isChunked(cacheName, userId = null) {
const meta = this.get(`${cacheName}_meta`, userId);
return meta && meta.totalChunks > 1;
}
}
// Create global instance
window.LocalStorageCache = LocalStorageCache;
LOG('LocalStorageCache manager loaded');
})();
+226
View File
@@ -0,0 +1,226 @@
/**
* Generic Modal System for Jellyfin-style dialogs
* Provides reusable modal functionality similar to cardBuilder.js
*/
window.ModalSystem = (function() {
'use strict';
// Store active modals
const activeModals = new Map();
/**
* Create a Jellyfin-style modal dialog
* @param {Object} options - Modal configuration
* @param {string} options.id - Unique modal ID
* @param {string} options.title - Modal title
* @param {string} options.content - HTML content for the modal body
* @param {Function} options.onClose - Callback when modal closes
* @param {Function} options.onOpen - Callback when modal opens
* @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true)
* @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true)
* @returns {Object} Modal instance
*/
function createModal(options = {}) {
const {
id,
title,
content,
onClose,
onOpen,
closeOnBackdrop = true,
closeOnEscape = true
} = options;
if (!id) {
throw new Error('Modal ID is required');
}
// Remove existing modal if it exists
removeModal(id);
// Create modal elements
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
backdrop.setAttribute('data-modal-id', id);
const dialogContainer = document.createElement('div');
dialogContainer.className = 'dialogContainer';
dialogContainer.setAttribute('data-modal-id', id);
const dialog = document.createElement('div');
dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened';
dialog.setAttribute('data-history', 'true');
dialog.setAttribute('data-autofocus', 'true');
dialog.setAttribute('data-removeonclose', 'true');
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
const dialogContent = document.createElement('div');
dialogContent.style.margin = '0';
dialogContent.style.padding = '1.25em 1.5em 1.5em';
// Add title if provided
if (title) {
const titleElement = document.createElement('h2');
titleElement.style.margin = '0 0 .5em';
titleElement.textContent = title;
dialogContent.appendChild(titleElement);
}
// Add content
if (content) {
if (typeof content === 'string') {
dialogContent.innerHTML += content;
} else if (content instanceof HTMLElement) {
dialogContent.appendChild(content);
}
}
// Assemble modal
dialog.appendChild(dialogContent);
dialogContainer.appendChild(dialog);
// Add to DOM
document.body.appendChild(backdrop);
document.body.appendChild(dialogContainer);
// Create modal instance
const modalInstance = {
id,
backdrop,
dialogContainer,
dialog,
dialogContent,
isOpen: true,
close: () => closeModal(id),
updateContent: (newContent) => updateModalContent(id, newContent),
addEventListener: (event, handler) => {
dialog.addEventListener(event, handler);
}
};
// Store modal instance
activeModals.set(id, modalInstance);
// Add event listeners
if (closeOnBackdrop) {
backdrop.addEventListener('click', () => closeModal(id));
dialogContainer.addEventListener('click', (e) => {
if (e.target === dialogContainer) {
closeModal(id);
}
});
}
if (closeOnEscape) {
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(id);
}
};
document.addEventListener('keydown', escapeHandler);
modalInstance._escapeHandler = escapeHandler;
}
// Call onOpen callback
if (onOpen && typeof onOpen === 'function') {
onOpen(modalInstance);
}
return modalInstance;
}
/**
* Close a modal by ID
* @param {string} id - Modal ID
*/
function closeModal(id) {
const modal = activeModals.get(id);
if (!modal) return;
// Remove escape handler if it exists
if (modal._escapeHandler) {
document.removeEventListener('keydown', modal._escapeHandler);
}
// Remove from DOM
if (modal.backdrop && modal.backdrop.parentNode) {
modal.backdrop.parentNode.removeChild(modal.backdrop);
}
if (modal.dialogContainer && modal.dialogContainer.parentNode) {
modal.dialogContainer.parentNode.removeChild(modal.dialogContainer);
}
// Remove from active modals
activeModals.delete(id);
// Call onClose callback if modal instance has it
if (modal.onClose && typeof modal.onClose === 'function') {
modal.onClose();
}
}
/**
* Remove a modal by ID (force removal)
* @param {string} id - Modal ID
*/
function removeModal(id) {
const modal = activeModals.get(id);
if (modal) {
closeModal(id);
}
}
/**
* Update modal content
* @param {string} id - Modal ID
* @param {string|HTMLElement} content - New content
*/
function updateModalContent(id, content) {
const modal = activeModals.get(id);
if (!modal) return;
if (typeof content === 'string') {
modal.dialogContent.innerHTML = content;
} else if (content instanceof HTMLElement) {
modal.dialogContent.innerHTML = '';
modal.dialogContent.appendChild(content);
}
}
/**
* Check if a modal is open
* @param {string} id - Modal ID
* @returns {boolean}
*/
function isModalOpen(id) {
return activeModals.has(id);
}
/**
* Get all active modal IDs
* @returns {Array<string>}
*/
function getActiveModalIds() {
return Array.from(activeModals.keys());
}
/**
* Close all modals
*/
function closeAllModals() {
const ids = Array.from(activeModals.keys());
ids.forEach(id => closeModal(id));
}
// Public API
return {
create: createModal,
close: closeModal,
remove: removeModal,
updateContent: updateModalContent,
isOpen: isModalOpen,
getActiveIds: getActiveModalIds,
closeAll: closeAllModals
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+309
View File
@@ -0,0 +1,309 @@
// Jellyfin Playlist Script
// Modifies playlist view page behavior to navigate to item details instead of playing
// Adds play button to playlist items
// Requires: cardBuilder.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[Playlist]', ...args);
const WARN = (...args) => console.warn('[Playlist]', ...args);
const ERR = (...args) => console.error('[Playlist]', ...args);
LOG('Script loaded - JS Injector mode');
let currentPlaylistId = null;
/**
* Dynamically checks if playlist items have already been modified
* @returns {boolean} - True if playlist items have been modified
*/
function isPlaylistPageModified() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
return false;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
return false;
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
if (playlistItems.length === 0) {
return false;
}
// Check if any playlist item has a play button (indicating modification)
for (const item of playlistItems) {
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (buttonsContainer && buttonsContainer.querySelector('.playBtn')) {
return true;
}
}
return false;
}
/**
* Checks if the current page is a playlist page by fetching the item details
* @param {string} itemId - The item ID from the URL
* @returns {Promise<boolean>} - True if the item is a Playlist
*/
async function isPlaylistPage(itemId) {
try {
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const url = `${serverUrl}/Items/${itemId}`;
const response = await fetch(url, {
headers: {
"Authorization": `MediaBrowser Token="${token}"`
}
});
if (!response.ok) {
WARN(`Failed to fetch item details: ${response.status}`);
return false;
}
const item = await response.json();
LOG(`Item type: ${item.Type}, Name: ${item.Name}`);
return item.Type === 'Playlist';
} catch (error) {
ERR('Error checking if item is playlist:', error);
return false;
}
}
/**
* Modifies playlist item click behavior to navigate to details page
*/
function modifyPlaylistItemClicks() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
WARN('Library page not found');
return;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
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) => {
const playlistItemId = item.getAttribute('data-playlistitemid');
const serverId = ApiClient.serverId();
if (!playlistItemId) {
WARN(`Playlist item ${index} has no data-playlistitemid`);
return;
}
// Remove existing click handlers
item.removeAttribute('data-action');
item.style.cursor = 'pointer';
// 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;
}
});
LOG(`Modified click handler for playlist item: ${playlistItemId}`);
});
}
/**
* Adds play button to each playlist item's listViewUserDataButtons container
*/
function addPlayButtons() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
WARN('Library page not found for play buttons');
return;
}
const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return;
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
LOG(`Found ${playlistItems.length} playlist items to add play buttons to`);
playlistItems.forEach((item, index) => {
const playlistItemId = item.getAttribute('data-playlistitemid');
const serverId = ApiClient.serverId();
if (!playlistItemId) {
WARN(`Playlist item ${index} has no data-playlistitemid`);
return;
}
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (!buttonsContainer) {
WARN(`No listViewUserDataButtons container found for playlist item ${index}`);
return;
}
// Check if play button already exists for this item
const existingPlayButton = buttonsContainer.querySelector('.playlist-play-button');
if (existingPlayButton) {
LOG(`Play button already exists for item ${index}`);
return;
}
// Create play button following the same pattern as other buttons
// Play button
const playButton = document.createElement('button');
playButton.setAttribute('is', 'paper-icon-button-light');
playButton.className = 'itemAction paper-icon-button-light playBtn';
playButton.setAttribute('data-action', 'resume');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
playButton.appendChild(playIcon);
// Prepend the play button to the container
buttonsContainer.insertBefore(playButton, buttonsContainer.firstChild);
LOG(`Added play button to playlist item ${index}: ${playlistItemId}`);
});
LOG(`Added play buttons to ${playlistItems.length} playlist items`);
}
/**
* Main function to modify the playlist page
* @param {string} itemId - The playlist item ID
*/
async function modifyPlaylistPage(itemId) {
if (isPlaylistPageModified()) {
LOG('Playlist page already modified, skipping');
return;
}
LOG(`Modifying playlist page for item: ${itemId}`);
currentPlaylistId = itemId;
// Wait a bit for the page to fully load
setTimeout(() => {
modifyPlaylistItemClicks();
addPlayButtons();
LOG('Playlist page modifications complete');
}, 500);
}
/**
* Resets the current playlist ID when navigating away from playlist page
*/
function resetModificationState() {
currentPlaylistId = null;
LOG('Reset playlist modification state');
}
/**
* Initialize playlist hook using utils
*/
function initializePlaylistHook() {
if (!window.KefinTweaksUtils) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initializePlaylistHook, 1000);
return;
}
LOG('Registering playlist handler with KefinTweaksUtils');
// Register handler for details pages
window.KefinTweaksUtils.onViewPage((view, element) => {
LOG('Details page detected via utils');
// Check if we're on a details 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(`Details page detected with item ID: ${itemId}`);
// Reset state when navigating to a new page
resetModificationState();
// Check if this is a playlist page
isPlaylistPage(itemId).then(isPlaylist => {
if (isPlaylist) {
LOG(`Playlist page detected: ${itemId}`);
modifyPlaylistPage(itemId);
} else {
LOG(`Not a playlist page: ${itemId}`);
}
}).catch(error => {
ERR('Error checking playlist page:', error);
});
}
} else {
// Not on a details page, reset state
resetModificationState();
}
}, {
immediate: true,
pages: ['details']
});
LOG('Playlist hook initialized successfully');
}
// 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');
})();
+295
View File
@@ -0,0 +1,295 @@
// Remove From Continue Watching
// Adds functionality to remove items from the continue watchlist list
//
// This script adds a "remove from continue watching" button to cards that have
// data-positionticks attribute (indicating they are in progress)
//
// No external dependencies required - self-contained functionality
(function() {
'use strict';
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');
// Add CSS for positioning the remove button in top right corner
const style = document.createElement('style');
style.textContent = `
.remove-continue-button {
position: absolute !important;
top: 8px !important;
right: 8px !important;
z-index: 10 !important;
background: rgba(0, 0, 0, 0.7) !important;
border: none !important;
border-radius: 50% !important;
width: 32px !important;
height: 32px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
opacity: 0 !important;
transform: scale(0.8) !important;
}
.cardOverlayContainer:hover .remove-continue-button {
opacity: 1 !important;
transform: scale(1) !important;
}
.remove-continue-button:hover {
background: rgba(220, 38, 38, 0.9) !important;
transform: scale(1.1) !important;
}
.remove-continue-button:disabled {
opacity: 0.6 !important;
cursor: not-allowed !important;
transform: scale(0.9) !important;
}
.remove-continue-button .material-icons {
color: white !important;
font-size: 18px !important;
}
`;
document.head.appendChild(style);
/************ Remove Continue Watching Button Observer ************/
// Function to add remove continue watching button to a card overlay container
function addRemoveContinueButton(overlayContainer) {
// Check if remove continue button already exists
if (overlayContainer && overlayContainer.querySelector('.remove-continue-button')) {
return;
}
// Find the card parent to get the item ID and check for data-positionticks
const card = overlayContainer.closest('.card');
if (!card) {
WARN('Could not find card parent for overlay container');
return;
}
// Only add button to cards with data-positionticks (in progress items)
const positionTicks = card.getAttribute('data-positionticks');
if (!positionTicks) {
return; // Not a continue watching item, skip
}
const itemId = card.getAttribute('data-id');
if (!itemId) {
WARN('Could not find data-id on card element');
return;
}
// Find the .cardOverlayButton-br container to position our button before it
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (!buttonContainer) {
WARN('Could not find .cardOverlayButton-br container');
return;
}
// Create remove continue watching button
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'remove-continue-button';
removeButton.setAttribute('data-action', 'none');
removeButton.setAttribute('data-id', itemId);
removeButton.title = 'Remove from Continue Watching';
// Create the close icon
const removeIcon = document.createElement('span');
removeIcon.className = 'material-icons';
removeIcon.textContent = 'close';
removeIcon.setAttribute('aria-hidden', 'true');
removeButton.appendChild(removeIcon);
// Add click event listener
removeButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Show loading state
const originalIcon = removeIcon.textContent;
removeIcon.textContent = 'hourglass_empty';
removeButton.disabled = true;
try {
// Call API to remove from continue watching
await removeFromContinueWatching(itemId);
// Remove the card from the DOM
card.remove();
LOG(`Successfully removed item ${itemId} from continue watching`);
} catch (error) {
ERROR('Failed to remove from continue watching:', error);
// Restore original state on error
removeIcon.textContent = originalIcon;
removeButton.disabled = false;
// Show error message to user
alert('Failed to remove from continue watching. Please try again.');
}
});
// Add the button as a sibling before the .cardOverlayButton-br container
buttonContainer.parentNode.insertBefore(removeButton, buttonContainer);
LOG(`Added remove continue watching button for item ${itemId}`);
}
// Function to fetch current user data for an item
async function fetchUserData(itemId) {
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const token = ApiClient.accessToken();
if (!userId || !serverUrl || !token) {
throw new Error('Missing required API credentials');
}
const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
const response = await fetch(userDataUrl, {
method: 'GET',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user data: ${response.status} ${response.statusText}`);
}
return await response.json();
}
// Function to update user data to reset progress while preserving played status
async function updateUserDataProgress(itemId, userData) {
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const token = ApiClient.accessToken();
if (!userId || !serverUrl || !token) {
throw new Error('Missing required API credentials');
}
// Create updated user data with progress reset
const updatedUserData = {
...userData,
PlayedPercentage: 0,
PlaybackPositionTicks: 0
};
const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
const response = await fetch(userDataUrl, {
method: 'POST',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Content-Type": "application/json"
},
body: JSON.stringify(updatedUserData)
});
if (!response.ok) {
throw new Error(`Failed to update user data: ${response.status} ${response.statusText}`);
}
return response;
}
// Function to call the API to remove item from continue watching
async function removeFromContinueWatching(itemId) {
try {
// First, fetch the current user data
const userData = await fetchUserData(itemId);
// Then update it to reset progress while preserving played status
await updateUserDataProgress(itemId, userData);
LOG(`Successfully reset progress for item ${itemId} while preserving played status`);
} catch (error) {
ERROR('Failed to reset progress:', error);
throw error;
}
}
// Function to process all existing overlay containers
function processExistingOverlayContainers() {
const overlayContainers = document.querySelectorAll('.cardOverlayContainer');
overlayContainers.forEach((overlayContainer) => {
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(overlayContainer);
}
});
}
// Set up MutationObserver to watch for new overlay containers
function setupRemoveContinueButtonObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is an overlay container
if (node.classList && node.classList.contains('cardOverlayContainer')) {
const buttonContainer = node.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(node);
}
}
// Check for overlay containers within the added node
const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
if (overlayContainers && overlayContainers.length > 0) {
overlayContainers.forEach((overlayContainer) => {
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(overlayContainer);
}
});
}
}
});
}
});
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Process any existing overlay containers
processExistingOverlayContainers();
}
// Initialize the observer
setupRemoveContinueButtonObserver();
// Debug function for troubleshooting (available in console)
window.debugRemoveContinueButtons = function() {
LOG('Manual debug trigger called');
LOG('Current overlay containers:', document.querySelectorAll('.cardOverlayContainer').length);
LOG('Current remove continue buttons:', document.querySelectorAll('.remove-continue-button').length);
LOG('Cards with data-positionticks:', document.querySelectorAll('.card[data-positionticks]').length);
processExistingOverlayContainers();
};
LOG('Remove continue watching functionality loaded successfully');
})();
+19
View File
@@ -28,4 +28,23 @@ div#searchPage {
div#searchPage {
padding-top: 5rem !important;
}
}
div#searchPage {
display: flex;
flex-direction: column;
}
div#searchPage>* {
order: 1;
}
div#searchPage>.jellyseerr-section {
order: 2;
padding-left: max(env(safe-area-inset-left), 3.3%);
}
div#smart-search-results {
padding-bottom: 0 !important;
padding-right: 0 !important;
}
+178 -265
View File
@@ -5,28 +5,18 @@
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
const ERR = (...args) => console.error('[SmartSearch]', ...args);
// Configuration
const CONFIG = {
enableJellyseerr: false, // Toggle for Jellyseerr integration
};
LOG('script load start');
// Monitor network requests to see what's happening
const originalFetch = window.fetch;
const originalXHR = window.XMLHttpRequest;
// Track if we should block XHR requests (only for the initial URL query)
// Configuration
const CONFIG = window.KefinTweaksConfig?.search || {
enableJellyseerr: false // Toggle for Jellyseerr integration
};
// Track if we should block XHR requests (only for direct URL navigation with query params)
let shouldBlockXHR = false;
let hasBlockedInitialSearch = false;
// Override fetch to log our requests
window.fetch = function(...args) {
LOG('FETCH request:', args[0]);
return originalFetch.apply(this, args);
};
// Override XHR to log and potentially block default search requests
// Override XHR to block automatic search requests from direct URL navigation
const OriginalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new OriginalXHR();
@@ -37,12 +27,12 @@
LOG('XHR request:', method, url);
// Check if this is a search request we want to block
// Our smart search uses fetch(), so any XHR search request should be blocked
// 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) {
LOG('Blocking initial default search XHR request:', url);
LOG('Blocking initial automatic search XHR request:', url);
hasBlockedInitialSearch = true;
// Disable blocking after this first request
shouldBlockXHR = false;
@@ -50,7 +40,7 @@
// Override send to do nothing
xhr.send = function() {
LOG('Initial default search XHR blocked');
LOG('Initial automatic search XHR blocked');
// Simulate a successful response
setTimeout(() => {
xhr.readyState = 4;
@@ -61,16 +51,16 @@
}
}, 100);
};
} else {
// Use original send for non-blocked requests
xhr.send = function(...args) {
return originalSend.apply(this, args);
};
}
return originalOpen.apply(this, [method, url, ...args]);
};
xhr.send = function(...args) {
LOG('XHR sending:', args);
return originalSend.apply(this, args);
};
return xhr;
};
@@ -90,14 +80,13 @@
LOG('Current hash:', window.location.hash);
if (queryFromUrl && (window.location.pathname.includes('search.html') || window.location.hash.includes('search'))) {
LOG('URL query detected, setting up protection');
LOG('URL query detected, enabling XHR blocking for automatic search prevention');
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
// Just enable XHR blocking - don't touch the input
LOG('XHR blocking enabled for initial search');
}
})();
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
@@ -127,6 +116,30 @@
LOG('Cached results:', { searchTerm, searchType, cacheKey, resultCount: results.total });
}
// Function to clear search results except jellyseerr-section
function clearSearchResultsExceptJellyseerr() {
LOG('Clearing search results except jellyseerr-section');
// Clear the smart search results container
const resultsContainer = document.getElementById('smart-search-results');
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
}
// 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;
@@ -137,9 +150,8 @@
.smart-search-wrapper { margin: 12px 0 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-primary, .smart-search-secondary { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; }
.smart-search-primary { background:#00a4dc; color:#fff; }
.smart-search-secondary { background:#444; color:#fff; }
.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-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; }
@@ -150,6 +162,7 @@
position: absolute;
z-index: 1;
right: 0;
cursor: pointer;
}
`;
document.head.appendChild(style);
@@ -214,57 +227,38 @@
}
}
// Fetch Jellyseerr results
async function fetchJellyseerrResults(searchTerm) {
try {
const jellyseerrUrl = `${ApiClient.serverAddress()}/JellyfinEnhanced/jellyseerr/search?query=${encodeURIComponent(searchTerm)}`;
LOG('Fetching Jellyseerr results:', jellyseerrUrl);
const response = await ApiClient.fetch({
url: jellyseerrUrl,
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Jellyfin-User-Id': ApiClient.getCurrentUserId()
}
});
const data = await response.json();
LOG('Jellyseerr response:', data);
return data;
} catch (err) {
WARN('Jellyseerr fetch failed', err);
return { movies: [], tv: [] };
}
}
// Convert Jellyseerr item to our format
function convertJellyseerrItem(item, type) {
return {
Id: `jellyseerr_${item.id}`,
Name: item.title || item.name,
Type: type === 'movie' ? 'Movie' : 'Series',
MediaType: 'Video',
ImageTags: item.posterPath ? { Primary: item.posterPath } : null,
ProductionYear: item.releaseDate ? new Date(item.releaseDate).getFullYear() : null,
Overview: item.overview,
// Jellyseerr specific data
JellyseerrId: item.id,
JellyseerrType: type,
ExternalId: item.tmdbId || item.tvdbId,
IsJellyseerr: true
};
}
// perform smart search
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
const results = { groupedItems: {}, total: 0 };
let results = { groupedItems: {}, total: 0 };
const searchPage = document.getElementById('searchPage');
if (!searchPage) {
ERR('searchPage not found');
return results;
}
// Hide search suggestions
const searchSuggestions = document.querySelector('.searchSuggestions');
const searchSuggestions = searchPage.querySelector('.searchSuggestions');
if (searchSuggestions) {
searchSuggestions.style.display = 'none';
searchSuggestions.remove();
}
const noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section');
if (!noItemsMessage) {
const noItemsMessage = document.createElement('div');
noItemsMessage.className = 'noItemsMessage dummy-section';
noItemsMessage.style.display = 'none';
searchPage.appendChild(noItemsMessage);
}
// Update URL with search query and type
const urlType = searchType === 'core' ? 'videos' : searchType;
updateSearchUrl(searchTerm, urlType);
// Handle Jellyseerr-only search
if (searchType === 'request') {
return results;
}
// Show loading spinner
@@ -296,19 +290,12 @@
});
});
// Add Jellyseerr search for core types (if enabled)
if (CONFIG.enableJellyseerr && (searchType === 'core' || searchType === 'all')) {
promises.push(fetchJellyseerrResults(searchTerm));
} else {
promises.push(Promise.resolve({ movies: [], tv: [] }));
}
const start = performance.now();
const responses = await Promise.all(promises);
const end = performance.now();
// Group results by type
responses.forEach((r, idx) => {
responses.forEach(async (r, idx) => {
if (idx < typeGroups.length) {
// Regular Jellyfin results
const type = typeGroups[idx];
@@ -320,38 +307,17 @@
// Artists: show all items, they often don't have primary images
filteredItems = r.Items;
} else {
// Other types: filter to show only those with images
filteredItems = r.Items.filter(item => item.ImageTags?.Primary);
// Other types: sort so items with images appear first
filteredItems = r.Items.sort((a, b) => {
const aHasImage = a.ImageTags?.Primary ? 1 : 0;
const bHasImage = b.ImageTags?.Primary ? 1 : 0;
return bHasImage - aHasImage; // Items with images first
});
}
results.groupedItems[type] = filteredItems;
results.total += filteredItems.length;
}
} else {
// Jellyseerr results
if (r && (r.movies || r.tv)) {
// Process Jellyseerr movies
if (r.movies && r.movies.length > 0) {
const jellyseerrMovies = r.movies.map(item => convertJellyseerrItem(item, 'movie'));
if (results.groupedItems['Movie']) {
results.groupedItems['Movie'] = [...results.groupedItems['Movie'], ...jellyseerrMovies];
} else {
results.groupedItems['Movie'] = jellyseerrMovies;
}
results.total += jellyseerrMovies.length;
}
// Process Jellyseerr TV shows
if (r.tv && r.tv.length > 0) {
const jellyseerrTv = r.tv.map(item => convertJellyseerrItem(item, 'tv'));
if (results.groupedItems['Series']) {
results.groupedItems['Series'] = [...results.groupedItems['Series'], ...jellyseerrTv];
} else {
results.groupedItems['Series'] = jellyseerrTv;
}
results.total += jellyseerrTv.length;
}
}
}
});
@@ -360,10 +326,6 @@
// Cache the results
cacheResults(searchTerm, searchType, results);
// Update URL with search query and type
const urlType = searchType === 'core' ? 'videos' : searchType;
updateSearchUrl(searchTerm, urlType);
// Hide loading spinner
setLoadingSpinner(false);
@@ -376,40 +338,6 @@
}
}
function createSmartItemElement(item) {
const serverId = ApiClient.serverId();
const div = document.createElement('div');
div.className = 'smart-search-item';
const img = document.createElement('img');
img.src = item.ImageTags?.Primary
? `${ApiClient.serverAddress()}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}&maxWidth=300`
: '/web/assets/img/icon-transparent.png';
img.alt = item.Name || 'Unknown';
img.onerror = () => { img.src = '/web/assets/img/icon-transparent.png'; };
const h = document.createElement('h3');
h.textContent = item.Name || 'Unknown';
h.title = h.textContent;
const p = document.createElement('p');
p.textContent = `${item.Type}${item.ProductionYear ? ` (${item.ProductionYear})` : ''}`;
p.title = p.textContent;
div.appendChild(img);
div.appendChild(h);
div.appendChild(p);
div.addEventListener('click', () => {
LOG('item click', { id: item.Id, name: item.Name });
try { Dashboard.navigate(`#!/details?id=${item.Id}&serverId=${serverId}`); } catch(e){ ERR(e); }
});
return div;
}
function getTypeDisplayName(itemType) {
const typeMap = {
'Movie': 'Movies',
@@ -438,7 +366,7 @@
LOG('Creating smart-search-results container');
smartResults = document.createElement('div');
smartResults.id = 'smart-search-results';
smartResults.className = 'smart-search-results emby-scroller';
smartResults.className = 'smart-search-results emby-scroller searchResults, padded-top, padded-bottom-page';
const searchPage = document.getElementById('searchPage');
if (searchPage) {
searchPage.appendChild(smartResults);
@@ -489,7 +417,7 @@
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, index);
const section = window.cardBuilder.renderCards(items, title, null, index);
frag.appendChild(section);
totalShown += items.length;
}
@@ -500,11 +428,11 @@
}
// main init
function initDualSearch() {
if (typeof ApiClient === 'undefined') { setTimeout(initDualSearch, 500); return; }
function initSmartSearch() {
if (typeof ApiClient === 'undefined') { setTimeout(initSmartSearch, 500); return; }
const originalInput = document.getElementById('searchTextInput');
if (!originalInput) { setTimeout(initDualSearch, 500); return; }
if (!originalInput) { setTimeout(initSmartSearch, 500); return; }
addCustomStyles();
@@ -517,53 +445,65 @@
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
}
}
LOG('initDualSearch - URL query:', queryFromUrl);
LOG('initDualSearch - originalInput exists:', !!originalInput);
LOG('initSmartSearch - URL query:', queryFromUrl);
LOG('initSmartSearch - originalInput exists:', !!originalInput);
if (originalInput) {
LOG('initDualSearch - originalInput value:', originalInput.value);
LOG('initSmartSearch - originalInput value:', originalInput.value);
}
if (queryFromUrl) {
LOG('initDualSearch - URL query detected, leaving input alone');
LOG('initSmartSearch - URL query detected, leaving input alone');
}
// create Smart search UI if not exist
// Replace original search input with our smart search input
let wrapper = document.getElementById('smart-search-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = 'smart-search-wrapper';
wrapper.className = 'smart-search-wrapper';
const input = document.createElement('input');
input.id = 'smart-search-input';
// Replace the original input with our smart search input
const input = originalInput.cloneNode(true);
input.id = 'searchTextInput'; // Use the same ID as original
input.className = 'smart-search-input emby-input searchfields-txtSearch';
input.type = 'text';
input.placeholder = 'Smart Search...';
input.placeholder = 'Search...';
input.value = ''; // Clear any existing value
input.removeAttribute('data-jellyseerr-listener');
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
btnAll.className = 'smart-search-secondary emby-button';
btnAll.className = 'smart-search-btn emby-button';
btnAll.textContent = 'All';
const btnCore = document.createElement('button');
btnCore.id = 'smart-search-core';
btnCore.className = 'smart-search-primary emby-button';
btnCore.className = 'smart-search-btn emby-button active';
btnCore.textContent = 'Movies/TV';
const btnMusic = document.createElement('button');
btnMusic.id = 'smart-search-music';
btnMusic.className = 'smart-search-secondary emby-button';
btnMusic.className = 'smart-search-btn emby-button';
btnMusic.textContent = 'Music';
const btnBooks = document.createElement('button');
btnBooks.id = 'smart-search-books';
btnBooks.className = 'smart-search-secondary emby-button';
btnBooks.className = 'smart-search-btn emby-button';
btnBooks.textContent = 'Books';
btnRow.appendChild(btnAll);
btnRow.appendChild(btnCore);
btnRow.appendChild(btnMusic);
btnRow.appendChild(btnBooks);
if (CONFIG.enableJellyseerr) {
const btnRequest = document.createElement('button');
btnRequest.id = 'smart-search-request';
btnRequest.className = 'smart-search-btn emby-button';
btnRequest.textContent = 'Request';
btnRow.appendChild(btnRequest);
}
const stats = document.createElement('div');
stats.id = 'smart-search-stats';
@@ -573,16 +513,20 @@
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
// Replace the original input with our wrapper
originalInput.parentElement.insertBefore(wrapper, originalInput);
originalInput.remove(); // Remove the original input
input.focus();
}
// Smart results container will be created lazily when needed
const smartInput = document.getElementById('smart-search-input');
const smartInput = document.getElementById('searchTextInput');
const smartCoreBtn = document.getElementById('smart-search-core');
const smartMusicBtn = document.getElementById('smart-search-music');
const smartBooksBtn = document.getElementById('smart-search-books');
const smartAllBtn = document.getElementById('smart-search-all');
const smartRequestBtn = document.getElementById('smart-search-request');
// Track current search type
let currentSearchType = 'videos';
@@ -590,31 +534,27 @@
// Function to update button states
function updateButtonStates(activeType) {
// Remove active class from all buttons
smartAllBtn.classList.remove('smart-search-primary');
smartAllBtn.classList.add('smart-search-secondary');
smartCoreBtn.classList.remove('smart-search-primary');
smartCoreBtn.classList.add('smart-search-secondary');
smartMusicBtn.classList.remove('smart-search-primary');
smartMusicBtn.classList.add('smart-search-secondary');
smartBooksBtn.classList.remove('smart-search-primary');
smartBooksBtn.classList.add('smart-search-secondary');
smartAllBtn.classList.remove('active');
smartCoreBtn.classList.remove('active');
smartMusicBtn.classList.remove('active');
smartBooksBtn.classList.remove('active');
smartRequestBtn.classList.remove('active');
// Add active class to the selected button
if (activeType === 'all') {
smartAllBtn.classList.remove('smart-search-secondary');
smartAllBtn.classList.add('smart-search-primary');
smartAllBtn.classList.add('active');
} else if (activeType === 'videos') {
smartCoreBtn.classList.remove('smart-search-secondary');
smartCoreBtn.classList.add('smart-search-primary');
smartCoreBtn.classList.add('active');
} else if (activeType === 'music') {
smartMusicBtn.classList.remove('smart-search-secondary');
smartMusicBtn.classList.add('smart-search-primary');
smartMusicBtn.classList.add('active');
} else if (activeType === 'books') {
smartBooksBtn.classList.remove('smart-search-secondary');
smartBooksBtn.classList.add('smart-search-primary');
smartBooksBtn.classList.add('active');
} else if (activeType === 'request') {
smartRequestBtn.classList.add('active');
}
}
// Function to handle search type toggle
function setSearchType(type) {
LOG('setSearchType called:', { type, currentSearchType });
@@ -629,6 +569,15 @@
const internalType = type === 'videos' ? 'core' : type;
LOG('Internal type mapping:', { type, internalType });
// For request type, don't use cache and always perform fresh search
if (type === 'request') {
LOG('Request type - clearing results and performing fresh Jellyseerr search');
// Clear existing search results except jellyseerr-section
clearSearchResultsExceptJellyseerr();
performSmartSearch(searchTerm, 'request');
return;
}
// Check if we have cached results for this search term and type
const cachedResults = getCachedResults(searchTerm, internalType);
if (cachedResults) {
@@ -664,11 +613,18 @@
};
smartInput.addEventListener('input', doSmartSearchDebounced);
smartInput.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ e.preventDefault(); const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType; performSmartSearch((smartInput.value||'').trim(), internalType); }});
smartInput.addEventListener('keydown', (e)=>{
if(e.key==='Enter'){
e.preventDefault();
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
performSmartSearch((smartInput.value||'').trim(), internalType);
}
});
smartCoreBtn.addEventListener('click', ()=>setSearchType('videos'));
smartMusicBtn.addEventListener('click', ()=>setSearchType('music'));
smartBooksBtn.addEventListener('click', ()=>setSearchType('books'));
smartAllBtn.addEventListener('click', ()=>setSearchType('all'));
smartRequestBtn.addEventListener('click', ()=>setSearchType('request'));
// Parse type parameter from URL
let typeFromUrl = 'videos'; // default
@@ -713,33 +669,26 @@
LOG('setMode', smartMode);
if (smartMode) {
wrapper.style.display = '';
originalInput.style.display = 'none';
// Hide all search results containers
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
// Hide search results containers (be more specific to avoid affecting other pages)
const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = 'none');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = '';
toggleBtn.textContent='Switch to Default Search';
// Keep XHR blocking enabled for smart mode
shouldBlockXHR = true;
// Add smart-search-mode class to body for CSS targeting
document.body.classList.add('smart-search-mode');
} else {
wrapper.style.display = 'none';
originalInput.style.display = '';
// Show all search results containers
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
// Show search results containers (be more specific to avoid affecting other pages)
const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = '');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = 'none';
toggleBtn.textContent='Switch to Smart Search';
// Disable XHR blocking for default search mode
shouldBlockXHR = false;
LOG('Disabled XHR blocking for default search mode');
// Remove smart-search-mode class from body
document.body.classList.remove('smart-search-mode');
// Input is left alone - let it keep its value
// Note: We don't restore the original input since we replaced it entirely
}
};
@@ -768,86 +717,50 @@
}
LOG('URL query in init:', queryFromUrl);
if (queryFromUrl) {
LOG('Query detected in init, enabling XHR blocking');
shouldBlockXHR = true;
// Also try to prevent search form submission
const searchForm = document.querySelector('form[action*="search"]');
LOG('Search form found:', !!searchForm);
if (searchForm) {
searchForm.addEventListener('submit', (e) => {
LOG('Search form submission prevented');
e.preventDefault();
e.stopPropagation();
return false;
});
}
// Try to hide search results containers immediately
const hideSearchResults = () => {
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
LOG('Found search results containers:', searchResults.length);
searchResults.forEach(sr => {
sr.style.display = 'none';
LOG('Hidden search result container');
});
};
// Hide immediately and on DOM changes
hideSearchResults();
document.addEventListener('DOMContentLoaded', hideSearchResults);
// Use mutation observer to hide any new search results
const hideObserver = new MutationObserver(() => {
hideSearchResults();
});
hideObserver.observe(document.body, { childList: true, subtree: true });
// Stop observing after 10 seconds
LOG('Calling initSmartSearch');
initSmartSearch();
}
}
// Initialize search hook using utils
function initializeSearchHook() {
if (!window.KefinTweaksUtils) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initializeSearchHook, 1000);
return;
}
LOG('Registering search page handler with KefinTweaksUtils');
// Register handler for search page
window.KefinTweaksUtils.onViewPage((view, element) => {
LOG('Search page detected via utils');
// Small delay to ensure DOM is ready
setTimeout(() => {
LOG('Stopping hide observer after 10 seconds');
hideObserver.disconnect();
}, 10000);
}
LOG('Calling initDualSearch');
initDualSearch();
}
}
// Mutation observer to detect when search page content is loaded dynamically
function setupMutationObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if search input appears in the DOM
const searchInput = document.getElementById('searchTextInput');
if (searchInput && !document.getElementById('smart-search-wrapper')) {
LOG('Search input detected via mutation observer');
initDualSearch();
}
const searchInput = document.getElementById('searchTextInput');
if (searchInput && !document.getElementById('smart-search-wrapper')) {
LOG('Search input found, initializing smart search');
initSmartSearch();
}
});
}, 100);
}, {
immediate: true,
pages: ['search']
});
// Observe the entire document for changes
observer.observe(document.body, {
childList: true,
subtree: true
});
LOG('Mutation observer setup complete');
LOG('Search hook initialized successfully');
}
// Initialize the hook when the script loads
initializeSearchHook();
if (document.readyState==='loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
setupMutationObserver();
});
} else {
init();
setupMutationObserver();
}
LOG('script load end');
})();
+19
View File
@@ -0,0 +1,19 @@
div#subtitleSearchResults {
overflow-y: scroll;
height: auto !important;
max-height: unset;
display: grid;
grid-auto-rows: min-content;
}
div#subtitleSearchResults button.btnDownload {
margin-right: 20px;
}
#videoOsdPage:not(.hide) button.btnSubtitles.hide {
display: block !important;
}
.dialogContainer [data-id="secondarysubtitle"] {
display: none;
}
+1263
View File
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
// KefinTweaks Utilities
// Provides standardized functionality for hooking into Emby.Page.onViewShow
// while maintaining original Jellyfin functionality
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Utils]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args);
LOG('Utils script loaded');
// Store the original onViewShow function
let originalOnViewShow = null;
// Initialize the utils by hooking into Emby.Page.onViewShow
function initialize() {
// Store the original onViewShow if it exists
if (window.Emby && window.Emby.Page && window.Emby.Page.onViewShow) {
originalOnViewShow = window.Emby.Page.onViewShow;
LOG('Stored original Emby.Page.onViewShow');
}
// Override onViewShow to maintain original functionality
if (window.Emby && window.Emby.Page) {
window.Emby.Page.onViewShow = function (...args) {
// Call original handler if it exists
if (originalOnViewShow) {
try {
originalOnViewShow.apply(this, ...args);
} catch (err) {
ERR('Error in original onViewShow handler:', err);
}
}
// Call our registered handlers
notifyHandlers(args[0], args[1]);
};
LOG('Hooked into Emby.Page.onViewShow');
} else {
WARN('Emby.Page.onViewShow not found - utils may not work correctly');
}
}
// Array to store registered handlers
const handlers = [];
/**
* Register a callback to be called when page view changes
* @param {Function} callback - Function to call when page view changes
* @param {Object} options - Options for the handler
*/
function onViewPage(callback, options = {}) {
if (typeof callback !== 'function') {
ERR('Callback must be a function');
return;
}
const handlerConfig = {
callback,
options: {
immediate: false, // Call immediately if on matching page
pages: [], // Specific pages to watch (empty = all pages)
...options
}
};
handlers.push(handlerConfig);
LOG(`Registered onViewPage handler (total: ${handlers.length})`);
// Call immediately if requested and we're on a matching page
if (handlerConfig.options.immediate) {
const currentView = getCurrentView();
if (currentView && shouldCallHandler(handlerConfig, currentView)) {
try {
callback(currentView, document);
} catch (err) {
ERR('Error in immediate handler call:', err);
}
}
}
// Return a function to unregister this handler
return () => {
const index = handlers.indexOf(handlerConfig);
if (index !== -1) {
handlers.splice(index, 1);
LOG(`Unregistered onViewPage handler (remaining: ${handlers.length})`);
}
};
}
/**
* Notify all registered handlers of a view change
* @param {string} view - The view name
* @param {Element} element - The view element
*/
function notifyHandlers(view, element) {
handlers.forEach((config) => {
if (shouldCallHandler(config, view)) {
try {
config.callback(view, element);
} catch (err) {
ERR('Error in onViewPage handler:', err);
}
}
});
}
/**
* Check if a handler should be called for a given view
* @param {Object} config - Handler configuration
* @param {string} view - The view name
* @returns {boolean} Whether the handler should be called
*/
function shouldCallHandler(config, view) {
const { pages } = config.options;
// If no specific pages are specified, call for all pages
if (pages.length === 0) return true;
// Get current URL hash to determine actual page
const currentUrl = window.location.hash;
// Check if the current URL matches any of the specified pages
return pages.some(page => {
if (typeof page === 'string') {
// Check if URL hash contains the page pattern
return currentUrl.includes(`/${page}.html`) || currentUrl.includes(`/${page}`) || currentUrl.includes(page);
} else if (page instanceof RegExp) {
return page.test(currentUrl);
}
return false;
});
}
/**
* Get the current view name
* @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/);
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;
}
/**
* Get the number of registered handlers
* @returns {number} Number of registered handlers
*/
function getHandlerCount() {
return handlers.length;
}
/**
* Clear all handlers
*/
function clearHandlers() {
handlers.length = 0;
LOG('Cleared all onViewPage handlers');
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
/**
* Add a custom menu link to the custom menu options
* @param {string} name - Display name for the menu item
* @param {string} icon - Material icon name
* @param {string} url - URL to navigate to
* @param {boolean} openInNewTab - Whether to open in new tab (default: false)
* @returns {Promise<boolean>} - True if successfully added, false otherwise
*/
function addCustomMenuLink(name, icon, url, openInNewTab = false) {
return new Promise((resolve) => {
// Check if menu container already exists
const existingContainer = document.querySelector('.customMenuOptions');
if (existingContainer) {
const success = addLinkToContainer(existingContainer, name, icon, url, openInNewTab);
resolve(success);
return;
}
LOG(`Custom menu container not found, waiting for it to appear for link: ${name}`);
// Use MutationObserver to watch for the menu container
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is the menu or contains it
if (node.matches?.('.customMenuOptions') || node.querySelector?.('.customMenuOptions')) {
const container = node.matches?.('.customMenuOptions') ? node : node.querySelector('.customMenuOptions');
LOG(`Custom menu container detected, adding link: ${name}`);
observer.disconnect();
const success = addLinkToContainer(container, name, icon, url, openInNewTab);
resolve(success);
return;
}
}
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Fallback timeout after 10 seconds
setTimeout(() => {
observer.disconnect();
WARN(`Menu not found after 10 seconds for link: ${name}`);
resolve(false);
}, 10000);
});
}
/**
* Add the link to the existing container
* @param {Element} container - The custom menu options container
* @param {string} name - Display name for the menu item
* @param {string} icon - Material icon name
* @param {string} url - URL to navigate to
* @param {boolean} openInNewTab - Whether to open in new tab
* @returns {boolean} - True if successfully added
*/
function addLinkToContainer(container, name, icon, url, openInNewTab) {
try {
// Create the menu link element
const link = document.createElement('a');
link.setAttribute('is', 'emby-linkbutton');
link.className = 'emby-button navMenuOption lnkMediaFolder';
link.href = url;
if (openInNewTab) {
link.setAttribute('rel', 'noopener noreferrer');
link.setAttribute('target', '_blank');
}
// Create the icon span
const iconSpan = document.createElement('span');
iconSpan.className = `material-icons navMenuOptionIcon ${icon}`;
iconSpan.setAttribute('aria-hidden', 'true');
// Create the text span
const textSpan = document.createElement('span');
textSpan.className = 'navMenuOptionText';
textSpan.textContent = name;
// Append icon and text to link
link.appendChild(iconSpan);
link.appendChild(textSpan);
// Append link to container
container.appendChild(link);
LOG(`Successfully added custom menu link: ${name}`);
return true;
} catch (err) {
ERR(`Error adding custom menu link ${name}:`, err);
return false;
}
}
// Expose utilities to global scope
window.KefinTweaksUtils = {
onViewPage,
getCurrentView,
getHandlerCount,
clearHandlers,
addCustomMenuLink
};
LOG('KefinTweaks Utils loaded and available at window.KefinTweaksUtils');
})();
+1804 -1
View File
File diff suppressed because it is too large Load Diff
+5905 -435
View File
File diff suppressed because it is too large Load Diff