mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
New Home Screen feature: Spotlights. Configuration UI and options vastly improved.
This commit is contained in:
@@ -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