mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
New Home Screen feature: Spotlights. Configuration UI and options vastly improved.
This commit is contained in:
@@ -16,10 +16,15 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t
|
||||
- [Configuration](#configuration)
|
||||
- [Features Overview](#features-overview)
|
||||
- [Data Caching](#data-caching)
|
||||
- [Feature Requests Completed](#feature-requests-completed)
|
||||
- [Feature Requests Planned](#feature-requests-planned)
|
||||
- [Community Requests Progress](#community-request-progress)
|
||||
- [Feature Requests Completed](#feature-requests-completed)
|
||||
- [Feature Requests Planned](#feature-requests-planned)
|
||||
- [Core Features](#core-features)
|
||||
- [Watchlist](#watchlist)
|
||||
- Watchlist Page
|
||||
- Series Progress Page
|
||||
- Movie History Page
|
||||
- User Statistics Page
|
||||
- [Enhanced Home Screen](#enhanced-home-screen)
|
||||
- [Enhanced Search](#enhanced-search)
|
||||
- [UI Enhancements](#ui-enhancements)
|
||||
@@ -42,7 +47,8 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t
|
||||
- [Script Details](#script-details)
|
||||
- [Dependency Scripts](#dependency-scripts)
|
||||
- [Feature Scripts](#feature-scripts)
|
||||
- [Auto-Inject Dependencies](#auto-inject-dependencies)
|
||||
- [Auto-Inject Dependencies](#auto-inject-dependencies)
|
||||
- [Info For Developers](#info-for-developers)
|
||||
- [License](#license)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
- [Support](#support)
|
||||
@@ -153,6 +159,9 @@ The features in KefinTweaks which use local data caching are listed below, along
|
||||
- Collections on Details Page
|
||||
- **Collections** [24h]:
|
||||
Sadly I know no way to retreive a list of Collections that an Item is a child of from the API directly. The Ancestors endpoint only returns the physical ancestors of an item. In order to be able to populate the "Included In" section, we fetch the children from every Collection in your library and add the Item ID of each child to the cache.
|
||||
<hr>
|
||||
|
||||
## Community Request Progress
|
||||
|
||||
### ✅ **Feature Requests Completed**
|
||||
|
||||
@@ -445,7 +454,7 @@ Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-upd
|
||||
| `deviceManager.js` | Device management with remove device functionality | `utils` |
|
||||
<br>
|
||||
|
||||
## Auto-Inject Dependencies
|
||||
### Auto-Inject Dependencies
|
||||
|
||||
KefinTweaks automatically enables required dependencies when you enable a script that needs them. For example:
|
||||
|
||||
@@ -454,17 +463,108 @@ KefinTweaks automatically enables required dependencies when you enable a script
|
||||
This ensures all scripts have their required dependencies without manual configuration.
|
||||
<hr>
|
||||
|
||||
### Info for Developers
|
||||
|
||||
This section will detail any information relevant for other developers who wish to take advatange of KefinTweaks features in their own projects:
|
||||
|
||||
Currently the only functionality that can be very easily included by developers of other projects is the Watchlist functionality.
|
||||
|
||||
The KefinTweaks Watchlist leverages the existing Jellyfin Item UserData field: "Likes". Here is how KefinTweaks handles the funcionality and how you can as well:
|
||||
|
||||
- Add a section (on the home screen or elsewhere) which queries the Items endpoint with Likes=True
|
||||
- Add a Watchlist toggle button to all card overlays which toggles the UserData Likes value
|
||||
- Add a listener for PlaybackStopped to check if a Watchlisted item has been watched and automatically remove it from the Watchlist
|
||||
|
||||
Even if you only implement the first bullet point and add a section for Watchlist items, KefinTweaks will automatically handle Watchlist syncing whenever the user connects with a KefinTweaks supported client.
|
||||
|
||||
## Client Support
|
||||
|
||||
KefinTweaks is a front end plugin that relies on the Jellyfin Web UI to function. This means that it will function wherever the Jellyfin Web UI is used, and won't function in any apps that don't use the Web UI.
|
||||
|
||||
Here is a list of known supported clients:
|
||||
|
||||
- Jellyfin Web - Works on any web browser
|
||||
- [Jellyfin Media Player](https://github.com/jellyfin/jellyfin-media-player)
|
||||
- [Jellyfin for Android](https://github.com/jellyfin/jellyfin-android)
|
||||
- [Jellyfin for iOS](https://github.com/jellyfin/jellyfin-ios)
|
||||
- [Jellyfin for WebOS](https://github.com/jellyfin/jellyfin-webos)
|
||||
- [Jellyfin for Tizen](https://github.com/jellyfin/jellyfin-tizen)
|
||||
<hr>
|
||||
|
||||
## Changes to default Jellyfin functionality
|
||||
|
||||
A few things to note about KefinTweaks functionality in general and how it interacts with the default Jellyfin functionality:
|
||||
|
||||
### What functionality is additive?
|
||||
|
||||
- Watchlist
|
||||
- Series Progress
|
||||
- Movie History
|
||||
- User Statistics
|
||||
- Enhanced Home Screen
|
||||
- Appearance/Skin Manager
|
||||
- Subtitle Search in Video OSD
|
||||
- Remove from Continue Watching
|
||||
- Breadcrumb Navigation
|
||||
- Custom Menu Links
|
||||
- Collections on Details Page
|
||||
|
||||
## What functionality is modified?
|
||||
|
||||
- Infinite Scroll for Library Pages
|
||||
- Pagination controls are hidden in favor of infinite scrolling
|
||||
- Playlist Page UX
|
||||
- Clicking playlist items navigates to the item page instead of playing the item
|
||||
- A play button is added to each item on the Playlist page to allow the user to play the item directly
|
||||
- Flatten Single Season Shows
|
||||
- The normal Seasons section is hidden for shows with only 1 season
|
||||
- Episodes from the single season are displayed in a section with the Next Up item being the one focused
|
||||
- Background Leak Fix
|
||||
- This removes extra backdrops from the DOM
|
||||
- It fixes an issue which causes Jellyfin to endless add backdrop items to the DOM if the Jellyfin browser tab is not focused
|
||||
|
||||
|
||||
### What functionality is overridden?
|
||||
|
||||
- Search
|
||||
- The default Jellyfin search is hidden in favor of the KefinTweaks search
|
||||
- KefinTweaks blocks default Jellyfin search requests, and only performs its own search requests
|
||||
- Dashboard Button Fix
|
||||
- This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser
|
||||
<hr>
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The general design philosophy here is to utilize as much pre-existing Jellyfin functionality as possible in order to ensure long term compatibility, and also to potentially make it a bit easier for anyone interested in taking these ideas into the core Jellyfin projects. Even where functionality is being "overridden" we still typically utilize underlying Jellyfin functionality to achieve the results desired.
|
||||
|
||||
The only real Plugin dependency that KefinTweaks has is JS Injector which is being used to both load the KefinTweaks scripts, and save configuration data on the server. Loading the scripts without JS Injector is very simple, but without another way of storing configuration data on the server, JS Injector is absolutely required if you wish to override any of the default configuration options in the UI.
|
||||
|
||||
The main goal of this project is to push the limits of what is possible through a "front end plugin". All of the code in this project is executed by the client. This is quite unusual in terms of the typical Jellyfin plugin, but I hope it may be inspiring for other future creations down the road. I would also be very happy if the changes included here helped push forward the progress to get these changes implemented in the core Jellyfin project so that everyone can take advantage of them without needing a third party plugin.
|
||||
<hr>
|
||||
|
||||
## AI Disclaimer
|
||||
|
||||
To preface this, I want to say that historically, all of the community oriented projects I have worked on in the past have been composed nearly entirely by code that I have written myself. As new development tools have been released, I have tried to take advantage of them as much as possible in order to improve my own learning and to make my projects better. I understand that everyone has different personal feelings regarding the use of AI, so I wanted to leave a statement to make it clear about how I have chosen to use AI for this project in particular.
|
||||
|
||||
I have started this project with a polar opposite approach from my past endeavors. With the rise of "vibe coding" I wanted to see what I could achieve using a similar philosophy. I understand the obvious short comings of using such a methodology, and I believe that in the majority of the situations it will be very detrimental in the long run. Where I can see this type of tool excelling, is the exact use case of KefinTweaks: mostly small self contained scripts which don't rely on any external pieces of logic. This is one of the main motivating factors that led me to believe I could achieve what I desire purely through "vibe coding".
|
||||
|
||||
As it stands right now, and how I expect to proceed going forward, is to act in a role of "Project Manager" instead of "coder/developer". I don't blindly include every bit of code that is provided by my prompts. I make very thorough prompts that essentially provide very detailed documentation of the features to be implemented, in the same way a real project manager might provide this documentation to their team. I am not doing this for practice as I no longer professionally work in the design industry and have no plans to, but moreso just for fun and to see what the results are.
|
||||
|
||||
The journey so far has been quite enjoyable. The scale of KefinTweaks has expanded a lot over time, so I do certainly have my concerns about the sustainability of this approach, but I plan to continue on like this unless I hit a roadblock. I hope that this choice will not deter you from trying this plugin, but I did want to make the use of AI clear as I understand that it is a hot issue in the industry and around the globe. My personal view is that AI should be used responsibly as a tool, just like any other tool. I think that AI has no place in replacing artists and other creative work that humans carry out.
|
||||
|
||||
|
||||
## 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
|
||||
- **Jellyfin Team**: For the fantastic Jellyfin media server
|
||||
- **[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
|
||||
- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming environment to ask questions and learn
|
||||
- **[movie-monk-b0t](https://github.com/movie-monk-b0t)**: For the IMDb Top 250 JSON data
|
||||
|
||||
## Support
|
||||
|
||||
@@ -478,11 +578,13 @@ This README and the inline comments in kefinTweaks.js and the other scripts is t
|
||||
## Roadmap
|
||||
|
||||
### Planned Features
|
||||
- **Jellyfin 10.11 Support**:
|
||||
- **Jellyfin 10.11 Support**
|
||||
- **Additional options for User configuration in the Jellyfin UI**
|
||||
|
||||
<hr>
|
||||
|
||||
### Version History
|
||||
- **v0.3.1**: New Home Screen Section feature: Spotlights! Improved Configuration UI.
|
||||
- **v0.3.0**: KefinTweaks configuration for admins in the Jellyfin UI!
|
||||
- **v0.2.4**: New Feature: Watchlist Export/Import
|
||||
- **v0.2.3**: New Feature: Collection Sorting, general fixes
|
||||
|
||||
+39
-14
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
console.log('[KefinTweaks Injector] Initializing...');
|
||||
const versionNumber = "0.3.0";
|
||||
const versionNumber = "0.3.1";
|
||||
|
||||
// Configuration: Start with defaults, then merge user config
|
||||
// This allows new scripts to work out of the box without requiring config updates
|
||||
@@ -62,10 +62,17 @@
|
||||
description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading'
|
||||
},
|
||||
{
|
||||
name: 'cardBuilder',
|
||||
script: 'cardBuilder.js',
|
||||
name: 'apiHelper',
|
||||
script: 'apiHelper.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'API helper functions for common Jellyfin operations'
|
||||
},
|
||||
{
|
||||
name: 'cardBuilder',
|
||||
script: 'cardBuilder.js',
|
||||
css: 'cardBuilder.css',
|
||||
dependencies: ['apiHelper'],
|
||||
description: 'Core card building functionality (required by other scripts)'
|
||||
},
|
||||
{
|
||||
@@ -261,21 +268,39 @@
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Auto-enable dependencies for enabled scripts
|
||||
// Auto-enable dependencies for enabled scripts (iteratively to handle transitive dependencies)
|
||||
function autoEnableDependencies() {
|
||||
let hasChanges = false;
|
||||
let maxIterations = 10; // Prevent infinite loops
|
||||
let iterations = 0;
|
||||
|
||||
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}'`);
|
||||
}
|
||||
});
|
||||
// Keep running until no more dependencies need to be enabled
|
||||
while (iterations < maxIterations) {
|
||||
let iterationChanges = false;
|
||||
|
||||
SCRIPT_DEFINITIONS.forEach(script => {
|
||||
if (ENABLED_SCRIPTS[script.name]) {
|
||||
script.dependencies.forEach(dep => {
|
||||
if (!ENABLED_SCRIPTS[dep]) {
|
||||
ENABLED_SCRIPTS[dep] = true;
|
||||
iterationChanges = true;
|
||||
hasChanges = true;
|
||||
console.log(`[KefinTweaks Injector] Auto-enabled dependency '${dep}' for '${script.name}'`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!iterationChanges) {
|
||||
break; // No more dependencies to enable
|
||||
}
|
||||
});
|
||||
|
||||
iterations++;
|
||||
}
|
||||
|
||||
if (iterations >= maxIterations) {
|
||||
console.warn('[KefinTweaks Injector] Reached max iterations while auto-enabling dependencies');
|
||||
}
|
||||
|
||||
return hasChanges;
|
||||
}
|
||||
|
||||
+343
-12
@@ -21,18 +21,349 @@ window.KefinTweaksDefaultConfig = {
|
||||
"skinManager": true
|
||||
},
|
||||
"homeScreen": {
|
||||
"enableNewAndTrending": true,
|
||||
"enableNewMovies": true,
|
||||
"enableNewEpisodes": true,
|
||||
"enableTrending": false,
|
||||
"enableDiscovery": true,
|
||||
"enableInfiniteScroll": true,
|
||||
"minPeopleAppearances": 10,
|
||||
"minGenreMovieCount": 50,
|
||||
"minimumShowsForNetwork": 5,
|
||||
"enableWatchlist": true,
|
||||
"enableSeasonal": true,
|
||||
"seasonalItemLimit": 16,
|
||||
"defaultItemLimit": 16,
|
||||
"defaultSortOrder": "Random",
|
||||
"defaultCardFormat": "Poster",
|
||||
"recentlyReleased": {
|
||||
"enabled": true,
|
||||
"order": 30,
|
||||
"movies": {
|
||||
"name": "Recently Released Movies",
|
||||
"enabled": true,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "ReleaseDate",
|
||||
"sortOrderDirection": "Descending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 30
|
||||
},
|
||||
"episodes": {
|
||||
"name": "Recently Aired Episodes",
|
||||
"enabled": true,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "PremiereDate",
|
||||
"sortOrderDirection": "Descending",
|
||||
"cardFormat": "Backdrop",
|
||||
"order": 31
|
||||
}
|
||||
},
|
||||
"trending": {
|
||||
"name": "Trending",
|
||||
"enabled": false,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 32
|
||||
},
|
||||
"popularTVNetworks": {
|
||||
"name": "Popular TV Networks",
|
||||
"enabled": true,
|
||||
"minimumShowsForNetwork": 5,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 61
|
||||
},
|
||||
"watchlist": {
|
||||
"name": "Watchlist",
|
||||
"enabled": true,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "DateAdded",
|
||||
"sortOrderDirection": "Descending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 60
|
||||
},
|
||||
"watchAgain": {
|
||||
"name": "Watch Again",
|
||||
"enabled": true,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 62
|
||||
},
|
||||
"upcoming": {
|
||||
"name": "Upcoming",
|
||||
"enabled": true,
|
||||
"itemLimit": 48,
|
||||
"cardFormat": "Thumb",
|
||||
"order": 20
|
||||
},
|
||||
"imdbTop250": {
|
||||
"name": "IMDb Top 250",
|
||||
"enabled": true,
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 21
|
||||
},
|
||||
"seasonal": {
|
||||
"enabled": true,
|
||||
"defaultItemLimit": 16,
|
||||
"defaultSortOrder": "Random",
|
||||
"defaultCardFormat": "Poster",
|
||||
"seasons": [
|
||||
{
|
||||
"id": "halloween",
|
||||
"name": "Halloween",
|
||||
"enabled": true,
|
||||
"startDate": "10-01",
|
||||
"endDate": "10-31",
|
||||
"sections": [
|
||||
{
|
||||
"id": "halloween-tag",
|
||||
"enabled": true,
|
||||
"name": "Halloween Movies",
|
||||
"type": "Tag",
|
||||
"source": "halloween",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 50
|
||||
},
|
||||
{
|
||||
"id": "halloween-horror",
|
||||
"enabled": true,
|
||||
"name": "Horror Genre",
|
||||
"type": "Genre",
|
||||
"source": "Horror",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 51
|
||||
},
|
||||
{
|
||||
"id": "halloween-thriller",
|
||||
"enabled": true,
|
||||
"name": "Thriller Genre",
|
||||
"type": "Genre",
|
||||
"source": "Thriller",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 52
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "thanksgiving",
|
||||
"name": "Thanksgiving",
|
||||
"enabled": true,
|
||||
"startDate": "11-20",
|
||||
"endDate": "11-30",
|
||||
"order": 50,
|
||||
"sections": [
|
||||
{
|
||||
"id": "seasonal-2-section-0",
|
||||
"enabled": true,
|
||||
"name": "Thanksgiving Movies",
|
||||
"type": "Tag",
|
||||
"source": "thanksgiving",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 50,
|
||||
"spotlight": true,
|
||||
"discoveryEnabled": false,
|
||||
"searchTerm": "",
|
||||
"includeItemTypes": [
|
||||
"Movie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "seasonal-2-section-1",
|
||||
"enabled": true,
|
||||
"name": "Thanksgiving Episodes",
|
||||
"type": "Parent",
|
||||
"source": "",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Thumb",
|
||||
"order": 51,
|
||||
"spotlight": false,
|
||||
"discoveryEnabled": false,
|
||||
"searchTerm": "thanksgiving",
|
||||
"includeItemTypes": [
|
||||
"Episode"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "christmas",
|
||||
"name": "Christmas",
|
||||
"enabled": true,
|
||||
"startDate": "12-01",
|
||||
"endDate": "12-31",
|
||||
"sections": [
|
||||
{
|
||||
"id": "christmas-genre",
|
||||
"enabled": true,
|
||||
"name": "Christmas Movies",
|
||||
"type": "Genre",
|
||||
"source": "Christmas",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 60
|
||||
},
|
||||
{
|
||||
"id": "christmas-family",
|
||||
"enabled": true,
|
||||
"name": "Family Movies",
|
||||
"type": "Genre",
|
||||
"source": "Family",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"order": 61
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"discovery": {
|
||||
"enabled": true,
|
||||
"infiniteScroll": true,
|
||||
"minPeopleAppearances": 10,
|
||||
"minGenreMovieCount": 50,
|
||||
"defaultItemLimit": 16,
|
||||
"defaultSortOrder": "Random",
|
||||
"defaultCardFormat": "Poster",
|
||||
"sectionTypes": {
|
||||
"spotlightGenre": {
|
||||
"enabled": true,
|
||||
"name": "Top Rated [Genre] Movies",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"spotlightNetwork": {
|
||||
"enabled": true,
|
||||
"name": "Top Rated Shows from [Studio]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"genreMovies": {
|
||||
"enabled": true,
|
||||
"name": "[Genre] Movies",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"studioShows": {
|
||||
"enabled": true,
|
||||
"name": "Shows from [Studio]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"collections": {
|
||||
"enabled": true,
|
||||
"name": "[Collection Name]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster",
|
||||
"minimumItems": 10
|
||||
},
|
||||
"becauseYouWatched": {
|
||||
"enabled": true,
|
||||
"name": "Because you watched [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"becauseYouLiked": {
|
||||
"enabled": true,
|
||||
"name": "Because you liked [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"starringTopActor": {
|
||||
"enabled": true,
|
||||
"name": "Starring [Actor]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"directedByTopDirector": {
|
||||
"enabled": true,
|
||||
"name": "Directed by [Director]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"writtenByTopWriter": {
|
||||
"enabled": true,
|
||||
"name": "Written by [Writer]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"becauseYouRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Because you recently watched [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"starringActorRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Starring [Actor] because you recently watched [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"directedByDirectorRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Directed by [Director] because you recently watched [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
},
|
||||
"writtenByWriterRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Written by [Writer] because you recently watched [Movie]",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
"cardFormat": "Poster"
|
||||
}
|
||||
},
|
||||
"spotlightDiscoveryChance": 0.5,
|
||||
"spotlight": {
|
||||
"autoPlay": true,
|
||||
"interval": 5000,
|
||||
"showDots": true,
|
||||
"showNavButtons": true,
|
||||
"showClearArt": true
|
||||
},
|
||||
"randomizeOrder": false
|
||||
},
|
||||
"customSections": []
|
||||
},
|
||||
"exclusiveElsewhere": {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,421 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks APIHelper]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks APIHelper]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks APIHelper]', ...args);
|
||||
|
||||
/**
|
||||
* Ensure ApiClient is available before using helper methods
|
||||
*/
|
||||
function ensureApiClient() {
|
||||
if (typeof ApiClient === 'undefined' || !ApiClient) {
|
||||
throw new Error('ApiClient is not available');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the active playback session for this device
|
||||
* @returns {Promise<Object>} - Active session object
|
||||
*/
|
||||
async function getActiveSession() {
|
||||
ensureApiClient();
|
||||
|
||||
const token = ApiClient.accessToken();
|
||||
const serverUrl = ApiClient.serverAddress();
|
||||
const deviceId = ApiClient.deviceId();
|
||||
|
||||
if (!deviceId) {
|
||||
throw new Error('Device ID not available');
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error('Server URL not available');
|
||||
}
|
||||
|
||||
LOG('Fetching active session for device:', deviceId);
|
||||
|
||||
const response = await fetch(`${serverUrl}/Sessions?deviceId=${deviceId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `MediaBrowser Token="${token}"`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const sessions = await response.json();
|
||||
|
||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||
return sessions[0];
|
||||
}
|
||||
|
||||
if (sessions && !Array.isArray(sessions)) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
throw new Error('No active session found for device');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PlayNow command directly to the active session
|
||||
* @param {Array<string>} ids - Item IDs to play
|
||||
* @param {Object} options - Additional playback options
|
||||
*/
|
||||
async function sendPlayNow(ids, options = {}) {
|
||||
const session = await getActiveSession();
|
||||
const sessionId = session.Id;
|
||||
|
||||
const token = ApiClient.accessToken();
|
||||
const serverUrl = ApiClient.serverAddress();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
playCommand: options.playCommand || 'PlayNow',
|
||||
itemIds: ids.join(',')
|
||||
});
|
||||
|
||||
if (options.startPositionTicks !== undefined) {
|
||||
params.set('startPositionTicks', options.startPositionTicks.toString());
|
||||
}
|
||||
if (options.subtitleStreamIndex !== undefined) {
|
||||
params.set('subtitleStreamIndex', options.subtitleStreamIndex.toString());
|
||||
}
|
||||
if (options.audioStreamIndex !== undefined) {
|
||||
params.set('audioStreamIndex', options.audioStreamIndex.toString());
|
||||
}
|
||||
if (options.mediaSourceId) {
|
||||
params.set('mediaSourceId', options.mediaSourceId);
|
||||
}
|
||||
|
||||
LOG('Sending PlayNow command:', {
|
||||
sessionId,
|
||||
params: params.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Emby-Token': token
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PlayNow request failed with HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query cache for API responses
|
||||
* Key: stringified options object
|
||||
* Value: { data: response, timestamp: Date.now() }
|
||||
*/
|
||||
const queryCache = new Map();
|
||||
|
||||
/**
|
||||
* Generate a cache key from options object
|
||||
* @param {Object} options - Query options
|
||||
* @returns {string} - Cache key
|
||||
*/
|
||||
function getCacheKey(options) {
|
||||
// Sort keys to ensure consistent cache keys regardless of property order
|
||||
const sorted = Object.keys(options).sort().reduce((acc, key) => {
|
||||
acc[key] = options[key];
|
||||
return acc;
|
||||
}, {});
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached query result if valid
|
||||
* @param {string} cacheKey - Cache key
|
||||
* @param {number} ttl - Time to live in milliseconds
|
||||
* @returns {Object|null} - Cached data or null if expired/missing
|
||||
*/
|
||||
function getCachedQuery(cacheKey, ttl) {
|
||||
const entry = queryCache.get(cacheKey);
|
||||
if (!entry) return null;
|
||||
|
||||
const age = Date.now() - entry.timestamp;
|
||||
if (age > ttl) {
|
||||
queryCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store query result in cache
|
||||
* @param {string} cacheKey - Cache key
|
||||
* @param {Object} data - Response data to cache
|
||||
*/
|
||||
function setCachedQuery(cacheKey, data) {
|
||||
queryCache.set(cacheKey, {
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL from options for getItems
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} options - Query options
|
||||
* @returns {string} - Constructed URL
|
||||
*/
|
||||
function buildItemsUrl(userId, options) {
|
||||
const serverAddress = ApiClient.serverAddress();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add all options as query parameters
|
||||
Object.keys(options).forEach(key => {
|
||||
const value = options[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return `${serverAddress}/Users/${userId}/Items?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Helper functions for Jellyfin operations
|
||||
*/
|
||||
const apiHelper = {
|
||||
/**
|
||||
* Generic data fetcher with universal caching
|
||||
* Always caches successful responses, but only checks cache when useCache is true
|
||||
* @param {string} url - Full URL to fetch
|
||||
* @param {boolean} useCache - Whether to use cache (skip fetch if valid cache exists, default: false)
|
||||
* @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes)
|
||||
* @returns {Promise<Object>} - Parsed JSON response
|
||||
*/
|
||||
getData: async function(url, useCache = false, ttl = 300000) {
|
||||
ensureApiClient();
|
||||
|
||||
const cacheKey = url;
|
||||
|
||||
// If useCache is true, check cache first
|
||||
if (useCache) {
|
||||
const cached = getCachedQuery(cacheKey, ttl);
|
||||
if (cached !== null) {
|
||||
LOG('Cache hit for URL:', url.substring(0, 100));
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
try {
|
||||
const token = ApiClient.accessToken();
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Emby-Token': token,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Always cache successful responses (regardless of useCache flag)
|
||||
if (data) {
|
||||
setCachedQuery(cacheKey, data);
|
||||
LOG('Cached data for URL:', url.substring(0, 100));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
ERR('Failed to fetch URL:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getWatchlistItems: async function(options = {}, useCache = false) {
|
||||
// First fetch the Library Items that contain items of the supported types from ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders"))
|
||||
const libraryItems = await ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders"));
|
||||
|
||||
// We now want to include the library items with ColletionType "tvshows", or "movies"
|
||||
const supportedLibraryItems = libraryItems.Items.filter(item => item.CollectionType === 'tvshows' || item.CollectionType === 'movies');
|
||||
|
||||
// Now use these support library items as the parent ids for the Items query, since the Items endpoint doesn't support ParentIds as an array we need to make a different call for each library. Querying with the parent id will dramatically reduce the time for the request to return so this is fine
|
||||
let watchlistItems = [];
|
||||
for (const libraryItem of supportedLibraryItems.reverse()) {
|
||||
// Get the item types to include based on the library type. if its a Movies library only use the Movie type. If it's tvshows then use Series, Season and Episode
|
||||
let itemTypes = options.IncludeItemTypes.split(',');
|
||||
if (itemTypes.length > 1) {
|
||||
if (libraryItem.CollectionType === 'movies') {
|
||||
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie');
|
||||
} else if (libraryItem.CollectionType === 'tvshows') {
|
||||
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode');
|
||||
}
|
||||
}
|
||||
|
||||
// Use getItems to fetch the items for the library item
|
||||
const data = await this.getItems({
|
||||
ParentId: libraryItem.Id,
|
||||
Filters: 'Likes',
|
||||
IncludeItemTypes: itemTypes.join(','),
|
||||
Recursive: true,
|
||||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: 'Primary,Backdrop,Thumb',
|
||||
...options
|
||||
}, useCache);
|
||||
|
||||
//const url = `${ApiClient.serverAddress()}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${ApiClient.getCurrentUserId()}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb&ParentId=${libraryItem.Id}`;
|
||||
//const data = await this.getData(url, useCache);
|
||||
watchlistItems.push(...data.Items);
|
||||
}
|
||||
|
||||
// Deuplicate any duplicate Id's in the watchlistItems array
|
||||
watchlistItems = watchlistItems.filter((item, index, self) =>
|
||||
index === self.findIndex((t) => t.Id === item.Id)
|
||||
);
|
||||
|
||||
return {
|
||||
Items: watchlistItems,
|
||||
TotalRecordCount: watchlistItems.length
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get items from Jellyfin API with optional caching
|
||||
* @param {Object} options - Query options (same as ApiClient.getItems)
|
||||
* @param {boolean} useCache - Whether to use cache (default: false)
|
||||
* @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes)
|
||||
* @returns {Promise<Object>} - API response
|
||||
*/
|
||||
getItems: async function(options = {}, useCache = false, ttl = 300000) {
|
||||
ensureApiClient();
|
||||
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const url = buildItemsUrl(userId, options);
|
||||
|
||||
// Use getData which handles caching
|
||||
return await this.getData(url, useCache, ttl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a URL with optional caching (deprecated - use getData instead)
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {Object} fetchOptions - Fetch options (headers, method, etc.)
|
||||
* @param {boolean} useCache - Whether to use cache (default: false)
|
||||
* @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes)
|
||||
* @returns {Promise<Object>} - Parsed JSON response
|
||||
* @deprecated Use getData instead. This method is kept for backward compatibility.
|
||||
*/
|
||||
fetchCached: async function(url, fetchOptions = {}, useCache = false, ttl = 300000) {
|
||||
ensureApiClient();
|
||||
|
||||
const cacheKey = getCacheKey({ url, fetchOptions });
|
||||
|
||||
// If useCache is true, check cache first
|
||||
if (useCache) {
|
||||
const cached = getCachedQuery(cacheKey, ttl);
|
||||
if (cached !== null) {
|
||||
LOG('Cache hit for URL:', url.substring(0, 100));
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Always cache successful responses
|
||||
if (data) {
|
||||
setCachedQuery(cacheKey, data);
|
||||
LOG('Cached fetch result:', url.substring(0, 100));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
ERR('Failed to fetch URL:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets user rating for an item
|
||||
* @param {string} itemId - The item ID
|
||||
* @param {number} rating - The rating value
|
||||
* @returns {Promise} - Promise that resolves when rating is set
|
||||
*/
|
||||
setUserRating: async function(itemId, rating) {
|
||||
ensureApiClient();
|
||||
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
return fetch(`${ApiClient._serverAddress}/Users/${userId}/Items/${itemId}/UserData`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Emby-Token': ApiClient.accessToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
Rating: rating,
|
||||
Likes: false
|
||||
})
|
||||
})
|
||||
.then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(t)))
|
||||
.then(response => {
|
||||
LOG('Rating updated for item:', itemId, response);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
ERR('Failed to set rating:', error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Plays an item using Jellyfin's playback system
|
||||
* @param {string|Array<string>} itemId - Single item ID or array of item IDs to play
|
||||
* @param {Object} options - Additional playback options
|
||||
* @returns {Promise} - Promise that resolves when play command is sent
|
||||
*/
|
||||
playItem: async function(itemId, options = {}) {
|
||||
const ids = Array.isArray(itemId) ? itemId : [itemId];
|
||||
|
||||
try {
|
||||
ensureApiClient();
|
||||
await sendPlayNow(ids, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
WARN('PlayNow command failed, attempting fallbacks:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof ApiClient !== 'undefined' && typeof ApiClient.play === 'function') {
|
||||
await ApiClient.play({ ids: ids });
|
||||
return;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
WARN('ApiClient.play fallback failed:', fallbackError);
|
||||
}
|
||||
|
||||
if (typeof PlaybackManager !== 'undefined' && PlaybackManager.play) {
|
||||
await PlaybackManager.play({
|
||||
ids: ids,
|
||||
serverId: ApiClient.serverId()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: Navigate to item details page
|
||||
const itemUrl = `/web/#/details?id=${ids[0]}&serverId=${ApiClient.serverId()}`;
|
||||
window.location.href = `${ApiClient.serverAddress()}${itemUrl}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose apiHelper to global window object
|
||||
window.apiHelper = apiHelper;
|
||||
|
||||
console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper');
|
||||
})();
|
||||
@@ -0,0 +1,393 @@
|
||||
/* Spotlight Section Styles */
|
||||
|
||||
.spotlight-section {
|
||||
position: relative;
|
||||
width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%)));
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.spotlight-banner-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.spotlight-items-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spotlight-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.spotlight-item[data-index="0"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spotlight-item:not([data-index="0"]) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spotlight-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 45%;
|
||||
background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%);
|
||||
padding: 40px;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.spotlight-overlay.has-title {
|
||||
padding: 70px 40px 20px 40px;
|
||||
}
|
||||
|
||||
.spotlight-section-title {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.spotlight-item-title {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
height: 80px;
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.spotlight-item-logo {
|
||||
max-width: 206.8px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.spotlight-clearart {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
max-height: 70%;
|
||||
object-fit: contain;
|
||||
z-index: 5;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spotlight-rating-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spotlight-rating-star {
|
||||
color: #4A9EFF;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.spotlight-rating-value {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spotlight-rating-value.na {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spotlight-metadata-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.spotlight-metadata-row .spotlight-end-time {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.spotlight-truncated-list {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spotlight-truncated-list .spotlight-ellipsis {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spotlight-tagline {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 18px;
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
line-height: 1.4;
|
||||
max-width: 90%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 75px;
|
||||
max-height: 75px;
|
||||
}
|
||||
|
||||
.spotlight-buttons-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.spotlight-play-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: #000;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.spotlight-play-button:hover {
|
||||
background: rgba(255,255,255,1);
|
||||
}
|
||||
|
||||
.spotlight-play-button .material-icons {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255,255,255,0.5);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button.watchlisted {
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button.watchlisted .material-icons {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button:not(.watchlisted):hover {
|
||||
border-color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.spotlight-watchlist-button .material-icons {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.spotlight-info-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255,255,255,0.5);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spotlight-info-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
border-color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.spotlight-info-button .material-icons {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.spotlight-overview-tooltip {
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
max-width: 400px;
|
||||
min-width: 300px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.spotlight-info-button:hover .spotlight-overview-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spotlight-nav-container {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.spotlight-nav-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.spotlight-nav-button:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.spotlight-nav-button .material-icons {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.spotlight-pause-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.spotlight-pause-button .material-icons {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.spotlight-dots {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.spotlight-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.spotlight-dot.active {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.spotlight-genre-link,
|
||||
.spotlight-person-link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spotlight-empty-writer-container {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
/* Mobile Styles */
|
||||
@media (max-width: 768px) {
|
||||
.metadata-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.spotlight-nav-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.spotlight-pause-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.spotlight-clearart {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
+893
-13
@@ -5,17 +5,90 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Helper function to shuffle array (Fisher-Yates)
|
||||
*/
|
||||
function shuffle(array) {
|
||||
const arr = [...array];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts items based on sort order and direction
|
||||
* @param {Array} items - Array of Jellyfin items
|
||||
* @param {string} sortOrder - Sort order: Random, ReleaseDate, CriticRating, CommunityRating, SortTitle, DateAdded
|
||||
* @param {string} sortOrderDirection - Direction: Ascending or Descending
|
||||
* @returns {Array} - Sorted array
|
||||
*/
|
||||
function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') {
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
const ascending = sortOrderDirection === 'Ascending';
|
||||
const sorted = [...items];
|
||||
|
||||
switch(sortOrder) {
|
||||
case 'Random':
|
||||
return shuffle(sorted);
|
||||
case 'ReleaseDate':
|
||||
return sorted.sort((a, b) => {
|
||||
const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
|
||||
const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
|
||||
return ascending ? dateA - dateB : dateB - dateA;
|
||||
});
|
||||
case 'CriticRating':
|
||||
return sorted.sort((a, b) => {
|
||||
const ratingA = a.CriticRating || 0;
|
||||
const ratingB = b.CriticRating || 0;
|
||||
return ascending ? ratingA - ratingB : ratingB - ratingA;
|
||||
});
|
||||
case 'CommunityRating':
|
||||
return sorted.sort((a, b) => {
|
||||
const ratingA = a.CommunityRating || 0;
|
||||
const ratingB = b.CommunityRating || 0;
|
||||
return ascending ? ratingA - ratingB : ratingB - ratingA;
|
||||
});
|
||||
case 'SortTitle':
|
||||
return sorted.sort((a, b) => {
|
||||
const titleA = (a.SortTitle || a.Name || '').toLowerCase();
|
||||
const titleB = (b.SortTitle || b.Name || '').toLowerCase();
|
||||
return ascending ? titleA.localeCompare(titleB) : titleB.localeCompare(titleA);
|
||||
});
|
||||
case 'DateAdded':
|
||||
return sorted.sort((a, b) => {
|
||||
const dateA = new Date(a.DateCreated || 0);
|
||||
const dateB = new Date(b.DateCreated || 0);
|
||||
return ascending ? dateA - dateB : dateB - dateA;
|
||||
});
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a random card format for a section
|
||||
* @returns {string} - Random card format: 'portrait', 'thumb'
|
||||
*/
|
||||
function getRandomCardFormat() {
|
||||
const formats = ['portrait', 'thumb'];
|
||||
return formats[Math.floor(Math.random() * formats.length)];
|
||||
}
|
||||
|
||||
// Main card builder object
|
||||
const cardBuilder = {
|
||||
/**
|
||||
* Main entry point function to build a Jellyfin card
|
||||
* @param {Object} item - The Jellyfin item object
|
||||
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
|
||||
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
|
||||
* @returns {HTMLElement} - The constructed card element
|
||||
*/
|
||||
buildCard: function(item, overflowCard = false, cardFormat = null) {
|
||||
return createJellyfinCardElement(item, overflowCard, cardFormat);
|
||||
buildCard: function(item, overflowCard = false, cardFormat = null, customFooterText = null) {
|
||||
return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -24,11 +97,27 @@
|
||||
* @param {string} title - Title for the scrollable container
|
||||
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
|
||||
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
|
||||
* @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
|
||||
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
|
||||
* @returns {HTMLElement} - The constructed scrollable container
|
||||
*/
|
||||
renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
|
||||
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
|
||||
renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') {
|
||||
// Handle Random card format - pick one format for entire section
|
||||
let finalCardFormat = cardFormat;
|
||||
if (cardFormat === 'random' || cardFormat === 'Random') {
|
||||
finalCardFormat = getRandomCardFormat();
|
||||
}
|
||||
|
||||
// Sort items if sortOrder is provided
|
||||
let sortedItems = items;
|
||||
if (sortOrder && sortOrder !== 'Random') {
|
||||
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
|
||||
} else if (sortOrder === 'Random') {
|
||||
sortedItems = shuffle([...items]);
|
||||
}
|
||||
|
||||
return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -37,10 +126,12 @@
|
||||
* @param {string} title - Title for the scrollable container
|
||||
* @param {string} viewMoreUrl - Optional URL to make title clickable
|
||||
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
|
||||
* @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
|
||||
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
|
||||
* @returns {Promise<HTMLElement>} - The constructed scrollable container
|
||||
*/
|
||||
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
|
||||
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') {
|
||||
const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args);
|
||||
|
||||
if (!itemIds || itemIds.length === 0) {
|
||||
@@ -57,11 +148,48 @@
|
||||
const items = response.Items;
|
||||
|
||||
LOG(`Fetched ${items.length} items for section: ${title}`);
|
||||
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
|
||||
|
||||
// Handle Random card format - pick one format for entire section
|
||||
let finalCardFormat = cardFormat;
|
||||
if (cardFormat === 'random' || cardFormat === 'Random') {
|
||||
finalCardFormat = getRandomCardFormat();
|
||||
}
|
||||
|
||||
// Sort items if sortOrder is provided
|
||||
let sortedItems = items;
|
||||
if (sortOrder && sortOrder !== 'Random') {
|
||||
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
|
||||
} else if (sortOrder === 'Random') {
|
||||
sortedItems = shuffle([...items]);
|
||||
}
|
||||
|
||||
return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks CardBuilder] Error fetching items:', error);
|
||||
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders a spotlight section (Netflix-style slim banner carousel)
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
* @param {string} title - Title for the spotlight section
|
||||
* @param {Object} options - Options for the spotlight carousel
|
||||
* @returns {HTMLElement} - The constructed spotlight container
|
||||
*/
|
||||
renderSpotlightSection: function(items, title, options = {}) {
|
||||
return createSpotlightSection(items, title, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sorts items based on sort order and direction (exposed for use in homeScreen)
|
||||
* @param {Array} items - Array of Jellyfin items
|
||||
* @param {string} sortOrder - Sort order
|
||||
* @param {string} sortOrderDirection - Direction
|
||||
* @returns {Array} - Sorted array
|
||||
*/
|
||||
sortItems: function(items, sortOrder, sortOrderDirection) {
|
||||
return sortItems(items, sortOrder, sortOrderDirection);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,15 +197,17 @@
|
||||
* Creates a Jellyfin card element from an item
|
||||
* @param {Object} item - The Jellyfin item object
|
||||
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
|
||||
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
|
||||
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
|
||||
* @returns {HTMLElement} - The constructed card element
|
||||
*/
|
||||
function createJellyfinCardElement(item, overflowCard = false, cardFormat = null) {
|
||||
function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null) {
|
||||
const serverId = ApiClient.serverId();
|
||||
const serverAddress = ApiClient.serverAddress();
|
||||
|
||||
// Determine card type based on cardFormat override or item type
|
||||
let cardClass, padderClass, imageParams;
|
||||
cardFormat = cardFormat?.toLowerCase() || null;
|
||||
|
||||
if (cardFormat) {
|
||||
// Use specified cardFormat
|
||||
@@ -208,8 +338,12 @@
|
||||
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
|
||||
} else if (item.ParentThumbImageTag) {
|
||||
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
|
||||
} else {
|
||||
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
|
||||
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
|
||||
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
|
||||
} else if (item.SeriesPrimaryImageTag) {
|
||||
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
|
||||
} else if (item.ImageTags?.Primary) {
|
||||
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
|
||||
}
|
||||
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
|
||||
} else if (item.ImageTags?.Primary) {
|
||||
@@ -434,6 +568,16 @@
|
||||
episodeBdi.appendChild(episodeLink);
|
||||
secondaryText.appendChild(episodeBdi);
|
||||
cardBox.appendChild(secondaryText);
|
||||
|
||||
// Add custom footer text if provided (e.g., air date)
|
||||
if (customFooterText) {
|
||||
const footerText = document.createElement('div');
|
||||
footerText.className = 'cardText cardTextCentered cardText-secondary';
|
||||
const footerBdi = document.createElement('bdi');
|
||||
footerBdi.textContent = customFooterText;
|
||||
footerText.appendChild(footerBdi);
|
||||
cardBox.appendChild(footerText);
|
||||
}
|
||||
} else {
|
||||
const secondaryText = document.createElement('div');
|
||||
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
|
||||
@@ -448,6 +592,742 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a spotlight section (Netflix-style slim banner carousel)
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
* @param {string} title - Title for the spotlight section
|
||||
* @param {Object} options - Options for the spotlight carousel
|
||||
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
|
||||
* @param {number} options.interval - Auto-play interval in ms (default: 5000)
|
||||
* @param {boolean} options.showDots - Show dot indicators (default: true)
|
||||
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
|
||||
* @returns {HTMLElement} - The constructed spotlight container
|
||||
*/
|
||||
function createSpotlightSection(items, title, options = {}) {
|
||||
if (!items || items.length === 0) {
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
const {
|
||||
autoPlay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showNavButtons = true,
|
||||
showClearArt = false,
|
||||
viewMoreUrl = null
|
||||
} = options;
|
||||
|
||||
const serverId = ApiClient.serverId();
|
||||
const serverAddress = ApiClient.serverAddress();
|
||||
let currentIndex = 0;
|
||||
let autoPlayTimer = null;
|
||||
let isPaused = false;
|
||||
|
||||
// Create main container
|
||||
const container = document.createElement('div');
|
||||
container.className = 'spotlight-section padded-left';
|
||||
|
||||
// Create banner container
|
||||
const bannerContainer = document.createElement('div');
|
||||
bannerContainer.className = 'spotlight-banner-container';
|
||||
|
||||
// Add section title in top left corner (absolute positioning, aligned with controls)
|
||||
if (title) {
|
||||
const sectionTitleEl = document.createElement('div');
|
||||
sectionTitleEl.className = 'spotlight-section-title';
|
||||
sectionTitleEl.textContent = title;
|
||||
bannerContainer.appendChild(sectionTitleEl);
|
||||
}
|
||||
|
||||
// Create items container (for fade transitions)
|
||||
const itemsContainer = document.createElement('div');
|
||||
itemsContainer.className = 'spotlight-items-container';
|
||||
|
||||
// Create items
|
||||
items.forEach((item, index) => {
|
||||
// Get item type early for use throughout
|
||||
const itemType = item.Type || 'Movie';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'spotlight-item';
|
||||
itemDiv.setAttribute('data-index', index);
|
||||
if (index === 0) {
|
||||
itemDiv.style.opacity = '1';
|
||||
} else {
|
||||
itemDiv.style.opacity = '0';
|
||||
}
|
||||
|
||||
let imageUrl = '';
|
||||
|
||||
if (item.Type === 'Episode' && item.ParentBackdropImageTags?.[0]) {
|
||||
// Series backdrop image
|
||||
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
|
||||
} else if (item.BackdropImageTags?.[0]) {
|
||||
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.BackdropImageTags[0]}`;
|
||||
} else if (item.ImageTags?.Primary) {
|
||||
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ImageTags.Primary}`;
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
itemDiv.style.backgroundImage = `url("${imageUrl}")`;
|
||||
}
|
||||
|
||||
// Create left overlay (40-50% width, dark semi-transparent)
|
||||
// Add top padding to prevent content from overlapping with section title
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'spotlight-overlay' + (title ? ' has-title' : '');
|
||||
|
||||
// Item title or logo
|
||||
let titleEl = null;
|
||||
const hasLogo = item.ImageTags?.Logo;
|
||||
|
||||
if (hasLogo) {
|
||||
// Use logo image instead of text title
|
||||
const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=200&quality=96&tag=${item.ImageTags.Logo}`;
|
||||
titleEl = document.createElement('img');
|
||||
titleEl.className = 'spotlight-item-logo';
|
||||
titleEl.src = logoUrl;
|
||||
titleEl.alt = item.Name || 'Unknown';
|
||||
} else {
|
||||
// Use text title as fallback
|
||||
titleEl = document.createElement('h3');
|
||||
titleEl.className = 'spotlight-item-title';
|
||||
titleEl.textContent = item.Name || 'Unknown';
|
||||
}
|
||||
|
||||
// Combined: Year + Time + Ends At + Genres (all on one line)
|
||||
const metadataRow = document.createElement('div');
|
||||
metadataRow.className = 'spotlight-metadata-row';
|
||||
|
||||
// Year
|
||||
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null);
|
||||
if (year) {
|
||||
const yearEl = document.createElement('span');
|
||||
yearEl.textContent = year;
|
||||
metadataRow.appendChild(yearEl);
|
||||
}
|
||||
|
||||
// Runtime
|
||||
if (item.RunTimeTicks) {
|
||||
const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60);
|
||||
const hours = Math.floor(runtimeMinutes / 60);
|
||||
const minutes = runtimeMinutes % 60;
|
||||
let runtimeText = '';
|
||||
if (hours > 0) {
|
||||
runtimeText = `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim();
|
||||
} else {
|
||||
runtimeText = `${minutes}m`;
|
||||
}
|
||||
|
||||
const runtimeEl = document.createElement('span');
|
||||
runtimeEl.textContent = runtimeText;
|
||||
metadataRow.appendChild(runtimeEl);
|
||||
|
||||
// End time (when it would end if started now)
|
||||
const now = new Date();
|
||||
// RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds
|
||||
const runtimeMs = item.RunTimeTicks / 10000;
|
||||
const endTime = new Date(now.getTime() + runtimeMs);
|
||||
const endTimeEl = document.createElement('span');
|
||||
endTimeEl.className = 'spotlight-end-time';
|
||||
endTimeEl.textContent = `Ends at ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
metadataRow.appendChild(endTimeEl);
|
||||
}
|
||||
|
||||
// Genres (on same line) - make clickable if IDs are available
|
||||
if (item.Genres && item.Genres.length > 0) {
|
||||
const genresContainer = document.createElement('span');
|
||||
|
||||
if (item.GenreItems && item.GenreItems.length > 0) {
|
||||
item.GenreItems.forEach((genreItem, index) => {
|
||||
const genreName = genreItem.Name;
|
||||
const genreId = genreItem.Id;
|
||||
|
||||
if (genreName) {
|
||||
const genreLink = document.createElement('a');
|
||||
genreLink.className = 'spotlight-genre-link';
|
||||
genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`;
|
||||
genreLink.textContent = genreName;
|
||||
genreLink.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
if (index > 0) {
|
||||
const comma = document.createElement('span');
|
||||
comma.textContent = ', ';
|
||||
genresContainer.appendChild(comma);
|
||||
}
|
||||
|
||||
genresContainer.appendChild(genreLink);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (genresContainer.children.length > 0) {
|
||||
metadataRow.appendChild(genresContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create truncated list that expands on ellipsis hover
|
||||
// items can be array of strings (names) or array of objects with Name and Id
|
||||
function createTruncatedList(label, items, maxItems = 3, isPeople = false) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'spotlight-truncated-list';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return container;
|
||||
}
|
||||
|
||||
const displayContainer = document.createElement('span');
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = `${label} `;
|
||||
displayContainer.appendChild(labelSpan);
|
||||
|
||||
// Extract names and IDs from items (handle both strings and objects)
|
||||
const itemsData = items.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item, id: null };
|
||||
} else if (item && typeof item === 'object') {
|
||||
return {
|
||||
name: item.Name || item.name || null,
|
||||
id: item.Id || item.id || null
|
||||
};
|
||||
}
|
||||
return container;
|
||||
}).filter(item => item && item.name);
|
||||
|
||||
if (itemsData.length === 0) return null;
|
||||
|
||||
const hasMore = itemsData.length > maxItems;
|
||||
|
||||
// Store references to hidden elements for easy show/hide
|
||||
const hiddenElements = [];
|
||||
|
||||
// Create spans for all names (but initially hide the ones beyond maxItems)
|
||||
itemsData.forEach((itemData, index) => {
|
||||
let nameElement;
|
||||
|
||||
// Create link if it's a person with an ID, otherwise just text
|
||||
if (isPeople && itemData.id) {
|
||||
nameElement = document.createElement('a');
|
||||
nameElement.className = 'spotlight-person-link';
|
||||
nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
|
||||
nameElement.textContent = itemData.name;
|
||||
nameElement.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
} else {
|
||||
nameElement = document.createElement('span');
|
||||
nameElement.textContent = itemData.name;
|
||||
}
|
||||
|
||||
nameElement.setAttribute('data-name-index', index);
|
||||
|
||||
// Hide names beyond maxItems initially
|
||||
if (index >= maxItems) {
|
||||
nameElement.style.display = 'none';
|
||||
hiddenElements.push(nameElement);
|
||||
}
|
||||
|
||||
displayContainer.appendChild(nameElement);
|
||||
|
||||
// Add comma separator (except for last item)
|
||||
if (index < itemsData.length - 1) {
|
||||
const comma = document.createElement('span');
|
||||
comma.textContent = ', ';
|
||||
comma.setAttribute('data-comma-after', index);
|
||||
|
||||
// Hide comma if it's after the last visible item
|
||||
if (index >= maxItems - 1) {
|
||||
comma.style.cssText = 'display: none;';
|
||||
hiddenElements.push(comma);
|
||||
}
|
||||
displayContainer.appendChild(comma);
|
||||
}
|
||||
});
|
||||
|
||||
// Add ellipsis as separate hoverable element if there are more items
|
||||
if (hasMore) {
|
||||
const ellipsisElement = document.createElement('span');
|
||||
ellipsisElement.className = 'spotlight-ellipsis';
|
||||
ellipsisElement.textContent = '...';
|
||||
displayContainer.appendChild(ellipsisElement);
|
||||
|
||||
let isExpanded = false;
|
||||
|
||||
// On ellipsis hover, reveal all hidden names and hide ellipsis
|
||||
ellipsisElement.addEventListener('mouseenter', () => {
|
||||
if (!isExpanded) {
|
||||
// Show all hidden elements
|
||||
hiddenElements.forEach(el => {
|
||||
el.style.display = '';
|
||||
});
|
||||
ellipsisElement.style.display = 'none';
|
||||
isExpanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Keep expanded state when mouse moves within the container
|
||||
// Only collapse when mouse leaves the entire container
|
||||
container.addEventListener('mouseleave', () => {
|
||||
if (isExpanded) {
|
||||
// Hide names beyond maxItems again
|
||||
hiddenElements.forEach(el => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
ellipsisElement.style.display = '';
|
||||
isExpanded = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(displayContainer);
|
||||
return container;
|
||||
}
|
||||
|
||||
// For Series: show seasons/episodes. For Movies: show director/writer
|
||||
let directorContainer = null;
|
||||
let writerContainer = null;
|
||||
let combinedContainer = null;
|
||||
let seriesInfoContainer = null;
|
||||
|
||||
// Create series info container
|
||||
seriesInfoContainer = document.createElement('div');
|
||||
seriesInfoContainer.className = 'spotlight-truncated-list';
|
||||
|
||||
if (itemType === 'Series') {
|
||||
// For Series, show seasons and episodes count
|
||||
const seasonsCount = item.ChildCount || 0;
|
||||
|
||||
// Initial text with seasons count
|
||||
const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`;
|
||||
seriesInfoContainer.textContent = seasonsText;
|
||||
|
||||
// Fetch episode count asynchronously and update
|
||||
(async () => {
|
||||
try {
|
||||
const episodesCount = item.RecursiveItemCount || 0;
|
||||
|
||||
// Update the series info container with episode count
|
||||
if (seriesInfoContainer) {
|
||||
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
|
||||
seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch episode count:', err);
|
||||
// Keep just the seasons text if episode fetch fails
|
||||
}
|
||||
})();
|
||||
} else if (itemType === 'Movie') {
|
||||
// For Movies, show director/writer (existing logic)
|
||||
if (item.People && Array.isArray(item.People)) {
|
||||
const directors = item.People.filter(p => p.Type === 'Director');
|
||||
const writers = item.People.filter(p => p.Type === 'Writer');
|
||||
|
||||
// Extract names for comparison
|
||||
const directorNames = directors.map(p => p.Name);
|
||||
const writerNames = writers.map(p => p.Name);
|
||||
|
||||
// Check if directors and writers are identical (same length and same names)
|
||||
const areIdentical = directorNames.length === writerNames.length &&
|
||||
directorNames.length > 0 &&
|
||||
directorNames.every((name, index) => name === writerNames[index]);
|
||||
|
||||
if (areIdentical) {
|
||||
// Combine into single "Directed and Written by" row (with person objects for links)
|
||||
combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true);
|
||||
// Add empty writer container to maintain spacing
|
||||
writerContainer = document.createElement('div');
|
||||
writerContainer.className = 'spotlight-empty-writer-container';
|
||||
} else {
|
||||
// Show separately (with person objects for links)
|
||||
directorContainer = createTruncatedList('Directed by', directors, 3, true);
|
||||
writerContainer = createTruncatedList('Written by', writers, 3, true);
|
||||
}
|
||||
}
|
||||
} else if (itemType === 'Season') {
|
||||
// For Seasons, show episodes count
|
||||
const episodesCount = item.RecursiveItemCount || 0;
|
||||
const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
|
||||
seriesInfoContainer.textContent = episodesText;
|
||||
} else if (itemType === 'Episode') {
|
||||
// For Episodes, show episode number
|
||||
const seasonNumber = item.ParentIndexNumber || 0;
|
||||
const seasonText = `Season ${seasonNumber}`;
|
||||
const episodeNumber = item.IndexNumber || 0;
|
||||
const episodeText = `Episode ${episodeNumber}`;
|
||||
seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`;
|
||||
}
|
||||
|
||||
// Taglines for Movies, Overview for Series
|
||||
let taglineEl = null;
|
||||
taglineEl = document.createElement('p');
|
||||
taglineEl.className = 'spotlight-tagline';
|
||||
if (itemType === 'Series') {
|
||||
// For Series, show Overview instead of Tagline
|
||||
if (item.Overview) {
|
||||
taglineEl.textContent = item.Overview;
|
||||
}
|
||||
} else {
|
||||
// For Movies, show Tagline (if available)
|
||||
if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) {
|
||||
taglineEl.textContent = item.Taglines[0]; // Show first tagline
|
||||
} else if (item.Overview) {
|
||||
taglineEl.textContent = item.Overview;
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons container
|
||||
const buttonsContainer = document.createElement('div');
|
||||
buttonsContainer.className = 'spotlight-buttons-container';
|
||||
|
||||
// Play button (material icon span button)
|
||||
const playButton = document.createElement('button');
|
||||
playButton.className = 'spotlight-play-button emby-button';
|
||||
const playIcon = document.createElement('span');
|
||||
playIcon.className = 'material-icons';
|
||||
playIcon.textContent = 'play_arrow';
|
||||
playButton.appendChild(playIcon);
|
||||
playButton.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (window.apiHelper && window.apiHelper.playItem) {
|
||||
await window.apiHelper.playItem(item.Id);
|
||||
} else {
|
||||
// Fallback to ApiClient.play if apiHelper is not available
|
||||
if (window.ApiClient && typeof window.ApiClient.play === 'function') {
|
||||
window.ApiClient.play({ ids: [item.Id] });
|
||||
} else {
|
||||
console.error('[KefinTweaks CardBuilder] No play method available');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks CardBuilder] Error playing item:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Watchlist button (material icon span button with bookmark icon)
|
||||
const watchlistButton = document.createElement('button');
|
||||
watchlistButton.className = 'spotlight-watchlist-button emby-button';
|
||||
|
||||
// Check watchlist status - try to get from cache or API
|
||||
let isInWatchlist = false;
|
||||
const sectionName = itemType === 'Movie' ? 'movies' :
|
||||
itemType === 'Series' ? 'series' :
|
||||
itemType === 'Season' ? 'seasons' :
|
||||
itemType === 'Episode' ? 'episodes' : null;
|
||||
|
||||
// Check watchlist cache if available
|
||||
if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) {
|
||||
isInWatchlist = window.watchlistCache[sectionName].data.some(watchlistItem => watchlistItem.Id === item.Id);
|
||||
}
|
||||
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons';
|
||||
watchlistIcon.textContent = 'bookmark';
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
if (isInWatchlist) {
|
||||
watchlistButton.classList.add('watchlisted');
|
||||
}
|
||||
watchlistButton.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const newStatus = !isInWatchlist;
|
||||
|
||||
try {
|
||||
// Update watchlist status using proper API
|
||||
await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false');
|
||||
|
||||
// Update local state
|
||||
isInWatchlist = newStatus;
|
||||
|
||||
// Update button class
|
||||
if (isInWatchlist) {
|
||||
watchlistButton.classList.add('watchlisted');
|
||||
} else {
|
||||
watchlistButton.classList.remove('watchlisted');
|
||||
}
|
||||
|
||||
// Update watchlist cache if available
|
||||
if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') {
|
||||
await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update watchlist status:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Info button (shows overview on hover)
|
||||
const infoButton = document.createElement('button');
|
||||
infoButton.className = 'spotlight-info-button emby-button';
|
||||
const infoIcon = document.createElement('span');
|
||||
infoIcon.className = 'material-icons';
|
||||
infoIcon.textContent = 'info';
|
||||
infoButton.appendChild(infoIcon);
|
||||
|
||||
// Overview tooltip (hidden by default, shown on hover)
|
||||
if (item.Overview) {
|
||||
const overviewTooltip = document.createElement('div');
|
||||
overviewTooltip.className = 'spotlight-overview-tooltip';
|
||||
overviewTooltip.textContent = item.Overview;
|
||||
infoButton.appendChild(overviewTooltip);
|
||||
}
|
||||
|
||||
buttonsContainer.appendChild(playButton);
|
||||
buttonsContainer.appendChild(watchlistButton);
|
||||
buttonsContainer.appendChild(infoButton);
|
||||
|
||||
// Rating with star icon
|
||||
const rating = item.CommunityRating || item.CriticRating;
|
||||
|
||||
if (rating && typeof rating === 'number') {
|
||||
const ratingContainer = document.createElement('div');
|
||||
ratingContainer.className = 'spotlight-rating-container';
|
||||
|
||||
const starIcon = document.createElement('span');
|
||||
starIcon.className = 'material-icons spotlight-rating-star';
|
||||
starIcon.textContent = 'star';
|
||||
|
||||
const ratingValue = document.createElement('span');
|
||||
ratingValue.className = 'spotlight-rating-value';
|
||||
if (rating && typeof rating === 'number') {
|
||||
ratingValue.textContent = rating.toFixed(1);
|
||||
} else {
|
||||
ratingValue.textContent = 'N/A';
|
||||
ratingValue.classList.add('na');
|
||||
}
|
||||
|
||||
ratingContainer.appendChild(starIcon);
|
||||
ratingContainer.appendChild(ratingValue);
|
||||
buttonsContainer.appendChild(ratingContainer);
|
||||
}
|
||||
|
||||
// Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons
|
||||
overlay.appendChild(titleEl);
|
||||
|
||||
const metadataContainer = document.createElement('div');
|
||||
metadataContainer.className = 'metadata-container';
|
||||
|
||||
if (metadataRow.children.length > 0) {
|
||||
metadataContainer.appendChild(metadataRow);
|
||||
}
|
||||
// For Series, show seasons/episodes. For Movies, show director/writer
|
||||
if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') {
|
||||
if (seriesInfoContainer) {
|
||||
metadataContainer.appendChild(seriesInfoContainer);
|
||||
}
|
||||
} else {
|
||||
let emptyDirectorContainer = document.createElement('div');
|
||||
emptyDirectorContainer.className = 'spotlight-empty-director-container';
|
||||
|
||||
let emptyWriterContainer = document.createElement('div');
|
||||
emptyWriterContainer.className = 'spotlight-empty-writer-container';
|
||||
|
||||
if (combinedContainer) {
|
||||
metadataContainer.appendChild(combinedContainer);
|
||||
metadataContainer.appendChild(emptyWriterContainer);
|
||||
} else {
|
||||
if (directorContainer) {
|
||||
metadataContainer.appendChild(directorContainer);
|
||||
}
|
||||
|
||||
if (writerContainer) {
|
||||
metadataContainer.appendChild(writerContainer);
|
||||
if (!directorContainer) {
|
||||
metadataContainer.appendChild(emptyDirectorContainer);
|
||||
}
|
||||
} else {
|
||||
if (!directorContainer) {
|
||||
metadataContainer.appendChild(emptyDirectorContainer);
|
||||
}
|
||||
metadataContainer.appendChild(emptyWriterContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metadataContainer.children.length > 0) {
|
||||
overlay.appendChild(metadataContainer);
|
||||
}
|
||||
if (taglineEl) {
|
||||
overlay.appendChild(taglineEl);
|
||||
}
|
||||
overlay.appendChild(buttonsContainer);
|
||||
|
||||
itemDiv.appendChild(overlay);
|
||||
|
||||
// ClearArt image (bottom right corner, if enabled and available)
|
||||
if (showClearArt && item.ImageTags?.Art) {
|
||||
const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`;
|
||||
const clearArtEl = document.createElement('img');
|
||||
clearArtEl.className = 'spotlight-clearart';
|
||||
clearArtEl.src = clearArtUrl;
|
||||
clearArtEl.alt = item.Name || 'Unknown';
|
||||
itemDiv.appendChild(clearArtEl);
|
||||
}
|
||||
|
||||
// Click to navigate (but not on buttons)
|
||||
infoButton.addEventListener('click', (e) => {
|
||||
window.location.href = `${serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
|
||||
});
|
||||
|
||||
itemsContainer.appendChild(itemDiv);
|
||||
});
|
||||
|
||||
bannerContainer.appendChild(itemsContainer);
|
||||
|
||||
// Navigation buttons (top right)
|
||||
if (showNavButtons && items.length > 1) {
|
||||
const navContainer = document.createElement('div');
|
||||
navContainer.className = 'spotlight-nav-container';
|
||||
|
||||
const prevButton = document.createElement('button');
|
||||
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
|
||||
const prevIcon = document.createElement('span');
|
||||
prevIcon.className = 'material-icons';
|
||||
prevIcon.textContent = 'chevron_left';
|
||||
prevButton.appendChild(prevIcon);
|
||||
prevButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Reset timer when manually navigating
|
||||
goToItem((currentIndex - 1 + items.length) % items.length, true);
|
||||
});
|
||||
|
||||
const nextButton = document.createElement('button');
|
||||
nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
|
||||
const nextIcon = document.createElement('span');
|
||||
nextIcon.className = 'material-icons';
|
||||
nextIcon.textContent = 'chevron_right';
|
||||
nextButton.appendChild(nextIcon);
|
||||
nextButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Reset timer when manually navigating
|
||||
goToItem((currentIndex + 1) % items.length, true);
|
||||
});
|
||||
|
||||
navContainer.appendChild(prevButton);
|
||||
navContainer.appendChild(nextButton);
|
||||
bannerContainer.appendChild(navContainer);
|
||||
}
|
||||
|
||||
// Pause button (bottom right)
|
||||
if (autoPlay && items.length > 1) {
|
||||
const navContainer = bannerContainer.querySelector('.spotlight-nav-container');
|
||||
const pauseButton = document.createElement('button');
|
||||
pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button';
|
||||
const pauseIcon = document.createElement('span');
|
||||
pauseIcon.className = 'material-icons';
|
||||
pauseIcon.textContent = 'pause';
|
||||
pauseButton.appendChild(pauseIcon);
|
||||
pauseButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
isPaused = !isPaused;
|
||||
if (isPaused) {
|
||||
clearInterval(autoPlayTimer);
|
||||
pauseIcon.textContent = 'play_arrow';
|
||||
} else {
|
||||
startAutoPlay();
|
||||
pauseIcon.textContent = 'pause';
|
||||
}
|
||||
});
|
||||
navContainer.insertBefore(pauseButton, navContainer.firstChild);
|
||||
}
|
||||
|
||||
// Dot indicators
|
||||
if (showDots && items.length > 1) {
|
||||
const dotsContainer = document.createElement('div');
|
||||
dotsContainer.className = 'spotlight-dots';
|
||||
|
||||
items.forEach((_, index) => {
|
||||
const dot = document.createElement('button');
|
||||
dot.className = 'spotlight-dot' + (index === 0 ? ' active' : '');
|
||||
dot.setAttribute('data-index', index);
|
||||
dot.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Reset timer when manually navigating
|
||||
goToItem(index, true);
|
||||
});
|
||||
dotsContainer.appendChild(dot);
|
||||
});
|
||||
|
||||
bannerContainer.appendChild(dotsContainer);
|
||||
}
|
||||
|
||||
// Go to item function (fade transition)
|
||||
function goToItem(index, resetTimer = true) {
|
||||
if (index === currentIndex) return;
|
||||
|
||||
const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
|
||||
const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`);
|
||||
|
||||
if (currentItem && nextItem) {
|
||||
currentItem.style.opacity = '0';
|
||||
nextItem.style.opacity = '1';
|
||||
}
|
||||
|
||||
currentIndex = index;
|
||||
|
||||
// Update dots
|
||||
if (showDots) {
|
||||
const dots = bannerContainer.querySelectorAll('.spotlight-dot');
|
||||
dots.forEach((dot, i) => {
|
||||
if (i === index) {
|
||||
dot.classList.add('active');
|
||||
} else {
|
||||
dot.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset auto-play timer when manually navigating (always reset delay)
|
||||
if (resetTimer && autoPlay && !isPaused) {
|
||||
clearInterval(autoPlayTimer);
|
||||
autoPlayTimer = null;
|
||||
startAutoPlay();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-play function
|
||||
function startAutoPlay() {
|
||||
// Always clear any existing timer first
|
||||
if (autoPlayTimer) {
|
||||
clearInterval(autoPlayTimer);
|
||||
autoPlayTimer = null;
|
||||
}
|
||||
|
||||
if (!autoPlay || items.length <= 1 || isPaused) return;
|
||||
|
||||
autoPlayTimer = setInterval(() => {
|
||||
// Don't reset timer when auto-advancing (to maintain interval)
|
||||
goToItem((currentIndex + 1) % items.length, false);
|
||||
}, interval);
|
||||
}
|
||||
|
||||
// Pause on hover - prevent auto-cycling when hovering
|
||||
let isHovering = false;
|
||||
bannerContainer.addEventListener('mouseenter', () => {
|
||||
isHovering = true;
|
||||
if (autoPlayTimer) {
|
||||
clearInterval(autoPlayTimer);
|
||||
autoPlayTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
bannerContainer.addEventListener('mouseleave', () => {
|
||||
isHovering = false;
|
||||
// Only restart if auto-play is enabled, not paused, and not hovering
|
||||
if (autoPlay && !isPaused && !isHovering) {
|
||||
startAutoPlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Start auto-play
|
||||
if (autoPlay) {
|
||||
startAutoPlay();
|
||||
}
|
||||
|
||||
container.appendChild(bannerContainer);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scrollable container with horizontal scrolling functionality
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
|
||||
+123
-4
@@ -32,6 +32,10 @@
|
||||
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.kefin-config-button:hover {
|
||||
background-color: var(--jf-palette-action-hover);
|
||||
}
|
||||
|
||||
.customMenuOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -41,24 +45,139 @@
|
||||
order: 1;
|
||||
}
|
||||
|
||||
a[href="#"].navMenuOption:has(.build) {
|
||||
.navMenuOption:has(.build) {
|
||||
order: 9999;
|
||||
margin-top: 2em !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a[href="#"].navMenuOption:has(.build)::before {
|
||||
.navMenuOption:has(.build)>.material-icons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navMenuOption:has(.build)>.material-icons::after {
|
||||
content: 'KefinTweaks';
|
||||
position: absolute;
|
||||
top: -1.5em;
|
||||
left: 0;
|
||||
top: -2.5em;
|
||||
left: -1.25em;
|
||||
font-family: 'Noto Sans';
|
||||
font-size: 14.88px;
|
||||
}
|
||||
|
||||
.listItemContent:has([data-skin-name]) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.listItemContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .listItem {
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#customSections_list .listItem {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Autocomplete suggestions styling */
|
||||
.autocomplete-suggestions {
|
||||
background: #1a1a1a !important;
|
||||
color: rgba(255,255,255,0.87) !important;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.5) !important;
|
||||
opacity: 1 !important;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
.autocomplete-suggestion {
|
||||
color: rgba(255,255,255,0.87) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.autocomplete-suggestion:hover {
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .checkboxContainer, #kefinTweaksConfigModal .checkboxListContainer {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .info-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
border-radius: 50%;
|
||||
cursor: help;
|
||||
color: rgba(255,255,255,0.8);
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
outline: none;
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .info-tooltip:hover,
|
||||
#kefinTweaksConfigModal .info-tooltip:focus-visible {
|
||||
border-color: rgba(255,255,255,0.8);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .info-tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 180%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #fff;
|
||||
padding: 0.4em 0.6em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
z-index: 20;
|
||||
font-family: 'Noto Sans';
|
||||
}
|
||||
|
||||
#kefinTweaksConfigModal .info-tooltip:hover::after,
|
||||
#kefinTweaksConfigModal .info-tooltip:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 6px);
|
||||
}
|
||||
|
||||
details[open] summary.listItemBodyText::after {
|
||||
content: '▲';
|
||||
}
|
||||
|
||||
summary.listItemBodyText::after {
|
||||
content: '▼';
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
summary.listItemBodyText {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kefin-config-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.preview-section-container .custom-scroller-container {
|
||||
max-width: calc(100% - 4rem);
|
||||
}
|
||||
|
||||
.navMenuOption:has(.build):hover:not(.navMenuOption-selected) {
|
||||
transform: none !important;
|
||||
margin-top: 2em !important;
|
||||
}
|
||||
+3228
-164
File diff suppressed because it is too large
Load Diff
@@ -124,4 +124,7 @@ div#discovery-loading-indicator:not(.show)::after {
|
||||
/* Ensure Editor's Choice Banner appears at top of home screen if enabled */
|
||||
.editorsChoiceContainer {
|
||||
order: 1 !important;
|
||||
}
|
||||
.spotlight-section {
|
||||
width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%)));
|
||||
}
|
||||
+2284
-288
File diff suppressed because it is too large
Load Diff
+11
-12
@@ -126,7 +126,6 @@
|
||||
.smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; }
|
||||
.smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; }
|
||||
.smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; }
|
||||
.smart-search-btn.active { background:#673AB7; }
|
||||
.smart-search-results { margin-top:12px; }
|
||||
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
|
||||
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
|
||||
@@ -452,7 +451,7 @@
|
||||
btnAll.textContent = 'All';
|
||||
const btnCore = document.createElement('button');
|
||||
btnCore.id = 'smart-search-core';
|
||||
btnCore.className = 'smart-search-btn emby-button active';
|
||||
btnCore.className = 'smart-search-btn emby-button raised button-submit active';
|
||||
btnCore.textContent = 'Movies/TV';
|
||||
const btnMusic = document.createElement('button');
|
||||
btnMusic.id = 'smart-search-music';
|
||||
@@ -513,26 +512,26 @@
|
||||
// Function to update button states
|
||||
function updateButtonStates(activeType) {
|
||||
// Remove active class from all buttons
|
||||
smartAllBtn.classList.remove('active');
|
||||
smartCoreBtn.classList.remove('active');
|
||||
smartMusicBtn.classList.remove('active');
|
||||
smartBooksBtn.classList.remove('active');
|
||||
smartAllBtn.classList.remove('active','button-submit','raised');
|
||||
smartCoreBtn.classList.remove('active','button-submit','raised');
|
||||
smartMusicBtn.classList.remove('active','button-submit','raised');
|
||||
smartBooksBtn.classList.remove('active','button-submit','raised');
|
||||
|
||||
if (smartRequestBtn) {
|
||||
smartRequestBtn.classList.remove('active');
|
||||
smartRequestBtn.classList.remove('active','button-submit','raised');
|
||||
}
|
||||
|
||||
// Add active class to the selected button
|
||||
if (activeType === 'all') {
|
||||
smartAllBtn.classList.add('active');
|
||||
smartAllBtn.classList.add('active','button-submit','raised');
|
||||
} else if (activeType === 'videos') {
|
||||
smartCoreBtn.classList.add('active');
|
||||
smartCoreBtn.classList.add('active','button-submit','raised');
|
||||
} else if (activeType === 'music') {
|
||||
smartMusicBtn.classList.add('active');
|
||||
smartMusicBtn.classList.add('active','button-submit','raised');
|
||||
} else if (activeType === 'books') {
|
||||
smartBooksBtn.classList.add('active');
|
||||
smartBooksBtn.classList.add('active','button-submit','raised');
|
||||
} else if (activeType === 'request' && smartRequestBtn) {
|
||||
smartRequestBtn.classList.add('active');
|
||||
smartRequestBtn.classList.add('active','button-submit','raised');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+191
-1
@@ -324,6 +324,195 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for user to be logged in
|
||||
* @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 10000 = 10 seconds)
|
||||
* @param {number} checkInterval - Interval between checks in milliseconds (default: 500)
|
||||
* @returns {Promise<boolean>} - True if logged in, false if timeout
|
||||
*/
|
||||
async function waitForLogin(maxWaitMs = 10000, checkInterval = 500) {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
if (window.ApiClient &&
|
||||
window.ApiClient._loggedIn &&
|
||||
window.ApiClient.accessToken &&
|
||||
window.ApiClient.serverAddress) {
|
||||
try {
|
||||
const token = window.ApiClient.accessToken();
|
||||
if (token) {
|
||||
LOG('User is logged in');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Token not available yet
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
WARN('Timeout waiting for user login');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current KefinTweaksConfig back to JS Injector plugin
|
||||
* @param {Object} config - Optional config object to save. If not provided, uses window.KefinTweaksConfig
|
||||
* @param {Object} options - Options for saving
|
||||
* @param {boolean} options.waitForLogin - Whether to wait for login if not logged in (default: true)
|
||||
* @param {number} options.loginWaitTime - Maximum time to wait for login in ms (default: 10000)
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async function saveConfigToJavaScriptInjector(config = null, options = {}) {
|
||||
try {
|
||||
const { waitForLogin: shouldWaitForLogin = true, loginWaitTime = 10000 } = options;
|
||||
|
||||
// Check if ApiClient is available
|
||||
if (!window.ApiClient) {
|
||||
if (shouldWaitForLogin) {
|
||||
LOG('ApiClient not available, waiting for initialization...');
|
||||
const loggedIn = await waitForLogin(loginWaitTime);
|
||||
if (!loggedIn) {
|
||||
WARN('ApiClient not available after waiting, cannot save config');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
throw new Error('ApiClient not available');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const isLoggedIn = window.ApiClient._loggedIn &&
|
||||
window.ApiClient.accessToken &&
|
||||
window.ApiClient.serverAddress;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (shouldWaitForLogin) {
|
||||
LOG('User not logged in, waiting for login...');
|
||||
const loggedIn = await waitForLogin(loginWaitTime);
|
||||
if (!loggedIn) {
|
||||
WARN('User not logged in after waiting, cannot save config');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
throw new Error('User not logged in');
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided config or fall back to window.KefinTweaksConfig
|
||||
const configToSave = config || window.KefinTweaksConfig;
|
||||
if (!configToSave) {
|
||||
throw new Error('No config provided and window.KefinTweaksConfig is not available');
|
||||
}
|
||||
|
||||
// Find JavaScript Injector plugin
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const token = window.ApiClient.accessToken();
|
||||
|
||||
if (!server || !token) {
|
||||
throw new Error('Server address or access token not available');
|
||||
}
|
||||
|
||||
let pluginsResponse = await fetch(`${server}/Plugins`, {
|
||||
headers: { 'X-Emby-Token': token }
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized - retry once after waiting if waitForLogin is enabled
|
||||
if (pluginsResponse.status === 401 && shouldWaitForLogin) {
|
||||
WARN('Received 401 Unauthorized, waiting for login and retrying...');
|
||||
const loggedIn = await waitForLogin(5000); // Wait up to 5 more seconds
|
||||
if (loggedIn) {
|
||||
// Retry with fresh token
|
||||
const freshToken = window.ApiClient.accessToken();
|
||||
pluginsResponse = await fetch(`${server}/Plugins`, {
|
||||
headers: { 'X-Emby-Token': freshToken }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!pluginsResponse.ok) {
|
||||
if (pluginsResponse.status === 401) {
|
||||
throw new Error('Unauthorized - user may not be logged in or session expired');
|
||||
}
|
||||
throw new Error(`Failed to get plugins: ${pluginsResponse.status} ${pluginsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const pluginsData = await pluginsResponse.json();
|
||||
const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []);
|
||||
|
||||
const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector');
|
||||
if (!plugin) {
|
||||
WARN('JavaScript Injector plugin not found, cannot save config');
|
||||
return false;
|
||||
}
|
||||
|
||||
const pluginId = plugin.Id;
|
||||
|
||||
// Get current injector config
|
||||
const configUrl = `${server}/Plugins/${pluginId}/Configuration`;
|
||||
const configResponse = await fetch(configUrl, {
|
||||
headers: { 'X-Emby-Token': token }
|
||||
});
|
||||
|
||||
if (!configResponse.ok) {
|
||||
throw new Error(`Failed to get plugin config: ${configResponse.statusText}`);
|
||||
}
|
||||
|
||||
const injectorConfig = await configResponse.json();
|
||||
|
||||
// Ensure CustomJavaScripts array exists
|
||||
if (!injectorConfig.CustomJavaScripts) {
|
||||
injectorConfig.CustomJavaScripts = [];
|
||||
}
|
||||
|
||||
// Create the script content
|
||||
const scriptContent = `// KefinTweaks Configuration
|
||||
// This file is automatically generated by KefinTweaks Configuration UI
|
||||
// Do not edit manually unless you know what you're doing
|
||||
|
||||
window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
|
||||
// Find or create KefinTweaks-Config script
|
||||
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-Config'
|
||||
);
|
||||
|
||||
if (existingScriptIndex !== -1) {
|
||||
// Update existing script
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
|
||||
} else {
|
||||
// Add new script
|
||||
injectorConfig.CustomJavaScripts.push({
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
});
|
||||
}
|
||||
|
||||
// Save the updated configuration
|
||||
const saveResponse = await fetch(configUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Emby-Token': token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(injectorConfig)
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`);
|
||||
}
|
||||
|
||||
LOG('Successfully saved config to JS Injector plugin');
|
||||
return true;
|
||||
} catch (err) {
|
||||
ERR('Error saving config to JS Injector:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the link to the existing container
|
||||
* @param {Element} container - The custom menu options container
|
||||
@@ -387,7 +576,8 @@
|
||||
getCurrentView,
|
||||
getHandlerCount,
|
||||
clearHandlers,
|
||||
addCustomMenuLink
|
||||
addCustomMenuLink,
|
||||
saveConfigToJavaScriptInjector
|
||||
};
|
||||
|
||||
LOG('Initialized successfully');
|
||||
|
||||
+64
-90
@@ -1082,26 +1082,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
document.head.appendChild(style);
|
||||
LOG('Custom watchlist icon CSS added');
|
||||
|
||||
/************ Helpers ************/
|
||||
|
||||
async function fetchLikedItems(type) {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const data = await res.json();
|
||||
return data.Items || [];
|
||||
} catch (err) {
|
||||
ERR("Failed to fetch liked items for type:", type, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/************ Progress Tab Functions ************/
|
||||
|
||||
// Generic cache factory function
|
||||
@@ -2281,14 +2261,14 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Check localStorage cache first
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const cachedData = localStorageCache.get(`watchlist_${section}`);
|
||||
if (cachedData) {
|
||||
if (cachedData && false) {
|
||||
LOG(`Using localStorage cache for watchlist ${section}`);
|
||||
watchlistCache[section].data = cachedData;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await fetchLikedItems(type);
|
||||
const items = watchlistCache[section].data;
|
||||
|
||||
// Sort items by release date descending (newest first)
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
@@ -2298,7 +2278,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
});
|
||||
|
||||
// Store in cache
|
||||
watchlistCache[section].data = sortedItems;
|
||||
//watchlistCache[section].data = sortedItems;
|
||||
|
||||
// Store in localStorage for next time (optimized)
|
||||
const optimizedData = optimizeWatchlistDataForStorage(sortedItems);
|
||||
@@ -2334,6 +2314,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
renderWatchlistContent();
|
||||
|
||||
try {
|
||||
// Fetch watchlist data
|
||||
const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode' });
|
||||
localStorageCache.set('watchlist_movies', watchlistData.Items.filter(item => item.Type === 'Movie'));
|
||||
localStorageCache.set('watchlist_series', watchlistData.Items.filter(item => item.Type === 'Series'));
|
||||
localStorageCache.set('watchlist_seasons', watchlistData.Items.filter(item => item.Type === 'Season'));
|
||||
localStorageCache.set('watchlist_episodes', watchlistData.Items.filter(item => item.Type === 'Episode'));
|
||||
watchlistCache.movies.data = watchlistData.Items.filter(item => item.Type === 'Movie');
|
||||
watchlistCache.series.data = watchlistData.Items.filter(item => item.Type === 'Series');
|
||||
watchlistCache.seasons.data = watchlistData.Items.filter(item => item.Type === 'Season');
|
||||
watchlistCache.episodes.data = watchlistData.Items.filter(item => item.Type === 'Episode');
|
||||
|
||||
// Initialize all watchlist sections in parallel
|
||||
await Promise.all([
|
||||
initWatchlistSection('movies', 'Movie'),
|
||||
@@ -2399,7 +2390,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Wait for progress and history, then init statistics
|
||||
await Promise.all([watchlistPromise, progressPromise, historyPromise]);
|
||||
await initStatisticsTab();
|
||||
|
||||
LOG('All tabs initialized');
|
||||
}
|
||||
|
||||
@@ -2626,7 +2616,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Debug logging to verify we're getting the right data
|
||||
LOG(`Series: ${series.Name}, Aired Episodes: ${totalEpisodes}, Watched: ${watchedCount}, Remaining: ${remainingCount}`);
|
||||
|
||||
if (totalEpisodes === 0) return null;
|
||||
if (totalEpisodes === 0 || watchedCount === 0) return null;
|
||||
|
||||
// Calculate total runtime from available episodes (only those with actual files)
|
||||
const availableEpisodes = episodes.filter(ep => ep.RunTimeTicks && ep.RunTimeTicks > 0);
|
||||
@@ -2708,19 +2698,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
});
|
||||
}
|
||||
|
||||
// Play movie function
|
||||
function playMovie(movieId) {
|
||||
LOG(`Playing movie: ${movieId}`);
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
|
||||
// Use Jellyfin's built-in play functionality
|
||||
apiClient.play({
|
||||
Ids: [movieId],
|
||||
UserId: userId
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMovieFavorite(movieId, buttonElement) {
|
||||
LOG(`Toggling favorite for movie: ${movieId}`);
|
||||
const apiClient = window.ApiClient;
|
||||
@@ -3712,62 +3689,58 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
{ type: 'Episode', section: 'episodes' }
|
||||
];
|
||||
|
||||
for (const { type, section } of types) {
|
||||
|
||||
const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode', Fields: 'ProviderIds,SeriesName,ParentId' }, false);
|
||||
const items = watchlistData.Items;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
// Fetch from server with Fields to get SeriesName for episodes/seasons
|
||||
const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=ProviderIds,SeriesName,ParentId&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`;
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const data = await res.json();
|
||||
const items = data.Items || [];
|
||||
const exportItem = {
|
||||
status: true,
|
||||
Name: item.Name,
|
||||
Type: item.Type // Add Type field for efficient import
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const exportItem = {
|
||||
status: true,
|
||||
Name: item.Name,
|
||||
Type: type // Add Type field for efficient import
|
||||
};
|
||||
// Add provider IDs (only if they exist)
|
||||
if (item.ProviderIds) {
|
||||
if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb;
|
||||
if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb;
|
||||
if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb;
|
||||
}
|
||||
|
||||
// Add provider IDs (only if they exist)
|
||||
if (item.ProviderIds) {
|
||||
if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb;
|
||||
if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb;
|
||||
if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb;
|
||||
// For episodes, add SeriesName and SeasonName
|
||||
if (item.Type === 'Episode') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
|
||||
// For episodes, add SeriesName and SeasonName
|
||||
if (type === 'Episode') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
// Get SeasonName from parent season
|
||||
if (item.ParentId) {
|
||||
try {
|
||||
const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`;
|
||||
const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const seasonData = await seasonRes.json();
|
||||
if (seasonData.Name) {
|
||||
exportItem.SeasonName = seasonData.Name;
|
||||
}
|
||||
} catch (err) {
|
||||
WARN('Failed to fetch season name for episode:', err);
|
||||
// Get SeasonName from parent season
|
||||
if (item.ParentId) {
|
||||
try {
|
||||
const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`;
|
||||
const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const seasonData = await seasonRes.json();
|
||||
if (seasonData.Name) {
|
||||
exportItem.SeasonName = seasonData.Name;
|
||||
}
|
||||
} catch (err) {
|
||||
WARN('Failed to fetch season name for episode:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For seasons, add SeriesName
|
||||
if (type === 'Season') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
// For seasons, add SeriesName
|
||||
if (item.Type === 'Season') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add if at least one provider ID exists
|
||||
if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) {
|
||||
exportData.push(exportItem);
|
||||
}
|
||||
// Only add if at least one provider ID exists
|
||||
if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) {
|
||||
exportData.push(exportItem);
|
||||
}
|
||||
} catch (err) {
|
||||
ERR(`Failed to fetch ${type} items:`, err);
|
||||
ERR(`Failed to export item:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4152,15 +4125,22 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Hook into onmessage
|
||||
socket.onmessage = function(event) {
|
||||
try {
|
||||
LOG('WebSocket message received:', event);
|
||||
// Pass it through so Jellyfin still works normally
|
||||
if (originalHandler) {
|
||||
originalHandler.call(this, event);
|
||||
}
|
||||
|
||||
// Parse the WebSocket message data
|
||||
// The event might have event.Data or event.data depending on implementation
|
||||
const messageData = event.Data || event.data;
|
||||
/* const messageData = event.Data || event.data;
|
||||
if (!messageData) {
|
||||
// If no data, just pass through to original handler
|
||||
if (originalHandler) originalHandler.call(this, event);
|
||||
return;
|
||||
}
|
||||
} */
|
||||
|
||||
const messageData = event.Data || event.data;
|
||||
const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
|
||||
|
||||
// Check if this is a UserDataChanged message
|
||||
@@ -4178,11 +4158,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Log parse errors but don't break the original handler
|
||||
WARN('WebSocket message parse error:', err);
|
||||
}
|
||||
|
||||
// Pass it through so Jellyfin still works normally
|
||||
if (originalHandler) {
|
||||
originalHandler.call(this, event);
|
||||
}
|
||||
};
|
||||
|
||||
playbackMonitorInitialized = true;
|
||||
@@ -7505,7 +7480,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
// Expose functions to global scope for onclick handlers
|
||||
window.toggleMovieFavorite = toggleMovieFavorite;
|
||||
window.playMovie = playMovie;
|
||||
|
||||
LOG('Initialized successfully');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user