mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(downloads): add media quality and queue reordering
This commit is contained in:
@@ -121,6 +121,8 @@ pub struct DownloadItem {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub media_format_selector: Option<String>,
|
pub media_format_selector: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub media_quality: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
pub queue_id: Option<String>,
|
pub queue_id: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub queue_position: Option<i32>,
|
pub queue_position: Option<i32>,
|
||||||
|
|||||||
+11
-4
@@ -5650,11 +5650,18 @@ async fn move_many_in_queue(
|
|||||||
ids: Vec<String>,
|
ids: Vec<String>,
|
||||||
queue_id: String,
|
queue_id: String,
|
||||||
direction: crate::ipc::QueueDirection,
|
direction: crate::ipc::QueueDirection,
|
||||||
|
target_index: Option<usize>,
|
||||||
) -> Result<Vec<String>, AppError> {
|
) -> Result<Vec<String>, AppError> {
|
||||||
Ok(state
|
Ok(match target_index {
|
||||||
.queue_manager
|
Some(target_index) => state
|
||||||
.move_many_in_queue(&ids, &queue_id, direction)
|
.queue_manager
|
||||||
.await)
|
.move_many_in_queue_to(&ids, &queue_id, target_index)
|
||||||
|
.await,
|
||||||
|
None => state
|
||||||
|
.queue_manager
|
||||||
|
.move_many_in_queue(&ids, &queue_id, direction)
|
||||||
|
.await,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
+64
-4
@@ -22,6 +22,34 @@ pub fn clamp_download_connections(connections: i32) -> i32 {
|
|||||||
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reorder_selected_queue_tasks(
|
||||||
|
queue_tasks: &[QueuedTask],
|
||||||
|
ids: &[String],
|
||||||
|
target_index: usize,
|
||||||
|
) -> Option<Vec<QueuedTask>> {
|
||||||
|
let selected_ids = ids.iter().collect::<HashSet<_>>();
|
||||||
|
let selected_tasks = queue_tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| selected_ids.contains(&task.id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if selected_tasks.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unselected_tasks = queue_tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| !selected_ids.contains(&task.id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let insert_index = target_index.min(unselected_tasks.len());
|
||||||
|
let mut reordered = Vec::with_capacity(queue_tasks.len());
|
||||||
|
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
|
||||||
|
reordered.extend(selected_tasks);
|
||||||
|
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
|
||||||
|
Some(reordered)
|
||||||
|
}
|
||||||
|
|
||||||
type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
|
type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -1591,10 +1619,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.saturating_add(1)
|
.saturating_add(1)
|
||||||
.min(unselected_tasks.len()),
|
.min(unselected_tasks.len()),
|
||||||
};
|
};
|
||||||
let mut reordered = Vec::with_capacity(queue_tasks.len());
|
let reordered = reorder_selected_queue_tasks(&queue_tasks, ids, insert_index)
|
||||||
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
|
.expect("selected queue tasks were present");
|
||||||
reordered.extend(selected_tasks);
|
|
||||||
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
|
|
||||||
|
|
||||||
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
|
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
|
||||||
pending[*pending_index] = reordered[queue_index].clone();
|
pending[*pending_index] = reordered[queue_index].clone();
|
||||||
@@ -1607,6 +1633,40 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically place a selected block at an insertion index among the
|
||||||
|
/// unselected tasks in one queue. This is the drag/drop counterpart to
|
||||||
|
/// move_many_in_queue; the index is clamped at the backend boundary so a
|
||||||
|
/// stale pointer position cannot create an invalid queue state.
|
||||||
|
pub async fn move_many_in_queue_to(
|
||||||
|
&self,
|
||||||
|
ids: &[String],
|
||||||
|
queue_id: &str,
|
||||||
|
target_index: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
let queue_positions = pending
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let queue_tasks = queue_positions
|
||||||
|
.iter()
|
||||||
|
.map(|index| pending[*index].clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if let Some(reordered) = reorder_selected_queue_tasks(&queue_tasks, ids, target_index) {
|
||||||
|
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
|
||||||
|
pending[*pending_index] = reordered[queue_index].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.queue_id == queue_id)
|
||||||
|
.map(|task| task.id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a task from pending if present (used by remove_download).
|
/// Remove a task from pending if present (used by remove_download).
|
||||||
/// Does NOT release a permit (the caller handles active permits via
|
/// Does NOT release a permit (the caller handles active permits via
|
||||||
/// release_permit if the task was already dispatched).
|
/// release_permit if the task was already dispatched).
|
||||||
|
|||||||
@@ -1247,6 +1247,33 @@ async fn multi_move_reorders_selected_items_as_one_atomic_block() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_move_reorders_a_selected_block_and_clamps_the_target() {
|
||||||
|
use firelink_lib::ipc::QueueDirection;
|
||||||
|
|
||||||
|
let (mgr, _spawner) = make_manager(3);
|
||||||
|
for id in ["a", "b", "c", "d", "e"] {
|
||||||
|
mgr.push(sample_task(id)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = vec!["b".to_string(), "d".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue_to(&selected, "main", 1).await,
|
||||||
|
vec!["a", "b", "d", "c", "e"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue_to(&selected, "main", usize::MAX).await,
|
||||||
|
vec!["a", "c", "e", "b", "d"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The original direction API remains unchanged for keyboard/button moves.
|
||||||
|
assert_eq!(
|
||||||
|
mgr.move_many_in_queue(&selected, "main", QueueDirection::Up)
|
||||||
|
.await,
|
||||||
|
vec!["a", "c", "b", "d", "e"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn moving_one_queue_does_not_reorder_another_queue() {
|
async fn moving_one_queue_does_not_reorder_another_queue() {
|
||||||
use firelink_lib::ipc::QueueDirection;
|
use firelink_lib::ipc::QueueDirection;
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
|
|
||||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
|
|||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
|
commonMediaQualitiesForRows,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
|
mediaQualityForRow,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
metadataSummaryState,
|
metadataSummaryState,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
|
selectExactMediaQuality,
|
||||||
updateRowIfCurrent,
|
updateRowIfCurrent,
|
||||||
type AddDownloadDraftRow
|
type AddDownloadDraftRow
|
||||||
} from '../utils/addDownloadMetadata';
|
} from '../utils/addDownloadMetadata';
|
||||||
@@ -519,6 +522,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const isApproximate = !exactBytes && approxBytes > 0;
|
const isApproximate = !exactBytes && approxBytes > 0;
|
||||||
return {
|
return {
|
||||||
name: `${quality} ${container}`,
|
name: `${quality} ${container}`,
|
||||||
|
quality,
|
||||||
ext: f.ext,
|
ext: f.ext,
|
||||||
bytes,
|
bytes,
|
||||||
isApproximate,
|
isApproximate,
|
||||||
@@ -1187,6 +1191,7 @@ export const AddDownloadsModal = () => {
|
|||||||
isMedia: item.isMedia,
|
isMedia: item.isMedia,
|
||||||
resumable: item.resumable,
|
resumable: item.resumable,
|
||||||
mediaFormatSelector: formatSelector,
|
mediaFormatSelector: formatSelector,
|
||||||
|
mediaQuality: mediaQualityForRow(item),
|
||||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes
|
||||||
}, action);
|
}, action);
|
||||||
@@ -1285,6 +1290,14 @@ export const AddDownloadsModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||||
|
const selectedReadyMediaItems = selectedItems.filter(item =>
|
||||||
|
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
|
||||||
|
);
|
||||||
|
const bulkQualityOptions = commonMediaQualitiesForRows(selectedReadyMediaItems);
|
||||||
|
const applyBulkMediaQuality = (quality: string) => {
|
||||||
|
const selectedIds = selectedReadyMediaItems.map(item => item.id);
|
||||||
|
setParsedItems(items => selectExactMediaQuality(items, selectedIds, quality));
|
||||||
|
};
|
||||||
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
||||||
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||||
const hasApproximateSize = selectedItems.some(item =>
|
const hasApproximateSize = selectedItems.some(item =>
|
||||||
@@ -1524,7 +1537,14 @@ export const AddDownloadsModal = () => {
|
|||||||
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
|
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
|
||||||
className="me-2 shrink-0 accent-purple-500"
|
className="me-2 shrink-0 accent-purple-500"
|
||||||
/>
|
/>
|
||||||
<div className="flex-[2] text-text-primary font-medium truncate pe-2" title={item.file}>{item.file}</div>
|
<div className="flex-[2] min-w-0 flex items-center gap-2 text-text-primary font-medium truncate pe-2" title={item.file}>
|
||||||
|
<span className="truncate">{item.file}</span>
|
||||||
|
{item.isMedia && item.status === 'ready' && mediaQualityForRow(item) ? (
|
||||||
|
<span className="add-download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
|
||||||
|
{mediaQualityForRow(item)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
|
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
|
||||||
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
||||||
{item.status === 'loading' ? (
|
{item.status === 'loading' ? (
|
||||||
@@ -1554,6 +1574,34 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
|
||||||
|
{selectedReadyMediaItems.length > 1 ? (
|
||||||
|
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||||
|
<div className="flex flex-col gap-1.5 relative z-10">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">
|
||||||
|
{t($ => $.addDownloads.applyQualityToSelected)}
|
||||||
|
</label>
|
||||||
|
{bulkQualityOptions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t($ => $.addDownloads.applyQualityToSelected)}>
|
||||||
|
{bulkQualityOptions.map(quality => (
|
||||||
|
<button
|
||||||
|
key={quality}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyBulkMediaQuality(quality)}
|
||||||
|
className="add-download-quality-chip add-download-quality-chip-button"
|
||||||
|
>
|
||||||
|
{quality}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-text-muted">
|
||||||
|
{t($ => $.addDownloads.noCommonQuality)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Media Format (Dynamic) */}
|
{/* Media Format (Dynamic) */}
|
||||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } from 'lucide-react';
|
||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -32,7 +32,10 @@ interface DownloadItemProps {
|
|||||||
handleResume: (item: DownloadItemType) => void;
|
handleResume: (item: DownloadItemType) => void;
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isQueueReorderable: boolean;
|
||||||
|
isQueueDragSource: boolean;
|
||||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||||
|
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +53,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
handleResume,
|
handleResume,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isQueueReorderable,
|
||||||
|
isQueueDragSource,
|
||||||
onMoveInQueue,
|
onMoveInQueue,
|
||||||
|
onQueueDragStart,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -60,6 +66,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const [isRowFocused, setIsRowFocused] = React.useState(false);
|
const [isRowFocused, setIsRowFocused] = React.useState(false);
|
||||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||||
const isActionVisible = isRowHovered || isRowFocused;
|
const isActionVisible = isRowHovered || isRowFocused;
|
||||||
|
const mediaQualityLabel = (() => {
|
||||||
|
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
|
||||||
|
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return normalized.length > 0 && normalized.length <= 48 ? normalized : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
const updateActionPosition = React.useCallback(() => {
|
const updateActionPosition = React.useCallback(() => {
|
||||||
const row = rowRef.current;
|
const row = rowRef.current;
|
||||||
@@ -182,12 +193,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
style={columnStyle('File Name')}
|
style={columnStyle('File Name')}
|
||||||
>
|
>
|
||||||
<div className="download-cell-content">
|
<div className="download-cell-content">
|
||||||
|
{isQueueReorderable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="download-queue-drag-handle app-icon-button"
|
||||||
|
aria-label={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||||
|
title={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||||
|
onPointerDown={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onQueueDragStart(download.id, event);
|
||||||
|
}}
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<span className="shrink-0 text-text-muted">
|
<span className="shrink-0 text-text-muted">
|
||||||
{getCategoryIcon(download.category)}
|
{getCategoryIcon(download.category)}
|
||||||
</span>
|
</span>
|
||||||
<span className="download-file-name" title={download.fileName}>
|
<span className="download-file-name" title={download.fileName}>
|
||||||
{download.fileName}
|
{download.fileName}
|
||||||
</span>
|
</span>
|
||||||
|
{mediaQualityLabel ? (
|
||||||
|
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.quality)}>
|
||||||
|
{mediaQualityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -314,7 +345,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onDoubleClick={(e) => e.stopPropagation()}
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
{isQueueReorderable && queueIndex !== -1 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||||
@@ -360,7 +391,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rowRef}
|
ref={rowRef}
|
||||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
data-download-id={download.id}
|
||||||
|
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
|
||||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@@ -384,6 +416,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => onClick(e, download)}
|
onClick={(e) => onClick(e, download)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (
|
||||||
|
isQueueReorderable &&
|
||||||
|
event.altKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ import {
|
|||||||
type DownloadColumnAlignment,
|
type DownloadColumnAlignment,
|
||||||
type DownloadTableColumnKey
|
type DownloadTableColumnKey
|
||||||
} from '../utils/downloadTableColumns';
|
} from '../utils/downloadTableColumns';
|
||||||
|
import {
|
||||||
|
targetIndexForBoundary
|
||||||
|
} from '../utils/queueOrdering';
|
||||||
|
|
||||||
interface DownloadTableProps {
|
interface DownloadTableProps {
|
||||||
filter: SidebarFilter;
|
filter: SidebarFilter;
|
||||||
@@ -100,9 +103,28 @@ interface ColumnLayoutBounds {
|
|||||||
right: number;
|
right: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueueDragState {
|
||||||
|
pointerId: number;
|
||||||
|
sourceId: string;
|
||||||
|
queueId: string;
|
||||||
|
ids: string[];
|
||||||
|
startY: number;
|
||||||
|
active: boolean;
|
||||||
|
targetIndex: number;
|
||||||
|
markerTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
const {
|
||||||
|
downloads,
|
||||||
|
queues,
|
||||||
|
assignToQueue,
|
||||||
|
openDeleteModal,
|
||||||
|
redownload,
|
||||||
|
moveInQueue,
|
||||||
|
moveManyInQueueToPosition
|
||||||
|
} = useDownloadStore();
|
||||||
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
@@ -153,6 +175,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const selectedIdsRef = useRef(selectedIds);
|
const selectedIdsRef = useRef(selectedIds);
|
||||||
const lastSelectedIdRef = useRef(lastSelectedId);
|
const lastSelectedIdRef = useRef(lastSelectedId);
|
||||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||||
|
const queueListElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const queueReorderableDownloadsRef = useRef<DownloadItem[]>([]);
|
||||||
|
const queueDragStateRef = useRef<QueueDragState | null>(null);
|
||||||
|
const queueDragCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
||||||
selectedIdsRef.current = selectedIds;
|
selectedIdsRef.current = selectedIds;
|
||||||
lastSelectedIdRef.current = lastSelectedId;
|
lastSelectedIdRef.current = lastSelectedId;
|
||||||
const [columnWidths, setColumnWidths] = useState(() => {
|
const [columnWidths, setColumnWidths] = useState(() => {
|
||||||
@@ -195,6 +222,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
);
|
);
|
||||||
const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))';
|
const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))';
|
||||||
const downloadsViewRef = useRef<HTMLDivElement>(null);
|
const downloadsViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const queueListRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
queueListElementRef.current = element;
|
||||||
|
animationParent(element);
|
||||||
|
}, [animationParent]);
|
||||||
|
|
||||||
const updateColumnDragState = (next: ColumnDragState | null) => {
|
const updateColumnDragState = (next: ColumnDragState | null) => {
|
||||||
columnDragStateRef.current = next;
|
columnDragStateRef.current = next;
|
||||||
@@ -545,7 +576,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
resizeCleanupRef.current?.();
|
resizeCleanupRef.current?.();
|
||||||
columnDragCleanupRef.current?.();
|
columnDragCleanupRef.current?.();
|
||||||
|
queueDragCleanupRef.current?.();
|
||||||
columnDragCleanupRef.current = null;
|
columnDragCleanupRef.current = null;
|
||||||
|
queueDragCleanupRef.current = null;
|
||||||
|
queueDragStateRef.current = null;
|
||||||
const captureTarget = columnDragCaptureTargetRef.current;
|
const captureTarget = columnDragCaptureTargetRef.current;
|
||||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||||
columnDragTargetRef.current = null;
|
columnDragTargetRef.current = null;
|
||||||
@@ -632,6 +666,140 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
|
const queueRowForId = (id: string): HTMLElement | null => {
|
||||||
|
const list = queueListElementRef.current;
|
||||||
|
if (!list) return null;
|
||||||
|
return Array.from(list.children).find(element =>
|
||||||
|
element instanceof HTMLElement && element.dataset.downloadId === id
|
||||||
|
) as HTMLElement | undefined || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueDropPosition = (
|
||||||
|
clientY: number,
|
||||||
|
items: DownloadItem[],
|
||||||
|
selectedIds: ReadonlySet<string>
|
||||||
|
): { targetIndex: number; markerTop: number } => {
|
||||||
|
const list = queueListElementRef.current;
|
||||||
|
if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 };
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
let boundaryIndex = items.length;
|
||||||
|
let markerTop = 0;
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const row = queueRowForId(items[index].id);
|
||||||
|
if (!row) continue;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
if (clientY < rect.top + rect.height / 2) {
|
||||||
|
boundaryIndex = index;
|
||||||
|
markerTop = rect.top - listRect.top + list.scrollTop;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
markerTop = rect.bottom - listRect.top + list.scrollTop;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
targetIndex: targetIndexForBoundary(items, selectedIds, boundaryIndex),
|
||||||
|
markerTop: Math.max(0, markerTop)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishQueueDrag = (cancelled = false) => {
|
||||||
|
const current = queueDragStateRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
queueDragCleanupRef.current?.();
|
||||||
|
queueDragCleanupRef.current = null;
|
||||||
|
queueDragStateRef.current = null;
|
||||||
|
setQueueDragState(null);
|
||||||
|
|
||||||
|
if (!cancelled && current.active) {
|
||||||
|
void moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex)
|
||||||
|
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
queueRowForId(current.sourceId)?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueueDragStart = (
|
||||||
|
id: string,
|
||||||
|
event: React.PointerEvent<HTMLButtonElement>
|
||||||
|
) => {
|
||||||
|
if (!queueReorderingEnabled || event.button !== 0) return;
|
||||||
|
const currentDownloads = useDownloadStore.getState().downloads;
|
||||||
|
const source = currentDownloads.find(download => download.id === id);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const reorderableById = new Map(
|
||||||
|
queueReorderableDownloadsRef.current.map(download => [download.id, download])
|
||||||
|
);
|
||||||
|
if (!reorderableById.has(id)) return;
|
||||||
|
|
||||||
|
const currentSelectedIds = selectedIdsRef.current;
|
||||||
|
const ids = (currentSelectedIds.has(id) ? Array.from(currentSelectedIds) : [id])
|
||||||
|
.filter(selectedId => {
|
||||||
|
const selected = reorderableById.get(selectedId);
|
||||||
|
return selected && (selected.queueId || MAIN_QUEUE_ID) === (source.queueId || MAIN_QUEUE_ID);
|
||||||
|
});
|
||||||
|
if (!ids.includes(id)) return;
|
||||||
|
|
||||||
|
if (!currentSelectedIds.has(id)) {
|
||||||
|
setSelectedIds(new Set([id]));
|
||||||
|
setLastSelectedId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueDragCleanupRef.current?.();
|
||||||
|
const queueId = source.queueId || MAIN_QUEUE_ID;
|
||||||
|
const selectedIdSet = new Set(ids);
|
||||||
|
const initialItems = queueReorderableDownloadsRef.current
|
||||||
|
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId);
|
||||||
|
const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet);
|
||||||
|
const initialState: QueueDragState = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
sourceId: id,
|
||||||
|
queueId,
|
||||||
|
ids,
|
||||||
|
startY: event.clientY,
|
||||||
|
active: false,
|
||||||
|
...initialPosition
|
||||||
|
};
|
||||||
|
queueDragStateRef.current = initialState;
|
||||||
|
setQueueDragState(initialState);
|
||||||
|
|
||||||
|
const pointerMove = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId !== event.pointerId) return;
|
||||||
|
const drag = queueDragStateRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
const distance = Math.abs(pointerEvent.clientY - drag.startY);
|
||||||
|
if (!drag.active && distance < 5) return;
|
||||||
|
|
||||||
|
const items = queueReorderableDownloadsRef.current
|
||||||
|
.filter(download => (download.queueId || MAIN_QUEUE_ID) === drag.queueId);
|
||||||
|
const nextPosition = queueDropPosition(pointerEvent.clientY, items, new Set(drag.ids));
|
||||||
|
const nextState = { ...drag, ...nextPosition, active: true };
|
||||||
|
queueDragStateRef.current = nextState;
|
||||||
|
setQueueDragState(nextState);
|
||||||
|
pointerEvent.preventDefault();
|
||||||
|
};
|
||||||
|
const pointerUp = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag();
|
||||||
|
};
|
||||||
|
const pointerCancel = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag(true);
|
||||||
|
};
|
||||||
|
const cancel = () => finishQueueDrag(true);
|
||||||
|
window.addEventListener('pointermove', pointerMove);
|
||||||
|
window.addEventListener('pointerup', pointerUp);
|
||||||
|
window.addEventListener('pointercancel', pointerCancel);
|
||||||
|
window.addEventListener('blur', cancel);
|
||||||
|
document.addEventListener('visibilitychange', cancel);
|
||||||
|
queueDragCleanupRef.current = () => {
|
||||||
|
window.removeEventListener('pointermove', pointerMove);
|
||||||
|
window.removeEventListener('pointerup', pointerUp);
|
||||||
|
window.removeEventListener('pointercancel', pointerCancel);
|
||||||
|
window.removeEventListener('blur', cancel);
|
||||||
|
document.removeEventListener('visibilitychange', cancel);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||||
const fileName = item.fileName?.trim();
|
const fileName = item.fileName?.trim();
|
||||||
if (!fileName) return null;
|
if (!fileName) return null;
|
||||||
@@ -722,6 +890,38 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||||
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
[filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]);
|
||||||
|
|
||||||
|
const queueReorderingEnabled = isQueueFilter && !queueSortConfig;
|
||||||
|
const queueReorderableDownloads = useMemo(
|
||||||
|
() => queueReorderingEnabled
|
||||||
|
? sortedDownloads.filter(download =>
|
||||||
|
download.status !== 'completed' &&
|
||||||
|
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[queueReorderingEnabled, sortedDownloads]
|
||||||
|
);
|
||||||
|
const queueReorderableIds = useMemo(
|
||||||
|
() => new Set(queueReorderableDownloads.map(download => download.id)),
|
||||||
|
[queueReorderableDownloads]
|
||||||
|
);
|
||||||
|
queueReorderableDownloadsRef.current = queueReorderableDownloads;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = queueDragStateRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
const currentQueueIds = new Set(
|
||||||
|
queueReorderableDownloads
|
||||||
|
.filter(download => (download.queueId || MAIN_QUEUE_ID) === current.queueId)
|
||||||
|
.map(download => download.id)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!queueReorderingEnabled ||
|
||||||
|
current.ids.some(id => !currentQueueIds.has(id))
|
||||||
|
) {
|
||||||
|
finishQueueDrag(true);
|
||||||
|
}
|
||||||
|
}, [queueReorderableDownloads, queueReorderingEnabled]);
|
||||||
|
|
||||||
const selectedDownloads = useMemo(
|
const selectedDownloads = useMemo(
|
||||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||||
[filteredDownloads, selectedIds]
|
[filteredDownloads, selectedIds]
|
||||||
@@ -1063,6 +1263,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<div className="downloads-title">
|
<div className="downloads-title">
|
||||||
{getFilterTitle()}
|
{getFilterTitle()}
|
||||||
<span className="downloads-count">{sortedDownloads.length}</span>
|
<span className="downloads-count">{sortedDownloads.length}</span>
|
||||||
|
{queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
|
||||||
|
<span className="downloads-queue-reorder-hint">
|
||||||
|
{t($ => $.downloadTable.queueReorderHint)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="downloads-summary">
|
<div className="downloads-summary">
|
||||||
<span className="downloads-summary-scope">{summaryScopeLabel}</span>
|
<span className="downloads-summary-scope">{summaryScopeLabel}</span>
|
||||||
@@ -1188,7 +1393,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="download-table-body" style={{ minWidth: tableMinWidthWithPadding }}>
|
<div className="download-table-body" style={{ minWidth: tableMinWidthWithPadding }}>
|
||||||
<div className="download-table-list" ref={animationParent}>
|
<div className="download-table-list" ref={queueListRef}>
|
||||||
{sortedDownloads.length === 0 ? (
|
{sortedDownloads.length === 0 ? (
|
||||||
<div className="downloads-empty-state">
|
<div className="downloads-empty-state">
|
||||||
<ArrowDownCircle aria-hidden="true" />
|
<ArrowDownCircle aria-hidden="true" />
|
||||||
@@ -1235,10 +1440,20 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
handleResume={handleResume}
|
handleResume={handleResume}
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
isSelected={selectedIds.has(d.id)}
|
isSelected={selectedIds.has(d.id)}
|
||||||
|
isQueueReorderable={queueReorderableIds.has(d.id)}
|
||||||
|
isQueueDragSource={Boolean(queueDragState?.active && queueDragState.ids.includes(d.id))}
|
||||||
onMoveInQueue={handleMoveInQueue}
|
onMoveInQueue={handleMoveInQueue}
|
||||||
|
onQueueDragStart={handleQueueDragStart}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{queueDragState?.active ? (
|
||||||
|
<div
|
||||||
|
className="download-queue-drop-marker"
|
||||||
|
style={{ top: `${queueDragState.markerTop}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const common = {
|
|||||||
clickToAdd: 'Click',
|
clickToAdd: 'Click',
|
||||||
addButtonOr: 'button or',
|
addButtonOr: 'button or',
|
||||||
toAddDownloads: 'to add downloads',
|
toAddDownloads: 'to add downloads',
|
||||||
|
queueReorderHint: 'Drag the handle to reorder. Alt+Arrow Up/Down also moves the focused row.',
|
||||||
|
queueDragHandle: 'Drag {{fileName}} to reorder',
|
||||||
|
queueReorderFailed: 'Could not reorder downloads',
|
||||||
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
|
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
|
||||||
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
|
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
|
||||||
interactionError: '{{message}}: {{detail}}',
|
interactionError: '{{message}}: {{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const common = {
|
|||||||
fetchingMediaStreams: 'Fetching media streams...',
|
fetchingMediaStreams: 'Fetching media streams...',
|
||||||
availableStreams: 'Available Streams',
|
availableStreams: 'Available Streams',
|
||||||
availableMediaStreams: 'Available media streams',
|
availableMediaStreams: 'Available media streams',
|
||||||
|
quality: 'Quality',
|
||||||
|
applyQualityToSelected: 'Apply exact quality to selected media',
|
||||||
|
noCommonQuality: 'No exact quality is available for every selected media item.',
|
||||||
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
|
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
|
||||||
saveLocation: 'Save Location',
|
saveLocation: 'Save Location',
|
||||||
browse: 'Browse',
|
browse: 'Browse',
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const fa = {
|
|||||||
clickToAdd: 'برای افزودن دانلودها، روی',
|
clickToAdd: 'برای افزودن دانلودها، روی',
|
||||||
addButtonOr: 'یا',
|
addButtonOr: 'یا',
|
||||||
toAddDownloads: 'کلیک کنید',
|
toAddDownloads: 'کلیک کنید',
|
||||||
|
queueReorderHint: 'دسته را بکشید تا جابهجا شود. با Alt+فلش بالا/پایین نیز ردیف انتخابشده جابهجا میشود.',
|
||||||
|
queueDragHandle: 'کشیدن {{fileName}} برای جابهجایی',
|
||||||
|
queueReorderFailed: 'جابهجایی دانلودها ناموفق بود',
|
||||||
nonResumableOne: '۱ دانلود از ادامهدادن پشتیبانی نمیکند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
|
nonResumableOne: '۱ دانلود از ادامهدادن پشتیبانی نمیکند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
|
||||||
nonResumableMany: '{{count}} دانلود از ادامهدادن پشتیبانی نمیکنند. اگر آنها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آنها مطمئن هستید؟',
|
nonResumableMany: '{{count}} دانلود از ادامهدادن پشتیبانی نمیکنند. اگر آنها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آنها مطمئن هستید؟',
|
||||||
interactionError: '{{message}}: {{detail}}',
|
interactionError: '{{message}}: {{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const fa = {
|
|||||||
fetchingMediaStreams: 'در حال دریافت جریانهای رسانه…',
|
fetchingMediaStreams: 'در حال دریافت جریانهای رسانه…',
|
||||||
availableStreams: 'جریانهای موجود',
|
availableStreams: 'جریانهای موجود',
|
||||||
availableMediaStreams: 'جریانهای رسانه موجود',
|
availableMediaStreams: 'جریانهای رسانه موجود',
|
||||||
|
quality: 'کیفیت',
|
||||||
|
applyQualityToSelected: 'اعمال کیفیت دقیق برای رسانههای انتخابشده',
|
||||||
|
noCommonQuality: 'کیفیت دقیقی برای همه رسانههای انتخابشده وجود ندارد.',
|
||||||
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازهسازی کنید.',
|
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازهسازی کنید.',
|
||||||
saveLocation: 'محل ذخیره',
|
saveLocation: 'محل ذخیره',
|
||||||
browse: 'انتخاب…',
|
browse: 'انتخاب…',
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const he = {
|
|||||||
clickToAdd: 'לחץ על לחצן',
|
clickToAdd: 'לחץ על לחצן',
|
||||||
addButtonOr: 'או',
|
addButtonOr: 'או',
|
||||||
toAddDownloads: 'כדי להוסיף הורדות',
|
toAddDownloads: 'כדי להוסיף הורדות',
|
||||||
|
queueReorderHint: 'גררו את הידית כדי לשנות את הסדר. Alt+חץ למעלה/למטה מזיז גם את השורה הממוקדת.',
|
||||||
|
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
|
||||||
|
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
|
||||||
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
|
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
|
||||||
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
|
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
|
||||||
interactionError: '{{message}}: {{detail}}',
|
interactionError: '{{message}}: {{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const he = {
|
|||||||
fetchingMediaStreams: 'טוען זרמי מדיה…',
|
fetchingMediaStreams: 'טוען זרמי מדיה…',
|
||||||
availableStreams: 'זרמים זמינים',
|
availableStreams: 'זרמים זמינים',
|
||||||
availableMediaStreams: 'זרמי מדיה זמינים',
|
availableMediaStreams: 'זרמי מדיה זמינים',
|
||||||
|
quality: 'איכות',
|
||||||
|
applyQualityToSelected: 'החלת איכות מדויקת על המדיה שנבחרה',
|
||||||
|
noCommonQuality: 'אין איכות מדויקת הזמינה לכל פריטי המדיה שנבחרו.',
|
||||||
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
|
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
|
||||||
saveLocation: 'מיקום שמירה',
|
saveLocation: 'מיקום שמירה',
|
||||||
browse: 'עיון…',
|
browse: 'עיון…',
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const ru = {
|
|||||||
clickToAdd: 'Нажмите кнопку',
|
clickToAdd: 'Нажмите кнопку',
|
||||||
addButtonOr: 'или',
|
addButtonOr: 'или',
|
||||||
toAddDownloads: 'чтобы добавить загрузки',
|
toAddDownloads: 'чтобы добавить загрузки',
|
||||||
|
queueReorderHint: 'Перетащите маркер для изменения порядка. Alt+стрелка вверх/вниз перемещает выбранную строку.',
|
||||||
|
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
|
||||||
|
queueReorderFailed: 'Не удалось изменить порядок загрузок',
|
||||||
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
|
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
|
||||||
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
|
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
|
||||||
interactionError: '{{message}}: {{detail}}',
|
interactionError: '{{message}}: {{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const ru = {
|
|||||||
fetchingMediaStreams: 'Получение медиапотоков…',
|
fetchingMediaStreams: 'Получение медиапотоков…',
|
||||||
availableStreams: 'Доступные потоки',
|
availableStreams: 'Доступные потоки',
|
||||||
availableMediaStreams: 'Доступные медиапотоки',
|
availableMediaStreams: 'Доступные медиапотоки',
|
||||||
|
quality: 'Качество',
|
||||||
|
applyQualityToSelected: 'Применить точное качество к выбранным медиа',
|
||||||
|
noCommonQuality: 'Для всех выбранных медиа нет одинакового качества.',
|
||||||
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
|
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
|
||||||
saveLocation: 'Папка сохранения',
|
saveLocation: 'Папка сохранения',
|
||||||
browse: 'Обзор…',
|
browse: 'Обзор…',
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const uk = {
|
|||||||
clickToAdd: 'Натисніть',
|
clickToAdd: 'Натисніть',
|
||||||
addButtonOr: 'або',
|
addButtonOr: 'або',
|
||||||
toAddDownloads: 'щоб додати завантаження',
|
toAddDownloads: 'щоб додати завантаження',
|
||||||
|
queueReorderHint: 'Перетягніть маркер, щоб змінити порядок. Alt+стрілка вгору/вниз переміщує сфокусований рядок.',
|
||||||
|
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
|
||||||
|
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
|
||||||
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
|
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
|
||||||
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
|
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
|
||||||
interactionError: '{{message}}: {{detail}}',
|
interactionError: '{{message}}: {{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const uk = {
|
|||||||
fetchingMediaStreams: 'Отримання медіапотоків…',
|
fetchingMediaStreams: 'Отримання медіапотоків…',
|
||||||
availableStreams: 'Доступні потоки',
|
availableStreams: 'Доступні потоки',
|
||||||
availableMediaStreams: 'Доступні медіапотоки',
|
availableMediaStreams: 'Доступні медіапотоки',
|
||||||
|
quality: 'Якість',
|
||||||
|
applyQualityToSelected: 'Застосувати точну якість до вибраних медіа',
|
||||||
|
noCommonQuality: 'Для всіх вибраних медіа немає однакової якості.',
|
||||||
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
|
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
|
||||||
saveLocation: 'Місце збереження',
|
saveLocation: 'Місце збереження',
|
||||||
browse: 'Огляд…',
|
browse: 'Огляд…',
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ const zhCN = {
|
|||||||
clickToAdd: '点击',
|
clickToAdd: '点击',
|
||||||
addButtonOr: '按钮或',
|
addButtonOr: '按钮或',
|
||||||
toAddDownloads: '来添加下载',
|
toAddDownloads: '来添加下载',
|
||||||
|
queueReorderHint: '拖动手柄调整顺序。Alt+上/下箭头也可移动当前行。',
|
||||||
|
queueDragHandle: '拖动 {{fileName}} 调整顺序',
|
||||||
|
queueReorderFailed: '无法调整下载顺序',
|
||||||
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
|
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
|
||||||
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
|
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
|
||||||
interactionError: '{{message}}:{{detail}}',
|
interactionError: '{{message}}:{{detail}}',
|
||||||
@@ -456,6 +459,9 @@ const zhCN = {
|
|||||||
fetchingMediaStreams: '正在获取媒体流…',
|
fetchingMediaStreams: '正在获取媒体流…',
|
||||||
availableStreams: '可用流',
|
availableStreams: '可用流',
|
||||||
availableMediaStreams: '可用的媒体流',
|
availableMediaStreams: '可用的媒体流',
|
||||||
|
quality: '画质',
|
||||||
|
applyQualityToSelected: '将精确画质应用到选中的媒体',
|
||||||
|
noCommonQuality: '所选媒体没有共同的精确画质。',
|
||||||
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
|
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
|
||||||
saveLocation: '保存位置',
|
saveLocation: '保存位置',
|
||||||
browse: '浏览',
|
browse: '浏览',
|
||||||
|
|||||||
@@ -726,6 +726,33 @@ html[data-list-density="relaxed"] {
|
|||||||
box-shadow: inset 3px 0 0 hsl(272 74% 62% / 0.9);
|
box-shadow: inset 3px 0 0 hsl(272 74% 62% / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-download-quality-chip,
|
||||||
|
.download-quality-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid hsl(272 74% 62% / 0.32);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(272 74% 58% / 0.12);
|
||||||
|
color: hsl(272 74% 62%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-chip-button {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 100ms ease, border-color 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-chip-button:hover,
|
||||||
|
.add-download-quality-chip-button:focus-visible {
|
||||||
|
border-color: hsl(272 74% 62% / 0.72);
|
||||||
|
background: hsl(272 74% 58% / 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
.add-download-select {
|
.add-download-select {
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -1933,6 +1960,15 @@ html[data-list-density="relaxed"] {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-queue-reorder-hint {
|
||||||
|
max-width: 340px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.download-table-scroll {
|
.download-table-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2103,6 +2139,7 @@ body.is-column-resizing .column-resize-handle:hover::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-table-list {
|
.download-table-list {
|
||||||
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -2111,6 +2148,18 @@ body.is-column-resizing .column-resize-handle:hover::after {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-queue-drop-marker {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
z-index: 8;
|
||||||
|
height: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--accent-color));
|
||||||
|
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.18), 0 0 8px hsl(var(--accent-color) / 0.55);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.download-row {
|
.download-row {
|
||||||
flex: 0 0 var(--download-row-height);
|
flex: 0 0 var(--download-row-height);
|
||||||
height: var(--download-row-height);
|
height: var(--download-row-height);
|
||||||
@@ -2162,6 +2211,30 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
background: hsl(var(--accent-color) / 0.32) !important;
|
background: hsl(var(--accent-color) / 0.32) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-row.is-queue-drag-source {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-queue-drag-handle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 22px;
|
||||||
|
margin-inline-start: -4px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-queue-drag-handle:hover,
|
||||||
|
.download-queue-drag-handle:focus-visible {
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
background: hsl(var(--item-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-queue-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.download-row:focus-visible {
|
.download-row:focus-visible {
|
||||||
outline: 2px solid hsl(var(--accent-color) / 0.7);
|
outline: 2px solid hsl(var(--accent-color) / 0.7);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ type CommandMap = {
|
|||||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||||
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
|
||||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down'; targetIndex?: number }; result: string[] };
|
||||||
remove_from_queue: { args: { id: string }; result: boolean };
|
remove_from_queue: { args: { id: string }; result: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1575,6 +1575,52 @@ describe('useDownloadStore', () => {
|
|||||||
expect(new Set(positions).size).toBe(4);
|
expect(new Set(positions).size).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates a drag target around staged rows before the atomic backend move', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'a', status: 'queued', queueId: 'drag-queue', queuePosition: 0 },
|
||||||
|
{ id: 'staged', status: 'staged', queueId: 'drag-queue', queuePosition: 1 },
|
||||||
|
{ id: 'b', status: 'queued', queueId: 'drag-queue', queuePosition: 2 },
|
||||||
|
{ id: 'c', status: 'queued', queueId: 'drag-queue', queuePosition: 3 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set(['a', 'b', 'c']),
|
||||||
|
pendingOrder: ['a', 'b', 'c']
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||||
|
if (command === 'move_many_in_queue') return ['c', 'a', 'b'];
|
||||||
|
if (command === 'get_pending_order') return ['c', 'a', 'b'];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().moveManyInQueueToPosition('c', 'drag-queue', 0);
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
|
||||||
|
ids: ['c'],
|
||||||
|
queueId: 'drag-queue',
|
||||||
|
direction: 'up',
|
||||||
|
targetIndex: 0
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads.map(item => item.id).sort()).toEqual(['a', 'b', 'c', 'staged']);
|
||||||
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'c')?.queuePosition).toBe(0);
|
||||||
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back a failed drag move and rejects so the UI can report the failure', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'first', status: 'queued', queueId: 'rollback-queue', queuePosition: 0 },
|
||||||
|
{ id: 'second', status: 'queued', queueId: 'rollback-queue', queuePosition: 1 }
|
||||||
|
] as any[],
|
||||||
|
backendRegisteredIds: new Set(['first', 'second'])
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useDownloadStore.getState().moveManyInQueueToPosition('second', 'rollback-queue', 0)
|
||||||
|
).rejects.toThrow('backend unavailable');
|
||||||
|
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
import { updateDockBadge } from '../utils/dockBadge';
|
import { updateDockBadge } from '../utils/dockBadge';
|
||||||
|
import {
|
||||||
|
moveSelectedBlockToIndex,
|
||||||
|
targetIndexForDesiredOrder
|
||||||
|
} from '../utils/queueOrdering';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
|
||||||
export type { DownloadCategory } from '../utils/downloads';
|
export type { DownloadCategory } from '../utils/downloads';
|
||||||
@@ -659,6 +663,7 @@ interface DownloadState {
|
|||||||
unregisterBackendIds: (ids: string[]) => void;
|
unregisterBackendIds: (ids: string[]) => void;
|
||||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||||
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
||||||
|
moveManyInQueueToPosition: (ids: string | string[], queueId: string, targetIndex: number) => Promise<void>;
|
||||||
removeFromQueue: (id: string) => Promise<void>;
|
removeFromQueue: (id: string) => Promise<void>;
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
pendingAddUrls: string;
|
pendingAddUrls: string;
|
||||||
@@ -904,6 +909,84 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
queueReorderPromises.set(queueId, trackedOperation);
|
queueReorderPromises.set(queueId, trackedOperation);
|
||||||
return trackedOperation;
|
return trackedOperation;
|
||||||
},
|
},
|
||||||
|
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex) => {
|
||||||
|
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||||
|
if (ids.length === 0) return Promise.resolve();
|
||||||
|
|
||||||
|
// Dragging must be one serialized, atomic queue operation. This prevents
|
||||||
|
// a second drag or a keyboard move from calculating against stale order
|
||||||
|
// while the backend is still applying the first drop.
|
||||||
|
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
|
||||||
|
const operation = previousOperation.catch(() => undefined).then(async () => {
|
||||||
|
const allDownloads = get().downloads;
|
||||||
|
const queueItems = queueItemsForReordering(allDownloads, queueId);
|
||||||
|
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||||
|
if (selectedItems.length === 0) return;
|
||||||
|
|
||||||
|
const selectedIds = new Set(selectedItems.map(item => item.id));
|
||||||
|
const previousPositions = new Map([
|
||||||
|
...activeQueueItems(allDownloads, queueId),
|
||||||
|
...queueItems
|
||||||
|
].map(item => [item.id, item.queuePosition]));
|
||||||
|
const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, targetIndex);
|
||||||
|
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
|
||||||
|
|
||||||
|
const registeredIdsToMove = selectedItems
|
||||||
|
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||||
|
.map(item => item.id);
|
||||||
|
if (registeredIdsToMove.length === 0) return;
|
||||||
|
|
||||||
|
// Staged rows are deliberately not registered with the backend. Convert
|
||||||
|
// the desired local order to a registered-only target before IPC so a
|
||||||
|
// staged row never shifts the backend insertion index.
|
||||||
|
const registeredItems = queueItems.filter(item => get().backendRegisteredIds.has(item.id));
|
||||||
|
const registeredSelectedIds = new Set(registeredIdsToMove);
|
||||||
|
const registeredDesiredOrder = reordered.filter(item => get().backendRegisteredIds.has(item.id));
|
||||||
|
const backendTargetIndex = targetIndexForDesiredOrder(
|
||||||
|
registeredItems,
|
||||||
|
registeredSelectedIds,
|
||||||
|
registeredDesiredOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await invoke('move_many_in_queue', {
|
||||||
|
ids: registeredIdsToMove,
|
||||||
|
queueId,
|
||||||
|
direction: 'up',
|
||||||
|
targetIndex: backendTargetIndex
|
||||||
|
}) as string[];
|
||||||
|
if (Array.isArray(order)) {
|
||||||
|
const globalOrder = await invoke('get_pending_order', { queueId: null })
|
||||||
|
.catch(() => null) as string[] | null;
|
||||||
|
set(state => ({
|
||||||
|
pendingOrder: Array.isArray(globalOrder)
|
||||||
|
? globalOrder
|
||||||
|
: [
|
||||||
|
...state.pendingOrder.filter(id => !order.includes(id)),
|
||||||
|
...order
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to move queue block to position:", error);
|
||||||
|
// The backend operation is atomic. Restore only queue positions so a
|
||||||
|
// progress/state event received while the RPC was in flight survives.
|
||||||
|
set(state => ({
|
||||||
|
downloads: state.downloads.map(download => previousPositions.has(download.id)
|
||||||
|
? { ...download, queuePosition: previousPositions.get(download.id) }
|
||||||
|
: download)
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const trackedOperation = operation.finally(() => {
|
||||||
|
if (queueReorderPromises.get(queueId) === trackedOperation) {
|
||||||
|
queueReorderPromises.delete(queueId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
queueReorderPromises.set(queueId, trackedOperation);
|
||||||
|
return trackedOperation;
|
||||||
|
},
|
||||||
removeFromQueue: async (id) => {
|
removeFromQueue: async (id) => {
|
||||||
try {
|
try {
|
||||||
await invoke('remove_from_queue', { id });
|
await invoke('remove_from_queue', { id });
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
|
commonMediaQualitiesForRows,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
|
mediaQualityForRow,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
isYouTubePlaylistUrl,
|
isYouTubePlaylistUrl,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
|
selectExactMediaQuality,
|
||||||
updateRowIfCurrent,
|
updateRowIfCurrent,
|
||||||
type AddDownloadDraftRow
|
type AddDownloadDraftRow
|
||||||
} from './addDownloadMetadata';
|
} from './addDownloadMetadata';
|
||||||
@@ -539,6 +542,52 @@ describe('add download metadata workflow', () => {
|
|||||||
])).toContain('Correct or remove 1 invalid URL');
|
])).toContain('Correct or remove 1 invalid URL');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers only qualities shared by every selected ready media row', () => {
|
||||||
|
const formats = (qualities: string[]) => qualities.map((quality, index) => ({
|
||||||
|
name: `${quality} MP4`,
|
||||||
|
quality,
|
||||||
|
selector: `${quality}-${index}`,
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: quality,
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}));
|
||||||
|
const rows = [
|
||||||
|
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p']), selectedFormat: 0 }),
|
||||||
|
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p']), selectedFormat: 0 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(commonMediaQualitiesForRows(rows)).toEqual(['720p']);
|
||||||
|
expect(mediaQualityForRow(rows[0])).toBe('1080p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
|
||||||
|
const mediaRow = row({
|
||||||
|
id: 'media',
|
||||||
|
file: 'clip.mp4',
|
||||||
|
isMedia: true,
|
||||||
|
formats: [{
|
||||||
|
name: '1080p MP4',
|
||||||
|
quality: '1080p',
|
||||||
|
selector: '1080',
|
||||||
|
ext: 'mp4',
|
||||||
|
formatLabel: '1080p',
|
||||||
|
detail: '10 MB',
|
||||||
|
type: 'Video',
|
||||||
|
bytes: 10
|
||||||
|
}],
|
||||||
|
selectedFormat: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const unchanged = selectExactMediaQuality([mediaRow], ['media'], '720p');
|
||||||
|
expect(unchanged[0]).toBe(mediaRow);
|
||||||
|
expect(selectExactMediaQuality([mediaRow], ['media'], '1080p')[0]).toMatchObject({
|
||||||
|
selectedFormat: 0,
|
||||||
|
file: 'clip.mp4'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
|
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
|
||||||
const originalLanguage = i18n.language;
|
const originalLanguage = i18n.language;
|
||||||
const twoReadyRows = [row(), row({ id: 'row-2' })];
|
const twoReadyRows = [row(), row({ id: 'row-2' })];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
|||||||
|
|
||||||
export interface AddMediaFormat {
|
export interface AddMediaFormat {
|
||||||
name: string;
|
name: string;
|
||||||
|
quality?: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
ext: string;
|
ext: string;
|
||||||
formatLabel: string;
|
formatLabel: string;
|
||||||
@@ -331,6 +332,73 @@ export const mediaFormatSelectorForRow = (
|
|||||||
return row.formats?.[row.selectedFormat]?.selector;
|
return row.formats?.[row.selectedFormat]?.selector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeMediaQualityLabel = (value: string | undefined): string => {
|
||||||
|
const normalized = value
|
||||||
|
?.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized && normalized.length <= 48 ? normalized : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaQualityForFormat = (
|
||||||
|
format: Pick<AddMediaFormat, 'quality' | 'name' | 'type' | 'formatLabel' | 'ext'>
|
||||||
|
): string => {
|
||||||
|
const explicitQuality = normalizeMediaQualityLabel(format.quality);
|
||||||
|
if (explicitQuality) return explicitQuality;
|
||||||
|
|
||||||
|
const nameQuality = normalizeMediaQualityLabel(format.name.trim().split(/\s+/)[0]);
|
||||||
|
if (nameQuality) return nameQuality;
|
||||||
|
|
||||||
|
const label = normalizeMediaQualityLabel(format.formatLabel);
|
||||||
|
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaQualityForRow = (
|
||||||
|
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
|
||||||
|
): string | undefined => {
|
||||||
|
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) return undefined;
|
||||||
|
const format = row.formats?.[row.selectedFormat];
|
||||||
|
return format ? mediaQualityForFormat(format) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonMediaQualitiesForRows = (
|
||||||
|
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>
|
||||||
|
): string[] => {
|
||||||
|
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
||||||
|
if (readyMediaRows.length < 2) return [];
|
||||||
|
|
||||||
|
const firstQualities = Array.from(new Set(
|
||||||
|
readyMediaRows[0].formats!.map(mediaQualityForFormat)
|
||||||
|
));
|
||||||
|
return firstQualities.filter(quality => readyMediaRows.every(row =>
|
||||||
|
row.formats!.some(format => mediaQualityForFormat(format) === quality)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectExactMediaQuality = (
|
||||||
|
rows: AddDownloadDraftRow[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
quality: string
|
||||||
|
): AddDownloadDraftRow[] => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
return rows.map(row => {
|
||||||
|
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
|
||||||
|
const selectedFormat = row.formats.findIndex(format => mediaQualityForFormat(format) === quality);
|
||||||
|
if (selectedFormat === -1) return row;
|
||||||
|
const format = row.formats[selectedFormat];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
selectedFormat,
|
||||||
|
size: format.bytes ? format.detail : undefined,
|
||||||
|
sizeBytes: format.bytes || undefined,
|
||||||
|
file: mediaFileNameForSelectedFormat(row.file, {
|
||||||
|
formats: row.formats,
|
||||||
|
selectedFormat
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const mediaFileNameForSelectedFormat = (
|
export const mediaFileNameForSelectedFormat = (
|
||||||
fileName: string,
|
fileName: string,
|
||||||
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
moveSelectedBlockToIndex,
|
||||||
|
targetIndexForBoundary,
|
||||||
|
targetIndexForDesiredOrder
|
||||||
|
} from './queueOrdering';
|
||||||
|
|
||||||
|
const items = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||||
|
|
||||||
|
describe('queue ordering', () => {
|
||||||
|
it('moves discontiguous selection as one block', () => {
|
||||||
|
expect(moveSelectedBlockToIndex(items, ['b', 'd'], 1).map(item => item.id))
|
||||||
|
.toEqual(['a', 'b', 'd', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates pointer boundaries after selected rows are removed', () => {
|
||||||
|
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
|
||||||
|
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a registered-only backend target from the desired local order', () => {
|
||||||
|
const current = [{ id: 'a' }, { id: 'staged' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
const desired = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'staged' }];
|
||||||
|
expect(targetIndexForDesiredOrder(current, ['c'], desired)).toBe(2);
|
||||||
|
|
||||||
|
expect(targetIndexForDesiredOrder(
|
||||||
|
[{ id: 'a' }, { id: 'c' }],
|
||||||
|
['c'],
|
||||||
|
[{ id: 'a' }, { id: 'staged' }, { id: 'c' }]
|
||||||
|
)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface QueueOrderItem {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the selected rows as one block into an insertion position among the
|
||||||
|
* unselected rows. The position is intentionally defined after selection is
|
||||||
|
* removed so callers can use the same semantics for UI and backend queues.
|
||||||
|
*/
|
||||||
|
export const moveSelectedBlockToIndex = <T extends QueueOrderItem>(
|
||||||
|
items: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
targetIndex: number
|
||||||
|
): T[] => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const selectedItems = items.filter(item => selected.has(item.id));
|
||||||
|
const unselectedItems = items.filter(item => !selected.has(item.id));
|
||||||
|
const insertionIndex = Math.max(0, Math.min(targetIndex, unselectedItems.length));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...unselectedItems.slice(0, insertionIndex),
|
||||||
|
...selectedItems,
|
||||||
|
...unselectedItems.slice(insertionIndex)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a pointer boundary in the original list to the insertion index
|
||||||
|
* used by moveSelectedBlockToIndex. A boundary is between rows and ranges
|
||||||
|
* from 0 (before the first row) through items.length (after the last row).
|
||||||
|
*/
|
||||||
|
export const targetIndexForBoundary = <T extends QueueOrderItem>(
|
||||||
|
items: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
boundaryIndex: number
|
||||||
|
): number => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const boundary = Math.max(0, Math.min(boundaryIndex, items.length));
|
||||||
|
return items
|
||||||
|
.slice(0, boundary)
|
||||||
|
.reduce((count, item) => count + (selected.has(item.id) ? 0 : 1), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a desired local order to the backend's registered-only queue.
|
||||||
|
* Staged rows are not registered with the backend, so they must not affect
|
||||||
|
* the target index sent over IPC.
|
||||||
|
*/
|
||||||
|
export const targetIndexForDesiredOrder = <T extends QueueOrderItem>(
|
||||||
|
currentItems: T[],
|
||||||
|
selectedIds: ReadonlySet<string> | readonly string[],
|
||||||
|
desiredItems: T[]
|
||||||
|
): number => {
|
||||||
|
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
|
||||||
|
const currentIds = new Set(currentItems.map(item => item.id));
|
||||||
|
const firstSelectedIndex = desiredItems.findIndex(item => selected.has(item.id));
|
||||||
|
if (firstSelectedIndex === -1) return currentItems.filter(item => !selected.has(item.id)).length;
|
||||||
|
return desiredItems
|
||||||
|
.slice(0, firstSelectedIndex)
|
||||||
|
.reduce((count, item) => count + (selected.has(item.id) || !currentIds.has(item.id) ? 0 : 1), 0);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user