Files
jellyfin-packaging/patches/jellyfin-web-download-dialog-v10.11.5.patch

932 lines
37 KiB
Diff
Raw Normal View History

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 = '<option value="0">Original Quality</option>';
+ 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 `<option value="${option.bitrate}"${selected}>${option.name} - ${mbps} Mbps</option>`;
+ }).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 `<option value="${stream.Index}" data-channels="${stream.Channels || 2}"${selected}>${displayTitle}</option>`;
+ }).join('');
+
+ if (audioStreams.length === 0) {
+ selectAudioTrack.innerHTML = '<option value="-1" data-channels="2">No Audio</option>';
+ }
+}
+
+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 = '<option value="none" selected>None</option>';
+
+ options += subtitleStreams.map(stream => {
+ const language = stream.Language || 'Unknown';
+ const codec = stream.Codec ? stream.Codec.toUpperCase() : '';
+ const displayTitle = stream.DisplayTitle || `${language} (${codec})`;
+
+ return `<option value="${stream.Index}">${displayTitle}</option>`;
+ }).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 @@
+<div class="formDialogHeader">
+ <button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
+ <span class="material-icons arrow_back"></span>
+ </button>
+ <h3 class="formDialogHeaderTitle">Download Options</h3>
+</div>
+
+<form>
+<div class="formDialogContent smoothScrollY" style="padding: 1.5em 1.5em 1.5em 1.5em; max-height: 60vh; overflow-y: auto;">
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Video Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectResolution">Resolution</label>
+ <select is="emby-select" id="selectResolution" class="emby-select-withcolor emby-select">
+ <option value="original">Original (Source)</option>
+ <option value="3840">4K (3840x2160)</option>
+ <option value="2560">1440p (2560x1440)</option>
+ <option value="1920" selected>1080p (1920x1080)</option>
+ <option value="1280">720p (1280x720)</option>
+ <option value="640">480p (640x480)</option>
+ </select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectQuality">Quality</label>
+ <select is="emby-select" id="selectQuality" class="emby-select-withcolor emby-select"></select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectVideoCodec">${LabelVideoCodec}</label>
+ <select is="emby-select" id="selectVideoCodec" class="emby-select-withcolor emby-select">
+ <option value="copy">Copy Original (No Transcode)</option>
+ <option value="h264">H.264 (Most Compatible)</option>
+ <option value="hevc">HEVC/H.265</option>
+ <option value="vp9">VP9</option>
+ <option value="av1" selected>AV1</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Audio Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioTrack">Audio Track</label>
+ <select is="emby-select" id="selectAudioTrack" class="emby-select-withcolor emby-select"></select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioCodec">${LabelAudioCodec}</label>
+ <select is="emby-select" id="selectAudioCodec" class="emby-select-withcolor emby-select">
+ <option value="aac">AAC (Recommended)</option>
+ <option value="ac3">AC3 (Dolby Digital)</option>
+ <option value="eac3">E-AC3 (Dolby Digital Plus)</option>
+ <option value="mp3">MP3</option>
+ <option value="opus">Opus (WebM)</option>
+ <option value="copy">Copy (No Transcode)</option>
+ </select>
+ </div>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectAudioBitrate">${LabelAudioBitrate}</label>
+ <select is="emby-select" id="selectAudioBitrate" class="emby-select-withcolor emby-select">
+ <option value="1536000">1536 kbps</option>
+ <option value="1024000">1024 kbps</option>
+ <option value="768000">768 kbps</option>
+ <option value="640000">640 kbps</option>
+ <option value="512000">512 kbps</option>
+ <option value="510000">510 kbps</option>
+ <option value="450000">450 kbps</option>
+ <option value="448000">448 kbps</option>
+ <option value="384000">384 kbps</option>
+ <option value="320000">320 kbps</option>
+ <option value="256000">256 kbps</option>
+ <option value="224000">224 kbps</option>
+ <option value="192000" selected>192 kbps</option>
+ <option value="160000">160 kbps</option>
+ <option value="128000">128 kbps</option>
+ <option value="96000">96 kbps</option>
+ <option value="64000">64 kbps</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Subtitle Settings</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectSubtitle">Subtitles</label>
+ <select is="emby-select" id="selectSubtitle" class="emby-select-withcolor emby-select">
+ <option value="none" selected>None</option>
+ </select>
+ </div>
+
+ <div class="selectContainer" id="subtitleMethodContainer" style="display: none;">
+ <label class="selectLabel" for="selectSubtitleMethod">Subtitle Method</label>
+ <select is="emby-select" id="selectSubtitleMethod" class="emby-select-withcolor emby-select">
+ <option value="Encode">Burn-in (Hardcoded)</option>
+ <option value="Embed" selected>Embed (Soft Subtitle)</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <h2 class="sectionTitle">Container Format</h2>
+
+ <div class="selectContainer">
+ <label class="selectLabel" for="selectContainer">Container</label>
+ <select is="emby-select" id="selectContainer" class="emby-select-withcolor emby-select">
+ <option value="mp4">MP4</option>
+ <option value="mkv" selected>MKV</option>
+ <option value="webm">WebM</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="verticalSection">
+ <div class="downloadInfo" style="padding: 1em; background: rgba(255,255,255,0.05); border-radius: 4px;">
+ <div id="estimatedSize" style="font-size: 1.1em; margin-bottom: 0.5em;">
+ <strong>Estimated Size:</strong> <span id="sizeValue">Calculating...</span>
+ </div>
+ <div id="downloadDuration" style="color: rgba(255,255,255,0.7);">
+ <strong>Duration:</strong> <span id="durationValue">-</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+<div class="formDialogFooter">
+ <button is="emby-button" type="button" class="raised button-cancel block btnCancel">
+ <span>${ButtonCancel}</span>
+ </button>
+ <button is="emby-button" type="submit" class="raised button-submit block">
+ <span>Download</span>
+ </button>
+</div>
+</form>
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);
});
}