// Jellyfin Subtitle Search Script // Adds subtitle search functionality to the video OSD // Allows users to search and download subtitles from remote sources // Requires: toaster.js module to be loaded before this script (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks SubtitleSearch]', ...args); const WARN = (...args) => console.warn('[KefinTweaks SubtitleSearch]', ...args); const ERR = (...args) => console.error('[KefinTweaks SubtitleSearch]', ...args); LOG('Initializing...'); // State management let isInitialized = false; let isAddingCCButton = false; let onViewPageUnregister = null; let dialogCheckInterval = null; // Configuration const CONFIG = { searchButtonText: 'Search Subtitles', searchIcon: 'search', downloadIcon: 'file_download', ccIcon: 'closed_caption' }; /** * Check if ApiClient is available */ function isApiClientAvailable() { return typeof ApiClient !== 'undefined' && ApiClient && typeof ApiClient.getCurrentUser === 'function'; } /** * Check if user has subtitle management permissions */ async function checkSubtitlePermissions() { if (!isApiClientAvailable()) { WARN('ApiClient not available, cannot check subtitle permissions'); return false; } if (!ApiClient._loggedIn) { LOG('User is not logged in, cannot check subtitle permissions'); return false; } try { const user = await ApiClient.getCurrentUser(); if (!user || !user.Policy) { WARN('User or Policy not available'); return false; } if (user.Policy.IsAdministrator) { LOG('User is admin, has subtitle management permission'); return true; } const hasPermission = user.Policy.EnableSubtitleManagement; LOG('Subtitle management permission:', hasPermission); return hasPermission; } catch (error) { ERR('Error checking subtitle permissions:', error); return false; } } /** * Get the current item ID from the OSD */ function getCurrentItemId() { const ratingButton = document.querySelector('#videoOsdPage:not(.hide) .btnUserRating'); if (!ratingButton) { WARN('Rating button not found, cannot get item ID'); return null; } const itemId = ratingButton.getAttribute('data-id'); LOG('Current item ID:', itemId); return itemId; } /** * Get the preferred subtitle language */ async function getPreferredLanguage() { if (!isApiClientAvailable()) { WARN('ApiClient not available, using default language: eng'); return 'eng'; } try { const user = await ApiClient.getCurrentUser(); if (!user || !user.Configuration) { return 'eng'; } // Try subtitle language preference first if (user.Configuration.SubtitleLanguagePreference) { LOG('Using subtitle language preference:', user.Configuration.SubtitleLanguagePreference); return user.Configuration.SubtitleLanguagePreference; } // Fall back to audio language preference if (user.Configuration.AudioLanguagePreference) { LOG('Using audio language preference:', user.Configuration.AudioLanguagePreference); return user.Configuration.AudioLanguagePreference; } // Default to English LOG('Using default language: eng'); return 'eng'; } catch (error) { ERR('Error getting preferred language:', error); return 'eng'; } } /** * Check if CC button already exists */ function ccButtonExists() { const existingButton = document.querySelector('#videoOsdPage:not(.hide) .btnSubtitles'); return existingButton !== null; } /** * Create the CC button element */ function createCCButton() { const button = document.createElement('button'); button.setAttribute('is', 'paper-icon-button-light'); button.className = 'btnSubtitles autoSize paper-icon-button-light'; button.title = 'Subtitles'; const icon = document.createElement('span'); icon.className = 'xlargePaperIconButton material-icons closed_caption'; icon.setAttribute('aria-hidden', 'true'); button.appendChild(icon); return button; } /** * Add CC button to OSD controls */ function addCCButton() { if (isAddingCCButton) { LOG('Already in process of adding CC button, skipping'); return; } if (ccButtonExists()) { LOG('CC button already exists, skipping'); return; } isAddingCCButton = true; try { const buttonsContainer = document.querySelector('#videoOsdPage:not(.hide) .osdControls > .buttons'); if (!buttonsContainer) { WARN('OSD buttons container not found'); return; } const ratingButton = buttonsContainer.querySelector('.btnUserRating'); if (!ratingButton) { WARN('Rating button not found in buttons container'); return; } const ccButton = createCCButton(); // Insert CC button before the rating button buttonsContainer.insertBefore(ccButton, ratingButton); // Add click handler to open subtitle dialog ccButton.addEventListener('click', () => { LOG('CC button clicked, opening subtitle dialog'); openSubtitleDialog(); }); LOG('CC button added successfully'); } catch (error) { ERR('Error adding CC button:', error); } finally { isAddingCCButton = false; } } /** * Create subtitle dialog with search functionality */ function createSubtitleDialog() { const dialog = document.createElement('div'); dialog.className = 'dialogContainer'; dialog.innerHTML = `

Subtitles

No subtitles available
`; return dialog; } /** * Open subtitle dialog */ function openSubtitleDialog() { // Remove any existing subtitle dialogs const existingDialogs = document.querySelectorAll('.dialogContainer'); existingDialogs.forEach(dialog => { if (dialog.querySelector('.actionSheetTitle')?.textContent === 'Subtitles') { dialog.remove(); } }); const dialog = createSubtitleDialog(); document.body.appendChild(dialog); // Add search button click handler const searchButton = dialog.querySelector('#subtitleSearchButton'); if (searchButton) { searchButton.addEventListener('click', () => { LOG('Search button clicked'); searchSubtitles(); }); } // Add click handler to close dialog when clicking outside dialog.addEventListener('click', (e) => { if (e.target === dialog) { dialog.remove(); } }); LOG('Subtitle dialog opened'); } /** * Create subtitle search results dialog */ function createSearchResultsDialog() { const dialog = document.createElement('div'); dialog.className = 'dialogContainer'; dialog.id = 'subtitleSearchResultsDialog'; let serverName = ApiClient.serverName() ?? 'Jellyfin'; dialog.innerHTML = `

Subtitle Search Results

Searching...
`; return dialog; } /** * Search for subtitles */ async function searchSubtitles() { const itemId = getCurrentItemId(); if (!itemId) { ERR('Cannot search subtitles without item ID'); return; } const language = await getPreferredLanguage(); // Remove any existing search results dialog const existingResultsDialog = document.querySelector('#subtitleSearchResultsDialog'); if (existingResultsDialog) { existingResultsDialog.remove(); } // Create and show search results dialog const resultsDialog = createSearchResultsDialog(); document.body.appendChild(resultsDialog); const resultsContainer = resultsDialog.querySelector('#subtitleSearchResults'); // Add click handler to close dialog when clicking outside resultsDialog.addEventListener('click', (e) => { if (e.target === resultsDialog) { resultsDialog.remove(); } }); try { LOG('Searching subtitles for item:', itemId, 'language:', language); if (!isApiClientAvailable()) { throw new Error('ApiClient not available for authorization'); } const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${language}`, { method: 'GET', headers: { 'Accept': 'application/json', 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const subtitles = await response.json(); LOG('Found subtitles:', subtitles.length); displaySubtitleResults(subtitles, resultsContainer, itemId); } catch (error) { ERR('Error searching subtitles:', error); resultsContainer.innerHTML = '
Error searching subtitles
'; } } /** * Display subtitle search results */ function displaySubtitleResults(subtitles, container, itemId) { if (subtitles.length === 0) { container.innerHTML = '
No subtitles found
'; return; } const resultsHTML = subtitles.map(subtitle => { const matchType = subtitle.IsHashMatch ? 'Perfect match' : 'Partial match'; const downloads = subtitle.DownloadCount?.toLocaleString() || '0'; return `
${subtitle.Name}
Format: ${subtitle.Format} ${downloads} downloads Framerate: ${subtitle.FrameRate}
${matchType}
`; }).join(''); container.innerHTML = resultsHTML; // Add download button handlers container.querySelectorAll('.btnDownload').forEach(button => { button.addEventListener('click', (e) => { const subId = e.currentTarget.getAttribute('data-subid'); const subtitleName = e.currentTarget.closest('.listItem').querySelector('.listItemBody>div:first-child').textContent; // Hide dialog immediately and show progress hideDialogAndShowProgress(); downloadSubtitle(itemId, subId, subtitleName); }); }); } /** * Get existing subtitle streams from item */ async function getExistingSubtitleStreams(itemId) { try { if (!isApiClientAvailable()) { throw new Error('ApiClient not available'); } const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}`, { method: 'GET', headers: { 'Accept': 'application/json', 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const itemData = await response.json(); const existingSubripStreams = []; if (itemData.MediaStreams) { itemData.MediaStreams.forEach((stream, index) => { if (stream.Type === 'Subtitle' && stream.Codec === 'subrip') { existingSubripStreams.push({ index: index, codec: stream.Codec, language: stream.Language, displayTitle: stream.DisplayTitle }); } }); } LOG('Existing subrip streams:', existingSubripStreams); return existingSubripStreams; } catch (error) { ERR('Error getting existing subtitle streams:', error); return []; } } /** * Poll for newly added subtitle stream with retry mechanism */ async function pollForNewSubtitleStream(itemId, existingStreams) { const maxAttempts = 20; const pollInterval = 5000; // 3 seconds for (let attempt = 1; attempt <= maxAttempts; attempt++) { LOG(`Polling attempt ${attempt}/${maxAttempts} for new subtitle stream`); try { const newStreamIndex = await findNewSubtitleStream(itemId, existingStreams); if (newStreamIndex !== null) { LOG(`New subtitle stream found on attempt ${attempt}`); return newStreamIndex; } // If not the last attempt, wait before trying again if (attempt < maxAttempts) { LOG(`No new subtitle found, waiting ${pollInterval}ms before next attempt`); await new Promise(resolve => setTimeout(resolve, pollInterval)); } } catch (error) { ERR(`Error on polling attempt ${attempt}:`, error); // If not the last attempt, wait before trying again if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, pollInterval)); } } } LOG('Max polling attempts reached, no new subtitle stream found'); return null; } /** * Find newly added subtitle stream */ async function findNewSubtitleStream(itemId, existingStreams) { try { if (!isApiClientAvailable()) { throw new Error('ApiClient not available'); } const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}`, { method: 'GET', headers: { 'Accept': 'application/json', 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const itemData = await response.json(); const currentSubripStreams = []; if (itemData.MediaStreams) { itemData.MediaStreams.forEach((stream, index) => { if (stream.Type === 'Subtitle' && stream.Codec === 'subrip') { currentSubripStreams.push({ index: index, codec: stream.Codec, language: stream.Language, displayTitle: stream.DisplayTitle }); } }); } LOG('Current subrip streams:', currentSubripStreams); // Find the new stream by comparing with existing ones const newStream = currentSubripStreams.find(currentStream => { return !existingStreams.some(existingStream => existingStream.language === currentStream.language && existingStream.displayTitle === currentStream.displayTitle ); }); if (newStream) { LOG('Found new subtitle stream:', newStream); return newStream.index; } LOG('No new subtitle stream found'); return null; } catch (error) { ERR('Error finding new subtitle stream:', error); return null; } } /** * Get session data using device ID */ async function getSession() { try { if (!isApiClientAvailable()) { throw new Error('ApiClient not available'); } 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('Getting session with deviceId:', deviceId); // Get session from the endpoint with device ID const response = await fetch(`${serverUrl}/Sessions?deviceId=${deviceId}`, { method: 'GET', headers: { 'Accept': 'application/json', 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const sessions = await response.json(); // Return the first session if available, or the sessions array itself if (Array.isArray(sessions) && sessions.length > 0) { LOG('Found session:', sessions[0].Id); return sessions[0]; } else if (sessions && !Array.isArray(sessions)) { // If it's not an array, it might be a single session object LOG('Found session:', sessions.Id); return sessions; } else { throw new Error('No active session found for device'); } } catch (error) { ERR('Error getting session:', error); throw error; } } /** * Send SetSubtitleStreamIndex command as fallback */ async function setSubtitleStreamIndex(sessionId, subtitleStreamIndex) { try { if (!isApiClientAvailable()) { throw new Error('ApiClient not available'); } const serverUrl = ApiClient.serverAddress(); const userId = ApiClient.getCurrentUserId(); LOG('Sending SetSubtitleStreamIndex command:', { sessionId, subtitleStreamIndex, userId }); const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Command`, { method: 'POST', headers: { 'Authorization': window.apiHelper.getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify({ Name: "SetSubtitleStreamIndex", ControllingUserId: userId, Arguments: { Index: subtitleStreamIndex } }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.text(); LOG('SetSubtitleStreamIndex command result:', result); return true; } catch (error) { ERR('Error sending SetSubtitleStreamIndex command:', error); return false; } } /** * Trigger custom playback progress event (to be called by external progress monitoring) */ function triggerPlaybackProgressEvent(progressData) { const event = new CustomEvent('playbackprogress', { detail: progressData }); document.dispatchEvent(event); } /** * Listen for progress updates to verify subtitle stream change */ function listenForSubtitleStreamChange(sessionId, expectedSubtitleIndex, timeout = 10000) { return new Promise((resolve) => { let timeoutId; let progressListener; let checkInterval; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); if (progressListener) { document.removeEventListener('playbackprogress', progressListener); } if (checkInterval) { clearInterval(checkInterval); } }; // Set timeout timeoutId = setTimeout(() => { LOG('Timeout waiting for subtitle stream verification'); cleanup(); resolve(false); }, timeout); // Listen for progress updates progressListener = (event) => { try { const progressData = event.detail; LOG('Progress update received:', progressData); if (progressData && progressData.SubtitleStreamIndex !== undefined) { const currentSubtitleIndex = progressData.SubtitleStreamIndex; LOG('Current subtitle stream index:', currentSubtitleIndex, 'Expected:', expectedSubtitleIndex); if (currentSubtitleIndex === expectedSubtitleIndex) { LOG('Subtitle stream verification successful'); cleanup(); resolve(true); } } } catch (error) { ERR('Error processing progress update:', error); } }; // Listen for custom progress events document.addEventListener('playbackprogress', progressListener); // Also check current session state periodically as backup checkInterval = setInterval(async () => { try { const session = await getSession(); if (session && session.PlayState && session.PlayState.SubtitleStreamIndex === expectedSubtitleIndex) { LOG('Subtitle stream verified via session check'); cleanup(); resolve(true); } } catch (error) { ERR('Error checking session state:', error); } }, 1000); }); } /** * Update playback progress with new subtitle stream using PlayNow command */ async function updatePlaybackProgress(subtitleStreamIndex, subtitleName) { try { if (!isApiClientAvailable()) { throw new Error('ApiClient not available'); } LOG('Changing subtitle stream to index:', subtitleStreamIndex); // Get session data const session = await getSession(); const sessionId = session.Id; const playState = session.PlayState; if (!playState) { throw new Error('No play state found in session'); } const serverUrl = ApiClient.serverAddress(); // Get current item ID const itemId = getCurrentItemId(); if (!itemId) { throw new Error('No current item ID found'); } // Prepare PlayNow command parameters const params = new URLSearchParams({ playCommand: "PlayNow", itemIds: itemId, startPositionTicks: playState.PositionTicks?.toString() || "0", subtitleStreamIndex: subtitleStreamIndex.toString(), audioStreamIndex: playState.AudioStreamIndex?.toString() || "0" }); LOG('PlayNow command parameters:', { sessionId, itemId, startPositionTicks: playState.PositionTicks, subtitleStreamIndex, audioStreamIndex: playState.AudioStreamIndex }); // Use PlayNow command to restart playback with new subtitle const commandResponse = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { method: 'POST', headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!commandResponse.ok) { throw new Error(`HTTP ${commandResponse.status}: ${commandResponse.statusText}`); } const commandResult = await commandResponse.text(); LOG('PlayNow command result:', commandResult); // Wait a moment for the playback to restart await new Promise(resolve => setTimeout(resolve, 1000)); // Verify that the subtitle stream was actually changed LOG('Verifying subtitle stream change...'); const verificationSuccess = await listenForSubtitleStreamChange(sessionId, subtitleStreamIndex, 10000); if (verificationSuccess) { LOG('Subtitle stream changed successfully using PlayNow command'); } else { LOG('PlayNow command did not change subtitle stream, trying SetSubtitleStreamIndex fallback'); // Try the direct command as fallback const fallbackSuccess = await setSubtitleStreamIndex(sessionId, subtitleStreamIndex); if (fallbackSuccess) { LOG('Subtitle stream changed successfully using SetSubtitleStreamIndex fallback'); } else { throw new Error('Both PlayNow and SetSubtitleStreamIndex commands failed'); } } } catch (error) { ERR('Error changing subtitle stream:', error); throw error; } } /** * Hide search results dialog and show download progress in top-right */ function hideDialogAndShowProgress() { // Hide the search results dialog const resultsDialog = document.querySelector('#subtitleSearchResultsDialog'); if (resultsDialog) { resultsDialog.style.display = 'none'; } // Show download progress indicator showDownloadProgress(); } /** * Show download progress message */ function showDownloadProgress() { if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Downloading subtitle...', null, false); } } /** * Show download success message */ function showDownloadSuccess() { hideDownloadProgress(); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Subtitle downloaded and activated!'); } } /** * Show download error message */ function showDownloadError(message) { hideDownloadProgress(); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(message || 'Failed to download subtitle', '5'); } } /** * Hide download progress UI (no-op now, kept for compatibility) */ function hideDownloadProgress() { // No longer needed since we use toaster.toast() which auto-dismisses } /** * Download subtitle */ async function downloadSubtitle(itemId, subId, subtitleName) { try { LOG('Downloading subtitle:', subId); if (!isApiClientAvailable()) { throw new Error('ApiClient not available for authorization'); } // Get existing subtitle streams before download const existingStreams = await getExistingSubtitleStreams(itemId); const serverUrl = ApiClient.serverAddress(); const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${subId}`, { method: 'POST', headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } LOG('Subtitle downloaded successfully'); // Poll for new subtitle stream with retry mechanism const newSubtitleIndex = await pollForNewSubtitleStream(itemId, existingStreams); if (newSubtitleIndex !== null) { LOG('New subtitle stream found at index:', newSubtitleIndex); // Update playback progress to use the new subtitle await updatePlaybackProgress(newSubtitleIndex, subtitleName); // Hide progress and show success hideDownloadProgress(); showDownloadSuccess(); // Remove the search results dialog on success const resultsDialog = document.querySelector('#subtitleSearchResultsDialog'); if (resultsDialog) { resultsDialog.remove(); } } else { LOG('Could not determine new subtitle stream index after polling'); // Hide progress and show error hideDownloadProgress(); showDownloadError('Could not detect new subtitle'); // Show the dialog again on error const resultsDialog = document.querySelector('#subtitleSearchResultsDialog'); if (resultsDialog) { resultsDialog.style.display = 'block'; } } // Show success message (optional) // You could add a toast notification here } catch (error) { ERR('Error downloading subtitle:', error); // Hide progress and show error hideDownloadProgress(); showDownloadError('Failed to download subtitle'); // Show the dialog again on error const resultsDialog = document.querySelector('#subtitleSearchResultsDialog'); if (resultsDialog) { resultsDialog.style.display = 'block'; } } } /** * Add search button to existing subtitle dialogs */ function addSearchButtonToExistingDialogs() { const subtitleDialogs = document.querySelectorAll('.dialogContainer:not(.hide)'); subtitleDialogs.forEach(dialog => { const title = dialog.querySelector('.actionSheetTitle'); if (title && title.innerHTML === 'Subtitles') { // Check if search button already exists if (dialog.querySelector('#subtitleSearchButton')) { return; } const scroller = dialog.querySelector('.actionSheetScroller'); if (!scroller) { return; } // Create search button const searchButton = document.createElement('button'); searchButton.setAttribute('is', 'emby-button'); searchButton.type = 'button'; searchButton.className = 'listItem listItem-button actionSheetMenuItem emby-button'; searchButton.id = 'subtitleSearchButton'; searchButton.innerHTML = `
${CONFIG.searchButtonText}
`; // Insert before the title title.parentNode.insertBefore(searchButton, title); // Add CSS offset to the dialog container const dialogContainer = dialog.querySelector('.focuscontainer.dialog'); if (dialogContainer) { const currentTop = dialogContainer.style.top; if (currentTop) { // Parse existing top value and add 33px const currentTopValue = parseInt(currentTop.replace('px', '')); dialogContainer.style.top = `${currentTopValue - 33}px`; } else { // If no existing top value, set to 33px dialogContainer.style.top = '-33px'; } } // Add click handler searchButton.addEventListener('click', () => { LOG('Search button clicked in existing dialog'); searchSubtitles(); }); LOG('Search button added to existing subtitle dialog'); } }); } /** * Start checking for subtitle dialogs periodically (only when on video page) * This is much more efficient than a MutationObserver watching the entire document */ function startDialogCheck() { // Clear any existing interval if (dialogCheckInterval) { clearInterval(dialogCheckInterval); } // Check for dialogs periodically while on video page // The interval is automatically stopped when navigating away via onViewPage dialogCheckInterval = setInterval(() => { addSearchButtonToExistingDialogs(); }, 500); // Check every 500ms } /** * Stop checking for subtitle dialogs */ function stopDialogCheck() { if (dialogCheckInterval) { clearInterval(dialogCheckInterval); dialogCheckInterval = null; } } /** * Initialize subtitle search functionality for video page */ async function initializeForVideoPage() { try { // Check if ApiClient is available first if (!isApiClientAvailable()) { LOG('ApiClient not available, skipping initialization'); return; } // Check permissions first const hasPermission = await checkSubtitlePermissions(); if (!hasPermission) { LOG('User does not have subtitle management permission, skipping initialization'); return; } LOG('User has subtitle management permission, initializing for video page...'); // Add CC button if it doesn't exist addCCButton(); // Start checking for subtitle dialogs startDialogCheck(); LOG('Subtitle search functionality initialized for video page'); } catch (error) { ERR('Error initializing subtitle search:', error); } } /** * Initialize subtitle search functionality */ async function initialize() { if (isInitialized) { LOG('Already initialized, skipping'); return; } try { // Wait for login to complete if (!window.userHelper?.waitForLogin) { LOG('userHelper.waitForLogin not available, waiting'); setTimeout(initialize, 1000); return; } await window.userHelper.waitForLogin(); // Check permissions first const hasPermission = await checkSubtitlePermissions(); if (!hasPermission) { LOG('User does not have subtitle management permission, skipping initialization'); return; } LOG('User has subtitle management permission, initializing...'); // Wait for KefinTweaksUtils to be available if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { LOG('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); setTimeout(initialize, 1000); return; } // Register onViewPage handler for video page onViewPageUnregister = window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { if (hash && hash.includes('#/video')) { LOG('Video page detected via onViewPage, initializing...'); // Small delay to ensure OSD is ready setTimeout(() => { initializeForVideoPage(); }, 500); } else { // Stop dialog checking when not on video page stopDialogCheck(); } }, { pages: ['video'] }); // Also check current page on initial load if (window.location.hash.includes('#/video')) { setTimeout(() => { initializeForVideoPage(); }, 500); } isInitialized = true; LOG('Subtitle search functionality initialized successfully'); } catch (error) { ERR('Error initializing subtitle search:', error); } } /** * Clean up resources */ function cleanup() { stopDialogCheck(); if (onViewPageUnregister) { onViewPageUnregister(); onViewPageUnregister = null; } isInitialized = false; LOG('Subtitle search functionality cleaned up'); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } // Expose functions globally for debugging window.subtitleSearchCleanup = cleanup; window.getSession = getSession; window.setSubtitleStreamIndex = setSubtitleStreamIndex; window.listenForSubtitleStreamChange = listenForSubtitleStreamChange; window.triggerPlaybackProgressEvent = triggerPlaybackProgressEvent; LOG('Initialized successfully'); })();