diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.js b/src/components/downloadOptionsDialog/downloadOptionsDialog.js new file mode 100644 index 0000000000..a240907c1f --- /dev/null +++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.js @@ -0,0 +1,576 @@ +import dialogHelper from '../dialogHelper/dialogHelper'; +import layoutManager from '../layoutManager'; +import globalize from '../../lib/globalize'; +import loading from '../loading/loading'; +import toast from '../toast/toast'; +import { getVideoQualityOptions } from '../qualityOptions'; +import template from './downloadOptionsDialog.template.html'; +import '../../elements/emby-select/emby-select'; +import '../../elements/emby-button/emby-button'; +import './downloadOptionsDialog.scss'; + +let currentItem; +let currentApiClient; + +function getBitrateOptionsForResolutionAndCodec(resolution, codec) { + // Codec efficiency factors (Jellyfin defaults) + const codecFactors = { + 'h264': 1.0, + 'hevc': 0.6, // 40% more efficient + 'vp9': 0.6, // 40% more efficient + 'av1': 0.5 // 50% more efficient + }; + + // Base bitrate ranges for H.264 (updated for 2025 standards) + const baseRanges = { + '3840': { min: 25000000, max: 80000000, label: '4K' }, // 25-80 Mbps + '2560': { min: 12000000, max: 35000000, label: '1440p' }, // 12-35 Mbps + '1920': { min: 5000000, max: 16000000, label: '1080p' }, // 5-16 Mbps + '1280': { min: 2500000, max: 8000000, label: '720p' }, // 2.5-8 Mbps + '640': { min: 1000000, max: 2500000, label: '480p' }, // 1-2.5 Mbps + 'original': { min: 50000000, max: 120000000, label: 'Original' } + }; + + if (resolution === 'original') { + return [ + { bitrate: 120000000, name: 'Extreme Quality' }, + { bitrate: 100000000, name: 'Maximum' }, + { bitrate: 80000000, name: 'High' }, + { bitrate: 60000000, name: 'Medium' }, + { bitrate: 50000000, name: 'Standard' } + ]; + } + + const range = baseRanges[resolution] || baseRanges['1920']; + const factor = codecFactors[codec] || 1.0; + + const min = Math.round(range.min * factor); + const max = Math.round(range.max * factor); + const step = (max - min) / 5; + + // Generate 6 quality levels + return [ + { bitrate: max, name: `Maximum (${range.label})` }, + { bitrate: Math.round(max - step), name: `Very High (${range.label})` }, + { bitrate: Math.round(max - step * 2), name: `High (${range.label})` }, + { bitrate: Math.round(max - step * 3), name: `Medium (${range.label})` }, + { bitrate: Math.round(max - step * 4), name: `Low (${range.label})` }, + { bitrate: min, name: `Minimum (${range.label})` } + ]; +} + +function populateQualityOptions(dlg, item) { + const selectQuality = dlg.querySelector('#selectQuality'); + const selectResolution = dlg.querySelector('#selectResolution'); + const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); + + const resolution = selectResolution?.value || '1920'; + const codec = selectVideoCodec?.value || 'h264'; + + if (codec === 'copy') { + selectQuality.innerHTML = ''; + return; + } + + const options = getBitrateOptionsForResolutionAndCodec(resolution, codec); + + selectQuality.innerHTML = options.map((option, index) => { + const selected = index === 2 ? ' selected' : ''; // Select "High" (3rd option) + const mbps = (option.bitrate / 1000000).toFixed(1); + return ``; + }).join(''); +} + +function populateAudioTracks(dlg, item) { + const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); + const audioStreams = item.MediaSources?.[0]?.MediaStreams?.filter(s => s.Type === 'Audio') || []; + + selectAudioTrack.innerHTML = audioStreams.map((stream, index) => { + const language = stream.Language || 'Unknown'; + const codec = stream.Codec ? stream.Codec.toUpperCase() : ''; + const channels = stream.Channels ? `${stream.Channels}.0` : ''; + const displayTitle = stream.DisplayTitle || `Track ${index + 1}`; + const selected = stream.IsDefault ? ' selected' : ''; + + return ``; + }).join(''); + + if (audioStreams.length === 0) { + selectAudioTrack.innerHTML = ''; + } +} + +function getRecommendedAudioBitrate(codec, channels) { + // Recommended bitrates based on codec and channel count (2025 standards) + const bitrates = { + 'aac': { + 2: 192000, // Stereo - transparent quality + 6: 256000, // 5.1 - Netflix/streaming standard + 8: 384000 // 7.1 - high quality + }, + 'ac3': { + 2: 192000, // Stereo + 6: 448000, // 5.1 - Dolby Digital standard (fixed at 448k) + 8: 640000 // 7.1 - Dolby Digital spec maximum + }, + 'eac3': { + 2: 224000, // Stereo - DD+ improved quality + 6: 384000, // 5.1 - DD+ standard + 8: 768000 // 7.1 - DD+ maximum + }, + 'opus': { + 2: 128000, // Stereo - excellent quality (Opus is very efficient) + 6: 256000, // 5.1 - high quality + 8: 384000 // 7.1 - very high quality + }, + 'mp3': { + 2: 192000, // Stereo - "very high quality" (320k is overkill) + 6: 192000, // Downmix to stereo (MP3 doesn't support multichannel) + 8: 192000 // Downmix to stereo + } + }; + + return bitrates[codec]?.[channels] || bitrates[codec]?.[2] || 192000; +} + +function getMaxAudioBitrate(codec) { + // Maximum sensible bitrates per codec + const maxBitrates = { + 'aac': 512000, // AAC practical max for 7.1 + 'ac3': 640000, // AC3 spec maximum + 'eac3': 1536000, // E-AC3 spec maximum (Atmos capable) + 'opus': 510000, // Opus practical max (very efficient) + 'mp3': 320000 // MP3 spec maximum + }; + + return maxBitrates[codec] || 640000; +} + +function updateAudioBitrateOptions(dlg) { + const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); + const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); + const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); + const audioBitrateContainer = dlg.querySelector('#selectAudioBitrate').closest('.selectContainer'); + + const codec = selectAudioCodec.value; + const selectedTrack = selectAudioTrack.options[selectAudioTrack.selectedIndex]; + const channels = parseInt(selectedTrack?.getAttribute('data-channels') || '2'); + + // Hide bitrate selector for "copy" + if (codec === 'copy') { + audioBitrateContainer.style.display = 'none'; + return; + } else { + audioBitrateContainer.style.display = 'block'; + } + + const maxBitrate = getMaxAudioBitrate(codec); + const recommendedBitrate = getRecommendedAudioBitrate(codec, channels); + + // Filter and update bitrate options based on codec + const options = selectAudioBitrate.querySelectorAll('option'); + let needsReset = false; + const currentBitrate = parseInt(selectAudioBitrate.value); + + options.forEach(option => { + const bitrate = parseInt(option.value); + const text = option.textContent.replace(' (Recommended)', ''); + + // Hide options exceeding codec maximum + if (bitrate > maxBitrate) { + option.style.display = 'none'; + option.disabled = true; + if (bitrate === currentBitrate) { + needsReset = true; + } + } else { + option.style.display = ''; + option.disabled = false; + } + + // Mark recommended bitrate + if (bitrate === recommendedBitrate && bitrate <= maxBitrate) { + option.textContent = text + ' (Recommended)'; + if (needsReset || currentBitrate > maxBitrate) { + option.selected = true; + } + } else { + option.textContent = text; + } + }); + + // If current selection exceeds max, select recommended + if (needsReset || currentBitrate > maxBitrate) { + selectAudioBitrate.value = recommendedBitrate.toString(); + } +} + +function populateSubtitles(dlg, item) { + const selectSubtitle = dlg.querySelector('#selectSubtitle'); + const subtitleStreams = item.MediaSources?.[0]?.MediaStreams?.filter(s => s.Type === 'Subtitle') || []; + + let options = ''; + + options += subtitleStreams.map(stream => { + const language = stream.Language || 'Unknown'; + const codec = stream.Codec ? stream.Codec.toUpperCase() : ''; + const displayTitle = stream.DisplayTitle || `${language} (${codec})`; + + return ``; + }).join(''); + + selectSubtitle.innerHTML = options; +} + +function updateSubtitleMethod(dlg) { + const selectSubtitle = dlg.querySelector('#selectSubtitle'); + const subtitleMethodContainer = dlg.querySelector('#subtitleMethodContainer'); + + if (selectSubtitle.value === 'none') { + subtitleMethodContainer.style.display = 'none'; + } else { + subtitleMethodContainer.style.display = 'block'; + } +} + +function updateEstimatedSize(dlg, item) { + const selectQuality = dlg.querySelector('#selectQuality'); + const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); + const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); + + const videoBitrate = parseInt(selectQuality.value) || 0; + const audioBitrate = parseInt(selectAudioBitrate.value) || 0; + const duration = item.RunTimeTicks ? item.RunTimeTicks / 10000000 : 0; // Convert to seconds + + // For "Copy Original" mode, use original file size if available + if (selectVideoCodec.value === 'copy') { + const originalSizeBytes = item.MediaSources?.[0]?.Size; + if (originalSizeBytes) { + const sizeGB = originalSizeBytes / 1024 / 1024 / 1024; + dlg.querySelector('#sizeValue').textContent = `~${sizeGB.toFixed(2)} GB`; + } else { + dlg.querySelector('#sizeValue').textContent = 'Unknown'; + } + } else if (videoBitrate > 0 && duration > 0) { + // Calculate size: (video + audio bitrate) * duration / 8 / 1024 / 1024 / 1024 = GB + const totalBitrate = videoBitrate + audioBitrate; + const sizeGB = (totalBitrate * duration) / 8 / 1024 / 1024 / 1024; + dlg.querySelector('#sizeValue').textContent = `~${sizeGB.toFixed(2)} GB`; + } else { + dlg.querySelector('#sizeValue').textContent = 'Unknown'; + } + + // Duration + if (duration > 0) { + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + dlg.querySelector('#durationValue').textContent = `${hours}h ${minutes}m`; + } +} + +function updateCodecCompatibility(dlg) { + const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); + const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); + const selectContainer = dlg.querySelector('#selectContainer'); + const resolutionContainer = dlg.querySelector('#selectResolution').closest('.selectContainer'); + const qualityContainer = dlg.querySelector('#selectQuality').closest('.selectContainer'); + const audioCodecContainer = dlg.querySelector('#selectAudioCodec').closest('.selectContainer'); + const audioBitrateContainer = dlg.querySelector('#selectAudioBitrate').closest('.selectContainer'); + const containerContainer = dlg.querySelector('#selectContainer').closest('.selectContainer'); + + const videoCodec = selectVideoCodec.value; + const container = selectContainer.value; + const currentAudioCodec = selectAudioCodec.value; + + // If "Copy Original" is selected, hide all transcode options + if (videoCodec === 'copy') { + resolutionContainer.style.display = 'none'; + qualityContainer.style.display = 'none'; + audioCodecContainer.style.display = 'none'; + audioBitrateContainer.style.display = 'none'; + containerContainer.style.display = 'none'; + return; + } else { + resolutionContainer.style.display = 'block'; + qualityContainer.style.display = 'block'; + audioCodecContainer.style.display = 'block'; + audioBitrateContainer.style.display = 'block'; + containerContainer.style.display = 'block'; + } + + // Filter container options based on video codec + const containerOptions = selectContainer.querySelectorAll('option'); + let needsContainerReset = false; + + containerOptions.forEach(option => { + const cont = option.value; + let compatible = true; + + // VP9 is WebM-optimized (not compatible with MP4) + if (videoCodec === 'vp9' && cont === 'mp4') { + compatible = false; + } + // HEVC not compatible with WebM + if (videoCodec === 'hevc' && cont === 'webm') { + compatible = false; + } + // H.264 not typically used with WebM + if (videoCodec === 'h264' && cont === 'webm') { + compatible = false; + } + // AV1 and MKV support all containers + + if (compatible) { + option.style.display = ''; + option.disabled = false; + } else { + option.style.display = 'none'; + option.disabled = true; + if (cont === container) { + needsContainerReset = true; + } + } + }); + + // Reset container if current selection is incompatible + if (needsContainerReset) { + if (videoCodec === 'vp9') { + selectContainer.value = 'webm'; + } else if (videoCodec === 'hevc' || videoCodec === 'h264') { + selectContainer.value = 'mkv'; + } else if (videoCodec === 'av1') { + selectContainer.value = 'mkv'; + } + } + + // Filter audio codec options based on container + const audioOptions = selectAudioCodec.querySelectorAll('option'); + let needsAudioReset = false; + + audioOptions.forEach(option => { + const codec = option.value; + let compatible = true; + + if (container === 'webm') { + // WebM only supports Opus + compatible = (codec === 'opus' || codec === 'copy'); + } else if (container === 'mp4') { + // MP4 doesn't support Opus + compatible = (codec !== 'opus'); + } + // MKV supports everything + + if (compatible) { + option.style.display = ''; + option.disabled = false; + } else { + option.style.display = 'none'; + option.disabled = true; + if (codec === currentAudioCodec) { + needsAudioReset = true; + } + } + }); + + // Reset audio codec if current selection is incompatible + if (needsAudioReset) { + if (container === 'webm') { + selectAudioCodec.value = 'opus'; + } else if (container === 'mp4') { + selectAudioCodec.value = 'aac'; + } + } +} + +function buildDownloadUrl(apiClient, item, options) { + const params = new URLSearchParams({ + videoCodec: options.videoCodec, + audioCodec: options.audioCodec, + container: options.container, + videoBitRate: options.videoBitRate, + audioBitRate: options.audioBitRate, + audioStreamIndex: options.audioStreamIndex, + playSessionId: new Date().getTime().toString() // Unique ID to prevent caching + }); + + // Force transcoding - disable stream copy + // User can select "Copy Original" if they want to copy instead + params.set('enableAutoStreamCopy', 'false'); + params.set('allowVideoStreamCopy', 'false'); + params.set('allowAudioStreamCopy', 'false'); + + // Add resolution if not original + if (options.maxWidth && options.maxWidth !== 'original') { + params.set('maxWidth', options.maxWidth); + // Calculate height based on 16:9 aspect ratio + const maxHeight = Math.round(parseInt(options.maxWidth) * 9 / 16); + params.set('maxHeight', maxHeight.toString()); + } + + // Subtitles + if (options.subtitleStreamIndex !== null && options.subtitleStreamIndex !== 'none') { + params.set('subtitleStreamIndex', options.subtitleStreamIndex); + params.set('subtitleMethod', options.subtitleMethod); + } + + // Ensure proper seeking and duration metadata (copyTimestamps=false uses re-encoded timestamps) + params.set('copyTimestamps', 'false'); + + const url = `${apiClient.getUrl(`/Videos/${item.Id}/stream?${params.toString()}`)}`; + console.log('Download URL:', url); + return url; +} + +function onSubmit(e) { + e.preventDefault(); + + const dlg = e.target; + const selectResolution = dlg.querySelector('#selectResolution'); + const selectQuality = dlg.querySelector('#selectQuality'); + const selectVideoCodec = dlg.querySelector('#selectVideoCodec'); + const selectAudioTrack = dlg.querySelector('#selectAudioTrack'); + const selectAudioCodec = dlg.querySelector('#selectAudioCodec'); + const selectAudioBitrate = dlg.querySelector('#selectAudioBitrate'); + const selectSubtitle = dlg.querySelector('#selectSubtitle'); + const selectSubtitleMethod = dlg.querySelector('#selectSubtitleMethod'); + const selectContainer = dlg.querySelector('#selectContainer'); + + // Create download + import('../../scripts/fileDownloader').then((fileDownloader) => { + let downloadUrl; + let filename; + let title; + + // Check if user wants original file + if (selectVideoCodec.value === 'copy') { + downloadUrl = currentApiClient.getItemDownloadUrl(currentItem.Id); + filename = currentItem.Path ? currentItem.Path.replace(/^.*[\\/]/, '') : `${currentItem.Name}.${currentItem.Container || 'mkv'}`; + title = `${currentItem.Name} (Original)`; + } else { + const options = { + maxWidth: selectResolution.value, + videoBitRate: parseInt(selectQuality.value), + videoCodec: selectVideoCodec.value, + audioStreamIndex: parseInt(selectAudioTrack.value), + audioCodec: selectAudioCodec.value, + audioBitRate: parseInt(selectAudioBitrate.value), + subtitleStreamIndex: selectSubtitle.value === 'none' ? null : parseInt(selectSubtitle.value), + subtitleMethod: selectSubtitleMethod.value, + container: selectContainer.value + }; + + downloadUrl = buildDownloadUrl(currentApiClient, currentItem, options); + + // Build filename in dotted notation + const resolutionName = selectResolution.options[selectResolution.selectedIndex].text.split(' ')[0]; + const codecName = selectVideoCodec.value.toUpperCase(); + const audioBitrateKbps = Math.round(options.audioBitRate / 1000); + const audioCodecName = selectAudioCodec.value.toUpperCase(); + + // Clean item name (replace spaces with dots, remove problematic characters) + const cleanName = currentItem.Name.replace(/\s+/g, '.').replace(/[^\w\.\-]/g, ''); + + // Format: Name.1080p.AV1.192kbps.AAC.mkv + filename = `${cleanName}.${resolutionName}.${codecName}.${audioBitrateKbps}kbps.${audioCodecName}.${options.container}`; + title = `${currentItem.Name} (${resolutionName} ${codecName})`; + } + + fileDownloader.download([{ + url: downloadUrl, + item: currentItem, + itemId: currentItem.Id, + serverId: currentApiClient.serverId(), + title: title, + filename: filename + }]); + + toast('Download started'); + dialogHelper.close(e.target.closest('.dialog')); + }); + + return false; +} + +export function show(item, apiClient) { + currentItem = item; + currentApiClient = apiClient; + + return new Promise((resolve) => { + const dlg = dialogHelper.createDialog({ + removeOnClose: true, + size: 'medium' + }); + + dlg.classList.add('formDialog'); + + let html = globalize.translateHtml(template, 'core'); + dlg.innerHTML = html; + + // Populate dropdowns + populateQualityOptions(dlg, item); + populateAudioTracks(dlg, item); + populateSubtitles(dlg, item); + updateEstimatedSize(dlg, item); + updateCodecCompatibility(dlg); // Set initial state + updateAudioBitrateOptions(dlg); // Set recommended bitrates + + // Event listeners + dlg.querySelector('#selectResolution').addEventListener('change', () => { + populateQualityOptions(dlg, item); + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectQuality').addEventListener('change', () => { + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectVideoCodec').addEventListener('change', () => { + updateCodecCompatibility(dlg); + populateQualityOptions(dlg, item); + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectAudioTrack').addEventListener('change', () => { + updateAudioBitrateOptions(dlg); + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectAudioCodec').addEventListener('change', () => { + updateCodecCompatibility(dlg); + updateAudioBitrateOptions(dlg); + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectAudioBitrate').addEventListener('change', () => { + updateEstimatedSize(dlg, item); + }); + + dlg.querySelector('#selectContainer').addEventListener('change', () => { + updateCodecCompatibility(dlg); + }); + + dlg.querySelector('#selectSubtitle').addEventListener('change', () => { + updateSubtitleMethod(dlg); + }); + + dlg.querySelector('form').addEventListener('submit', onSubmit); + + dlg.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(dlg); + }); + + if (layoutManager.tv) { + import('../autoFocuser').then(({ default: autoFocuser }) => { + autoFocuser.autoFocus(dlg); + }); + } + + dialogHelper.open(dlg).then(() => { + resolve(); + }); + }); +} + +export default { + show: show +}; diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.scss b/src/components/downloadOptionsDialog/downloadOptionsDialog.scss new file mode 100644 index 0000000000..6d168e75c2 --- /dev/null +++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.scss @@ -0,0 +1,65 @@ +.verticalSection { + margin-bottom: 1.5em; + + &:last-child { + margin-bottom: 0; + } +} + +.sectionTitle { + margin: 0 0 0.75em 0; + font-size: 1.1em; + font-weight: 500; + color: rgba(255, 255, 255, 0.87); +} + +.selectContainer { + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } +} + +.selectLabel { + display: block; + margin-bottom: 0.5em; + font-size: 0.95em; + color: rgba(255, 255, 255, 0.7); +} + +.emby-select { + width: 100%; +} + +.downloadInfo { + background: rgba(var(--primary-rgb), 0.1); + border-left: 3px solid rgba(var(--primary-rgb), 0.5); + padding: 1em; + border-radius: 4px; + margin-bottom: 8em; +} + +#estimatedSize, +#downloadDuration { + font-size: 0.95em; +} + +#sizeValue, +#durationValue { + color: rgba(var(--primary-rgb), 1); + font-weight: 500; +} + +.formDialogFooter { + position: sticky; + bottom: 0; + background: var(--dialog-background, #202020); + padding: 1em 1.5em; + margin: 0 -1.5em; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 1em; + justify-content: flex-end; + z-index: 1000; +} diff --git a/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html b/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html new file mode 100644 index 0000000000..be024f63c1 --- /dev/null +++ b/src/components/downloadOptionsDialog/downloadOptionsDialog.template.html @@ -0,0 +1,138 @@ +
+ +

Download Options

+
+ +
+
+
+

Video Settings

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Audio Settings

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Subtitle Settings

+ +
+ + +
+ + +
+ +
+

Container Format

+ +
+ + +
+
+ +
+
+
+ Estimated Size: Calculating... +
+
+ Duration: - +
+
+
+
+ +
+ + +
+
diff --git a/src/components/itemContextMenu.js b/src/components/itemContextMenu.js index 7f07c203e4..1e1a86f9e7 100644 --- a/src/components/itemContextMenu.js +++ b/src/components/itemContextMenu.js @@ -13,6 +13,7 @@ import { playbackManager } from './playback/playbackmanager'; import toast from './toast/toast'; import * as userSettings from '../scripts/settings/userSettings'; import { AppFeature } from 'constants/appFeature'; +import downloadOptionsDialog from './downloadOptionsDialog/downloadOptionsDialog'; /** Item types that support downloading all children. */ const DOWNLOAD_ALL_TYPES = [ @@ -410,18 +411,26 @@ function executeCommand(item, id, options) { }); break; case 'download': - import('../scripts/fileDownloader').then((fileDownloader) => { - const downloadHref = apiClient.getItemDownloadUrl(itemId); - fileDownloader.download([{ - url: downloadHref, - item, - itemId, - serverId, - title: item.Name, - filename: item.Path.replace(/^.*[\\/]/, '') - }]); - getResolveFunction(getResolveFunction(resolve, id), id)(); - }); + // Show download options dialog for videos, direct download for others + if (item.Type === 'Movie' || item.Type === 'Episode' || item.Type === 'Video') { + downloadOptionsDialog.show(item, apiClient).then(() => { + getResolveFunction(getResolveFunction(resolve, id), id)(); + }); + } else { + // Direct download for non-video items (books, music, etc.) + import('../scripts/fileDownloader').then((fileDownloader) => { + const downloadHref = apiClient.getItemDownloadUrl(itemId); + fileDownloader.download([{ + url: downloadHref, + item, + itemId, + serverId, + title: item.Name, + filename: item.Path.replace(/^.*[\\/]/, '') + }]); + getResolveFunction(getResolveFunction(resolve, id), id)(); + }); + } break; case 'downloadall': { const downloadItems = items => { diff --git a/src/components/playback/playersettingsmenu.js b/src/components/playback/playersettingsmenu.js index 5b0aca301f..12b2488fc3 100644 --- a/src/components/playback/playersettingsmenu.js +++ b/src/components/playback/playersettingsmenu.js @@ -93,12 +93,16 @@ function getQualitySecondaryText(player) { return stream.Type === 'Video'; })[0]; + const videoCodec = videoStream ? videoStream.Codec : null; + const videoBitRate = videoStream ? videoStream.BitRate : null; const videoWidth = videoStream ? videoStream.Width : null; const videoHeight = videoStream ? videoStream.Height : null; const options = qualityoptions.getVideoQualityOptions({ currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player), isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player), + videoCodec, + videoBitRate, videoWidth: videoWidth, videoHeight: videoHeight, enableAuto: true diff --git a/src/scripts/fileDownloader.js b/src/scripts/fileDownloader.js index 24f1631017..dde5c18ae6 100644 --- a/src/scripts/fileDownloader.js +++ b/src/scripts/fileDownloader.js @@ -3,8 +3,7 @@ import shell from './shell'; export function download(items) { if (!shell.downloadFiles(items)) { - multiDownload(items.map(function (item) { - return item.url; - })); + // Pass full item objects (with url and filename) to multiDownload + multiDownload(items); } } diff --git a/src/scripts/multiDownload.js b/src/scripts/multiDownload.js index c9f40f56a4..cef4c8a3bf 100644 --- a/src/scripts/multiDownload.js +++ b/src/scripts/multiDownload.js @@ -27,25 +27,35 @@ function fallback(urls) { })(); } -function download(url) { +function download(url, filename) { const a = document.createElement('a'); - a.download = ''; + a.download = filename || ''; a.href = url; a.click(); } -export default function (urls) { - if (!urls) { - throw new Error('`urls` required'); +export default function (items) { + if (!items) { + throw new Error('`items` required'); } + // Support both old format (array of URLs) and new format (array of objects with url+filename) + const isOldFormat = typeof items[0] === 'string'; + + console.log('multiDownload received items:', items); + console.log('isOldFormat:', isOldFormat); + if (typeof document.createElement('a').download === 'undefined' || browser.iOS) { + const urls = isOldFormat ? items : items.map(item => item.url || item); return fallback(urls); } let delay = 0; - urls.forEach(function (url) { - setTimeout(download.bind(null, url), 100 * ++delay); + items.forEach(function (item) { + const url = isOldFormat ? item : (item.url || item); + const filename = isOldFormat ? '' : (item.filename || ''); + console.log('Downloading:', { url, filename }); + setTimeout(download.bind(null, url, filename), 100 * ++delay); }); }