If you have ${serverName} open in multiple tabs the subtitle will be downloaded, but the player will not switch to the subtitle and it will appear as if the download is hanging.
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 token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${language}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 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 = '
`;
}).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 token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 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 token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 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 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('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': 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 token = ApiClient.accessToken();
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': 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 token = ApiClient.accessToken();
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': 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 token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${subId}`, {
method: 'POST',
headers: {
'Authorization': 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 {
// 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...');
// 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');
})();