`;
}).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');
})();