Compare commits

...

42 Commits

Author SHA1 Message Date
NimBold d895fb1b73 docs(readme): restore project icons 2026-07-26 19:58:15 +03:30
NimBold 5372a76eda chore(deps): update Firelink packages and FFmpeg sources 2026-07-26 19:53:14 +03:30
NimBold 54693456b1 docs(readme): update installation badges for Firefox and Chromium 2026-07-26 19:32:56 +03:30
NimBold edc0c1c6ad docs(readme): restore Firefox install badge
Remove maintainer-focused wording, add the Firefox Add-ons pill, and update the paired extension pointer.
2026-07-26 19:29:14 +03:30
NimBold c10b889a41 docs(readme): refresh project documentation
Clarify Firelink status, installation, development, support, credits, and the paired Companion revision.
2026-07-26 19:05:45 +03:30
NimBold 530324ab9c fix(i18n): refine download action translations
Align Persian verb forms and Chinese/Russian action labels with established IDM and FDM terminology references.
2026-07-26 18:51:59 +03:30
NimBold 240055497d fix(queue): preserve lifecycle ownership during recovery
Require queue-permit ownership before resuming a paused GID and rebuild a job when its GID disappears during recovery.

Advance the Browser companion with the automatic-capture handoff guard for #28.
2026-07-26 18:10:04 +03:30
NimBold 7787f4e6c0 test(queue): synchronize dispatcher integration assertion 2026-07-26 03:22:07 +03:30
NimBold cf09bfa2e4 fix(downloads): harden power management and aria2 recovery 2026-07-26 03:04:50 +03:30
NimBold 931e1f2f1e fix(queue): preserve aria2 connection pool 2026-07-25 17:54:28 +03:30
NimBold 956b70dc10 fix(extension): include capture recovery fix (#28)
Advance the Browser companion to bd3c7e0, which keeps disabled or malformed Firefox captures untouched and recovers pending captures safely during startup.
2026-07-25 16:50:04 +03:30
NimBold dd7c2196fe fix(queue): dispatch admitted downloads concurrently 2026-07-25 16:50:00 +03:30
NimBold d62e0429a4 fix(downloads): animate table column reordering 2026-07-25 16:23:07 +03:30
NimBold f3af5b0584 chore(deps): refresh application dependencies and engines 2026-07-25 15:35:54 +03:30
NimBold 5614022dbd fix(downloads): bound connection recovery churn
Require a healthy multi-connection baseline, cap consecutive automatic recovery attempts, and preserve zero-progress recovery. Align Download Properties speed-cap controls with shared app styles.
2026-07-24 06:57:26 +03:30
NimBold b1659fa3d0 fix(ui): harden download table layout (#32)
Refs #32
2026-07-24 04:29:27 +03:30
NimBold 9a40b30a0f fix(add-modal): harden playlist media selection 2026-07-24 03:34:42 +03:30
NimBold 4f89a6c79d fix(i18n): keep font family names consistent
Keep bundled font names untranslated across every supported locale while preserving localized descriptions.

Refs #31
2026-07-23 19:55:12 +03:30
NimBold f5d918ec1e fix(settings): keep bundled font labels compact
Use canonical font names across locales, align system-mode descriptions, and document the bundled OFL license text.

Refs #31
2026-07-23 17:38:33 +03:30
NimBold b579c35e42 feat(settings): add curated multilingual fonts
Bundle Vazirmatn, Noto Sans Hebrew, Noto Sans SC, and Roboto with locale-aware fallbacks while keeping custom font import out of scope.

Refs #31
2026-07-23 17:29:29 +03:30
NimBold 1f1ec828f9 feat(settings): add locale-aware font preferences
Bundle optional Inter and Outfit families, preserve the platform system default, and provide script-aware fallbacks for every supported locale.

Refs #31
2026-07-23 17:13:00 +03:30
NimBold 228db1eaa2 fix(settings): improve look and feel organization
Prevent localized select labels from being clipped and order appearance settings by scope and frequency.

Refs #31
2026-07-23 16:54:53 +03:30
NimBold 9a790073f5 fix(ui): align all window control styles
Lower macOS and minimal controls so every preset shares the same titlebar centerline.

Refs #31
2026-07-23 16:44:01 +03:30
NimBold f3eff02069 fix(ui): align window control vertical spacing
Lower GNOME and minimal controls to the same titlebar baseline as Windows 11.

Refs #31
2026-07-23 16:42:53 +03:30
NimBold 4374219685 fix(ui): refine Windows window controls
Place the close action at the outer sidebar corner, round the caption hit regions, and align the controls with the titlebar content.

Refs #31
2026-07-23 16:40:09 +03:30
NimBold 4e3ceb21a5 feat(ui): add cross-platform window control styles
Persist automatic and explicit macOS, Windows, GNOME, and minimal control presets with localized settings and platform-aware fallback.

Refs #31
2026-07-23 16:25:39 +03:30
NimBold 6adbccdf43 fix(ui): mirror window controls for right sidebar
Match native macOS traffic-light spacing and reference the related UI enhancement.

Refs #31
2026-07-23 16:10:48 +03:30
NimBold 454fad712b fix(modals): import AddDownloadsModal and adjust modal backdrop animation 2026-07-23 15:25:34 +03:30
NimBold 7f450a802f fix(i18n): update queue reorder hint to include hotkey usage 2026-07-23 14:50:06 +03:30
NimBold f2b1fb9d4a fix(downloads): restore smooth queue reorder animations 2026-07-23 14:40:53 +03:30
NimBold 9302911ac7 fix(downloads): harden queue drag and reorder behavior 2026-07-23 07:40:02 +03:30
NimBold 18e9200b74 fix(downloads): restore queue reorder interactions 2026-07-23 06:45:49 +03:30
NimBold 3559c69968 fix(downloads): harden queue reordering and summary stats 2026-07-23 06:23:04 +03:30
NimBold 3587fb0c0d fix(downloads): harden queue admission and live speed controls 2026-07-23 04:13:07 +03:30
NimBold b60818d3af feat(downloads): add live aria2 speed limits 2026-07-23 03:21:34 +03:30
NimBold 5463d27ed3 feat(settings): add opt-in calendar localization 2026-07-23 03:08:52 +03:30
NimBold 3f298715a6 fix(downloads): avoid quadratic queue drag lookups 2026-07-23 03:02:22 +03:30
NimBold ceab8a5fdf feat(downloads): add media quality and queue reordering 2026-07-23 02:55:33 +03:30
NimBold f4e5e211cc feat(downloads): add details controls and aggregate summaries 2026-07-23 02:31:34 +03:30
NimBold 9d737598c6 fix(macos): handle Cmd+M minimization (#30) 2026-07-22 19:02:17 +03:30
NimBold d3e5e8c208 fix(macos): restore native window lifecycle (#30)
Install the native macOS application menu for Command-M, handle Dock reopen events only when no window is visible, and preserve early secondary-launch restores until the main window exists. Refs #30.
2026-07-22 18:34:56 +03:30
NimBold 17eb8e5d39 docs(release): link reported issues 2026-07-22 17:48:27 +03:30
62 changed files with 7804 additions and 690 deletions
+5 -5
View File
@@ -11,20 +11,20 @@ This release makes everyday download management easier to organize, review, and
### New features ### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, including RTL layouts. These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve. - **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, responding to the Chinese-language request in [#17](https://github.com/nimbold/Firelink/issues/17). These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions. - **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default. - **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies. - **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies, addressing [#23](https://github.com/nimbold/Firelink/issues/23).
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. - **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. This also follows the connection-setting report in [#20](https://github.com/nimbold/Firelink/issues/20).
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery. - **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements ### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late. - Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, while reducing the chance of stale state creating duplicate or misleading rows. - Reuse unfinished downloads when their filenames match, addressing [#26](https://github.com/nimbold/Firelink/issues/26), while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement. - Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable. - Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path. - Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path, addressing the media reports in [#22](https://github.com/nimbold/Firelink/issues/22) and [#24](https://github.com/nimbold/Firelink/issues/24).
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable. - Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity. - Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels. - Refine localized wording, including Persian status labels.
+77 -79
View File
@@ -1,107 +1,89 @@
<div align="center"> <p align="center">
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" /> <img src="src/assets/app-icon.png" alt="Firelink app icon" width="128" height="128" />
</p>
# Firelink # Firelink
**A fast, focused desktop download manager for macOS, Windows, and Linux.** > A fast, focused desktop download manager for macOS, Windows, and Linux.
[![Version](https://img.shields.io/badge/version-1.2.0-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases) [![Latest release](https://img.shields.io/github/v/release/nimbold/Firelink?style=flat-square)](https://github.com/nimbold/Firelink/releases/latest)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#supported-platforms) [![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[![Windows](.github/badges/windows.svg)](#supported-platforms) [![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#supported-platforms) [![macOS](https://img.shields.io/badge/macOS-Apple%20silicon-111111?style=flat-square&logo=apple&logoColor=white)](#installation)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE) [![Windows](.github/badges/windows.svg)](#installation)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml) [![Linux](https://img.shields.io/badge/Linux-x64-FCC624?style=flat-square&logo=linux&logoColor=black)](#installation)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Changelog](CHANGELOG.md) <p align="center">
</div>
<br/>
<div align="center">
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" /> <img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" /> <img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" /> <img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" /> <img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
</p>
<details> ## What is Firelink?
<summary><b>View more screenshots</b></summary>
<br/>
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
</details>
</div>
## Overview Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
Firelink manages direct downloads, browser captures, media extraction, playlists, scheduling, and file placement from one desktop app. It uses a Rust/Tauri backend with a React and TypeScript interface, and bundles the engines needed for its download and media workflows. It uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
The current desktop release is **1.2.0**, paired with [Firelink Companion 2.0.6](https://github.com/nimbold/Firelink-Extension/releases). The release focuses on a more dependable download table and queue, better browser handoffs, broader localization, and safer packaged builds. ## Status
Firelink `1.2.0` is the latest desktop release.
It is paired with [Firelink Companion `2.0.6`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.0.6).
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian. Translation corrections are welcome.
## Features ## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls. - Segmented HTTP and HTTPS downloads with retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA. - Media and playlist downloads through yt-dlp, FFmpeg, and Deno.
- **Playlist downloads** for YouTube playlists with queueing and efficient rendering for large lists. - An Add window for metadata, duplicate handling, save locations, and download options.
- **Customizable download table** with selectable columns, sorting, drag-to-reorder, and bulk actions. - Persistent queues with pause, resume, retry, redownload, and scheduling.
- **Add window** for metadata, duplicate handling, captured links, clipboard-prefilled URLs, save locations, and live connection limits. - A customizable download table with sorting, column selection, reordering, and bulk actions.
- **Persistent queues** with pause, resume, retry, redownload, scheduling, and multi-select actions. - File organization with categories, default folders, and per-download locations.
- **Optional batch folders** for grouping related multi-link downloads in a new, editable folder. - Browser handoff through local pairing and signed requests.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions. - Tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Browser handoff** through local pairing, signed requests, Add-window review, replay protection, and server checks. - RTL support for Hebrew and Persian.
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Localization** for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian, with RTL support. Non-English translations are LLM-assisted and welcome user review.
- **Diagnostics** with engine health checks, structured logs, and package verification.
## Installation ## Installation
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases). Download the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
| Platform | Package | Notes | | Platform | Package | Notes |
| --- | --- | --- | | --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. | | macOS Apple silicon | `.dmg` | Ad-hoc signed and not notarized. macOS may require approval in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. | | Windows x64 | NSIS `.exe` | Unsigned. Windows SmartScreen may display a warning. |
| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. See the notes below. | | Windows x64 portable | `.zip` | Extract to a writable folder and launch `firelink.exe`. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage as the self-contained option. | | Linux x64 | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage. |
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. You do not need to install those engines separately. All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separate engine installation is required.
<details> <details>
<summary><strong>Windows portable ZIP notes</strong></summary> <summary><strong>Windows portable ZIP notes</strong></summary>
The portable ZIP is a secondary distribution. Extract it to a writable folder and launch `firelink.exe`: - Keep the folder writable. Avoid `Program Files` and read-only media.
- Settings, queues, logs, and WebView data are stored beside the executable in `data/`.
- Keep it out of `Program Files`, read-only media, and folders that block SQLite or WebView writes. - Close Firelink before moving or copying the folder.
- Settings, queues, logs, and WebView data stay beside the executable under `data/`. - Treat the folder as sensitive because it contains the extension pairing credential.
- Close Firelink before moving or copying the folder, and close the installed app before launching the portable copy.
- Credentials, browser cookies, and URL query or fragment data are not saved in portable queue records.
- Saved site passwords remain in the Windows credential store and are not copied into the archive.
- The folder contains the extension pairing credential. Treat it as sensitive and do not share it.
- Saved absolute download locations may need to be selected again after moving the folder to another drive.
- The installer remains the supported path for `firelink://` browser launch registration. - The installer remains the supported path for `firelink://` browser launch registration.
</details> </details>
## Browser Extension ## Browser integration
[Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink. Captured downloads always open Firelink's Add window so you can review them before starting or queuing them. [Firelink Companion](https://github.com/nimbold/Firelink-Extension) connects browser downloads, links, and media pages to Firelink.
It provides automatic capture for regular downloads, explicit **Fetch media** actions, link and selected-text context menus, Firefox and Chromium support, signed local requests, and a safe browser fallback when Firelink is unavailable. Captured links open Firelink's Add window for review before they are started or queued.
<p align="center"> <p>
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a> <a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install from Firefox Add-ons" /></a>&nbsp;&nbsp;
&nbsp;&nbsp; <a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Manual install for Chromium browsers" /></a>
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p> </p>
Install the extension, open Firelink, and pair it from **Settings -> Integrations**. Use the latest [Firelink Companion 2.0.6 release](https://github.com/nimbold/Firelink-Extension/releases) with Firelink 1.2.0. Chromium users can load `firelink-chromium.zip` by following the [manual installation instructions](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation). See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
## Supported Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported with a native build, bundled-engine checks, launch smoke test, and ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported with a native build, engine checks, installer smoke test, NSIS installer, and portable ZIP. |
| **Linux x64** | Supported with native builds, bundled-engine checks, package/AppImage smoke tests, `.deb`, `.rpm`, and AppImage packages. |
## Development ## Development
@@ -112,34 +94,50 @@ Install the extension, open Firelink, and pair it from **Settings -> Integration
- Rust and Cargo - Rust and Cargo
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/) - [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
Clone the repository with its browser-extension submodule: ### Quick start
```sh ```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink cd Firelink
npm install npm install
npm run tauri dev
``` ```
Launch the app with `npm run tauri dev`. Run the core checks with: Run the main checks with:
```sh ```sh
node --test scripts/*.node-test.js node --test scripts/*.node-test.js
npm test -- --run npm test -- --run
npm run build npm run build
cd src-tauri cd src-tauri && cargo test --all-targets
cargo test --all-targets
``` ```
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification. See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
## Help and Project Status ## Contributing and support
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues). - Report bugs and request features in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Open focused pull requests with tests for behavior changes.
- Report translation corrections in an issue or pull request.
- Read [CHANGELOG.md](CHANGELOG.md) for release history. - Read [CHANGELOG.md](CHANGELOG.md) for release history.
- The project is actively maintained, but macOS builds are not notarized and Windows installers are not code-signed yet.
## Technology and License ## Credits
Firelink is built with [Tauri 2](https://tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/), [SQLite](https://www.sqlite.org/), [aria2](https://aria2.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [FFmpeg](https://ffmpeg.org/), and [Deno](https://deno.com/). Core technologies:
Firelink is available under the [MIT License](LICENSE). - [Tauri 2](https://v2.tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/)
- [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/)
- [SQLite](https://www.sqlite.org/)
Bundled engines:
- [aria2](https://aria2.github.io/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [FFmpeg](https://ffmpeg.org/)
- [Deno](https://deno.com/)
Font licenses, engine versions, source references, and third-party notices are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## License
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
+17
View File
@@ -4,6 +4,23 @@ Firelink distributes separate executable tools. Firelink's MIT license does not
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`. Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
## Bundled fonts
Firelink bundles variable WOFF2 web fonts through Fontsource. Each font is
licensed under the SIL Open Font License, Version 1.1. The corresponding
license and author information are available from the linked project sources.
- Inter: <https://github.com/rsms/inter>
- Outfit: <https://github.com/Outfitio/Outfit-Fonts>
- Roboto: <https://github.com/googlefonts/roboto-classic>
- Vazirmatn: <https://github.com/rastikerdar/vazirmatn>
- Noto Sans Hebrew: <https://github.com/notofonts/hebrew>
- Noto Sans SC: <https://github.com/notofonts/noto-cjk>
Fontsource distribution: <https://github.com/fontsource/font-files>
License text: <https://openfontlicense.org/open-font-license-official-text/>
## aria2 ## aria2
- Project: <https://aria2.github.io/> - Project: <https://aria2.github.io/>
+12 -12
View File
@@ -8,14 +8,14 @@
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152" "sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
}, },
"deno": { "deno": {
"version": "2.9.3", "version": "2.9.4",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-pc-windows-msvc.zip", "url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-pc-windows-msvc.zip",
"sha256": "60343461ac5fe3a31f4ef12667f2946bb852e20655c8610aeb7e751e87f7df3a" "sha256": "68ed08b05c56cf887e9aa509947dc3f468f7e12f47a13e5c1abd51d46d1453ef"
}, },
"ffmpeg": { "ffmpeg": {
"version": "8.1.2-29-g703dcc25b9", "version": "8.1.2-31-g8c9502e9b0",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-gpl-8.1.zip", "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-win64-gpl-8.1.zip",
"sha256": "ebf57e8b1a10b176b88c3cbc66e68a4aed472cf47520b0fbf003e892fb3be642" "sha256": "2b6a26a3943892dc61ea62545253efc1495580f67346285a6b12713d70c1a2ff"
}, },
"aria2c": { "aria2c": {
"version": "1.37.0", "version": "1.37.0",
@@ -30,14 +30,14 @@
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b" "sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
}, },
"deno": { "deno": {
"version": "2.9.3", "version": "2.9.4",
"url": "https://github.com/denoland/deno/releases/download/v2.9.3/deno-x86_64-unknown-linux-gnu.zip", "url": "https://github.com/denoland/deno/releases/download/v2.9.4/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "8101865641cbede56f08ad19c0a67a87df84bce127fee0d3e3e1f7467717ffa6" "sha256": "c24f955d9fbfe0ea5ae2b501c8e71ae76e31e4c9782390a54a284b3364fda725"
}, },
"ffmpeg": { "ffmpeg": {
"version": "8.1.2-29-g703dcc25b9", "version": "8.1.2-31-g8c9502e9b0",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-linux64-gpl-8.1.tar.xz", "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-26-13-28/ffmpeg-n8.1.2-31-g8c9502e9b0-linux64-gpl-8.1.tar.xz",
"sha256": "c6c54589dd3443fb86b441351218d1f0e0cba8221134c3d2e7e3eda80e984747" "sha256": "00a4cce5118f477da5d6758630ecd70f71ac7601dca377d819b619613cb577a0"
}, },
"aria2c": { "aria2c": {
"version": "1.37.0", "version": "1.37.0",
+2 -2
View File
@@ -23,10 +23,10 @@
"sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034" "sha256": "f65b4ba782b25e9f4374765d421793c6613f692d8e50b2888079657793619034"
}, },
"deno": { "deno": {
"version": "2.9.3", "version": "2.9.4",
"source": "https://github.com/denoland/deno", "source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable", "build": "official aarch64-apple-darwin executable",
"sha256": "80c83cdfb20289f8818a71220b570df363f6f0ba93580c29c70f08ab14e93568" "sha256": "433088c827fa0e39ff162ab0e475f1fd4c7690eaedec500cf678edc3865e9287"
} }
}, },
"runtimeTrees": { "runtimeTrees": {
-3
View File
@@ -5,9 +5,6 @@
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" /> <link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firelink Download Manager</title> <title>Firelink Download Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
+80 -29
View File
@@ -9,6 +9,12 @@
"version": "1.2.0", "version": "1.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"@formkit/auto-animate": "^0.10.0", "@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
@@ -19,19 +25,19 @@
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.25.0", "lucide-react": "^1.27.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-i18next": "^17.0.10", "react-i18next": "^17.0.11",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8", "@types/react": "^19.2.17",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4", "autoprefixer": "^10.5.4",
"postcss": "^8.5.21", "postcss": "^8.5.23",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.1.5", "vite": "^8.1.5",
@@ -81,6 +87,60 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-hebrew": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-hebrew/-/noto-sans-hebrew-5.3.0.tgz",
"integrity": "sha512-jGIZ2EOCTtT1ZhLthz6nEpPXGMniSPI/0tv5CDFDZ7MPYyoj6FskW7WJ0YTyesFADdzjx7F2Pg5W1PWD6y9UFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
"integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/outfit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz",
"integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/roboto": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.3.0.tgz",
"integrity": "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/vazirmatn": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/vazirmatn/-/vazirmatn-5.3.0.tgz",
"integrity": "sha512-ZA68W8JHbMdNzzwjYHUWKB2PH22QpxNEnk0ukcXM+XiqYMV6ZWJZAKuNXcrMR2JjuIAwsfAU3KknCXPI6wXUvw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formkit/auto-animate": { "node_modules/@formkit/auto-animate": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.10.0.tgz",
@@ -1783,12 +1843,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/html-parse-stringify": { "node_modules/html-parse-stringify": {
"version": "3.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "funding": {
"void-elements": "3.1.0" "url": "https://locize.com"
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
@@ -2078,9 +2138,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.25.0", "version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2163,9 +2223,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.21", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2219,13 +2279,13 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.10", "version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1", "html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -2625,15 +2685,6 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+11 -5
View File
@@ -39,6 +39,12 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"@formkit/auto-animate": "^0.10.0", "@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
@@ -49,19 +55,19 @@
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.25.0", "lucide-react": "^1.27.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-i18next": "^17.0.10", "react-i18next": "^17.0.11",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8", "@types/react": "^19.2.17",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4", "autoprefixer": "^10.5.4",
"postcss": "^8.5.21", "postcss": "^8.5.23",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.1.5", "vite": "^8.1.5",
+15 -24
View File
@@ -566,9 +566,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.3.0" version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -1457,13 +1457,13 @@ dependencies = [
[[package]] [[package]]
name = "foreign-types-macros" name = "foreign-types-macros"
version = "0.2.3" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -3402,7 +3402,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"quick-xml 0.41.0", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -3557,15 +3557,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -3907,9 +3898,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.0" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [ dependencies = [
"zeroize", "zeroize",
] ]
@@ -5931,9 +5922,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-backend" name = "wayland-backend"
version = "0.3.15" version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
dependencies = [ dependencies = [
"cc", "cc",
"downcast-rs", "downcast-rs",
@@ -5944,9 +5935,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-client" name = "wayland-client"
version = "0.31.14" version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"rustix", "rustix",
@@ -5981,12 +5972,12 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.10" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml 0.39.4", "quick-xml",
"quote", "quote",
] ]
Binary file not shown.
+75
View File
@@ -70,6 +70,18 @@ pub struct Queue {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub is_main: bool, pub is_main: bool,
#[serde(default)]
#[ts(optional)]
pub max_concurrent: Option<usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct QueueConcurrencyConfig {
pub id: String,
#[serde(default)]
pub max_concurrent: Option<usize>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -121,6 +133,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>,
@@ -200,6 +214,59 @@ pub enum Theme {
Nord, Nord,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum FontFamily {
System,
Inter,
Outfit,
Vazirmatn,
NotoSansHebrew,
NotoSansSc,
Roboto,
Serif,
Monospace,
}
impl Default for FontFamily {
fn default() -> Self {
Self::System
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum WindowControlStyle {
Auto,
Macos,
Windows,
Gnome,
Minimal,
}
impl Default for WindowControlStyle {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum CalendarPreference {
Gregorian,
Persian,
Hebrew,
}
impl Default for CalendarPreference {
fn default() -> Self {
Self::Gregorian
}
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
@@ -265,6 +332,12 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings { pub struct PersistedSettings {
pub theme: Theme, pub theme: Theme,
#[serde(default)]
pub font_family: FontFamily,
#[serde(default)]
pub window_control_style: WindowControlStyle,
#[serde(default)]
pub calendar_preference: CalendarPreference,
#[serde(default = "default_language_preference")] #[serde(default = "default_language_preference")]
pub language: String, pub language: String,
pub base_download_folder: String, pub base_download_folder: String,
@@ -305,6 +378,8 @@ pub struct PersistedSettings {
pub ask_where_to_save_each_file: bool, pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool, pub remember_last_used_download_directory: bool,
pub prevents_sleep_while_downloading: bool, pub prevents_sleep_while_downloading: bool,
#[serde(default)]
pub prevents_display_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource, pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>, pub site_logins: Vec<SiteLogin>,
pub auto_check_updates: bool, pub auto_check_updates: bool,
+734 -170
View File
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
const ASSERTION_REASON: &str = "Firelink active download";
/// Owns the OS power assertions for the whole backend transfer lifecycle.
///
/// The frontend may request the policy, but it does not own the assertion:
/// queue permits are the source of truth for whether a transfer is actually
/// active. Keeping the two assertions separate also means display sleep can
/// be opted into without changing the system-sleep policy.
struct PowerState {
runtime_enabled: AtomicBool,
prevent_system_sleep: AtomicBool,
prevent_display_sleep: AtomicBool,
active_transfers: AtomicUsize,
}
enum PowerCommand {
Reconcile(Option<Sender<Result<(), String>>>),
}
struct PowerWorker {
sender: Option<Sender<PowerCommand>>,
join_handle: Option<JoinHandle<()>>,
}
impl PowerWorker {
fn start(state: Arc<PowerState>) -> Result<Self, String> {
let (sender, receiver) = mpsc::channel();
let join_handle = thread::Builder::new()
.name("firelink-power".to_string())
.spawn(move || run_power_worker(state, receiver))
.map_err(|error| format!("failed to start power-management worker: {error}"))?;
Ok(Self {
sender: Some(sender),
join_handle: Some(join_handle),
})
}
fn send(&self, command: PowerCommand) -> Result<(), String> {
self.sender
.as_ref()
.ok_or_else(|| "power-management worker is stopping".to_string())?
.send(command)
.map_err(|_| "power-management worker stopped unexpectedly".to_string())
}
}
impl Drop for PowerWorker {
fn drop(&mut self) {
// Closing the sender lets the worker drop its OS assertions on the
// same thread that created them. This matters on Windows, where
// SetThreadExecutionState is thread-local, and avoids calling the
// Linux keepawake D-Bus destructor from an arbitrary queue thread.
self.sender.take();
if let Some(join_handle) = self.join_handle.take() {
let _ = join_handle.join();
}
}
}
pub struct PowerManager {
state: Arc<PowerState>,
worker: Mutex<Option<PowerWorker>>,
}
impl PowerManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
state: Arc::new(PowerState {
runtime_enabled: AtomicBool::new(false),
prevent_system_sleep: AtomicBool::new(true),
prevent_display_sleep: AtomicBool::new(false),
active_transfers: AtomicUsize::new(0),
}),
worker: Mutex::new(None),
})
}
/// Enables OS integration after AppState has been registered. This keeps
/// QueueManager tests and pre-window startup work from creating real
/// assertions in the test process or during partial initialization.
pub fn activate(&self) -> Result<(), String> {
self.state.runtime_enabled.store(true, Ordering::Release);
self.reconcile()
}
pub fn set_preferences(
&self,
prevent_system_sleep: bool,
prevent_display_sleep: bool,
) -> Result<(), String> {
self.state
.prevent_system_sleep
.store(prevent_system_sleep, Ordering::Release);
self.state
.prevent_display_sleep
.store(prevent_display_sleep, Ordering::Release);
self.reconcile()
}
pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> {
self.state
.prevent_system_sleep
.store(enabled, Ordering::Release);
self.reconcile()
}
pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> {
self.state
.prevent_display_sleep
.store(enabled, Ordering::Release);
self.reconcile()
}
pub fn set_active_transfer_count(self: &Arc<Self>, count: usize) {
self.state.active_transfers.store(count, Ordering::Release);
if self.state.runtime_enabled.load(Ordering::Acquire) {
if let Err(error) = self.enqueue_reconcile() {
log::error!(
"power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}"
);
}
}
}
pub fn active_transfer_count(&self) -> usize {
self.state.active_transfers.load(Ordering::Acquire)
}
fn reconcile(&self) -> Result<(), String> {
if !self.state.runtime_enabled.load(Ordering::Acquire) {
return Ok(());
}
self.ensure_worker()?;
let (response_sender, response_receiver) = mpsc::channel();
if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) {
self.restart_worker();
return Err(error);
}
match response_receiver.recv() {
Ok(result) => result,
Err(_) => {
self.restart_worker();
Err("power-management worker stopped before reporting reconciliation".to_string())
}
}
}
fn enqueue_reconcile(&self) -> Result<(), String> {
self.ensure_worker()?;
if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) {
self.restart_worker();
return Err(error);
}
Ok(())
}
fn ensure_worker(&self) -> Result<(), String> {
let mut worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
if worker.is_none() {
*worker = Some(PowerWorker::start(Arc::clone(&self.state))?);
}
Ok(())
}
fn send_command(&self, command: PowerCommand) -> Result<(), String> {
let worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
worker
.as_ref()
.ok_or_else(|| "power-management worker is not running".to_string())?
.send(command)
}
fn restart_worker(&self) {
let mut worker = self
.worker
.lock()
.unwrap_or_else(|error| error.into_inner());
worker.take();
}
}
struct OsPowerAssertions {
#[cfg(windows)]
assertion: Option<keepawake::KeepAwake>,
#[cfg(not(windows))]
system: Option<keepawake::KeepAwake>,
#[cfg(not(windows))]
display: Option<keepawake::KeepAwake>,
system_enabled: bool,
display_enabled: bool,
}
impl OsPowerAssertions {
fn reconcile(&mut self, state: &PowerState) -> Result<(), String> {
let active = state.active_transfers.load(Ordering::Acquire);
let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire);
let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire);
#[cfg(windows)]
{
return self.reconcile_combined(desired_system, desired_display, active);
}
#[cfg(not(windows))]
self.reconcile_independent(desired_system, desired_display, active)
}
#[cfg(windows)]
fn reconcile_combined(
&mut self,
desired_system: bool,
desired_display: bool,
active: usize,
) -> Result<(), String> {
if self.system_enabled == desired_system && self.display_enabled == desired_display {
return Ok(());
}
if let Some(assertion) = self.assertion.take() {
drop_assertion(assertion, "combined");
if self.system_enabled {
log::info!("power: system-sleep prevention released");
}
if self.display_enabled {
log::info!("power: display-sleep prevention released");
}
self.system_enabled = false;
self.display_enabled = false;
}
if desired_system || desired_display {
let assertion = create_assertion(|| {
keepawake::Builder::default()
.display(desired_display)
.idle(desired_system)
.sleep(desired_system)
.reason(ASSERTION_REASON)
.create()
})
.map_err(|error| format!("failed to apply power prevention: {error}"))?;
self.assertion = Some(assertion);
self.system_enabled = desired_system;
self.display_enabled = desired_display;
if desired_system {
log::info!(
"power: system-sleep prevention enabled for {active} active transfer(s)"
);
}
if desired_display {
log::info!(
"power: display-sleep prevention enabled for {active} active transfer(s)"
);
}
}
Ok(())
}
#[cfg(not(windows))]
fn reconcile_independent(
&mut self,
desired_system: bool,
desired_display: bool,
active: usize,
) -> Result<(), String> {
let mut first_error = None;
if desired_system {
if self.system.is_none() {
match create_assertion(|| {
keepawake::Builder::default()
.idle(true)
.sleep(true)
.reason(ASSERTION_REASON)
.create()
}) {
Ok(assertion) => {
self.system = Some(assertion);
self.system_enabled = true;
log::info!(
"power: system-sleep prevention enabled for {active} active transfer(s)"
);
}
Err(error) => {
first_error = Some(format!("failed to prevent system sleep: {error}"))
}
}
}
} else if let Some(assertion) = self.system.take() {
drop_assertion(assertion, "system-sleep");
self.system_enabled = false;
log::info!("power: system-sleep prevention released");
}
if desired_display {
if self.display.is_none() {
match create_assertion(|| {
keepawake::Builder::default()
.display(true)
.reason(ASSERTION_REASON)
.create()
}) {
Ok(assertion) => {
self.display = Some(assertion);
self.display_enabled = true;
log::info!(
"power: display-sleep prevention enabled for {active} active transfer(s)"
);
}
Err(error) => {
let display_error = format!("failed to prevent display sleep: {error}");
if first_error.is_none() {
first_error = Some(display_error);
} else {
log::error!("power: {display_error}");
}
}
}
}
} else if let Some(assertion) = self.display.take() {
drop_assertion(assertion, "display-sleep");
self.display_enabled = false;
log::info!("power: display-sleep prevention released");
}
first_error.map_or(Ok(()), Err)
}
}
fn create_assertion(
create: impl FnOnce() -> keepawake::Result<keepawake::KeepAwake>,
) -> Result<keepawake::KeepAwake, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create))
.map_err(|_| "keepawake assertion creation panicked".to_string())?
.map_err(|error| error.to_string())
}
fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() {
log::error!("power: keepawake {kind} assertion cleanup panicked");
}
}
fn run_power_worker(state: Arc<PowerState>, receiver: Receiver<PowerCommand>) {
let mut assertions = OsPowerAssertions {
#[cfg(windows)]
assertion: None,
#[cfg(not(windows))]
system: None,
#[cfg(not(windows))]
display: None,
system_enabled: false,
display_enabled: false,
};
while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() {
let result = assertions.reconcile(&state);
if let Some(response) = response {
let _ = response.send(result);
} else if let Err(error) = result {
let active = state.active_transfers.load(Ordering::Acquire);
log::error!(
"power: failed to reconcile assertions for {active} active transfer(s): {error}"
);
}
}
#[cfg(windows)]
if let Some(assertion) = assertions.assertion.take() {
drop_assertion(assertion, "combined");
}
#[cfg(not(windows))]
{
if let Some(assertion) = assertions.display.take() {
drop_assertion(assertion, "display-sleep");
}
if let Some(assertion) = assertions.system.take() {
drop_assertion(assertion, "system-sleep");
}
}
}
#[cfg(test)]
mod tests {
use super::PowerManager;
#[test]
fn tracks_transfer_activity_before_runtime_activation() {
let manager = PowerManager::new();
manager.set_active_transfer_count(2);
assert_eq!(manager.active_transfer_count(), 2);
manager.set_active_transfer_count(0);
assert_eq!(manager.active_transfer_count(), 0);
}
#[test]
fn preference_changes_are_safe_before_runtime_activation() {
let manager = PowerManager::new();
assert!(manager.set_preferences(false, true).is_ok());
assert!(manager.set_system_prevention(true).is_ok());
assert!(manager.set_display_prevention(false).is_ok());
}
#[test]
fn runtime_worker_can_activate_without_active_transfers() {
let manager = PowerManager::new();
assert!(manager.activate().is_ok());
manager.set_active_transfer_count(0);
assert_eq!(manager.active_transfer_count(), 0);
}
}
+927 -64
View File
File diff suppressed because it is too large Load Diff
+75 -2
View File
@@ -1,6 +1,7 @@
use crate::ipc::{ use crate::ipc::{
AppFontSize, ListRowDensity, MediaCookieSource, PersistedSettings, PostQueueAction, ProxyMode, AppFontSize, CalendarPreference, FontFamily, ListRowDensity, MediaCookieSource,
SchedulerSettings, SettingsTab, Theme, PersistedSettings, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
WindowControlStyle,
}; };
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::HashMap; use std::collections::HashMap;
@@ -192,11 +193,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"theme", "theme",
&["system", "light", "dark", "dracula", "nord"], &["system", "light", "dark", "dracula", "nord"],
); );
sanitize_allowed_string(
state,
"fontFamily",
&[
"system",
"inter",
"outfit",
"vazirmatn",
"noto-sans-hebrew",
"noto-sans-sc",
"roboto",
"serif",
"monospace",
],
);
sanitize_allowed_string(
state,
"windowControlStyle",
&["auto", "macos", "windows", "gnome", "minimal"],
);
sanitize_allowed_string( sanitize_allowed_string(
state, state,
"language", "language",
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"], &["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
); );
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]); sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]); sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]); sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
@@ -416,6 +438,9 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings { fn default_settings() -> PersistedSettings {
PersistedSettings { PersistedSettings {
theme: Theme::System, theme: Theme::System,
font_family: FontFamily::System,
window_control_style: WindowControlStyle::Auto,
calendar_preference: CalendarPreference::Gregorian,
language: "system".to_string(), language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(), base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true, category_subfolders_enabled: true,
@@ -461,6 +486,7 @@ fn default_settings() -> PersistedSettings {
ask_where_to_save_each_file: false, ask_where_to_save_each_file: false,
remember_last_used_download_directory: false, remember_last_used_download_directory: false,
prevents_sleep_while_downloading: true, prevents_sleep_while_downloading: true,
prevents_display_sleep_while_downloading: false,
media_cookie_source: MediaCookieSource::default(), media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(), site_logins: Vec::new(),
auto_check_updates: true, auto_check_updates: true,
@@ -470,6 +496,7 @@ fn default_settings() -> PersistedSettings {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::ipc::{FontFamily, WindowControlStyle};
use super::{ use super::{
decode_stored_settings, default_settings, preserve_portable_pairing_token, decode_stored_settings, default_settings, preserve_portable_pairing_token,
preserve_scheduler_runtime_keys, preserve_scheduler_runtime_keys,
@@ -676,6 +703,47 @@ mod tests {
assert_eq!(settings.sidebar_position, "auto"); assert_eq!(settings.sidebar_position, "auto");
} }
#[test]
fn invalid_font_family_uses_system_font() {
let stored = json!({
"state": {"fontFamily": "comic-sans"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(matches!(settings.font_family, FontFamily::System));
}
#[test]
fn curated_font_families_are_preserved() {
for value in ["vazirmatn", "noto-sans-hebrew", "noto-sans-sc", "roboto"] {
let stored = json!({
"state": {"fontFamily": value},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(serde_json::to_value(settings.font_family).unwrap(), json!(value));
}
}
#[test]
fn invalid_window_control_style_uses_automatic_layout() {
let stored = json!({
"state": {"windowControlStyle": "neon"},
"version": 5
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(matches!(
settings.window_control_style,
WindowControlStyle::Auto
));
}
#[test] #[test]
fn clamps_out_of_range_download_settings() { fn clamps_out_of_range_download_settings() {
let stored = json!({ let stored = json!({
@@ -703,6 +771,7 @@ mod tests {
"perServerConnections": 5, "perServerConnections": 5,
"showNotifications": "yes", "showNotifications": "yes",
"theme": "not-a-theme", "theme": "not-a-theme",
"calendarPreference": "lunar",
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}] "siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
}, },
"version": 3 "version": 3
@@ -715,6 +784,10 @@ mod tests {
assert_eq!(settings.per_server_connections, 5); assert_eq!(settings.per_server_connections, 5);
assert!(settings.show_notifications); assert!(settings.show_notifications);
assert!(matches!(settings.theme, crate::ipc::Theme::System)); assert!(matches!(settings.theme, crate::ipc::Theme::System));
assert!(matches!(
settings.calendar_preference,
crate::ipc::CalendarPreference::Gregorian
));
assert_eq!(settings.site_logins.len(), 1); assert_eq!(settings.site_logins.len(), 1);
assert_eq!(settings.site_logins[0].id, "valid"); assert_eq!(settings.site_logins[0].id, "valid");
} }
+1 -1
View File
@@ -23,7 +23,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*" "csp": "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* http://127.0.0.1:* ws://127.0.0.1:*"
} }
}, },
"bundle": { "bundle": {
+819 -7
View File
@@ -1,6 +1,6 @@
use firelink_lib::queue::{ use firelink_lib::queue::{
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
MEDIA_RUN_CANCELLED, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
}; };
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -13,6 +13,12 @@ use tokio::time::timeout;
struct CountingSpawner { struct CountingSpawner {
add_uri_calls: AtomicUsize, add_uri_calls: AtomicUsize,
media_calls: AtomicUsize, media_calls: AtomicUsize,
speed_limit_calls: AtomicUsize,
last_speed_limit: std::sync::Mutex<Option<String>>,
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
block_speed_limit: std::sync::atomic::AtomicBool,
speed_limit_started: tokio::sync::Notify,
speed_limit_release: tokio::sync::Notify,
} }
struct DelayedAria2Spawner { struct DelayedAria2Spawner {
@@ -21,6 +27,13 @@ struct DelayedAria2Spawner {
remove_uri_calls: AtomicUsize, remove_uri_calls: AtomicUsize,
} }
struct BlockingAria2Spawner {
first_started: tokio::sync::Notify,
second_started: tokio::sync::Notify,
release_first: tokio::sync::Notify,
add_uri_calls: AtomicUsize,
}
struct FailFirstAria2Spawner { struct FailFirstAria2Spawner {
add_uri_calls: AtomicUsize, add_uri_calls: AtomicUsize,
fail_first: std::sync::atomic::AtomicBool, fail_first: std::sync::atomic::AtomicBool,
@@ -31,6 +44,34 @@ struct RefreshOutcomeSpawner {
refresh_calls: AtomicUsize, refresh_calls: AtomicUsize,
} }
struct RecreateSpawner {
recreate_calls: AtomicUsize,
remove_calls: AtomicUsize,
recreate_started: tokio::sync::Notify,
release_recreate: tokio::sync::Notify,
block_recreate: std::sync::atomic::AtomicBool,
outcome: std::sync::Mutex<Aria2RecreateOutcome>,
}
impl RecreateSpawner {
fn new(block_recreate: bool) -> Self {
Self {
recreate_calls: AtomicUsize::new(0),
remove_calls: AtomicUsize::new(0),
recreate_started: tokio::sync::Notify::new(),
release_recreate: tokio::sync::Notify::new(),
block_recreate: std::sync::atomic::AtomicBool::new(block_recreate),
outcome: std::sync::Mutex::new(Aria2RecreateOutcome::NewGid(
"gid-recreate-new".to_string(),
)),
}
}
fn set_outcome(&self, outcome: Aria2RecreateOutcome) {
*self.outcome.lock().unwrap() = outcome;
}
}
impl FailFirstAria2Spawner { impl FailFirstAria2Spawner {
fn new() -> Self { fn new() -> Self {
Self { Self {
@@ -50,6 +91,49 @@ impl DelayedAria2Spawner {
} }
} }
impl BlockingAria2Spawner {
fn new() -> Self {
Self {
first_started: tokio::sync::Notify::new(),
second_started: tokio::sync::Notify::new(),
release_first: tokio::sync::Notify::new(),
add_uri_calls: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl SidecarSpawner for BlockingAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
match call {
1 => {
self.first_started.notify_one();
self.release_first.notified().await;
Ok("gid-first".to_string())
}
2 => {
self.second_started.notify_one();
Ok("gid-second".to_string())
}
_ => Ok(format!("gid-{call}")),
}
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("media is not used by blocking aria2 tests")
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl SidecarSpawner for DelayedAria2Spawner { impl SidecarSpawner for DelayedAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> { async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
@@ -102,6 +186,12 @@ impl CountingSpawner {
Self { Self {
add_uri_calls: AtomicUsize::new(0), add_uri_calls: AtomicUsize::new(0),
media_calls: AtomicUsize::new(0), media_calls: AtomicUsize::new(0),
speed_limit_calls: AtomicUsize::new(0),
last_speed_limit: std::sync::Mutex::new(None),
add_speed_limits: std::sync::Mutex::new(Vec::new()),
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
speed_limit_started: tokio::sync::Notify::new(),
speed_limit_release: tokio::sync::Notify::new(),
} }
} }
} }
@@ -132,14 +222,70 @@ impl SidecarSpawner for RefreshOutcomeSpawner {
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner { impl SidecarSpawner for RecreateSpawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> { async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
Ok("gid-recreate-old".to_string())
}
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
assert_eq!(gid, "gid-recreate-new");
self.remove_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn recreate_uri(
&self,
_id: &str,
_gid: &str,
_payload: &SpawnPayload,
) -> Result<Aria2RecreateOutcome, String> {
self.recreate_calls.fetch_add(1, Ordering::SeqCst);
self.recreate_started.notify_one();
if self.block_recreate.load(Ordering::SeqCst) {
self.release_recreate.notified().await;
}
Ok(self.outcome.lock().unwrap().clone())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("media is not used by recreation tests")
}
}
#[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result<String, String> {
self.add_uri_calls.fetch_add(1, Ordering::SeqCst); self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
self.add_speed_limits
.lock()
.unwrap()
.push(payload.speed_limit.clone());
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst))) Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
} }
async fn remove_uri(&self, _gid: &str) -> Result<(), String> { async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
async fn set_download_speed_limit(
&self,
_gid: &str,
limit: Option<&str>,
) -> Result<(), String> {
self.speed_limit_calls.fetch_add(1, Ordering::SeqCst);
*self.last_speed_limit.lock().unwrap() = limit.map(str::to_string);
if self
.block_speed_limit
.load(std::sync::atomic::Ordering::SeqCst)
{
self.speed_limit_started.notify_one();
self.speed_limit_release.notified().await;
}
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> { async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.media_calls.fetch_add(1, Ordering::SeqCst); self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(()) Ok(())
@@ -258,6 +404,207 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
assert_eq!(mgr.available_permits(), 2); assert_eq!(mgr.available_permits(), 2);
} }
#[tokio::test]
async fn backend_power_activity_follows_active_permits_not_queue_reservations() {
let (mgr, _spawner) = make_manager(2);
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
let reservation = mgr
.reserve_enqueue_generation("queued", 1)
.await
.expect("queue reservation should succeed");
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
mgr.rollback_enqueue_reservation("queued", 1, reservation)
.await;
assert!(mgr.ensure_aria2_permit("active").await);
assert_eq!(mgr.power_manager().active_transfer_count(), 1);
mgr.release_permit("active").await;
assert_eq!(mgr.power_manager().active_transfer_count(), 0);
}
#[tokio::test]
async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("speed-limit");
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("speed-limit").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
manager
.set_aria2_download_speed_limit("speed-limit", Some("512K".to_string()))
.await
.unwrap();
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 1);
assert_eq!(
spawner.last_speed_limit.lock().unwrap().as_deref(),
Some("512K")
);
assert!(manager.aria2_speed_limited("speed-limit").await);
manager
.set_aria2_download_speed_limit("speed-limit", None)
.await
.unwrap();
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 2);
assert!(spawner.last_speed_limit.lock().unwrap().is_none());
assert!(!manager.aria2_speed_limited("speed-limit").await);
manager
.apply_completion(
"speed-limit",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn live_aria2_speed_limit_rejects_invalid_and_non_active_requests() {
let (manager, spawner) = make_manager(1);
assert!(manager
.set_aria2_download_speed_limit("missing", Some("not-a-rate".to_string()))
.await
.is_err());
assert!(manager
.set_aria2_download_speed_limit("missing", Some("512K".to_string()))
.await
.is_err());
assert_eq!(spawner.speed_limit_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("speed-stale");
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("speed-stale").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
spawner
.block_speed_limit
.store(true, std::sync::atomic::Ordering::SeqCst);
let started = spawner.speed_limit_started.notified();
let setter = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.set_aria2_download_speed_limit("speed-stale", Some("512K".to_string()))
.await
})
};
timeout(Duration::from_secs(1), started)
.await
.expect("speed RPC should start");
manager
.remember_gid("speed-stale".to_string(), "gid-replaced".to_string())
.await;
spawner.speed_limit_release.notify_one();
assert!(setter.await.unwrap().is_err());
assert!(manager.aria2_speed_limited("speed-stale").await);
manager
.apply_completion(
"speed-stale",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
use firelink_lib::queue::PendingOutcome;
let (mgr, spawner) = make_manager(1);
let manager = Arc::new(mgr);
let mut task = aria2_task("speed-retry");
task.payload.max_tries = Some(1);
task.payload.speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial aria2 add should run");
manager
.handle_aria2_event(
"gid-1",
PendingOutcome::Error(
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
),
)
.await;
manager
.set_aria2_download_speed_limit("speed-retry", Some("512K".to_string()))
.await
.unwrap();
timeout(Duration::from_secs(4), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("retry should re-add after backoff");
assert_eq!(
spawner.add_speed_limits.lock().unwrap().as_slice(),
&[Some("1M".to_string()), Some("512K".to_string())]
);
manager
.handle_aria2_event(
"gid-2",
PendingOutcome::Error("permanent failure".to_string()),
)
.await;
dispatcher.abort();
}
#[tokio::test] #[tokio::test]
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() { async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
let (mgr, _spawner) = make_manager(2); let (mgr, _spawner) = make_manager(2);
@@ -569,6 +916,285 @@ async fn aria2_resume_waits_for_shrunk_capacity() {
.expect("resume task should not panic")); .expect("resume task should not panic"));
} }
#[tokio::test]
async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() {
let (manager, _spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("a1").is_none()
|| manager.aria2_gid_for_download("b1").is_none()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both eligible queues should finish dispatching");
assert!(manager.aria2_gid_for_download("a1").is_some());
assert!(manager.aria2_gid_for_download("b1").is_some());
assert!(manager.aria2_gid_for_download("a2").is_none());
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_admits_next_task_while_first_add_uri_is_blocked() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(BlockingAria2Spawner::new());
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
2,
spawner.clone(),
));
manager.push(aria2_task("blocked-first")).await.unwrap();
manager.push(aria2_task("admitted-second")).await.unwrap();
let first_started = spawner.first_started.notified();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), first_started)
.await
.expect("the first addUri should start");
timeout(Duration::from_secs(1), spawner.second_started.notified())
.await
.expect("a blocked addUri must not serialize admission of the next task");
spawner.release_first.notify_one();
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("blocked-first").is_none()
|| manager.aria2_gid_for_download("admitted-second").is_none()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both admitted tasks should finish dispatching");
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_rotates_eligible_queues_without_starving_later_work() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 1 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("first queue task should dispatch");
manager.release_permit("a1").await;
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("b1").is_none() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("later queue should be selected before another task from queue-a");
assert!(manager.aria2_gid_for_download("a2").is_none());
dispatcher.abort();
}
#[tokio::test]
async fn queue_limit_increase_wakes_waiting_work_and_decrease_keeps_active_tasks() {
let (manager, spawner) = make_manager(3);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(2))])
.await
.unwrap();
for id in ["a1", "a2", "a3"] {
manager.push(aria2_task_in_queue(id, "queue-a")).await.unwrap();
}
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("queue override should allow two active transfers");
manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(1))])
.await
.unwrap();
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
manager.release_permit("a1").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
spawner.add_uri_calls.load(Ordering::SeqCst),
2,
"reducing a queue limit must not admit work while one active transfer remains"
);
manager.release_permit("a2").await;
timeout(Duration::from_secs(1), async {
while spawner.add_uri_calls.load(Ordering::SeqCst) < 3 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("queue work should resume after active count falls below the reduced limit");
dispatcher.abort();
}
#[tokio::test]
async fn queue_overrides_never_exceed_the_global_ceiling() {
let (manager, spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(12)),
("queue-b".to_string(), Some(12)),
])
.await
.unwrap();
for (id, queue_id) in [
("a1", "queue-a"),
("a2", "queue-a"),
("b1", "queue-b"),
("b2", "queue-b"),
] {
manager.push(aria2_task_in_queue(id, queue_id)).await.unwrap();
}
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
assert_eq!(manager.available_permits(), 0);
dispatcher.abort();
}
#[tokio::test]
async fn queue_limit_configuration_rejects_malformed_input() {
let (manager, _spawner) = make_manager(2);
assert!(manager
.replace_queue_limits(vec![(String::new(), Some(1))])
.await
.is_err());
assert!(manager
.replace_queue_limits(vec![("queue-a".to_string(), Some(0))])
.await
.is_err());
assert!(manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-a".to_string(), None),
])
.await
.is_err());
manager
.replace_queue_limits(vec![("queue-a".to_string(), None)])
.await
.unwrap();
}
#[tokio::test]
async fn stale_queue_resume_reservation_is_removed_when_activation_fails() {
let (manager, _spawner) = make_manager(1);
let previous = manager
.reserve_enqueue_generation("candidate", 1)
.await
.unwrap();
assert!(previous.is_none());
let candidate = manager
.acquire_aria2_permit_candidate_for_queue("candidate", "queue-a", 1)
.await
.expect("candidate should reserve the queue slot");
manager.release_registered_id("candidate").await;
assert!(!manager
.park_aria2_permit_if_missing_for_queue("candidate", "queue-a", 1, candidate)
.await);
assert_eq!(manager.available_permits(), 1);
assert!(manager.ensure_aria2_permit_for_queue("replacement", "queue-a").await);
manager.release_permit("replacement").await;
}
#[tokio::test]
async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() {
let (manager, spawner) = make_manager(2);
let manager = Arc::new(manager);
manager
.replace_queue_limits(vec![
("queue-a".to_string(), Some(1)),
("queue-b".to_string(), Some(1)),
])
.await
.unwrap();
assert!(manager
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
.await);
manager
.reserve_enqueue_generation("duplicate", 1)
.await
.unwrap();
manager
.commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1)
.await
.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 0);
assert_eq!(manager.available_permits(), 1);
manager.release_permit("duplicate").await;
dispatcher.abort();
}
fn aria2_task(id: &str) -> QueuedTask { fn aria2_task(id: &str) -> QueuedTask {
QueuedTask { QueuedTask {
id: id.to_string(), id: id.to_string(),
@@ -579,6 +1205,12 @@ fn aria2_task(id: &str) -> QueuedTask {
} }
} }
fn aria2_task_in_queue(id: &str, queue_id: &str) -> QueuedTask {
let mut task = aria2_task(id);
task.queue_id = queue_id.to_string();
task
}
fn media_task(id: &str) -> QueuedTask { fn media_task(id: &str) -> QueuedTask {
QueuedTask { QueuedTask {
id: id.to_string(), id: id.to_string(),
@@ -714,11 +1346,11 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
outcome: Aria2RefreshOutcome::Paused, outcome: Aria2RefreshOutcome::Paused,
refresh_calls: AtomicUsize::new(0), refresh_calls: AtomicUsize::new(0),
}); });
let manager = QueueManager::test_new( let manager = Arc::new(QueueManager::test_new(
app.handle().clone(), app.handle().clone(),
1, 1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>, Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
); ));
manager.push(aria2_task("refresh-paused")).await.unwrap(); manager.push(aria2_task("refresh-paused")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-paused").await); assert!(manager.ensure_aria2_permit("refresh-paused").await);
manager manager
@@ -741,6 +1373,159 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_
assert!(manager.is_registered("refresh-paused").await); assert!(manager.is_registered("refresh-paused").await);
} }
#[tokio::test]
async fn connection_recovery_recreates_gid_without_releasing_transfer_permit() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(false));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("recreate").as_deref() == Some("gid-recreate-old") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate").await;
manager
.refresh_aria2_connections("recreate", "gid-recreate-old", epoch)
.await
.unwrap();
assert_eq!(spawner.recreate_calls.load(Ordering::SeqCst), 1);
assert_eq!(
manager.aria2_gid_for_download("recreate").as_deref(),
Some("gid-recreate-new")
);
assert!(manager.has_active_permit("recreate").await);
assert_eq!(manager.available_permits(), 0);
manager.release_permit("recreate").await;
dispatcher.abort();
}
#[tokio::test]
async fn stale_recreated_gid_is_removed_without_rebinding_or_leaking_permit() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(true));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate-stale")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("recreate-stale").as_deref()
== Some("gid-recreate-old")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate-stale").await;
let refresh = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.refresh_aria2_connections("recreate-stale", "gid-recreate-old", epoch)
.await
})
};
timeout(Duration::from_secs(1), spawner.recreate_started.notified())
.await
.expect("recreation should reach its async boundary");
manager.next_aria2_control_epoch("recreate-stale").await;
spawner.release_recreate.notify_one();
refresh.await.unwrap().unwrap();
assert_eq!(spawner.remove_calls.load(Ordering::SeqCst), 1);
assert_eq!(
manager.aria2_gid_for_download("recreate-stale").as_deref(),
Some("gid-recreate-old")
);
assert!(manager.has_active_permit("recreate-stale").await);
assert_eq!(manager.available_permits(), 0);
manager.release_permit("recreate-stale").await;
dispatcher.abort();
}
#[tokio::test]
async fn unavailable_recreated_gid_retires_stale_lifecycle_for_manual_resume() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(RecreateSpawner::new(false));
spawner.set_outcome(Aria2RecreateOutcome::Unavailable(
"aria2.addUri unavailable".to_string(),
));
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
));
manager.push(aria2_task("recreate-unavailable")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager
.aria2_gid_for_download("recreate-unavailable")
.as_deref()
== Some("gid-recreate-old")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial gid should be mapped");
let epoch = manager.current_aria2_control_epoch("recreate-unavailable").await;
manager
.refresh_aria2_connections("recreate-unavailable", "gid-recreate-old", epoch)
.await
.unwrap();
assert_eq!(manager.aria2_gid_for_download("recreate-unavailable"), None);
assert!(!manager.has_active_permit("recreate-unavailable").await);
assert!(!manager.is_registered("recreate-unavailable").await);
assert_eq!(manager.available_permits(), 1);
dispatcher.abort();
}
#[tokio::test] #[tokio::test]
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() { async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
let app = mock_builder() let app = mock_builder()
@@ -750,11 +1535,11 @@ async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
outcome: Aria2RefreshOutcome::Resumed, outcome: Aria2RefreshOutcome::Resumed,
refresh_calls: AtomicUsize::new(0), refresh_calls: AtomicUsize::new(0),
}); });
let manager = QueueManager::test_new( let manager = Arc::new(QueueManager::test_new(
app.handle().clone(), app.handle().clone(),
1, 1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>, Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
); ));
manager.push(aria2_task("refresh-stale")).await.unwrap(); manager.push(aria2_task("refresh-stale")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-stale").await); assert!(manager.ensure_aria2_permit("refresh-stale").await);
manager manager
@@ -1247,6 +2032,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;
+83 -18
View File
@@ -2,7 +2,10 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from
import { schedulerCompletionState } from './utils/schedulerCompletion'; import { schedulerCompletionState } from './utils/schedulerCompletion';
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable"; import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
// Keep the primary Add action eager so the modal cannot disappear behind a
// null Suspense fallback while its development chunk is being transformed.
import { AddDownloadsModal } from './components/AddDownloadsModal';
import { KeychainPermissionModal } from './components/KeychainPermissionModal'; import { KeychainPermissionModal } from './components/KeychainPermissionModal';
import { extractValidDownloadUrls } from './utils/url'; import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard'; import { readClipboardDownloadUrls } from './utils/clipboard';
@@ -17,6 +20,7 @@ import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge'; import { updateDockBadge } from './utils/dockBadge';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
import { resolveWindowControlStyle } from './utils/windowControlStyle';
import { import {
getKeychainAccessReady, getKeychainAccessReady,
getKeychainConsentVersion, getKeychainConsentVersion,
@@ -28,6 +32,7 @@ import { PanelLeft } from 'lucide-react';
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n'; import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDownloadBytes } from './utils/downloadProgress';
const loadSettingsView = () => import('./components/SettingsView'); const loadSettingsView = () => import('./components/SettingsView');
const loadSchedulerView = () => import('./components/SchedulerView'); const loadSchedulerView = () => import('./components/SchedulerView');
@@ -44,9 +49,6 @@ const SettingsView = lazy(loadSettingsView);
const SchedulerView = lazy(loadSchedulerView); const SchedulerView = lazy(loadSchedulerView);
const SpeedLimiterView = lazy(loadSpeedLimiterView); const SpeedLimiterView = lazy(loadSpeedLimiterView);
const LogsView = lazy(loadLogsView); const LogsView = lazy(loadLogsView);
const AddDownloadsModal = lazy(() => import('./components/AddDownloadsModal').then(module => ({
default: module.AddDownloadsModal,
})));
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({ const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
default: module.PropertiesModal, default: module.PropertiesModal,
}))); })));
@@ -99,6 +101,7 @@ const PageLoadingFallback = () => {
let automaticUpdateCheckStarted = false; let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>(); const processingScheduleKeys = new Set<string>();
let powerPreferencesSync: Promise<void> = Promise.resolve();
const waitForSettingsHydration = (): Promise<void> => { const waitForSettingsHydration = (): Promise<void> => {
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve(); if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
@@ -166,6 +169,7 @@ function App() {
const { i18n, t } = useTranslation(); const { i18n, t } = useTranslation();
const platform = usePlatformInfo(); const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all'); const [filter, setFilter] = useState<SidebarFilter>('all');
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
const [coreReady, setCoreReady] = useState(false); const [coreReady, setCoreReady] = useState(false);
const [keychainConsentVersion, setKeychainConsentVersion] = useState(''); const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
@@ -185,11 +189,13 @@ function App() {
}); });
const theme = useSettingsStore(state => state.theme); const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
const languagePreference = useSettingsStore(state => state.language); const languagePreference = useSettingsStore(state => state.language);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const sidebarPosition = useSettingsStore(state => state.sidebarPosition); const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
const toggleSidebar = useSettingsStore(state => state.toggleSidebar); const toggleSidebar = useSettingsStore(state => state.toggleSidebar);
const activeView = useSettingsStore(state => state.activeView); const activeView = useSettingsStore(state => state.activeView);
const fontFamily = useSettingsStore(state => state.fontFamily);
const appFontSize = useSettingsStore(state => state.appFontSize); const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity); const listRowDensity = useSettingsStore(state => state.listRowDensity);
@@ -218,6 +224,16 @@ function App() {
download.status === 'queued' || download.status === 'staged' download.status === 'queued' || download.status === 'staged'
).length; ).length;
const doneCount = downloads.filter(download => download.status === 'completed').length; const doneCount = downloads.filter(download => download.status === 'completed').length;
const handleDownloadTableSummaryChange = useCallback((summary: DownloadTableStatusSummary | null) => {
setDownloadTableSummary(summary);
}, []);
const formatStatusSummaryBytes = (value: number | null, isEstimated = false): string => {
if (value === null) return t($ => $.downloadTable.summary.unknown);
const formatted = formatDownloadBytes(value);
return isEstimated
? t($ => $.downloadTable.summary.estimated, { value: formatted })
: formatted;
};
const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds); const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null); const pendingPostActionTimer = useRef<number | null>(null);
@@ -230,10 +246,14 @@ function App() {
>>([]); >>([]);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const preventsDisplaySleepWhileDownloading = useSettingsStore(
state => state.preventsDisplaySleepWhileDownloading
);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast, removeToast } = useToast(); const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac'); const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
// Keep dialogs out of the titlebar area while platform detection is still // Keep dialogs out of the titlebar area while platform detection is still
@@ -634,6 +654,10 @@ function App() {
}); });
}, [addToast, coreReady, showKeychainModal]); }, [addToast, coreReady, showKeychainModal]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-family', fontFamily);
}, [fontFamily]);
useEffect(() => { useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize); window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]); }, [appFontSize]);
@@ -696,17 +720,34 @@ function App() {
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]); }, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
useEffect(() => { useEffect(() => {
invoke('set_prevent_sleep', { const sync = () => {
prevent: preventsSleepWhileDownloading && activeTransferCount > 0 powerPreferencesSync = powerPreferencesSync
}).catch(error => { .catch(() => undefined)
console.error('Failed to update sleep prevention:', error); .then(() => invoke('set_power_preferences', {
addToast({ preventSystemSleep: preventsSleepWhileDownloading,
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }), preventDisplaySleep: preventsDisplaySleepWhileDownloading
variant: 'error', }))
isActionable: true .catch(error => {
}); console.error('Failed to update power prevention:', error);
}); addToast({
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]); message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
};
if (useSettingsStore.persist.hasHydrated()) {
sync();
return;
}
return useSettingsStore.persist.onFinishHydration(sync);
}, [
addToast,
preventsDisplaySleepWhileDownloading,
preventsSleepWhileDownloading,
t
]);
useEffect(() => { useEffect(() => {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
@@ -1009,7 +1050,10 @@ function App() {
hasWindowChrome ? 'app-shell--window-chrome' : '' hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}> }`}>
{usesCustomWindowControls && ( {usesCustomWindowControls && (
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} /> <WindowControls
side={isSidebarOnRight ? 'right' : 'left'}
controlStyle={windowControlStyle}
/>
)} )}
<div <div
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${ className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
@@ -1062,7 +1106,12 @@ function App() {
)} )}
<div className="flex-1 flex flex-col overflow-hidden relative"> <div className="flex-1 flex flex-col overflow-hidden relative">
<Suspense fallback={<PageLoadingFallback />}> <Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && <DownloadTable filter={filter} />} {activeView === 'downloads' && (
<DownloadTable
filter={filter}
onSummaryChange={handleDownloadTableSummaryChange}
/>
)}
{activeView === 'settings' && <SettingsView />} {activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />} {activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />} {activeView === 'speedLimiter' && <SpeedLimiterView />}
@@ -1073,6 +1122,21 @@ function App() {
{/* Status Bar */} {/* Status Bar */}
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0"> <div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
<span>{t($ => $.status.ready)}</span> <span>{t($ => $.status.ready)}</span>
{activeView === 'downloads' && downloadTableSummary ? (
<div className="app-statusbar-summary" dir="ltr" aria-live="polite">
<span className="app-statusbar-summary-metric">
<span dir="auto">{t($ => $.downloadTable.summary.downloaded)}</span>
<strong dir="auto">{formatStatusSummaryBytes(downloadTableSummary.summary.downloadedBytes)}</strong>
</span>
<span className="app-statusbar-summary-metric">
<span dir="auto">{t($ => $.downloadTable.summary.remaining)}</span>
<strong dir="auto">{formatStatusSummaryBytes(
downloadTableSummary.summary.remainingBytes,
downloadTableSummary.summary.remainingIsEstimated
)}</strong>
</span>
</div>
) : null}
<div className="flex gap-3 tabular-nums"> <div className="flex gap-3 tabular-nums">
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span> <span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
<span>{t($ => $.status.queued, { count: queuedCount })}</span> <span>{t($ => $.status.queued, { count: queuedCount })}</span>
@@ -1081,8 +1145,9 @@ function App() {
</div> </div>
</div> </div>
{isAddModalOpen && <AddDownloadsModal />}
<Suspense fallback={null}> <Suspense fallback={null}>
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && <PropertiesModal />} {selectedPropertiesDownloadId !== null && <PropertiesModal />}
{isDeleteModalOpen && <DeleteConfirmationModal />} {isDeleteModalOpen && <DeleteConfirmationModal />}
{showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />} {showKeychainModal && <KeychainPermissionModal consentVersion={keychainConsentVersion} />}
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CalendarPreference = "gregorian" | "persian" | "hebrew";
+1 -1
View File
@@ -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, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FontFamily = "system" | "inter" | "outfit" | "vazirmatn" | "noto-sans-hebrew" | "noto-sans-sc" | "roboto" | "serif" | "monospace";
+4 -1
View File
@@ -1,5 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppFontSize } from "./AppFontSize"; import type { AppFontSize } from "./AppFontSize";
import type { CalendarPreference } from "./CalendarPreference";
import type { FontFamily } from "./FontFamily";
import type { ListRowDensity } from "./ListRowDensity"; import type { ListRowDensity } from "./ListRowDensity";
import type { MediaCookieSource } from "./MediaCookieSource"; import type { MediaCookieSource } from "./MediaCookieSource";
import type { ProxyMode } from "./ProxyMode"; import type { ProxyMode } from "./ProxyMode";
@@ -7,5 +9,6 @@ import type { SchedulerSettings } from "./SchedulerSettings";
import type { SettingsTab } from "./SettingsTab"; import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin"; import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme"; import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Queue = { id: string, name: string, isMain: boolean, }; export type Queue = { id: string, name: string, isMain: boolean, maxConcurrent?: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type QueueConcurrencyConfig = { id: string, maxConcurrent: number | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WindowControlStyle = "auto" | "macos" | "windows" | "gnome" | "minimal";
+311 -7
View File
@@ -33,14 +33,22 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
import { import {
canSubmitMetadataRows, canSubmitMetadataRows,
appendRequestUrlsAfterVersion, appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
commonMediaQualitiesForRows,
mediaFileNameForSelectedFormat, mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForFormat,
mediaQualityForRow,
mediaTypeForFormat,
mediaFormatSelectorForRow, mediaFormatSelectorForRow,
metadataSummaryState, metadataSummaryState,
playlistFilePrefix, playlistFilePrefix,
reconcileDownloadRows, reconcileDownloadRows,
refreshFailedMetadataRows, refreshFailedMetadataRows,
selectExactMediaSelection,
updateRowIfCurrent, updateRowIfCurrent,
type AddDownloadDraftRow type AddDownloadDraftRow,
type MediaSelection
} from '../utils/addDownloadMetadata'; } from '../utils/addDownloadMetadata';
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
@@ -186,6 +194,9 @@ export const AddDownloadsModal = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false); const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
const playlistMediaSelectionsRef = useRef(new Map<string, MediaSelection>());
const [playlistMediaTypeSelections, setPlaylistMediaTypeSelections] = useState<Record<string, MediaSelection['mediaType']>>({});
const [checksumEnabled, setChecksumEnabled] = useState(false); const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256'); const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState(''); const [checksumValue, setChecksumValue] = useState('');
@@ -245,6 +256,9 @@ export const AddDownloadsModal = () => {
setPlaylistExpansions({}); setPlaylistExpansions({});
playlistRequestsRef.current.clear(); playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear(); latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setPlaylistQualityExpanded(true);
return; return;
} }
@@ -273,6 +287,9 @@ export const AddDownloadsModal = () => {
metadataRequestsRef.current.clear(); metadataRequestsRef.current.clear();
playlistRequestsRef.current.clear(); playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear(); latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setPlaylistQualityExpanded(true);
setSelectedItemIndex(null); setSelectedItemIndex(null);
setPendingUseSharedDestination(false); setPendingUseSharedDestination(false);
setPendingDestinationOverrides({}); setPendingDestinationOverrides({});
@@ -322,6 +339,8 @@ export const AddDownloadsModal = () => {
// arrives instead of reusing entries extracted under stale cookies. // arrives instead of reusing entries extracted under stale cookies.
setPlaylistExpansions({}); setPlaylistExpansions({});
latestPlaylistRequestRef.current.clear(); latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({});
setUrls(current => appendRequestUrlsAfterVersion( setUrls(current => appendRequestUrlsAfterVersion(
current, current,
pendingAddRequestContexts, pendingAddRequestContexts,
@@ -383,6 +402,17 @@ export const AddDownloadsModal = () => {
latestPlaylistRequestRef.current.delete(sourceUrl); latestPlaylistRequestRef.current.delete(sourceUrl);
} }
} }
for (const sourceUrl of playlistMediaSelectionsRef.current.keys()) {
if (!activeUrls.has(sourceUrl)) {
playlistMediaSelectionsRef.current.delete(sourceUrl);
}
}
setPlaylistMediaTypeSelections(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
);
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
});
setPlaylistExpansions(current => { setPlaylistExpansions(current => {
const retained = Object.fromEntries( const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl)) Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
@@ -519,6 +549,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,
@@ -528,6 +559,26 @@ export const AddDownloadsModal = () => {
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video' type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
}; };
}); });
const requestedSelection = row.playlistSourceUrl
? playlistMediaSelectionsRef.current.get(row.playlistSourceUrl)
: undefined;
const requestedFormatIndex = requestedSelection
? mappedFormats.findIndex(format =>
mediaTypeForFormat(format) === requestedSelection.mediaType
&& mediaFormatForFormat(format) === requestedSelection.format
&& mediaQualityForFormat(format) === requestedSelection.quality
)
: -1;
const fallbackFormatIndex = requestedSelection
? mappedFormats.findIndex(format =>
mediaTypeForFormat(format) === requestedSelection.mediaType
&& mediaQualityForFormat(format) === requestedSelection.quality
)
: -1;
const selectedFormatIndex = requestedFormatIndex >= 0
? requestedFormatIndex
: fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0;
const selectedFormat = mappedFormats[selectedFormatIndex];
setParsedItems(current => updateRowIfCurrent( setParsedItems(current => updateRowIfCurrent(
current, current,
row.id, row.id,
@@ -537,13 +588,13 @@ export const AddDownloadsModal = () => {
...currentRow, ...currentRow,
downloadUrl: row.sourceUrl, downloadUrl: row.sourceUrl,
file: canonicalizeDownloadFileName( file: canonicalizeDownloadFileName(
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}` `${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${selectedFormat.ext}`
), ),
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined, size: selectedFormat.bytes ? selectedFormat.detail : undefined,
sizeBytes: mappedFormats[0].bytes || undefined, sizeBytes: selectedFormat.bytes || undefined,
status: 'ready', status: 'ready',
formats: mappedFormats, formats: mappedFormats,
selectedFormat: 0, selectedFormat: selectedFormatIndex,
playlistError: undefined playlistError: undefined
}) })
)); ));
@@ -1187,6 +1238,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);
@@ -1262,11 +1314,23 @@ export const AddDownloadsModal = () => {
}); });
}; };
const clearPlaylistMediaSelection = (sourceUrl: string | undefined) => {
if (!sourceUrl) return;
playlistMediaSelectionsRef.current.delete(sourceUrl);
setPlaylistMediaTypeSelections(current => {
if (!(sourceUrl in current)) return current;
const next = { ...current };
delete next[sourceUrl];
return next;
});
};
const selectMediaFormat = (index: number) => { const selectMediaFormat = (index: number) => {
if (selectedItemIndex === null) return; if (selectedItemIndex === null) return;
const selectedItem = parsedItems[selectedItemIndex]; const selectedItem = parsedItems[selectedItemIndex];
const format = selectedItem?.formats?.[index]; const format = selectedItem?.formats?.[index];
if (!selectedItem || !format) return; if (!selectedItem || !format) return;
clearPlaylistMediaSelection(selectedItem.playlistSourceUrl);
setParsedItems(items => items.map((item, itemIndex) => setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === selectedItemIndex itemIndex === selectedItemIndex
@@ -1285,6 +1349,120 @@ export const AddDownloadsModal = () => {
}; };
const selectedItems = parsedItems.filter(item => item.selected !== false); const selectedItems = parsedItems.filter(item => item.selected !== false);
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
const selectedPlaylistRows = selectedPlaylistSourceUrl
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
: [];
const selectedPlaylistReadyRows = selectedPlaylistRows.filter(item =>
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
);
const selectedPlaylistFormatObject = selectedItem?.formats?.[selectedItem.selectedFormat ?? -1]
|| selectedPlaylistReadyRows[0]?.formats?.[selectedPlaylistReadyRows[0].selectedFormat ?? -1];
const detectedPlaylistMediaType = selectedPlaylistFormatObject
? mediaTypeForFormat(selectedPlaylistFormatObject)
: 'Video';
const playlistVideoFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Video');
const playlistAudioFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Audio');
const requestedPlaylistMediaType = selectedPlaylistSourceUrl
? playlistMediaTypeSelections[selectedPlaylistSourceUrl]
: undefined;
const selectedPlaylistMediaType = requestedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
? 'Audio'
: requestedPlaylistMediaType === 'Video' && playlistVideoFormatOptions.length > 0
? 'Video'
: detectedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
? 'Audio'
: playlistVideoFormatOptions.length > 0 ? 'Video' : 'Audio';
const playlistFormatOptions = selectedPlaylistMediaType === 'Audio'
? playlistAudioFormatOptions
: playlistVideoFormatOptions;
const hasPlaylistFormatOptions = playlistVideoFormatOptions.length > 0
|| playlistAudioFormatOptions.length > 0;
const currentPlaylistFormat = selectedPlaylistFormatObject
? mediaFormatForFormat(selectedPlaylistFormatObject)
: undefined;
const selectedPlaylistFormat = currentPlaylistFormat && playlistFormatOptions.includes(currentPlaylistFormat)
? currentPlaylistFormat
: playlistFormatOptions[0];
const playlistQualityOptions = selectedPlaylistFormat
? commonMediaQualitiesForRows(
selectedPlaylistRows,
selectedPlaylistMediaType,
selectedPlaylistFormat
)
: [];
const playlistSelectionForRow = (row: AddDownloadDraftRow): MediaSelection | undefined => {
const format = row.formats?.[row.selectedFormat ?? -1];
return format ? {
mediaType: mediaTypeForFormat(format),
format: mediaFormatForFormat(format),
quality: mediaQualityForFormat(format)
} : undefined;
};
const appliedPlaylistSelection = selectedPlaylistReadyRows.length > 0
? (() => {
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
return firstSelection && selectedPlaylistReadyRows.every(row => {
const selection = playlistSelectionForRow(row);
return selection?.mediaType === firstSelection.mediaType
&& selection.format === firstSelection.format
&& selection.quality === firstSelection.quality;
})
? firstSelection
: undefined;
})()
: undefined;
const appliedPlaylistFormat = selectedPlaylistReadyRows.length > 0
? (() => {
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
return firstSelection
&& firstSelection.mediaType === selectedPlaylistMediaType
&& selectedPlaylistReadyRows.every(row => {
const selection = playlistSelectionForRow(row);
return selection?.mediaType === selectedPlaylistMediaType
&& selection.format === firstSelection.format;
})
? firstSelection.format
: undefined;
})()
: undefined;
const applyPlaylistMediaSelection = (selection: MediaSelection) => {
if (!selectedPlaylistSourceUrl) return;
playlistMediaSelectionsRef.current.set(selectedPlaylistSourceUrl, selection);
setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: selection.mediaType
}));
const selectedIds = selectedPlaylistReadyRows.map(item => item.id);
setParsedItems(items => selectExactMediaSelection(items, selectedIds, selection));
};
const applyPlaylistMediaQuality = (quality: string) => {
if (!selectedPlaylistFormat) return;
applyPlaylistMediaSelection({
mediaType: selectedPlaylistMediaType,
format: selectedPlaylistFormat,
quality
});
};
const applyPlaylistMediaFormat = (format: string) => {
const qualityOptions = commonMediaQualitiesForRows(
selectedPlaylistRows,
selectedPlaylistMediaType,
format
);
const currentQuality = appliedPlaylistSelection?.quality
|| mediaQualityForRow(selectedPlaylistReadyRows[0]);
const quality = currentQuality && qualityOptions.includes(currentQuality)
? currentQuality
: qualityOptions[0];
if (!quality) return;
applyPlaylistMediaSelection({
mediaType: selectedPlaylistMediaType,
format,
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 +1702,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' ? (
@@ -1612,12 +1797,131 @@ export const AddDownloadsModal = () => {
})} })}
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10"> <div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span> <span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
</div> </div>
)} )}
{selectedPlaylistSourceUrl && selectedPlaylistReadyRows.length > 0 ? (
<div className="add-download-playlist-quality relative z-10">
<button
type="button"
className="add-download-playlist-quality-toggle"
aria-expanded={playlistQualityExpanded}
onClick={() => setPlaylistQualityExpanded(expanded => !expanded)}
>
<span className="min-w-0 text-start">
<span className="block text-xs font-semibold text-text-primary">
{t($ => $.addDownloads.playlistQuality)}
</span>
<span className="mt-0.5 block text-[10px] text-text-muted">
{t($ => $.addDownloads.playlistQualitySelected, {
selected: selectedPlaylistRows.length,
total: parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl).length
})}
</span>
</span>
{playlistQualityExpanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
{playlistQualityExpanded ? (
hasPlaylistFormatOptions ? (
<>
{playlistVideoFormatOptions.length > 0 && playlistAudioFormatOptions.length > 0 ? (
<div className="add-download-playlist-media-type" role="tablist" aria-label={t($ => $.addDownloads.mediaFormat)}>
<button
type="button"
role="tab"
aria-selected={selectedPlaylistMediaType === 'Video'}
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: 'Video'
}))}
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Video' ? 'is-selected' : ''}`}
>
<Film size={13} /> {t($ => $.addDownloads.video)}
</button>
<button
type="button"
role="tab"
aria-selected={selectedPlaylistMediaType === 'Audio'}
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
...current,
[selectedPlaylistSourceUrl]: 'Audio'
}))}
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Audio' ? 'is-selected' : ''}`}
>
<Music size={13} /> {t($ => $.addDownloads.audio)}
</button>
</div>
) : null}
<div className="add-download-playlist-quality-options mt-2">
<div className="add-download-playlist-quality-column">
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.quality)}</div>
{playlistQualityOptions.length > 0 ? (
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.quality)}>
{playlistQualityOptions.map(quality => {
const isApplied = appliedPlaylistSelection?.mediaType === selectedPlaylistMediaType
&& appliedPlaylistSelection.format === selectedPlaylistFormat
&& appliedPlaylistSelection.quality === quality;
return (
<button
key={quality}
type="button"
role="radio"
aria-checked={isApplied}
onClick={() => applyPlaylistMediaQuality(quality)}
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
>
<span className="add-download-quality-option-radio" aria-hidden="true" />
<span className="flex-1 text-start">{quality}</span>
</button>
);
})}
</div>
) : (
<span className="text-[10px] text-text-muted">
{t($ => $.addDownloads.noCommonQuality)}
</span>
)}
</div>
{playlistFormatOptions.length > 0 ? (
<div className="add-download-playlist-quality-column">
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.format)}</div>
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.format)}>
{playlistFormatOptions.map(format => {
const isApplied = appliedPlaylistFormat === format;
return (
<button
key={format}
type="button"
role="radio"
aria-checked={isApplied}
onClick={() => applyPlaylistMediaFormat(format)}
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
>
<span className="add-download-quality-option-radio" aria-hidden="true" />
<span className="flex-1 text-start font-mono">{format}</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
</>
) : (
<div className="px-3 py-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.noCommonFormat)}
</div>
)
) : null}
</div>
) : null}
</section> </section>
)} )}
+92 -59
View File
@@ -1,9 +1,11 @@
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 } 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';
import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import { import {
downloadProgressColorClass, downloadProgressColorClass,
formatDownloadTotal, formatDownloadTotal,
@@ -12,7 +14,7 @@ import {
import { import {
COLUMN_ALIGNMENT_JUSTIFY, COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH, DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET, getDownloadActionPosition,
getColumnGridColumn, getColumnGridColumn,
type DownloadColumnAlignment, type DownloadColumnAlignment,
type DownloadTableColumnKey type DownloadTableColumnKey
@@ -20,9 +22,7 @@ import {
interface DownloadItemProps { interface DownloadItemProps {
download: DownloadItemType; download: DownloadItemType;
index: number;
queueIndex: number; queueIndex: number;
queueLength: number;
columnOrder: DownloadTableColumnKey[]; columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>; columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
tableGridTemplate: string; tableGridTemplate: string;
@@ -32,15 +32,16 @@ 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<HTMLDivElement>) => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void; onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
} }
export const DownloadItem = React.memo<DownloadItemProps>(({ export const DownloadItem = React.memo<DownloadItemProps>(({
download, download,
index,
queueIndex, queueIndex,
queueLength,
columnOrder, columnOrder,
columnAlignments, columnAlignments,
tableGridTemplate, tableGridTemplate,
@@ -50,16 +51,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
handleResume, handleResume,
getCategoryIcon, getCategoryIcon,
isSelected, isSelected,
isQueueReorderable,
isQueueDragSource,
onMoveInQueue, onMoveInQueue,
onQueueDragStart,
onClick, onClick,
}) => { }) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null); const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false); const [isRowHovered, setIsRowHovered] = React.useState(false);
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 hasRowActions = download.status !== 'completed';
const isActionVisible = hasRowActions && (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 dateAddedLabel = download.dateAdded
? formatDateTime(download.dateAdded, {
locale: i18n.language,
calendar: calendarPreference
})
: '-';
const updateActionPosition = React.useCallback(() => { const updateActionPosition = React.useCallback(() => {
const row = rowRef.current; const row = rowRef.current;
@@ -71,19 +88,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const rowRect = row.getBoundingClientRect(); const rowRect = row.getBoundingClientRect();
const horizontalViewportRect = horizontalViewport.getBoundingClientRect(); const horizontalViewportRect = horizontalViewport.getBoundingClientRect();
const verticalViewportRect = verticalViewport.getBoundingClientRect(); const verticalViewportRect = verticalViewport.getBoundingClientRect();
const viewportTolerance = 1; const nextPosition = getDownloadActionPosition(
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top); rowRect,
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom); horizontalViewportRect,
const visibleHeight = Math.max(0, visibleBottom - visibleTop); verticalViewportRect,
const isVisible = visibleHeight > viewportTolerance; window.innerWidth
const actionHeight = Math.min(rowRect.height, visibleHeight); );
const nextPosition: React.CSSProperties = {
top: visibleTop,
right: Math.max(0, window.innerWidth - horizontalViewportRect.right) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.height ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
setActionPosition(previous => ( setActionPosition(previous => (
previous?.top === nextPosition.top && previous?.top === nextPosition.top &&
@@ -99,11 +109,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
React.useEffect(() => { React.useEffect(() => {
if (!isActionVisible) return; if (!isActionVisible) return;
let frame = 0; let frame: number | null = null;
const schedulePositionUpdate = () => { const schedulePositionUpdate = () => {
if (frame) window.cancelAnimationFrame(frame); if (frame !== null) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => { frame = window.requestAnimationFrame(() => {
frame = 0; frame = null;
updateActionPosition(); updateActionPosition();
}); });
}; };
@@ -127,7 +137,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
window.removeEventListener('resize', schedulePositionUpdate); window.removeEventListener('resize', schedulePositionUpdate);
window.removeEventListener('scroll', schedulePositionUpdate, true); window.removeEventListener('scroll', schedulePositionUpdate, true);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
if (frame) window.cancelAnimationFrame(frame); if (frame !== null) window.cancelAnimationFrame(frame);
}; };
}, [isActionVisible, updateActionPosition]); }, [isActionVisible, updateActionPosition]);
@@ -179,6 +189,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
'File Name': ( 'File Name': (
<div <div
className="download-column-cell download-file-cell download-column-file-name" className="download-column-cell download-file-cell download-column-file-name"
data-column-key="File Name"
style={columnStyle('File Name')} style={columnStyle('File Name')}
> >
<div className="download-cell-content"> <div className="download-cell-content">
@@ -188,12 +199,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<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>
), ),
Size: ( Size: (
<div <div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums" className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
data-column-key="Size"
style={columnStyle('Size')} style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel} title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel} aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
@@ -216,6 +233,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
Status: ( Status: (
<div <div
className="download-column-cell download-status-cell" className="download-column-cell download-status-cell"
data-column-key="Status"
data-column-alignment={columnAlignments.Status} data-column-alignment={columnAlignments.Status}
style={columnStyle('Status')} style={columnStyle('Status')}
> >
@@ -279,61 +297,42 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div> </div>
), ),
Speed: ( Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}> <div className="download-column-cell download-cell-truncate" data-column-key="Speed" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}> <span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed} {displaySpeed}
</span> </span>
</div> </div>
), ),
ETA: ( ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}> <div className="download-column-cell download-cell-truncate" data-column-key="ETA" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}> <span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta} {displayEta}
</span> </span>
</div> </div>
), ),
'Date Added': ( 'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}> <div className="download-column-cell download-cell-right download-column-date-added" data-column-key="Date Added" style={columnStyle('Date Added')}>
<span <span
className="download-cell-content download-date-value tabular-nums" className="download-cell-content download-date-value tabular-nums"
title={download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'} title={dateAddedLabel}
> >
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'} {dateAddedLabel}
</span> </span>
</div> </div>
), ),
}; };
const rowActions = ( const rowActions = hasRowActions ? (
<div <div
className="download-row-actions items-center gap-0.5" className="download-row-actions items-center gap-0.5"
style={{ style={{
...actionPosition, ...actionPosition,
visibility: isActionVisible && actionPosition?.visibility === 'visible' ? 'visible' : 'hidden',
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`, width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
> >
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
<button
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && ( {canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}> <button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" /> <Pause size={14} fill="currentColor" />
@@ -355,17 +354,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<MoreVertical size={14} /> <MoreVertical size={14} />
</button> </button>
</div> </div>
); ) : null;
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}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }} className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
style={{ minWidth: tableMinWidth }}
tabIndex={0} tabIndex={0}
onMouseEnter={() => { onMouseEnter={() => {
setIsRowHovered(true); setIsRowHovered(true);
updateActionPosition(); if (hasRowActions) updateActionPosition();
}} }}
onMouseLeave={event => { onMouseLeave={event => {
const nextTarget = event.relatedTarget; const nextTarget = event.relatedTarget;
@@ -375,7 +375,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}} }}
onFocus={() => { onFocus={() => {
setIsRowFocused(true); setIsRowFocused(true);
updateActionPosition(); if (hasRowActions) updateActionPosition();
}} }}
onBlur={event => { onBlur={event => {
const nextTarget = event.relatedTarget; const nextTarget = event.relatedTarget;
@@ -383,15 +383,48 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
setIsRowFocused(false); setIsRowFocused(false);
} }
}} }}
onPointerDown={event => {
// Modifier clicks belong to selection. Starting a row drag first can
// capture the pointer and suppress the click that applies Cmd/Ctrl or
// Shift selection.
if (
isQueueReorderable &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
onQueueDragStart(download.id, event);
}
}}
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 });
}} }}
> >
{columnOrder.map(columnKey => ( <div
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment> className="download-row-motion"
))} style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
>
{columnOrder.map(columnKey => (
<React.Fragment key={columnKey}>{cells[columnKey]}</React.Fragment>
))}
</div>
{rowActions} {rowActions}
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+164 -14
View File
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle,
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { resolveCategoryDestination } from '../utils/downloadLocations'; import { resolveCategoryDestination } from '../utils/downloadLocations';
import { import {
getPauseResumeAction,
isIdentityLocked as getIdentityLocked, isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked isTransferLocked as getTransferLocked
} from '../utils/downloadActions'; } from '../utils/downloadActions';
@@ -17,19 +18,25 @@ import {
} from '../utils/downloadProgress'; } from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads'; import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
type LoginMode = 'matching' | 'custom' | 'none'; type LoginMode = 'matching' | 'custom' | 'none';
const formatLastTry = (value?: string): string => { const formatLastTry = (
value: string | undefined,
locale: string,
calendar: CalendarPreference
): string => {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); return formatDateTime(value, {
return Number.isNaN(date.getTime()) locale,
? '-' calendar,
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); options: { dateStyle: 'medium', timeStyle: 'short' }
});
}; };
export const PropertiesModal = () => { export const PropertiesModal = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const categoryLabel = (category: string) => { const categoryLabel = (category: string) => {
switch (category) { switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics); case 'Musics': return t($ => $.navigation.categories.musics);
@@ -54,7 +61,7 @@ export const PropertiesModal = () => {
: undefined : undefined
)); ));
const { baseDownloadFolder, perServerConnections } = useSettingsStore(); const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
// Form states // Form states
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
@@ -65,6 +72,8 @@ export const PropertiesModal = () => {
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching'); const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -79,6 +88,7 @@ export const PropertiesModal = () => {
const [mirrors, setMirrors] = useState(''); const [mirrors, setMirrors] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
useEffect(() => { useEffect(() => {
if (selectedPropertiesDownloadId) { if (selectedPropertiesDownloadId) {
@@ -135,6 +145,11 @@ export const PropertiesModal = () => {
} }
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
useEffect(() => {
const activeLimit = item?.speedLimit?.trim();
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
}, [item?.speedLimit, selectedPropertiesDownloadId]);
useEffect(() => { useEffect(() => {
if (!selectedPropertiesDownloadId || connectionsDirty) return; if (!selectedPropertiesDownloadId || connectionsDirty) return;
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
@@ -205,8 +220,59 @@ export const PropertiesModal = () => {
} }
}; };
const handlePauseResume = async () => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id);
const action = currentItem ? getPauseResumeAction(currentItem.status) : null;
if (!currentItem || !action || isPauseResumePending) return;
if (action === 'pause' && currentItem.resumable === false) {
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
if (!confirmPause) return;
}
setErrorMessage('');
setIsPauseResumePending(true);
try {
if (action === 'pause') {
await useDownloadStore.getState().pauseDownload(currentItem.id);
} else {
const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id);
if (!resumed) {
throw new Error(t($ => $.downloadTable.backendRejectedStart));
}
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const message = action === 'pause'
? t($ => $.downloadTable.pauseFailed)
: t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName });
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
} finally {
setIsPauseResumePending(false);
}
};
const handleLiveSpeedLimit = async (limit: string | null) => {
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
setErrorMessage('');
setIsLiveSpeedLimitPending(true);
try {
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
if (limit === null) setLiveSpeedLimitValue('');
} catch (error) {
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
detail: error instanceof Error ? error.message : String(error)
}));
} finally {
setIsLiveSpeedLimitPending(false);
}
};
const identityLocked = getIdentityLocked(item.status); const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status); const transferLocked = getTransferLocked(item.status);
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max( const observedConnectionTotal = Math.max(
1, 1,
@@ -262,6 +328,11 @@ export const PropertiesModal = () => {
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})(); })();
const statusLabel = t($ => $.downloads.status[item.status]); const statusLabel = t($ => $.downloads.status[item.status]);
const pauseResumeAction = getPauseResumeAction(item.status);
const pauseResumeLabel = pauseResumeAction === 'pause'
? t($ => $.downloadTable.pause)
: t($ => $.downloadTable.resume);
const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play;
const sizeDescription = sizeDisplay.totalIsEstimate const sizeDescription = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, { ? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '', downloaded: sizeDisplay.downloaded ?? '',
@@ -334,9 +405,9 @@ export const PropertiesModal = () => {
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div> <div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div> <div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div> <div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div> <div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div> <div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div> <div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && ( {item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0"> <div className="flex gap-1.5 col-span-4 min-w-0">
@@ -400,19 +471,83 @@ export const PropertiesModal = () => {
{t($ => $.properties.savedPerDownload)} {t($ => $.properties.savedPerDownload)}
</div> </div>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speed)}</label> <label className="text-xs text-text-muted text-right">{t($ => $.properties.speedCap)}</label>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-xs text-text-primary"> <label className="inline-flex min-h-7 items-center gap-2 rounded-md border border-border-modal bg-bg-input px-2.5 py-1.5 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" /> <input
type="checkbox"
checked={speedLimitEnabled}
onChange={e => setSpeedLimitEnabled(e.target.checked)}
disabled={transferLocked}
className="accent-accent disabled:opacity-50"
/>
{t($ => $.properties.limit)} {t($ => $.properties.limit)}
</label> </label>
{speedLimitEnabled && ( {speedLimitEnabled && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> <input
type="number"
value={speedLimitValue}
min={1}
step={128}
onChange={e => setSpeedLimitValue(e.target.value)}
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<span className="text-xs text-text-muted">KiB/s</span> <span className="text-xs text-text-muted">KiB/s</span>
</div> </div>
)} )}
</div> </div>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
{liveSpeedLimitAvailable ? (
<>
<label htmlFor="live-speed-limit" className="block text-xs font-semibold text-text-primary">
{t($ => $.properties.liveSpeedLimit)}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="live-speed-limit"
type="text"
inputMode="decimal"
value={liveSpeedLimitValue}
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
disabled={isLiveSpeedLimitPending}
aria-describedby="live-speed-limit-hint"
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
disabled={isLiveSpeedLimitPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitApply)}
</button>
<button
type="button"
onClick={() => void handleLiveSpeedLimit(null)}
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
className="app-button px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitClear)}
</button>
</div>
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitHint)}
</p>
</>
) : (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.liveSpeedLimitUnavailable)}
</p>
)}
</div>
)}
</div> </div>
</section> </section>
@@ -513,12 +648,27 @@ export const PropertiesModal = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button"
onClick={() => setSelectedPropertiesDownloadId(null)} onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs" className="app-button px-4 text-xs"
> >
{t($ => $.properties.cancel)} {t($ => $.properties.cancel)}
</button> </button>
{pauseResumeAction && (
<button
type="button"
onClick={() => void handlePauseResume()}
disabled={isPauseResumePending}
aria-label={pauseResumeLabel}
title={pauseResumeLabel}
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<PauseResumeIcon size={14} fill="currentColor" />
{pauseResumeLabel}
</button>
)}
<button <button
type="button"
onClick={handleSave} onClick={handleSave}
disabled={transferLocked} disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`} className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
+14 -8
View File
@@ -11,6 +11,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform'; import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../utils/dateTime';
const days = [ const days = [
{ value: 0, key: 'su' }, { value: 0, key: 'su' },
@@ -54,9 +55,10 @@ function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'non
} }
export default function SchedulerView() { export default function SchedulerView() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler); const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const setScheduler = useSettingsStore(state => state.setScheduler); const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues); const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings); const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
@@ -75,14 +77,18 @@ export default function SchedulerView() {
const scheduledRun = nextScheduledRun(draft); const scheduledRun = nextScheduledRun(draft);
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled); if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay); if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
return scheduledRun.toLocaleString(undefined, { return formatDateTime(scheduledRun, {
weekday: 'short', locale: i18n.language,
month: 'short', calendar: calendarPreference,
day: 'numeric', options: {
hour: 'numeric', weekday: 'short',
minute: '2-digit' month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
}); });
}, [draft, t]); }, [calendarPreference, draft, i18n.language, t]);
const hasUnsavedChanges = useMemo( const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings), () => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings] [draft, savedSettings]
+125 -34
View File
@@ -1,8 +1,10 @@
import { useCallback, useRef, useState, useEffect } from 'react'; import { useCallback, useRef, useState, useEffect } from 'react';
import { import {
type AppFontSize, type AppFontSize,
type FontFamily,
type ListRowDensity, type ListRowDensity,
type SidebarPosition, type SidebarPosition,
type WindowControlStyle,
type SettingsState, type SettingsState,
SettingsTab, SettingsTab,
runSettingsPersistenceTransaction, runSettingsPersistenceTransaction,
@@ -693,6 +695,29 @@ runEngineChecks(false);
{ value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) }, { value: 'uk', label: t($ => $.settings.lookAndFeel.languageUkrainian) },
{ value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) }, { value: 'ru', label: t($ => $.settings.lookAndFeel.languageRussian) },
] as const; ] as const;
const calendarOptions = [
{ value: 'gregorian', label: t($ => $.settings.lookAndFeel.calendarGregorian) },
{ value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) },
{ value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) },
] as const;
const windowControlStyleOptions: Array<{ value: WindowControlStyle; label: string }> = [
{ value: 'auto', label: t($ => $.settings.lookAndFeel.windowControlStyleAutomatic) },
{ value: 'macos', label: t($ => $.settings.lookAndFeel.windowControlStyleMacos) },
{ value: 'windows', label: t($ => $.settings.lookAndFeel.windowControlStyleWindows) },
{ value: 'gnome', label: t($ => $.settings.lookAndFeel.windowControlStyleGnome) },
{ value: 'minimal', label: t($ => $.settings.lookAndFeel.windowControlStyleMinimal) },
];
const fontFamilyOptions: Array<{ value: FontFamily; label: string }> = [
{ value: 'system', label: t($ => $.settings.lookAndFeel.fontFamilySystem) },
{ value: 'inter', label: t($ => $.settings.lookAndFeel.fontFamilyInter) },
{ value: 'outfit', label: t($ => $.settings.lookAndFeel.fontFamilyOutfit) },
{ value: 'vazirmatn', label: t($ => $.settings.lookAndFeel.fontFamilyVazirmatn) },
{ value: 'noto-sans-hebrew', label: t($ => $.settings.lookAndFeel.fontFamilyNotoSansHebrew) },
{ value: 'noto-sans-sc', label: t($ => $.settings.lookAndFeel.fontFamilyNotoSansSc) },
{ value: 'roboto', label: t($ => $.settings.lookAndFeel.fontFamilyRoboto) },
{ value: 'serif', label: t($ => $.settings.lookAndFeel.fontFamilySerif) },
{ value: 'monospace', label: t($ => $.settings.lookAndFeel.fontFamilyMonospace) },
];
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => { const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type; const active = activeTab === type;
@@ -853,40 +878,6 @@ runEngineChecks(false);
{/* Look & Feel Pane */} {/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && ( {activeTab === 'lookandfeel' && (
<div className="settings-pane max-w-[720px]"> <div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.language)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.language)}</span>
<small>{t($ => $.settings.lookAndFeel.languageDescription)}</small>
</div>
<select
value={settings.language}
onChange={(event) => settings.setLanguage(event.target.value as typeof settings.language)}
className="app-control w-48"
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
<small>{t($ => $.settings.lookAndFeel.sidebarPositionDescription)}</small>
</div>
<select
value={settings.sidebarPosition}
onChange={(event) => settings.setSidebarPosition(event.target.value as SidebarPosition)}
className="app-control w-48"
>
<option value="auto">{t($ => $.settings.lookAndFeel.sidebarPositionAutomatic)}</option>
<option value="left">{t($ => $.settings.lookAndFeel.sidebarPositionLeft)}</option>
<option value="right">{t($ => $.settings.lookAndFeel.sidebarPositionRight)}</option>
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.appTheme)}</h2> <h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.appTheme)}</h2>
<div className="mac-settings-group"> <div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row"> <div className="mac-settings-row settings-choice-row">
@@ -924,6 +915,21 @@ runEngineChecks(false);
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.display)}</h2> <h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.display)}</h2>
<div className="mac-settings-group"> <div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.fontFamily)}</span>
<small>{t($ => $.settings.lookAndFeel.fontFamilyDescription)}</small>
</div>
<select
value={settings.fontFamily}
onChange={(event) => settings.setFontFamily(event.target.value as FontFamily)}
className="app-control w-60"
>
{fontFamilyOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row"> <div className="mac-settings-row">
<div className="settings-row-label"> <div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.fontSize)}</span> <span>{t($ => $.settings.lookAndFeel.fontSize)}</span>
@@ -956,6 +962,78 @@ runEngineChecks(false);
</div> </div>
</div> </div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.languageAndRegion)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.language)}</span>
<small>{t($ => $.settings.lookAndFeel.languageDescription)}</small>
</div>
<select
value={settings.language}
onChange={(event) => settings.setLanguage(event.target.value as typeof settings.language)}
className="app-control w-60"
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.calendar)}</span>
<small>{t($ => $.settings.lookAndFeel.calendarDescription)}</small>
</div>
<select
value={settings.calendarPreference}
onChange={(event) => settings.setCalendarPreference(event.target.value as typeof settings.calendarPreference)}
className="app-control w-60"
>
{calendarOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.windowControls)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.windowControlStyle)}</span>
<small>{t($ => $.settings.lookAndFeel.windowControlsDescription)}</small>
</div>
<select
value={settings.windowControlStyle}
onChange={(event) => settings.setWindowControlStyle(event.target.value as WindowControlStyle)}
className="app-control w-60"
>
{windowControlStyleOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.layout)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.sidebarPosition)}</span>
<small>{t($ => $.settings.lookAndFeel.sidebarPositionDescription)}</small>
</div>
<select
value={settings.sidebarPosition}
onChange={(event) => settings.setSidebarPosition(event.target.value as SidebarPosition)}
className="app-control w-60"
>
<option value="auto">{t($ => $.settings.lookAndFeel.sidebarPositionAutomatic)}</option>
<option value="left">{t($ => $.settings.lookAndFeel.sidebarPositionLeft)}</option>
<option value="right">{t($ => $.settings.lookAndFeel.sidebarPositionRight)}</option>
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.osIntegration)}</h2> <h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.osIntegration)}</h2>
<div className="mac-settings-group"> <div className="mac-settings-group">
{platform.os === 'macos' && ( {platform.os === 'macos' && (
@@ -1397,6 +1475,19 @@ runEngineChecks(false);
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventSleepDescription)}</p> <p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventSleepDescription)}</p>
</div> </div>
</label> </label>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsDisplaySleepWhileDownloading}
onChange={(e) => settings.setPreventsDisplaySleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">{t($ => $.settings.power.preventDisplaySleep)}</p>
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventDisplaySleepDescription)}</p>
</div>
</label>
</div> </div>
)} )}
+40 -1
View File
@@ -22,7 +22,7 @@ interface SidebarProps {
export const Sidebar: React.FC<SidebarProps> = (props) => { export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props; const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore(); const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore(); const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast(); const { addToast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -475,6 +475,45 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<Pause size={14} className="me-2 text-text-secondary" /> <Pause size={14} className="me-2 text-text-secondary" />
{t($ => $.actions.pauseQueue)} {t($ => $.actions.pauseQueue)}
</button> </button>
{(() => {
const queue = queues.find(candidate => candidate.id === contextMenu.id);
if (!queue) return null;
return (
<div
role="group"
aria-labelledby="queue-concurrency-label"
className="px-3 py-2 text-[12px] text-text-secondary"
onClick={event => event.stopPropagation()}
>
<label id="queue-concurrency-label" htmlFor="queue-concurrency-select" className="block mb-1">
{t($ => $.sidebar.queueConcurrency)}
</label>
<select
id="queue-concurrency-select"
aria-label={t($ => $.sidebar.queueConcurrency)}
value={queue.maxConcurrent?.toString() ?? ''}
className="w-full rounded border border-border-color bg-bg-context-menu px-1.5 py-1 text-[12px] text-text-primary outline-none focus:border-accent"
onChange={event => {
const value = event.currentTarget.value === ''
? null
: Number(event.currentTarget.value);
void setQueueConcurrency(queue.id, value).catch(error => {
addToast({
message: t($ => $.sidebar.queueConcurrencyFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
}}
>
<option value="">{t($ => $.sidebar.queueConcurrencyGlobal)}</option>
{Array.from({ length: 12 }, (_, index) => index + 1).map(value => (
<option key={value} value={value}>{value}</option>
))}
</select>
</div>
);
})()}
<div className="h-px bg-border-color my-1 mx-2" /> <div className="h-px bg-border-color my-1 mx-2" />
<button <button
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover" className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
+4 -2
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { Maximize2, Minus, X } from 'lucide-react'; import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react'; import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
@@ -11,14 +12,15 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
interface WindowControlsProps { interface WindowControlsProps {
side: 'left' | 'right'; side: 'left' | 'right';
controlStyle: ResolvedWindowControlStyle;
} }
export function WindowControls({ side }: WindowControlsProps) { export function WindowControls({ side, controlStyle }: WindowControlsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
className={`window-controls window-controls--${side}`} className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
aria-label={t($ => $.window.controls)} aria-label={t($ => $.window.controls)}
> >
<button <button
+60
View File
@@ -216,6 +216,13 @@ const common = {
connectionCountUnknown: '—/{{total}}', connectionCountUnknown: '—/{{total}}',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: 'Speed cap', speedCap: 'Speed cap',
liveSpeedLimit: 'Live speed cap',
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
liveSpeedLimitPlaceholder: 'e.g. 1024K',
liveSpeedLimitApply: 'Apply',
liveSpeedLimitClear: 'Clear',
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
category: 'Category', category: 'Category',
lastTry: 'Last try', lastTry: 'Last try',
dateAdded: 'Date added', dateAdded: 'Date added',
@@ -326,6 +333,9 @@ const common = {
queueNameExists: 'A queue with this name already exists', queueNameExists: 'A queue with this name already exists',
startQueueFailed: 'Could not start queue: {{detail}}', startQueueFailed: 'Could not start queue: {{detail}}',
pauseQueueFailed: 'Could not pause queue: {{detail}}', pauseQueueFailed: 'Could not pause queue: {{detail}}',
queueConcurrency: 'Concurrent downloads',
queueConcurrencyGlobal: 'Use global limit',
queueConcurrencyFailed: 'Could not update queue concurrency: {{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: 'Unknown Queue', unknownQueue: 'Unknown Queue',
@@ -353,6 +363,15 @@ const common = {
addDownload: 'Add Download', addDownload: 'Add Download',
resumeAll: 'Resume All', resumeAll: 'Resume All',
pauseAll: 'Pause All', pauseAll: 'Pause All',
summary: {
items: '{{count}} items',
selected: '{{count}} selected',
downloaded: 'Downloaded',
remaining: 'Remaining',
active: 'Active',
unknown: 'Unknown',
estimated: 'Estimated {{value}}',
},
queueEmpty: 'Queue is empty', queueEmpty: 'Queue is empty',
noCompletedDownloads: 'No Completed Downloads', noCompletedDownloads: 'No Completed Downloads',
noDownloads: 'No Downloads', noDownloads: 'No Downloads',
@@ -361,6 +380,11 @@ const common = {
clickToAdd: 'Click', clickToAdd: 'Click',
addButtonOr: 'button or', addButtonOr: 'button or',
toAddDownloads: 'to add downloads', toAddDownloads: 'to add downloads',
queueReorderHint: 'Drag rows to reorder or use the hotkeys:',
queueReorderShortcut: '{{key}} + Arrow Up/Down moves the focused row',
queuePriorityControls: 'Move selected downloads in the queue',
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}}',
@@ -447,6 +471,14 @@ 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',
format: 'Format',
video: 'Video',
audio: 'Audio',
noCommonFormat: 'No single format is available for all selected items.',
noCommonQuality: 'No single quality is available for all selected items.',
playlistQuality: 'Playlist quality',
playlistQualitySelected: '{{selected}} of {{total}} selected',
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',
@@ -553,12 +585,22 @@ const common = {
}, },
lookAndFeel: { lookAndFeel: {
language: 'Language', language: 'Language',
languageAndRegion: 'Language & region',
layout: 'Layout',
languageDescription: 'Choose the Firelink interface language.', languageDescription: 'Choose the Firelink interface language.',
sidebarPosition: 'Sidebar position', sidebarPosition: 'Sidebar position',
sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.', sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.',
sidebarPositionAutomatic: 'Automatic (recommended)', sidebarPositionAutomatic: 'Automatic (recommended)',
sidebarPositionLeft: 'Left', sidebarPositionLeft: 'Left',
sidebarPositionRight: 'Right', sidebarPositionRight: 'Right',
windowControls: 'Window controls',
windowControlStyle: 'Control style',
windowControlsDescription: 'Choose how Firelink draws its custom window buttons. Automatic follows your operating system.',
windowControlStyleAutomatic: 'Automatic (recommended)',
windowControlStyleMacos: 'macOS Traffic Lights',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Minimal',
languageSystem: 'System default', languageSystem: 'System default',
languageEnglish: 'English', languageEnglish: 'English',
languageChinese: 'Simplified Chinese', languageChinese: 'Simplified Chinese',
@@ -566,6 +608,11 @@ const common = {
languagePersian: 'Persian', languagePersian: 'Persian',
languageUkrainian: 'Ukrainian', languageUkrainian: 'Ukrainian',
languageRussian: 'Russian', languageRussian: 'Russian',
calendar: 'Calendar',
calendarDescription: 'Choose the calendar used for displayed dates and times. Gregorian is the default; this does not change scheduling or stored timestamps.',
calendarGregorian: 'Gregorian',
calendarPersian: 'Persian',
calendarHebrew: 'Hebrew',
appTheme: 'App Theme', appTheme: 'App Theme',
theme: 'Theme', theme: 'Theme',
ariaLabel: 'App theme', ariaLabel: 'App theme',
@@ -576,6 +623,17 @@ const common = {
nord: 'Nord', nord: 'Nord',
systemAppearance: 'System follows the current {{platform}} light or dark appearance.', systemAppearance: 'System follows the current {{platform}} light or dark appearance.',
display: 'Display', display: 'Display',
fontFamily: 'Font',
fontFamilyDescription: 'Choose the interface font. System uses platform and locale-aware fallbacks.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Font size', fontSize: 'Font size',
fontSizeDescription: 'Scales the download list and compact controls.', fontSizeDescription: 'Scales the download list and compact controls.',
small: 'Small', small: 'Small',
@@ -680,6 +738,8 @@ const common = {
title: 'Power Management', title: 'Power Management',
preventSleep: 'Prevent system sleep while downloads are active', preventSleep: 'Prevent system sleep while downloads are active',
preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.', preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.',
preventDisplaySleep: 'Keep the display awake while downloads are active',
preventDisplaySleepDescription: 'Optional and separate from system-sleep prevention. The display will remain on until active transfers finish.',
}, },
engine: { engine: {
title: 'Media Downloader & Engines', title: 'Media Downloader & Engines',
+65 -5
View File
@@ -49,7 +49,7 @@ const fa = {
description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.', description: 'برخی از دانلودهایی که اضافه می‌کنید از قبل در صف یا روی دیسک وجود دارند. لطفاً نحوه حل این تداخل‌ها را انتخاب کنید.',
rename: 'تغییر نام', rename: 'تغییر نام',
replace: 'جایگزینی', replace: 'جایگزینی',
skip: 'رد شدن', skip: 'رد کردن',
}, },
removeDownload: { removeDownload: {
title: 'حذف دانلود', title: 'حذف دانلود',
@@ -166,7 +166,7 @@ const fa = {
afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.', afterCompletionDescription: 'انتخاب کنید پس از پایان دانلودهای شروع‌شده توسط زمان‌بند، چه اتفاقی بیفتد.',
actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.', actionWarning: 'این اقدام می‌تواند سایر کارهای کامپیوتر را مختل کند. Firelink بلافاصله پس از اتمام صف زمان‌بندی‌شده، آن را اجرا می‌کند.',
systemPermissions: 'مجوزهای سیستم', systemPermissions: 'مجوزهای سیستم',
macPermissionDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.', macPermissionDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن به مجوز macOS Automation برای System Events نیاز دارد.',
permissionGranted: 'مجوز اتوماسیون اعطا شد', permissionGranted: 'مجوز اتوماسیون اعطا شد',
permissionChecking: 'در حال بررسی مجوز اتوماسیون…', permissionChecking: 'در حال بررسی مجوز اتوماسیون…',
permissionMissing: 'مجوز اتوماسیون موجود نیست', permissionMissing: 'مجوز اتوماسیون موجود نیست',
@@ -178,8 +178,8 @@ const fa = {
requestingPermission: 'در حال درخواست مجوز اتوماسیون…', requestingPermission: 'در حال درخواست مجوز اتوماسیون…',
enablePermissionDetails: 'Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید و سپس به Firelink بازگردید.', enablePermissionDetails: 'Firelink را در بخش System Settings > Privacy & Security > Automation برای System Events فعال کنید و سپس به Firelink بازگردید.',
systemActions: 'اقدامات سیستم', systemActions: 'اقدامات سیستم',
windowsActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.', windowsActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسترسی‌های سیستم Windows استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
linuxActionsDescription: 'خواب، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.', linuxActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید', validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید',
validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید', validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید',
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد', validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
@@ -201,7 +201,7 @@ const fa = {
}, },
postActions: { postActions: {
none: 'هیچ کاری انجام نده', none: 'هیچ کاری انجام نده',
sleep: 'خواب', sleep: 'خوابیدن',
restart: 'راه‌اندازی مجدد', restart: 'راه‌اندازی مجدد',
shutdown: 'خاموش کردن', shutdown: 'خاموش کردن',
}, },
@@ -216,6 +216,13 @@ const fa = {
connectionCountUnknown: '—/{{total}} فعال', connectionCountUnknown: '—/{{total}} فعال',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: 'سقف سرعت', speedCap: 'سقف سرعت',
liveSpeedLimit: 'سقف سرعت زنده',
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال می‌شود. سرعت دانلودهای رسانه‌ای هنگام اجرا قابل تغییر نیست.',
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
liveSpeedLimitApply: 'اعمال',
liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
category: 'دسته', category: 'دسته',
lastTry: 'آخرین تلاش', lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن', dateAdded: 'تاریخ افزودن',
@@ -326,6 +333,9 @@ const fa = {
queueNameExists: 'یک صف با این نام از قبل وجود دارد', queueNameExists: 'یک صف با این نام از قبل وجود دارد',
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}', startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}', pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
queueConcurrency: 'دانلودهای هم‌زمان',
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
queueConcurrencyFailed: 'به‌روزرسانی هم‌زمانی صف ناموفق بود: {{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: 'صف نامشخص', unknownQueue: 'صف نامشخص',
@@ -353,6 +363,15 @@ const fa = {
addDownload: 'افزودن دانلود', addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه', resumeAll: 'ادامه همه',
pauseAll: 'توقف همه', pauseAll: 'توقف همه',
summary: {
items: '{{count}} مورد',
selected: '{{count}} انتخاب‌شده',
downloaded: 'دانلودشده',
remaining: 'باقی‌مانده',
active: 'فعال',
unknown: 'نامشخص',
estimated: '{{value}} (تخمینی)',
},
queueEmpty: 'صف خالی است', queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد', noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد', noDownloads: 'هیچ دانلودی وجود ندارد',
@@ -361,6 +380,11 @@ const fa = {
clickToAdd: 'برای افزودن دانلودها، روی', clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا', addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید', toAddDownloads: 'کلیک کنید',
queueReorderHint: 'ردیف‌ها را بکشید تا مرتب شوند یا از کلیدهای میانبر استفاده کنید:',
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخاب‌شده را جابه‌جا می‌کند',
queuePriorityControls: 'جابه‌جایی دانلودهای انتخاب‌شده در صف',
queueDragHandle: 'کشیدن {{fileName}} برای جابه‌جایی',
queueReorderFailed: 'جابه‌جایی دانلودها ناموفق بود',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟', nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟', nonResumableMany: '{{count}} دانلود از ادامه‌دادن پشتیبانی نمی‌کنند. اگر آن‌ها را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن‌ها مطمئن هستید؟',
interactionError: '{{message}}: {{detail}}', interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const fa = {
fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…', fetchingMediaStreams: 'در حال دریافت جریان‌های رسانه…',
availableStreams: 'جریان‌های موجود', availableStreams: 'جریان‌های موجود',
availableMediaStreams: 'جریان‌های رسانه موجود', availableMediaStreams: 'جریان‌های رسانه موجود',
quality: 'کیفیت',
format: 'قالب',
video: 'ویدئو',
audio: 'صوتی',
noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخاب‌شده وجود ندارد.',
noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخاب‌شده وجود ندارد.',
playlistQuality: 'کیفیت فهرست پخش',
playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده',
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.', metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.',
saveLocation: 'محل ذخیره', saveLocation: 'محل ذخیره',
browse: 'انتخاب…', browse: 'انتخاب…',
@@ -553,12 +585,22 @@ const fa = {
}, },
lookAndFeel: { lookAndFeel: {
language: 'زبان', language: 'زبان',
languageAndRegion: 'زبان و منطقه',
layout: 'چیدمان',
languageDescription: 'زبان رابط Firelink را انتخاب کنید.', languageDescription: 'زبان رابط Firelink را انتخاب کنید.',
sidebarPosition: 'جایگاه نوار کناری', sidebarPosition: 'جایگاه نوار کناری',
sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.', sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.',
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)', sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
sidebarPositionLeft: 'چپ', sidebarPositionLeft: 'چپ',
sidebarPositionRight: 'راست', sidebarPositionRight: 'راست',
windowControls: 'کنترل‌های پنجره',
windowControlStyle: 'سبک کنترل‌ها',
windowControlsDescription: 'نحوه نمایش دکمه‌های سفارشی پنجره Firelink را انتخاب کنید. حالت خودکار از سیستم‌عامل شما پیروی می‌کند.',
windowControlStyleAutomatic: 'خودکار (پیشنهادشده)',
windowControlStyleMacos: 'چراغ‌های macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'ساده',
languageSystem: 'زبان سیستم', languageSystem: 'زبان سیستم',
languageEnglish: 'انگلیسی', languageEnglish: 'انگلیسی',
languageChinese: 'چینی ساده‌شده', languageChinese: 'چینی ساده‌شده',
@@ -566,6 +608,11 @@ const fa = {
languagePersian: 'فارسی', languagePersian: 'فارسی',
languageUkrainian: 'اوکراینی', languageUkrainian: 'اوکراینی',
languageRussian: 'روسی', languageRussian: 'روسی',
calendar: 'تقویم',
calendarDescription: 'تقویم مورد استفاده برای نمایش تاریخ و زمان را انتخاب کنید. تقویم میلادی پیش‌فرض است و زمان‌بندی یا مُهرهای زمانی ذخیره‌شده را تغییر نمی‌دهد.',
calendarGregorian: 'میلادی',
calendarPersian: 'هجری شمسی',
calendarHebrew: 'عبری',
appTheme: 'ظاهر برنامه', appTheme: 'ظاهر برنامه',
theme: 'ظاهر', theme: 'ظاهر',
ariaLabel: 'ظاهر برنامه', ariaLabel: 'ظاهر برنامه',
@@ -576,6 +623,17 @@ const fa = {
nord: 'Nord', nord: 'Nord',
systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.', systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.',
display: 'نمایش', display: 'نمایش',
fontFamily: 'فونت',
fontFamilyDescription: 'فونت رابط کاربری را انتخاب کنید. حالت سیستم از فونت‌های سیستمی متناسب با سیستم‌عامل و زبان استفاده می‌کند.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'اندازه قلم', fontSize: 'اندازه قلم',
fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.', fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.',
small: 'کوچک', small: 'کوچک',
@@ -680,6 +738,8 @@ const fa = {
title: 'مدیریت انرژی', title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند', preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.', preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها',
preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقال‌های فعال روشن می‌ماند.',
}, },
engine: { engine: {
title: 'دانلودکننده رسانه و موتورها', title: 'دانلودکننده رسانه و موتورها',
+60
View File
@@ -216,6 +216,13 @@ const he = {
connectionCountUnknown: '—/{{total}} פעילות', connectionCountUnknown: '—/{{total}} פעילות',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: 'הגבלת מהירות', speedCap: 'הגבלת מהירות',
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
liveSpeedLimitApply: 'החל',
liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
category: 'קטגוריה', category: 'קטגוריה',
lastTry: 'ניסיון אחרון', lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה', dateAdded: 'תאריך הוספה',
@@ -326,6 +333,9 @@ const he = {
queueNameExists: 'כבר קיים תור בשם זה', queueNameExists: 'כבר קיים תור בשם זה',
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}', startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}', pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
queueConcurrency: 'הורדות בו-זמניות',
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: 'תור לא ידוע', unknownQueue: 'תור לא ידוע',
@@ -353,6 +363,15 @@ const he = {
addDownload: 'הוספת הורדה', addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל', resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל', pauseAll: 'השהיית הכל',
summary: {
items: '{{count}} פריטים',
selected: '{{count}} נבחרו',
downloaded: 'הורדו',
remaining: 'נותרו',
active: 'פעילות',
unknown: 'לא ידוע',
estimated: '{{value}} (הערכה)',
},
queueEmpty: 'התור ריק', queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו', noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות', noDownloads: 'אין הורדות',
@@ -361,6 +380,11 @@ const he = {
clickToAdd: 'לחץ על לחצן', clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או', addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות', toAddDownloads: 'כדי להוסיף הורדות',
queueReorderHint: 'גררו שורות כדי לשנות את הסדר או השתמשו במקשי הקיצור:',
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?', nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?', nonResumableMany: '{{count}} הורדות אינן תומכות בחידוש. אם תשהה אותן, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
interactionError: '{{message}}: {{detail}}', interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const he = {
fetchingMediaStreams: 'טוען זרמי מדיה…', fetchingMediaStreams: 'טוען זרמי מדיה…',
availableStreams: 'זרמים זמינים', availableStreams: 'זרמים זמינים',
availableMediaStreams: 'זרמי מדיה זמינים', availableMediaStreams: 'זרמי מדיה זמינים',
quality: 'איכות',
format: 'פורמט',
video: 'וידאו',
audio: 'שמע',
noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.',
noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.',
playlistQuality: 'איכות רשימת השמעה',
playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו',
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.', metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
saveLocation: 'מיקום שמירה', saveLocation: 'מיקום שמירה',
browse: 'עיון…', browse: 'עיון…',
@@ -553,12 +585,22 @@ const he = {
}, },
lookAndFeel: { lookAndFeel: {
language: 'שפה', language: 'שפה',
languageAndRegion: 'שפה ואזור',
layout: 'פריסה',
languageDescription: 'בחר את שפת הממשק של Firelink.', languageDescription: 'בחר את שפת הממשק של Firelink.',
sidebarPosition: 'מיקום סרגל הצד', sidebarPosition: 'מיקום סרגל הצד',
sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.', sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.',
sidebarPositionAutomatic: 'אוטומטי (מומלץ)', sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
sidebarPositionLeft: 'שמאל', sidebarPositionLeft: 'שמאל',
sidebarPositionRight: 'ימין', sidebarPositionRight: 'ימין',
windowControls: 'פקדי חלון',
windowControlStyle: 'סגנון פקדים',
windowControlsDescription: 'בחר כיצד Firelink מצייר את כפתורי החלון המותאמים אישית. מצב אוטומטי עוקב אחר מערכת ההפעלה שלך.',
windowControlStyleAutomatic: 'אוטומטי (מומלץ)',
windowControlStyleMacos: 'רמזורי macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'מינימלי',
languageSystem: 'שפת המערכת', languageSystem: 'שפת המערכת',
languageEnglish: 'אנגלית', languageEnglish: 'אנגלית',
languageChinese: 'סינית מפושטת', languageChinese: 'סינית מפושטת',
@@ -566,6 +608,11 @@ const he = {
languagePersian: 'פרסית', languagePersian: 'פרסית',
languageUkrainian: 'אוקראינית', languageUkrainian: 'אוקראינית',
languageRussian: 'רוסית', languageRussian: 'רוסית',
calendar: 'לוח שנה',
calendarDescription: 'בחר את לוח השנה לתצוגת תאריכים ושעות. הלוח הגרגוריאני הוא ברירת המחדל; הבחירה אינה משנה תזמון או חותמות זמן שמורות.',
calendarGregorian: 'גרגוריאני',
calendarPersian: 'פרסי',
calendarHebrew: 'עברי',
appTheme: 'ערכת נושא לאפליקציה', appTheme: 'ערכת נושא לאפליקציה',
theme: 'ערכת נושא', theme: 'ערכת נושא',
ariaLabel: 'ערכת נושא לאפליקציה', ariaLabel: 'ערכת נושא לאפליקציה',
@@ -576,6 +623,17 @@ const he = {
nord: 'Nord', nord: 'Nord',
systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.', systemAppearance: 'מערכת עוקבת אחר המראה הנוכחי (בהיר או כהה) של {{platform}}.',
display: 'תצוגה', display: 'תצוגה',
fontFamily: 'גופן',
fontFamilyDescription: 'בחר את גופן הממשק. מצב המערכת משתמש בגופני מערכת המתאימים לפלטפורמה ולשפה.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'גודל גופן', fontSize: 'גודל גופן',
fontSizeDescription: 'משנה את קנה המידה של רשימת ההורדות והפקדים הקומפקטיים.', fontSizeDescription: 'משנה את קנה המידה של רשימת ההורדות והפקדים הקומפקטיים.',
small: 'קטן', small: 'קטן',
@@ -680,6 +738,8 @@ const he = {
title: 'ניהול צריכת חשמל', title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות', preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.', preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות',
preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.',
}, },
engine: { engine: {
title: 'מוריד מדיה ומנועים', title: 'מוריד מדיה ומנועים',
+61 -1
View File
@@ -203,7 +203,7 @@ const ru = {
none: 'Ничего не делать', none: 'Ничего не делать',
sleep: 'Спящий режим', sleep: 'Спящий режим',
restart: 'Перезагрузка', restart: 'Перезагрузка',
shutdown: 'Завершение работы', shutdown: 'Выключение',
}, },
}, },
properties: { properties: {
@@ -216,6 +216,13 @@ const ru = {
connectionCountUnknown: '—/{{total}} активных', connectionCountUnknown: '—/{{total}} активных',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: 'Ограничение скорости', speedCap: 'Ограничение скорости',
liveSpeedLimit: 'Текущее ограничение скорости',
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
liveSpeedLimitPlaceholder: 'например, 1024K',
liveSpeedLimitApply: 'Применить',
liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
category: 'Категория', category: 'Категория',
lastTry: 'Последняя попытка', lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления', dateAdded: 'Дата добавления',
@@ -326,6 +333,9 @@ const ru = {
queueNameExists: 'Очередь с таким названием уже существует', queueNameExists: 'Очередь с таким названием уже существует',
startQueueFailed: 'Не удалось запустить очередь: {{detail}}', startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}', pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
queueConcurrency: 'Одновременные загрузки',
queueConcurrencyGlobal: 'Использовать общий лимит',
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: 'Неизвестная очередь', unknownQueue: 'Неизвестная очередь',
@@ -353,6 +363,15 @@ const ru = {
addDownload: 'Добавить загрузку', addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все', resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все', pauseAll: 'Приостановить все',
summary: {
items: '{{count}} элементов',
selected: 'Выбрано: {{count}}',
downloaded: 'Загружено',
remaining: 'Осталось',
active: 'Активные',
unknown: 'Неизвестно',
estimated: 'Около {{value}}',
},
queueEmpty: 'Очередь пуста', queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок', noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок', noDownloads: 'Нет загрузок',
@@ -361,6 +380,11 @@ const ru = {
clickToAdd: 'Нажмите кнопку', clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или', addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки', toAddDownloads: 'чтобы добавить загрузки',
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок, или используйте горячие клавиши:',
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
queueReorderFailed: 'Не удалось изменить порядок загрузок',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?', nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?', nonResumableMany: '{{count}} загрузок не поддерживают возобновление. Если вы приостановите их, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
interactionError: '{{message}}: {{detail}}', interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const ru = {
fetchingMediaStreams: 'Получение медиапотоков…', fetchingMediaStreams: 'Получение медиапотоков…',
availableStreams: 'Доступные потоки', availableStreams: 'Доступные потоки',
availableMediaStreams: 'Доступные медиапотоки', availableMediaStreams: 'Доступные медиапотоки',
quality: 'Качество',
format: 'Формат',
video: 'Видео',
audio: 'Аудио',
noCommonFormat: 'Для всех выбранных элементов нет общего формата.',
noCommonQuality: 'Для всех выбранных элементов нет общего качества.',
playlistQuality: 'Качество плейлиста',
playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}',
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.', metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
saveLocation: 'Папка сохранения', saveLocation: 'Папка сохранения',
browse: 'Обзор…', browse: 'Обзор…',
@@ -553,12 +585,22 @@ const ru = {
}, },
lookAndFeel: { lookAndFeel: {
language: 'Язык', language: 'Язык',
languageAndRegion: 'Язык и регион',
layout: 'Компоновка',
languageDescription: 'Выберите язык интерфейса Firelink.', languageDescription: 'Выберите язык интерфейса Firelink.',
sidebarPosition: 'Положение боковой панели', sidebarPosition: 'Положение боковой панели',
sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.', sidebarPositionDescription: 'Выберите, где отображается боковая панель. В автоматическом режиме следует направлению интерфейса.',
sidebarPositionAutomatic: 'Автоматически (рекомендуется)', sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
sidebarPositionLeft: 'Слева', sidebarPositionLeft: 'Слева',
sidebarPositionRight: 'Справа', sidebarPositionRight: 'Справа',
windowControls: 'Элементы управления окном',
windowControlStyle: 'Стиль элементов управления',
windowControlsDescription: 'Выберите, как Firelink отображает пользовательские кнопки окна. Автоматический режим учитывает вашу ОС.',
windowControlStyleAutomatic: 'Автоматически (рекомендуется)',
windowControlStyleMacos: 'Светофоры macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Минималистичный',
languageSystem: 'Системный по умолчанию', languageSystem: 'Системный по умолчанию',
languageEnglish: 'Английский', languageEnglish: 'Английский',
languageChinese: 'Упрощённый китайский', languageChinese: 'Упрощённый китайский',
@@ -566,6 +608,11 @@ const ru = {
languagePersian: 'Персидский', languagePersian: 'Персидский',
languageUkrainian: 'Украинский', languageUkrainian: 'Украинский',
languageRussian: 'Русский', languageRussian: 'Русский',
calendar: 'Календарь',
calendarDescription: 'Выберите календарь для отображения дат и времени. По умолчанию используется григорианский; расписание и сохранённые метки времени не изменяются.',
calendarGregorian: 'Григорианский',
calendarPersian: 'Персидский',
calendarHebrew: 'Еврейский',
appTheme: 'Тема приложения', appTheme: 'Тема приложения',
theme: 'Тема', theme: 'Тема',
ariaLabel: 'Тема приложения', ariaLabel: 'Тема приложения',
@@ -576,6 +623,17 @@ const ru = {
nord: 'Nord', nord: 'Nord',
systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.', systemAppearance: 'Системная тема соответствует текущему светлому или тёмному оформлению {{platform}}.',
display: 'Отображение', display: 'Отображение',
fontFamily: 'Шрифт',
fontFamilyDescription: 'Выберите шрифт интерфейса. Системный режим использует системные шрифты с учётом ОС и языка.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Размер шрифта', fontSize: 'Размер шрифта',
fontSizeDescription: 'Масштабирует список загрузок и компактные элементы управления.', fontSizeDescription: 'Масштабирует список загрузок и компактные элементы управления.',
small: 'Мелкий', small: 'Мелкий',
@@ -680,6 +738,8 @@ const ru = {
title: 'Управление питанием', title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок', preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.', preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
preventDisplaySleep: 'Не выключать экран во время активных загрузок',
preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.',
}, },
engine: { engine: {
title: 'Загрузчик медиа и движки', title: 'Загрузчик медиа и движки',
+60
View File
@@ -216,6 +216,13 @@ const uk = {
connectionCountUnknown: '—/{{total}} активних', connectionCountUnknown: '—/{{total}} активних',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: 'Обмеження швидкості', speedCap: 'Обмеження швидкості',
liveSpeedLimit: 'Поточне обмеження швидкості',
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
liveSpeedLimitApply: 'Застосувати',
liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
category: 'Категорія', category: 'Категорія',
lastTry: 'Остання спроба', lastTry: 'Остання спроба',
dateAdded: 'Дата додавання', dateAdded: 'Дата додавання',
@@ -326,6 +333,9 @@ const uk = {
queueNameExists: 'Черга з такою назвою вже існує', queueNameExists: 'Черга з такою назвою вже існує',
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}', startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}', pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
queueConcurrency: 'Одночасні завантаження',
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: 'Невідома черга', unknownQueue: 'Невідома черга',
@@ -353,6 +363,15 @@ const uk = {
addDownload: 'Додати завантаження', addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі', resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі', pauseAll: 'Призупинити всі',
summary: {
items: '{{count}} елементів',
selected: 'Вибрано: {{count}}',
downloaded: 'Завантажено',
remaining: 'Залишилось',
active: 'Активні',
unknown: 'Невідомо',
estimated: 'Приблизно {{value}}',
},
queueEmpty: 'Черга порожня', queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень', noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень', noDownloads: 'Немає завантажень',
@@ -361,6 +380,11 @@ const uk = {
clickToAdd: 'Натисніть', clickToAdd: 'Натисніть',
addButtonOr: 'або', addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження', toAddDownloads: 'щоб додати завантаження',
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок, або використовуйте гарячі клавіші:',
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?', nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?', nonResumableMany: '{{count}} завантажень не підтримують відновлення. Якщо ви призупините їх, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
interactionError: '{{message}}: {{detail}}', interactionError: '{{message}}: {{detail}}',
@@ -447,6 +471,14 @@ const uk = {
fetchingMediaStreams: 'Отримання медіапотоків…', fetchingMediaStreams: 'Отримання медіапотоків…',
availableStreams: 'Доступні потоки', availableStreams: 'Доступні потоки',
availableMediaStreams: 'Доступні медіапотоки', availableMediaStreams: 'Доступні медіапотоки',
quality: 'Якість',
format: 'Формат',
video: 'Відео',
audio: 'Аудіо',
noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.',
noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.',
playlistQuality: 'Якість плейлиста',
playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}',
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.', metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
saveLocation: 'Місце збереження', saveLocation: 'Місце збереження',
browse: 'Огляд…', browse: 'Огляд…',
@@ -553,12 +585,22 @@ const uk = {
}, },
lookAndFeel: { lookAndFeel: {
language: 'Мова', language: 'Мова',
languageAndRegion: 'Мова й регіон',
layout: 'Компонування',
languageDescription: 'Виберіть мову інтерфейсу Firelink.', languageDescription: 'Виберіть мову інтерфейсу Firelink.',
sidebarPosition: 'Положення бічної панелі', sidebarPosition: 'Положення бічної панелі',
sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.', sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.',
sidebarPositionAutomatic: 'Автоматично (рекомендовано)', sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
sidebarPositionLeft: 'Ліворуч', sidebarPositionLeft: 'Ліворуч',
sidebarPositionRight: 'Праворуч', sidebarPositionRight: 'Праворуч',
windowControls: 'Керування вікном',
windowControlStyle: 'Стиль елементів керування',
windowControlsDescription: 'Виберіть, як Firelink відображає власні кнопки вікна. Автоматичний режим враховує вашу ОС.',
windowControlStyleAutomatic: 'Автоматично (рекомендовано)',
windowControlStyleMacos: 'Світлофори macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Мінімальний',
languageSystem: 'Мова системи', languageSystem: 'Мова системи',
languageEnglish: 'Англійська', languageEnglish: 'Англійська',
languageChinese: 'Спрощена китайська', languageChinese: 'Спрощена китайська',
@@ -566,6 +608,11 @@ const uk = {
languagePersian: 'Перська', languagePersian: 'Перська',
languageUkrainian: 'Українська', languageUkrainian: 'Українська',
languageRussian: 'Російська', languageRussian: 'Російська',
calendar: 'Календар',
calendarDescription: 'Виберіть календар для відображення дат і часу. Типово використовується григоріанський; розклад і збережені мітки часу не змінюються.',
calendarGregorian: 'Григоріанський',
calendarPersian: 'Перський',
calendarHebrew: 'Єврейський',
appTheme: 'Тема програми', appTheme: 'Тема програми',
theme: 'Тема', theme: 'Тема',
ariaLabel: 'Тема програми', ariaLabel: 'Тема програми',
@@ -576,6 +623,17 @@ const uk = {
nord: 'Nord', nord: 'Nord',
systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.', systemAppearance: 'Система слідкує за поточним світлим або темним виглядом {{platform}}.',
display: 'Відображення', display: 'Відображення',
fontFamily: 'Шрифт',
fontFamilyDescription: 'Виберіть шрифт інтерфейсу. Системний режим використовує системні шрифти з урахуванням ОС і мови.',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: 'Розмір шрифту', fontSize: 'Розмір шрифту',
fontSizeDescription: 'Масштабує список завантажень та компактні елементи керування.', fontSizeDescription: 'Масштабує список завантажень та компактні елементи керування.',
small: 'Дрібний', small: 'Дрібний',
@@ -680,6 +738,8 @@ const uk = {
title: 'Керування живленням', title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень', preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.', preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень',
preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.',
}, },
engine: { engine: {
title: 'Завантажувач медіа та рушії', title: 'Завантажувач медіа та рушії',
+64 -4
View File
@@ -33,7 +33,7 @@ const zhCN = {
dismissNotification: '关闭通知', dismissNotification: '关闭通知',
queueName: '队列名称', queueName: '队列名称',
addNewQueue: '新建队列', addNewQueue: '新建队列',
startQueue: '启动队列', startQueue: '开始队列',
pauseQueue: '暂停队列', pauseQueue: '暂停队列',
renameQueue: '重命名队列', renameQueue: '重命名队列',
deleteQueue: '删除队列', deleteQueue: '删除队列',
@@ -71,7 +71,7 @@ const zhCN = {
moveUp: '上移', moveUp: '上移',
moveDown: '下移', moveDown: '下移',
pause: '暂停', pause: '暂停',
start: '启动', start: '开始',
resume: '恢复', resume: '恢复',
options: '选项', options: '选项',
}, },
@@ -216,6 +216,13 @@ const zhCN = {
connectionCountUnknown: '—/{{total}} 个连接', connectionCountUnknown: '—/{{total}} 个连接',
connectionsUnavailable: '—', connectionsUnavailable: '—',
speedCap: '速度上限', speedCap: '速度上限',
liveSpeedLimit: '实时速度上限',
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
liveSpeedLimitPlaceholder: '例如 1024K',
liveSpeedLimitApply: '应用',
liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
category: '类别', category: '类别',
lastTry: '上次尝试', lastTry: '上次尝试',
dateAdded: '添加日期', dateAdded: '添加日期',
@@ -326,6 +333,9 @@ const zhCN = {
queueNameExists: '同名队列已存在', queueNameExists: '同名队列已存在',
startQueueFailed: '无法启动队列:{{detail}}', startQueueFailed: '无法启动队列:{{detail}}',
pauseQueueFailed: '无法暂停队列:{{detail}}', pauseQueueFailed: '无法暂停队列:{{detail}}',
queueConcurrency: '并行下载数',
queueConcurrencyGlobal: '使用全局限制',
queueConcurrencyFailed: '无法更新队列并发数:{{detail}}',
}, },
downloadTable: { downloadTable: {
unknownQueue: '未知队列', unknownQueue: '未知队列',
@@ -353,6 +363,15 @@ const zhCN = {
addDownload: '添加下载', addDownload: '添加下载',
resumeAll: '全部恢复', resumeAll: '全部恢复',
pauseAll: '全部暂停', pauseAll: '全部暂停',
summary: {
items: '{{count}} 个项目',
selected: '已选择 {{count}}',
downloaded: '已下载',
remaining: '剩余',
active: '进行中',
unknown: '未知',
estimated: '估计 {{value}}',
},
queueEmpty: '队列为空', queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载', noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载', noDownloads: '没有下载',
@@ -361,6 +380,11 @@ const zhCN = {
clickToAdd: '点击', clickToAdd: '点击',
addButtonOr: '按钮或', addButtonOr: '按钮或',
toAddDownloads: '来添加下载', toAddDownloads: '来添加下载',
queueReorderHint: '拖动行调整顺序,或使用快捷键:',
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
queuePriorityControls: '移动队列中选中的下载',
queueDragHandle: '拖动 {{fileName}} 调整顺序',
queueReorderFailed: '无法调整下载顺序',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?', nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?', nonResumableMany: '{{count}} 个下载不支持断点续传。如果您暂停它们,稍后您将不得不重新开始。确定要暂停吗?',
interactionError: '{{message}}{{detail}}', interactionError: '{{message}}{{detail}}',
@@ -375,14 +399,14 @@ const zhCN = {
copyPathFailed: '无法复制文件路径', copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名', missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败', redownloadFailed: '重新下载失败',
startResume: '启动/恢复', startResume: '开始/恢复',
addToQueue: '添加到队列', addToQueue: '添加到队列',
copyAddress: '复制地址', copyAddress: '复制地址',
remove: '移除', remove: '移除',
open: '打开', open: '打开',
showInFolder: '在文件夹中显示', showInFolder: '在文件夹中显示',
pause: '暂停', pause: '暂停',
start: '启动', start: '开始',
resume: '恢复', resume: '恢复',
redownload: '重新下载', redownload: '重新下载',
copyFilePath: '复制文件路径', copyFilePath: '复制文件路径',
@@ -447,6 +471,14 @@ const zhCN = {
fetchingMediaStreams: '正在获取媒体流…', fetchingMediaStreams: '正在获取媒体流…',
availableStreams: '可用流', availableStreams: '可用流',
availableMediaStreams: '可用的媒体流', availableMediaStreams: '可用的媒体流',
quality: '画质',
format: '格式',
video: '视频',
audio: '音频',
noCommonFormat: '所选项目没有共同格式。',
noCommonQuality: '所选项目没有共同画质。',
playlistQuality: '播放列表画质',
playlistQualitySelected: '已选择 {{selected}} / {{total}}',
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。', metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
saveLocation: '保存位置', saveLocation: '保存位置',
browse: '浏览', browse: '浏览',
@@ -553,12 +585,22 @@ const zhCN = {
}, },
lookAndFeel: { lookAndFeel: {
language: '语言', language: '语言',
languageAndRegion: '语言和地区',
layout: '布局',
languageDescription: '选择 Firelink 的界面语言。', languageDescription: '选择 Firelink 的界面语言。',
sidebarPosition: '侧边栏位置', sidebarPosition: '侧边栏位置',
sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。', sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。',
sidebarPositionAutomatic: '自动(推荐)', sidebarPositionAutomatic: '自动(推荐)',
sidebarPositionLeft: '左侧', sidebarPositionLeft: '左侧',
sidebarPositionRight: '右侧', sidebarPositionRight: '右侧',
windowControls: '窗口控件',
windowControlStyle: '控件样式',
windowControlsDescription: '选择 Firelink 绘制自定义窗口按钮的方式。“自动”会跟随操作系统。',
windowControlStyleAutomatic: '自动(推荐)',
windowControlStyleMacos: 'macOS 交通灯',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: '极简',
languageSystem: '系统默认', languageSystem: '系统默认',
languageEnglish: '英语', languageEnglish: '英语',
languageChinese: '简体中文', languageChinese: '简体中文',
@@ -566,6 +608,11 @@ const zhCN = {
languagePersian: '波斯语', languagePersian: '波斯语',
languageUkrainian: '乌克兰语', languageUkrainian: '乌克兰语',
languageRussian: '俄语', languageRussian: '俄语',
calendar: '日历',
calendarDescription: '选择显示日期和时间所使用的日历。默认使用公历;此选择不会改变计划或已保存的时间戳。',
calendarGregorian: '公历',
calendarPersian: '波斯历',
calendarHebrew: '希伯来历',
appTheme: '应用主题', appTheme: '应用主题',
theme: '主题', theme: '主题',
ariaLabel: '应用主题', ariaLabel: '应用主题',
@@ -576,6 +623,17 @@ const zhCN = {
nord: 'Nord', nord: 'Nord',
systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。', systemAppearance: '系统跟随当前的 {{platform}} 浅色或深色外观。',
display: '显示', display: '显示',
fontFamily: '字体',
fontFamilyDescription: '选择界面字体。“系统”会使用适合操作系统和语言的系统字体及备用字体。',
fontFamilySystem: 'System UI (recommended)',
fontFamilyInter: 'Inter',
fontFamilyOutfit: 'Outfit',
fontFamilyVazirmatn: 'Vazirmatn',
fontFamilyNotoSansHebrew: 'Noto Sans Hebrew',
fontFamilyNotoSansSc: 'Noto Sans SC',
fontFamilyRoboto: 'Roboto',
fontFamilySerif: 'Serif',
fontFamilyMonospace: 'Monospace',
fontSize: '字体大小', fontSize: '字体大小',
fontSizeDescription: '缩放下载列表和紧凑型控件。', fontSizeDescription: '缩放下载列表和紧凑型控件。',
small: '小', small: '小',
@@ -680,6 +738,8 @@ const zhCN = {
title: '电源管理', title: '电源管理',
preventSleep: '下载活动时阻止系统睡眠', preventSleep: '下载活动时阻止系统睡眠',
preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。', preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。',
preventDisplaySleep: '下载活动时保持显示器唤醒',
preventDisplaySleepDescription: '这是与阻止系统睡眠分开的可选设置。活动传输完成前,显示器将保持开启。',
}, },
engine: { engine: {
title: '媒体下载器和引擎', title: '媒体下载器和引擎',
+11
View File
@@ -94,6 +94,17 @@ describe('translation catalogs', () => {
'settings.integrations.chromiumZip', 'settings.integrations.chromiumZip',
'settings.lookAndFeel.dracula', 'settings.lookAndFeel.dracula',
'settings.lookAndFeel.nord', 'settings.lookAndFeel.nord',
'settings.lookAndFeel.fontFamilyInter',
'settings.lookAndFeel.fontFamilyOutfit',
'settings.lookAndFeel.fontFamilyVazirmatn',
'settings.lookAndFeel.fontFamilyNotoSansHebrew',
'settings.lookAndFeel.fontFamilyNotoSansSc',
'settings.lookAndFeel.fontFamilyRoboto',
'settings.lookAndFeel.fontFamilySystem',
'settings.lookAndFeel.fontFamilySerif',
'settings.lookAndFeel.fontFamilyMonospace',
'settings.lookAndFeel.windowControlStyleWindows',
'settings.lookAndFeel.windowControlStyleGnome',
'settings.network.chromeWindows', 'settings.network.chromeWindows',
'settings.network.chromeMacos', 'settings.network.chromeMacos',
'settings.network.edgeWindows', 'settings.network.edgeWindows',
+604 -32
View File
@@ -11,7 +11,7 @@
--item-hover: 0 0% 0% / 0.05; --item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50%; --item-selected: 211 100% 50%;
--accent-color: 211 100% 50%; --accent-color: 211 100% 50%;
--stripe-bg: 0 0% 0% / 0.04; --stripe-bg: 0 0% 0% / 0.035;
--text-primary: 0 0% 10%; --text-primary: 0 0% 10%;
--text-secondary: 0 0% 40%; --text-secondary: 0 0% 40%;
@@ -54,7 +54,7 @@
--item-hover: 0 0% 0% / 0.05; --item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50%; --item-selected: 211 100% 50%;
--accent-color: 211 100% 50%; --accent-color: 211 100% 50%;
--stripe-bg: 0 0% 0% / 0.04; --stripe-bg: 0 0% 0% / 0.035;
--text-primary: 0 0% 10%; --text-primary: 0 0% 10%;
--text-secondary: 0 0% 40%; --text-secondary: 0 0% 40%;
--text-muted: 0 0% 55%; --text-muted: 0 0% 55%;
@@ -84,7 +84,7 @@
--item-hover: 0 0% 100% / 0.07; --item-hover: 0 0% 100% / 0.07;
--item-selected: 211 100% 56%; --item-selected: 211 100% 56%;
--accent-color: 211 100% 56%; --accent-color: 211 100% 56%;
--stripe-bg: 0 0% 100% / 0.04; --stripe-bg: 0 0% 100% / 0.025;
--text-primary: 0 0% 91%; --text-primary: 0 0% 91%;
--text-secondary: 0 0% 75%; --text-secondary: 0 0% 75%;
--text-muted: 0 0% 50%; --text-muted: 0 0% 50%;
@@ -128,7 +128,7 @@
--item-hover: 326 100% 74% / 0.10; --item-hover: 326 100% 74% / 0.10;
--item-selected: 326 100% 74%; --item-selected: 326 100% 74%;
--accent-color: 326 100% 74%; --accent-color: 326 100% 74%;
--stripe-bg: 0 0% 100% / 0.04; --stripe-bg: 0 0% 100% / 0.025;
--text-primary: 60 30% 96%; --text-primary: 60 30% 96%;
--text-secondary: 228 14% 74%; --text-secondary: 228 14% 74%;
--text-muted: 229 12% 58%; --text-muted: 229 12% 58%;
@@ -172,7 +172,7 @@
--item-hover: 193 43% 67% / 0.12; --item-hover: 193 43% 67% / 0.12;
--item-selected: 193 43% 67%; --item-selected: 193 43% 67%;
--accent-color: 193 43% 67%; --accent-color: 193 43% 67%;
--stripe-bg: 0 0% 100% / 0.04; --stripe-bg: 0 0% 100% / 0.025;
--text-primary: 218 27% 92%; --text-primary: 218 27% 92%;
--text-secondary: 219 28% 80%; --text-secondary: 219 28% 80%;
--text-muted: 218 17% 63%; --text-muted: 218 17% 63%;
@@ -223,8 +223,75 @@
--color-surface-overlay: hsl(var(--surface-overlay)); --color-surface-overlay: hsl(var(--surface-overlay));
--color-bg-context-menu: hsl(var(--surface-overlay)); --color-bg-context-menu: hsl(var(--surface-overlay));
/* Apple System Fonts */ /*
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; * Keep the default platform UI stack, and let each locale contribute a
* script-aware fallback. Optional custom families are bundled locally so
* the Tauri app remains usable offline.
*/
--font-locale-fallback: sans-serif;
--font-stack-system: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-inter: "Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-outfit: "Outfit Variable", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, var(--font-locale-fallback), "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-stack-vazirmatn: "Vazirmatn Variable", var(--font-stack-system);
--font-stack-noto-sans-hebrew: "Noto Sans Hebrew Variable", var(--font-stack-system);
--font-stack-noto-sans-sc: "Noto Sans SC Variable", var(--font-stack-system);
--font-stack-roboto: "Roboto Variable", var(--font-stack-system);
--font-stack-serif: Georgia, "Times New Roman", var(--font-locale-fallback), serif;
--font-stack-monospace: ui-monospace, "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", var(--font-locale-fallback), monospace;
--font-sans: var(--font-stack-system);
}
html[lang="fa"] {
--font-locale-fallback: "Geeza Pro", "Noto Sans Arabic", "Segoe UI", Tahoma, Arial, sans-serif;
}
html[lang="he"] {
--font-locale-fallback: "Arial Hebrew", "Segoe UI", Arial, sans-serif;
}
html[lang="zh-CN"] {
--font-locale-fallback: "PingFang SC", "Microsoft YaHei UI", "Noto Sans CJK SC", sans-serif;
}
html[lang="ru"],
html[lang="uk"] {
--font-locale-fallback: "Segoe UI", "Noto Sans", Roboto, Arial, sans-serif;
}
html[data-font-family="system"] {
--font-sans: var(--font-stack-system);
}
html[data-font-family="inter"] {
--font-sans: var(--font-stack-inter);
}
html[data-font-family="outfit"] {
--font-sans: var(--font-stack-outfit);
}
html[data-font-family="vazirmatn"] {
--font-sans: var(--font-stack-vazirmatn);
}
html[data-font-family="noto-sans-hebrew"] {
--font-sans: var(--font-stack-noto-sans-hebrew);
}
html[data-font-family="noto-sans-sc"] {
--font-sans: var(--font-stack-noto-sans-sc);
}
html[data-font-family="roboto"] {
--font-sans: var(--font-stack-roboto);
}
html[data-font-family="serif"] {
--font-sans: var(--font-stack-serif);
}
html[data-font-family="monospace"] {
--font-sans: var(--font-stack-monospace);
} }
@layer base { @layer base {
@@ -498,8 +565,9 @@ html[data-list-density="relaxed"] {
} }
.app-modal-backdrop { .app-modal-backdrop {
background: hsl(0 0% 0% / 0.2); /* Keep the scrim animation from applying opacity to the modal subtree. */
animation: fade-in 150ms ease-out; background-color: hsl(0 0% 0% / 0.2);
animation: modal-backdrop-in 150ms ease-out;
overflow: auto; overflow: auto;
padding: 16px; padding: 16px;
} }
@@ -726,6 +794,163 @@ 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-playlist-quality {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border-modal));
}
.add-download-playlist-quality-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 10px;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 8px;
color: hsl(var(--text-primary));
text-align: start;
transition: background-color 100ms ease, border-color 100ms ease;
}
.add-download-playlist-quality-toggle:hover {
border-color: hsl(var(--border-modal));
background: hsl(var(--item-hover));
}
.add-download-playlist-quality-options {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr);
gap: 12px;
}
.add-download-playlist-media-type {
display: inline-flex;
gap: 2px;
margin-top: 8px;
padding: 2px;
border: 1px solid hsl(var(--border-modal));
border-radius: 7px;
background: hsl(var(--bg-input) / 0.52);
}
.add-download-playlist-media-type-option {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
gap: 5px;
padding: 3px 9px;
border-radius: 5px;
color: hsl(var(--text-muted));
font-size: 11px;
font-weight: 650;
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
}
.add-download-playlist-media-type-option:hover {
color: hsl(var(--text-primary));
}
.add-download-playlist-media-type-option:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.62);
outline-offset: 1px;
}
.add-download-playlist-media-type-option.is-selected {
background: hsl(var(--surface-raised) / 0.9);
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
color: hsl(var(--text-primary));
}
.add-download-playlist-quality-column {
min-width: 0;
}
.add-download-playlist-quality-label {
margin-bottom: 5px;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
text-transform: uppercase;
}
.add-download-playlist-quality-list {
display: flex;
flex-direction: column;
max-height: 166px;
gap: 3px;
overflow-y: auto;
padding-inline-end: 2px;
}
.add-download-playlist-quality-toggle:focus-visible,
.add-download-quality-option:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.62);
outline-offset: 1px;
}
.add-download-quality-option {
display: flex;
align-items: center;
min-height: 32px;
gap: 9px;
padding: 6px 9px;
border: 1px solid hsl(var(--border-modal));
border-radius: 7px;
background: hsl(var(--bg-input) / 0.68);
color: hsl(var(--text-secondary));
font-size: 12px;
text-align: start;
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease;
}
.add-download-quality-option:hover {
border-color: hsl(var(--text-muted) / 0.58);
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.add-download-quality-option.is-selected {
border-color: hsl(var(--accent-color) / 0.48);
background: hsl(var(--accent-color) / 0.1);
color: hsl(var(--text-primary));
}
.add-download-quality-option-radio {
flex: 0 0 auto;
width: 14px;
height: 14px;
border: 1px solid hsl(var(--text-muted) / 0.72);
border-radius: 50%;
background: transparent;
}
.add-download-quality-option.is-selected .add-download-quality-option-radio {
border-color: hsl(var(--accent-color));
background: hsl(var(--accent-color));
box-shadow: inset 0 0 0 3px hsl(var(--bg-input));
}
.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-select { .add-download-select {
appearance: auto; appearance: auto;
cursor: default; cursor: default;
@@ -1131,6 +1356,7 @@ html[data-list-density="relaxed"] {
} }
.app-statusbar { .app-statusbar {
position: relative;
height: 26px; height: 26px;
font-size: 10px; font-size: 10px;
background: hsl(var(--statusbar-bg)); background: hsl(var(--statusbar-bg));
@@ -1138,6 +1364,39 @@ html[data-list-density="relaxed"] {
box-shadow: none; box-shadow: none;
} }
.app-statusbar-summary {
position: absolute;
left: 50%;
display: flex;
align-items: center;
gap: 12px;
max-width: min(52%, 560px);
overflow: hidden;
transform: translateX(-50%);
white-space: nowrap;
font-size: 10px;
line-height: 1;
pointer-events: none;
}
.app-statusbar-summary-metric {
display: inline-flex;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.app-statusbar-summary-metric > span {
color: hsl(var(--text-muted));
}
.app-statusbar-summary-metric > strong {
overflow: hidden;
color: hsl(var(--text-secondary));
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
.sidebar-nav-item { .sidebar-nav-item {
height: 31px; height: 31px;
border-radius: 8px; border-radius: 8px;
@@ -1659,7 +1918,8 @@ html[data-list-density="relaxed"] {
z-index: 60; z-index: 60;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; direction: ltr;
gap: 9px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
pointer-events: auto; pointer-events: auto;
} }
@@ -1672,6 +1932,7 @@ html[data-list-density="relaxed"] {
.window-controls--right { .window-controls--right {
left: auto; left: auto;
right: 22px; right: 22px;
flex-direction: row-reverse;
} }
.window-control { .window-control {
@@ -1719,6 +1980,142 @@ html[data-list-density="relaxed"] {
filter: brightness(0.9); filter: brightness(0.9);
} }
/* Windows 11 caption-button treatment: full-width hit targets, neutral
glyphs, side-aware close placement, and rounded outer corners. */
.window-controls--style-windows {
top: 18px;
gap: 0;
flex-direction: row;
border-radius: 10px;
}
.window-controls--style-windows .window-control {
width: 46px;
height: 32px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
order: initial;
}
.window-controls--style-windows .window-control.minimize {
order: 2;
}
.window-controls--style-windows .window-control.maximize {
order: 3;
}
.window-controls--style-windows .window-control.close {
order: 1;
}
/* Keep the close action at the outer top corner on either sidebar side. */
.window-controls--right.window-controls--style-windows .window-control.minimize {
order: 1;
}
.window-controls--right.window-controls--style-windows .window-control.maximize {
order: 2;
}
.window-controls--right.window-controls--style-windows .window-control.close {
order: 3;
}
.window-controls--left.window-controls--style-windows .window-control.close {
border-radius: 10px 0 0 10px;
}
.window-controls--left.window-controls--style-windows .window-control.maximize {
border-radius: 0 10px 10px 0;
}
.window-controls--right.window-controls--style-windows .window-control.minimize {
border-radius: 10px 0 0 10px;
}
.window-controls--right.window-controls--style-windows .window-control.close {
border-radius: 0 10px 10px 0;
}
.window-controls--style-windows .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-windows .window-control.close:hover {
color: white;
background: #c42b1c;
}
.window-controls--style-windows .window-control:active {
transform: none;
filter: brightness(0.9);
}
/* Keep the smaller macOS traffic lights centered on the same lower titlebar
line as the larger caption-button presets. */
.window-controls--style-macos {
top: 27px;
}
/* GNOME/Adwaita header-bar treatment: compact, borderless icon buttons. */
.window-controls--style-gnome {
top: 18px;
gap: 4px;
}
.window-controls--style-gnome .window-control,
.window-controls--style-minimal .window-control {
border: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
}
.window-controls--style-gnome .window-control {
width: 32px;
height: 32px;
border-radius: 6px;
}
.window-controls--style-gnome .window-control:hover,
.window-controls--style-minimal .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-gnome .window-control.close,
.window-controls--style-gnome .window-control.minimize,
.window-controls--style-gnome .window-control.maximize,
.window-controls--style-minimal .window-control.close,
.window-controls--style-minimal .window-control.minimize,
.window-controls--style-minimal .window-control.maximize {
background: transparent;
}
/* Minimal keeps the compact footprint while removing colored circles. */
.window-controls--style-minimal {
top: 23px;
gap: 4px;
}
.window-controls--style-minimal .window-control {
width: 22px;
height: 22px;
border-radius: 6px;
}
.window-controls--style-minimal .window-control:active,
.window-controls--style-gnome .window-control:active {
filter: none;
}
.main-titlebar { .main-titlebar {
height: 52px; height: 52px;
display: flex; display: flex;
@@ -1805,9 +2202,11 @@ html[data-list-density="relaxed"] {
} }
.downloads-content-header { .downloads-content-header {
height: 58px; min-height: 58px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
padding: 0 16px; padding: 0 16px;
background: hsl(var(--main-bg)); background: hsl(var(--main-bg));
} }
@@ -1837,6 +2236,21 @@ html[data-list-density="relaxed"] {
background: hsl(var(--item-hover)); background: hsl(var(--item-hover));
} }
.downloads-header-actions {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
margin-inline-start: auto;
}
.downloads-selection-status {
color: hsl(var(--text-primary));
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.downloads-table { .downloads-table {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -1897,6 +2311,59 @@ html[data-list-density="relaxed"] {
font-weight: 600; font-weight: 600;
} }
.downloads-queue-reorder-hint {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 420px;
min-height: 17px;
overflow: visible;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-queue-reorder-hint > span:first-child {
display: block;
min-width: 0;
overflow: hidden;
padding-block: 1px;
text-overflow: ellipsis;
line-height: 1.2;
}
.downloads-queue-reorder-shortcut {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 3px;
direction: ltr;
color: hsl(var(--text-muted));
}
.downloads-queue-reorder-shortcut kbd {
min-width: 16px;
height: 17px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border-color));
border-bottom-color: hsl(var(--border-color) / 0.72);
border-radius: 4px;
background: hsl(var(--item-hover));
color: hsl(var(--text-secondary));
font-family: inherit;
font-size: 9px;
font-weight: 650;
line-height: 1;
}
.download-table-scroll { .download-table-scroll {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -2067,6 +2534,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;
@@ -2075,22 +2543,100 @@ body.is-column-resizing .column-resize-handle:hover::after {
flex-direction: column; flex-direction: column;
} }
.download-table-list-content {
display: flex;
flex: 1 0 100%;
min-height: 100%;
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);
transition: top 100ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 100ms ease;
will-change: top;
}
.download-queue-drag-preview {
position: absolute;
inset-inline: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
height: var(--download-row-height);
margin: var(--download-row-margin) 0;
padding: 0 var(--download-row-padding-x);
overflow: hidden;
border: 1px solid hsl(var(--accent-color) / 0.58);
border-radius: 6px;
background: hsl(var(--surface-overlay) / 0.94);
box-shadow: 0 8px 20px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.14);
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
pointer-events: none;
transform: translateZ(0);
will-change: top;
}
.download-queue-drag-preview-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
}
.download-queue-drag-preview-name {
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-queue-drag-preview-count {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: hsl(var(--accent-color) / 0.2);
color: hsl(var(--text-primary));
font-size: 10px;
font-weight: 700;
}
.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);
display: grid;
align-items: center;
width: 100%; width: 100%;
padding: 0 var(--download-row-padding-x);
margin: var(--download-row-margin) 0; margin: var(--download-row-margin) 0;
border-radius: 6px; border-radius: 6px;
color: hsl(var(--text-primary)); color: hsl(var(--text-primary));
font-size: var(--download-row-font-size); font-size: var(--download-row-font-size);
overflow: hidden; overflow: visible;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.download-row-motion {
display: grid;
align-items: center;
width: 100%;
height: 100%;
padding: 0 var(--download-row-padding-x);
border-radius: inherit;
color: inherit;
font-size: inherit;
overflow: hidden;
user-select: inherit;
-webkit-user-select: inherit;
}
html[data-list-density="compact"] .download-row, html[data-list-density="compact"] .download-row,
html[data-list-density="compact"] .download-ghost-row { html[data-list-density="compact"] .download-ghost-row {
height: var(--download-row-height); height: var(--download-row-height);
@@ -2103,37 +2649,61 @@ html[data-list-density="relaxed"] .download-ghost-row {
margin-block: var(--download-row-margin); margin-block: var(--download-row-margin);
} }
.download-row > div { .download-row-motion > div {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
padding-inline: var(--download-column-padding-x); padding-inline: var(--download-column-padding-x);
} }
.download-row > div:first-child { .download-row-motion > div:first-child {
padding-inline-start: 0; padding-inline-start: 0;
} }
.download-row > .download-status-cell { .download-row-motion > .download-status-cell {
padding-inline: 8px; padding-inline: 8px;
} }
.download-row.is-selected { .download-row.is-selected .download-row-motion {
background: hsl(var(--accent-color) / 0.26) !important; background: hsl(var(--accent-color) / 0.26) !important;
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28); box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28);
} }
.download-row.is-selected:hover { .download-row.is-selected:hover .download-row-motion {
background: hsl(var(--accent-color) / 0.32) !important; background: hsl(var(--accent-color) / 0.32) !important;
} }
.download-row.is-queue-drag-source {
opacity: 0.16;
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.3);
}
.download-row.is-queue-reorderable {
cursor: grab;
touch-action: none;
transition: background-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}
.download-row.is-queue-reorderable:active {
cursor: grabbing;
}
.download-table-list-content > .download-row:nth-child(even) .download-row-motion {
background: hsl(var(--stripe-bg));
}
body.is-queue-dragging,
body.is-queue-dragging * {
cursor: grabbing !important;
}
.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;
} }
.download-row:hover { .download-row:hover .download-row-motion {
background: hsl(var(--item-hover)); background: hsl(var(--item-hover));
} }
.download-column-cell { .download-column-cell {
display: flex; display: flex;
@@ -2219,16 +2789,18 @@ html[data-list-density="relaxed"] .download-ghost-row {
.download-row-actions { .download-row-actions {
position: fixed; position: fixed;
right: 0;
display: flex; display: flex;
align-self: stretch;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
z-index: 2; z-index: 2;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
padding: 0; padding: 0 2px;
background: transparent; border: 1px solid hsl(var(--border-color) / 0.88);
border-radius: 6px;
background: hsl(var(--surface-raised));
box-shadow: 0 1px 2px hsl(var(--shadow-color));
visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 120ms ease; transition: opacity 120ms ease;
@@ -2239,11 +2811,6 @@ html[data-list-density="relaxed"] .download-ghost-row {
pointer-events: auto; pointer-events: auto;
} }
.download-row > .download-row-actions {
overflow: visible;
padding: 0;
}
.download-row-actions .app-icon-button, .download-row-actions .app-icon-button,
.download-row-actions .app-icon-button:hover:not(:disabled), .download-row-actions .app-icon-button:hover:not(:disabled),
.download-row-actions .app-icon-button:active:not(:disabled) { .download-row-actions .app-icon-button:active:not(:disabled) {
@@ -2607,6 +3174,11 @@ html[dir="rtl"] .download-context-menu-chevron {
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes modal-backdrop-in {
from { background-color: hsl(0 0% 0% / 0); }
to { background-color: hsl(0 0% 0% / 0.2); }
}
@keyframes pulse-progress { @keyframes pulse-progress {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.6; } 50% { opacity: 0.6; }
+9 -2
View File
@@ -16,6 +16,7 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { EnqueueItem } from './bindings/EnqueueItem'; import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted'; import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { PlatformInfo } from './bindings/PlatformInfo'; import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
type CommandMap = { type CommandMap = {
fetch_metadata: { fetch_metadata: {
@@ -37,7 +38,7 @@ type CommandMap = {
reveal_in_file_manager: { args: { path: string }; result: void }; reveal_in_file_manager: { args: { path: string }; result: void };
open_downloaded_file: { args: { path: string }; result: void }; open_downloaded_file: { args: { path: string }; result: void };
pause_download: { args: { id: string }; result: void }; pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string }; result: boolean }; resume_download: { args: { id: string; queueId: string }; result: boolean };
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void }; remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void }; detach_download_for_reconfigure: { args: { id: string }; result: void };
begin_dock_badge_session: { args: undefined; result: number }; begin_dock_badge_session: { args: undefined; result: number };
@@ -45,9 +46,15 @@ type CommandMap = {
get_platform_info: { args: undefined; result: PlatformInfo }; get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string }; approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void }; set_prevent_sleep: { args: { prevent: boolean }; result: void };
set_power_preferences: {
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
result: void;
};
perform_system_action: { args: { action: PostQueueAction }; result: void }; perform_system_action: { args: { action: PostQueueAction }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void }; ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void };
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void }; set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void }; request_automation_permission: { args: undefined; result: void };
check_automation_permission: { args: undefined; result: void }; check_automation_permission: { args: undefined; result: void };
@@ -98,7 +105,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 };
}; };
+6
View File
@@ -1,5 +1,11 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter/wght.css";
import "@fontsource-variable/noto-sans-hebrew/wght.css";
import "@fontsource-variable/noto-sans-sc/wght.css";
import "@fontsource-variable/outfit/wght.css";
import "@fontsource-variable/roboto/wght.css";
import "@fontsource-variable/vazirmatn/wght.css";
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import { i18nReady } from "./i18n"; import { i18nReady } from "./i18n";
+301
View File
@@ -267,6 +267,166 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false); expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
}); });
it('persists a queue concurrency override only after backend synchronization', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]);
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: 'main', maxConcurrent: null },
{ id: 'queue-a', maxConcurrent: 2 }
]
}
);
});
it('retains the previous queue concurrency after backend synchronization fails', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
.rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
});
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
let releaseFirstSync!: () => void;
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
let syncCalls = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_queue_concurrency_limits') {
syncCalls += 1;
if (syncCalls === 1) await firstSyncReleased;
}
return undefined;
});
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
await vi.waitFor(() => expect(syncCalls).toBe(1));
useDownloadStore.setState(state => ({
queues: state.queues.filter(queue => queue.id !== 'queue-a')
}));
releaseFirstSync();
await expect(update).rejects.toThrow('Queue no longer exists.');
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true }
]);
expect(syncCalls).toBe(2);
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_queue_concurrency_limits');
expect(configCalls[1]).toEqual([
'set_queue_concurrency_limits',
{ limits: [{ id: 'main', maxConcurrent: null }] }
]);
});
it('updates an active normal download after applying a live speed limit', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
id: 'live-speed',
limit: '2M'
});
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_download_speed_limit');
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
id: 'live-speed',
limit: null
}]);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
});
it('rejects live speed changes for media and inactive downloads', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
] as any[]
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
.rejects.toThrow('media downloads');
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
.rejects.toThrow('active download');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
});
it('keeps the prior live speed limit when the backend rejects the update', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed-failure',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
return undefined;
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
.rejects.toThrow('aria2 unavailable');
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
});
it('coalesces duplicate live speed updates for one download', async () => {
useDownloadStore.setState({
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
});
let releaseBackend!: () => void;
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
expect(second).toBe(first);
releaseBackend();
await first;
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
.toHaveLength(1);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
});
it('normalizes malformed persisted queues around one canonical main queue', () => { it('normalizes malformed persisted queues around one canonical main queue', () => {
expect(normalizePersistedQueues([ expect(normalizePersistedQueues([
{ id: 'custom-a', name: ' Downloads ', isMain: false }, { id: 'custom-a', name: ' Downloads ', isMain: false },
@@ -286,6 +446,47 @@ describe('useDownloadStore', () => {
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001'); ]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
}); });
it('keeps only valid persisted queue concurrency overrides', () => {
expect(normalizePersistedQueues([
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
])).toEqual([
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false },
{ id: 'large', name: 'Large', isMain: false },
{ id: 'null', name: 'Null', isMain: false }
]);
});
it('synchronizes normalized queue limits before startup resume can run', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
return [
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
];
}
if (cmd === 'db_get_all_downloads') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
{ id: 'queue-a', maxConcurrent: null }
]
}
);
});
it('remaps persisted downloads when queue records are malformed or missing', async () => { it('remaps persisted downloads when queue records are malformed or missing', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') { if (cmd === 'db_get_all_queues') {
@@ -731,6 +932,10 @@ describe('useDownloadStore', () => {
const calls = vi.mocked(ipc.invokeCommand).mock.calls; const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true); expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true); expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
id: 'resume-generation',
queueId: 'MAIN'
});
expect(enqueueGeneration).toBe('1'); expect(enqueueGeneration).toBe('1');
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String)); expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
@@ -1575,6 +1780,102 @@ describe('useDownloadStore', () => {
expect(new Set(positions).size).toBe(4); expect(new Set(positions).size).toBe(4);
}); });
it('rolls back and rejects a failed keyboard or header queue move', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'first', status: 'queued', queueId: 'move-failure-queue', queuePosition: 0 },
{ id: 'second', status: 'queued', queueId: 'move-failure-queue', queuePosition: 1 }
] as any[],
backendRegisteredIds: new Set(['second'])
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(
useDownloadStore.getState().moveInQueue('second', 'up')
).rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
});
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('keeps a drag anchored when the queue changes before its serialized operation runs', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 }
] as any[],
backendRegisteredIds: new Set()
});
const operation = useDownloadStore.getState().moveManyInQueueToPosition(
'b',
'concurrent-drag-queue',
2,
'c'
);
useDownloadStore.setState({
downloads: [
{ id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 },
{ id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 },
{ id: 'new', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 },
{ id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 3 }
] as any[]
});
await operation;
expect(useDownloadStore.getState().downloads
.filter(item => item.queueId === 'concurrent-drag-queue')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(item => item.id)
).toEqual(['a', 'new', 'b', 'c']);
});
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: [{
+230 -7
View File
@@ -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';
@@ -24,6 +28,7 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>(); const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>(); const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>(); const queueControlGenerations = new Map<string, number>();
let queueConfigurationQueue: Promise<void> = Promise.resolve();
type DownloadLifecycleOperation = { type DownloadLifecycleOperation = {
kind: string; kind: string;
promise: Promise<unknown>; promise: Promise<unknown>;
@@ -581,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
export type { DownloadStatus }; export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue'; const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
const MAX_QUEUE_CONCURRENT = 12;
const queueNameKey = (name: string): string => name.trim().toLowerCase(); const queueNameKey = (name: string): string => name.trim().toLowerCase();
export const normalizePersistedQueueState = (queues: Queue[]) => { const normalizeQueueConcurrency = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
};
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
maxConcurrent?: number | null;
};
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
return maxConcurrent === undefined
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
};
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
const validQueues = queues.filter(queue => const validQueues = queues.filter(queue =>
queue && typeof queue.id === 'string' && typeof queue.name === 'string' queue && typeof queue.id === 'string' && typeof queue.name === 'string'
); ).map(queueWithNormalizedConcurrency);
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID) const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|| validQueues.find(queue => queue.isMain); || validQueues.find(queue => queue.isMain);
const persistedMainId = persistedMain?.id.trim(); const persistedMainId = persistedMain?.id.trim();
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME; const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }]; const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
if (persistedMain?.maxConcurrent !== undefined) {
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
}
const normalized: Queue[] = [normalizedMain];
const seenIds = new Set([MAIN_QUEUE_ID]); const seenIds = new Set([MAIN_QUEUE_ID]);
const seenNames = new Set([queueNameKey(mainName)]); const seenNames = new Set([queueNameKey(mainName)]);
const queueIdRemap = new Map<string, string>(); const queueIdRemap = new Map<string, string>();
@@ -616,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
} }
seenIds.add(id); seenIds.add(id);
seenNames.add(queueNameKey(name)); seenNames.add(queueNameKey(name));
normalized.push({ id, name, isMain: false }); const normalizedQueue = { id, name, isMain: false } as Queue;
if (queue.maxConcurrent !== undefined) {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
}
normalized.push(normalizedQueue);
} }
return { queues: normalized, queueIdRemap }; return { queues: normalized, queueIdRemap };
}; };
export const normalizePersistedQueues = (queues: Queue[]): Queue[] => export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
normalizePersistedQueueState(queues).queues; normalizePersistedQueueState(queues).queues;
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
await invoke('set_queue_concurrency_limits', {
limits: queues.map(queue => ({
id: queue.id,
maxConcurrent: queue.maxConcurrent ?? null
}))
});
};
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
left.length === right.length && left.every((queue, index) => {
const other = right[index];
return other !== undefined
&& queue.id === other.id
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
});
export type { DownloadItem, Queue }; export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload; export type ExtensionDownloadRequest = ExtensionDownload;
export type AddDownloadAction = export type AddDownloadAction =
@@ -659,6 +707,12 @@ 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,
beforeId?: string | null
) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>; removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean; isAddModalOpen: boolean;
pendingAddUrls: string; pendingAddUrls: string;
@@ -701,6 +755,8 @@ interface DownloadState {
startAll: () => Promise<number>; startAll: () => Promise<number>;
pauseAll: () => Promise<number>; pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>; assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean; addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean; renameQueue: (id: string, name: string) => boolean;
removeQueue: (id: string) => Promise<void>; removeQueue: (id: string) => Promise<void>;
@@ -789,7 +845,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
lastTry: new Date().toISOString() lastTry: new Date().toISOString()
}); });
const resumedExisting = await invoke('resume_download', { id }); const resumedExisting = await invoke('resume_download', {
id,
queueId: targetItem.queueId || MAIN_QUEUE_ID
});
let dispatchSucceeded = resumedExisting; let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) { if (!dispatchSucceeded) {
@@ -894,6 +953,92 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? { ...download, queuePosition: previousPositions.get(download.id) } ? { ...download, queuePosition: previousPositions.get(download.id) }
: download) : download)
})); }));
throw error;
}
});
const trackedOperation = operation.finally(() => {
if (queueReorderPromises.get(queueId) === trackedOperation) {
queueReorderPromises.delete(queueId);
}
});
queueReorderPromises.set(queueId, trackedOperation);
return trackedOperation;
},
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => {
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 unselectedItems = queueItems.filter(item => !selectedIds.has(item.id));
const anchoredTargetIndex = beforeId
? unselectedItems.findIndex(item => item.id === beforeId)
: -1;
const resolvedTargetIndex = anchoredTargetIndex >= 0
? anchoredTargetIndex
: Math.max(0, Math.min(targetIndex, unselectedItems.length));
const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex);
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(() => { const trackedOperation = operation.finally(() => {
@@ -1468,6 +1613,79 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
})); }));
}); });
}, },
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
id,
'speed-limit',
async () => {
await waitForPendingStartupResume();
const item = get().downloads.find(download => download.id === id);
if (!item) throw new Error('Download no longer exists.');
if (item.isMedia) {
throw new Error('Live speed control is unavailable for media downloads.');
}
if (!['downloading', 'retrying'].includes(item.status)) {
throw new Error('Live speed control requires an active download.');
}
const trimmed = limit?.trim() || '';
const normalizedLimit = trimmed
? normalizeSpeedLimitForBackend(trimmed)
: null;
if (trimmed && normalizedLimit === null) {
throw new Error('Enter a valid speed limit.');
}
await invoke('set_download_speed_limit', {
id,
limit: normalizedLimit
});
if (get().downloads.some(download => download.id === id)) {
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
}
},
true,
preemptDispatch
),
setQueueConcurrency: (id, maxConcurrent) => {
const operation = queueConfigurationQueue.then(async () => {
if (
maxConcurrent !== null
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
) {
throw new Error('Queue concurrency must be between 1 and 12.');
}
const currentQueues = get().queues;
if (!currentQueues.some(queue => queue.id === id)) {
throw new Error('Queue no longer exists.');
}
const nextQueues = currentQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
await synchronizeQueueConcurrencyLimits(nextQueues);
const latestQueues = get().queues;
if (!latestQueues.some(queue => queue.id === id)) {
await synchronizeQueueConcurrencyLimits(latestQueues);
throw new Error('Queue no longer exists.');
}
const rebasedQueues = latestQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
await synchronizeQueueConcurrencyLimits(rebasedQueues);
}
set({ queues: rebasedQueues });
});
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
return operation;
},
addQueue: (name) => { addQueue: (name) => {
const normalizedName = name.trim(); const normalizedName = name.trim();
if (!normalizedName) return false; if (!normalizedName) return false;
@@ -1689,7 +1907,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
try { try {
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => { const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
try { try {
return [JSON.parse(value) as Queue]; return [JSON.parse(value) as PersistedQueue];
} catch { } catch {
console.warn('Skipping malformed persisted queue record during startup'); console.warn('Skipping malformed persisted queue record during startup');
return []; return [];
@@ -1713,6 +1931,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? normalizeQueuePositions(downloads) ? normalizeQueuePositions(downloads)
: state.downloads : state.downloads
})); }));
// The backend dispatcher is live before the frontend finishes startup.
// Synchronize the normalized queue policy before any saved download is
// allowed to claim a permit.
await synchronizeQueueConcurrencyLimits(queues);
// Reset interrupted active downloads to queued. // Reset interrupted active downloads to queued.
set((state) => ({ set((state) => ({
+18
View File
@@ -21,6 +21,24 @@ describe('last used download directory preference', () => {
}); });
}); });
describe('calendar preference', () => {
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
vi.clearAllMocks();
useSettingsStore.setState({ calendarPreference: 'gregorian' });
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
useSettingsStore.getState().setCalendarPreference('persian');
await new Promise(resolve => setTimeout(resolve, 0));
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
});
});
describe('useSettingsStore global speed limit persistence', () => { describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
+69 -2
View File
@@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc';
import { info } from '../utils/logger'; import { info } from '../utils/logger';
import type { ActiveView } from '../bindings/ActiveView'; import type { ActiveView } from '../bindings/ActiveView';
import type { AppFontSize } from '../bindings/AppFontSize'; import type { AppFontSize } from '../bindings/AppFontSize';
import type { FontFamily } from '../bindings/FontFamily';
import type { ListRowDensity } from '../bindings/ListRowDensity'; import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource'; import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction'; import type { PostQueueAction } from '../bindings/PostQueueAction';
@@ -14,6 +15,7 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab'; import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin'; import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme'; import type { Theme } from '../bindings/Theme';
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
import { import {
DEFAULT_CATEGORY_SUBFOLDERS, DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings normalizeDownloadLocationSettings
@@ -21,6 +23,11 @@ import {
import { normalizeSpeedLimitForBackend } from '../utils/downloads'; import { normalizeSpeedLimitForBackend } from '../utils/downloads';
import i18n from '../i18n'; import i18n from '../i18n';
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales'; import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
import {
DEFAULT_CALENDAR_PREFERENCE,
isCalendarPreference,
type CalendarPreference
} from '../utils/dateTime';
let settingsQueue: Promise<void> = Promise.resolve(); let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null; let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
@@ -68,6 +75,18 @@ const notifySettingsPersistenceError = () => {
}; };
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const; const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
const FONT_FAMILY_VALUES = [
'system',
'inter',
'outfit',
'vazirmatn',
'noto-sans-hebrew',
'noto-sans-sc',
'roboto',
'serif',
'monospace',
] as const;
const WINDOW_CONTROL_STYLE_VALUES = ['auto', 'macos', 'windows', 'gnome', 'minimal'] as const;
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const; const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const; const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const; const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const;
@@ -155,6 +174,8 @@ const tauriStorage: StateStorage = {
export type { export type {
ActiveView, ActiveView,
AppFontSize, AppFontSize,
FontFamily,
CalendarPreference,
ListRowDensity, ListRowDensity,
MediaCookieSource, MediaCookieSource,
PostQueueAction, PostQueueAction,
@@ -162,13 +183,17 @@ export type {
SchedulerSettings, SchedulerSettings,
SettingsTab, SettingsTab,
SiteLogin, SiteLogin,
Theme Theme,
WindowControlStyle
}; };
export type SidebarPosition = 'auto' | 'left' | 'right'; export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState { export interface SettingsState {
theme: Theme; theme: Theme;
fontFamily: FontFamily;
windowControlStyle: WindowControlStyle;
calendarPreference: CalendarPreference;
language: AppLocalePreference; language: AppLocalePreference;
baseDownloadFolder: string; baseDownloadFolder: string;
categorySubfoldersEnabled: boolean; categorySubfoldersEnabled: boolean;
@@ -212,6 +237,7 @@ export interface SettingsState {
customUserAgent: string; customUserAgent: string;
askWhereToSaveEachFile: boolean; askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean; preventsSleepWhileDownloading: boolean;
preventsDisplaySleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource; mediaCookieSource: MediaCookieSource;
siteLogins: SiteLogin[]; siteLogins: SiteLogin[];
extensionPairingToken: string; extensionPairingToken: string;
@@ -224,6 +250,9 @@ export interface SettingsState {
showKeychainModal: boolean; showKeychainModal: boolean;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
setFontFamily: (fontFamily: FontFamily) => void;
setWindowControlStyle: (style: WindowControlStyle) => void;
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
setLanguage: (language: AppLocalePreference) => void; setLanguage: (language: AppLocalePreference) => void;
setBaseDownloadFolder: (path: string) => void; setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>; approveDownloadRoot: (path: string) => Promise<string>;
@@ -258,6 +287,7 @@ export interface SettingsState {
setCustomUserAgent: (userAgent: string) => void; setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void; setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void; setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void; setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void; setLastUsedDownloadDirectory: (path: string) => void;
@@ -280,6 +310,9 @@ export const useSettingsStore = create<SettingsState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
theme: 'system', theme: 'system',
fontFamily: 'system',
windowControlStyle: 'auto',
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
language: 'system', language: 'system',
baseDownloadFolder: '~/Downloads', baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true, categorySubfoldersEnabled: true,
@@ -330,6 +363,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: '', customUserAgent: '',
askWhereToSaveEachFile: false, askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true, preventsSleepWhileDownloading: true,
preventsDisplaySleepWhileDownloading: false,
mediaCookieSource: 'none', mediaCookieSource: 'none',
siteLogins: [], siteLogins: [],
extensionPairingToken: '', extensionPairingToken: '',
@@ -342,6 +376,18 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false, showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); }, setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setFontFamily: (fontFamily) => {
info('Settings updated: fontFamily');
set({ fontFamily });
},
setWindowControlStyle: (windowControlStyle) => {
info('Settings updated: windowControlStyle');
set({ windowControlStyle });
},
setCalendarPreference: (calendarPreference) => {
info('Settings updated: calendarPreference');
set({ calendarPreference });
},
setLanguage: (language) => { info('Settings updated: language'); set({ language }); }, setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => { setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder'); info('Settings updated: baseDownloadFolder');
@@ -414,6 +460,10 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: preventsSleepWhileDownloading'); info('Settings updated: preventsSleepWhileDownloading');
set({ preventsSleepWhileDownloading }); set({ preventsSleepWhileDownloading });
}, },
setPreventsDisplaySleepWhileDownloading: (preventsDisplaySleepWhileDownloading) => {
info('Settings updated: preventsDisplaySleepWhileDownloading');
set({ preventsDisplaySleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); }, setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => { setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory'); info('Settings updated: rememberLastUsedDownloadDirectory');
@@ -512,7 +562,7 @@ export const useSettingsStore = create<SettingsState>()(
{ {
name: 'firelink-settings', name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage), storage: createJSONStorage(() => tauriStorage),
version: 5, version: 6,
migrate: (persistedState) => { migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') { if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState; return persistedState as SettingsState;
@@ -548,6 +598,9 @@ export const useSettingsStore = create<SettingsState>()(
}, },
partialize: (state): PersistedSettingsSnapshot => ({ partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme, theme: state.theme,
fontFamily: state.fontFamily,
windowControlStyle: state.windowControlStyle,
calendarPreference: state.calendarPreference,
language: state.language, language: state.language,
baseDownloadFolder: state.baseDownloadFolder, baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled, categorySubfoldersEnabled: state.categorySubfoldersEnabled,
@@ -585,6 +638,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: state.customUserAgent, customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile, askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource, mediaCookieSource: state.mediaCookieSource,
siteLogins: state.siteLogins, siteLogins: state.siteLogins,
keychainAccessGranted: state.keychainAccessGranted, keychainAccessGranted: state.keychainAccessGranted,
@@ -608,6 +662,15 @@ export const useSettingsStore = create<SettingsState>()(
theme: isAllowedSetting(THEME_VALUES, persisted.theme) theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme ? persisted.theme
: currentState.theme, : currentState.theme,
fontFamily: isAllowedSetting(FONT_FAMILY_VALUES, persisted.fontFamily)
? persisted.fontFamily
: currentState.fontFamily,
windowControlStyle: isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, persisted.windowControlStyle)
? persisted.windowControlStyle
: currentState.windowControlStyle,
calendarPreference: isCalendarPreference(persisted.calendarPreference)
? persisted.calendarPreference
: currentState.calendarPreference,
language: isAppLocalePreference(persisted.language) language: isAppLocalePreference(persisted.language)
? persisted.language ? persisted.language
: currentState.language, : currentState.language,
@@ -649,6 +712,10 @@ export const useSettingsStore = create<SettingsState>()(
persisted.preventsSleepWhileDownloading, persisted.preventsSleepWhileDownloading,
currentState.preventsSleepWhileDownloading currentState.preventsSleepWhileDownloading
), ),
preventsDisplaySleepWhileDownloading: persistedBoolean(
persisted.preventsDisplaySleepWhileDownloading,
currentState.preventsDisplaySleepWhileDownloading
),
keychainAccessGranted: persistedBoolean( keychainAccessGranted: persistedBoolean(
persisted.keychainAccessGranted, persisted.keychainAccessGranted,
currentState.keychainAccessGranted currentState.keychainAccessGranted
+203
View File
@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
appendRequestUrlsAfterVersion, appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
canSubmitMetadataRows, canSubmitMetadataRows,
commonMediaQualitiesForRows,
mediaFormatSelectorForRow, mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat, mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForRow,
mediaTypeForFormat,
metadataSummaryMessage, metadataSummaryMessage,
isYouTubePlaylistUrl, isYouTubePlaylistUrl,
playlistFilePrefix, playlistFilePrefix,
reconcileDownloadRows, reconcileDownloadRows,
refreshFailedMetadataRows, refreshFailedMetadataRows,
selectExactMediaSelection,
selectExactMediaQuality,
updateRowIfCurrent, updateRowIfCurrent,
type AddDownloadDraftRow type AddDownloadDraftRow
} from './addDownloadMetadata'; } from './addDownloadMetadata';
@@ -539,6 +546,202 @@ 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('keeps playlist choices available when one ready row remains selected', () => {
const formats = [
{
name: '720p MP4',
quality: '720p',
selector: '720',
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: 'Audio only M4A',
quality: 'Audio only',
selector: 'audio',
ext: 'm4a',
formatLabel: 'M4A • AAC',
detail: '4 MB',
type: 'Audio',
bytes: 4
}
];
const selectedRow = row({ isMedia: true, formats, selectedFormat: 0 });
expect(commonMediaFormatsForRows([selectedRow], 'Video')).toEqual(['MP4']);
expect(commonMediaQualitiesForRows([selectedRow], 'Video', 'MP4')).toEqual(['720p']);
expect(commonMediaFormatsForRows([selectedRow], 'Audio')).toEqual(['M4A']);
expect(commonMediaQualitiesForRows([selectedRow], 'Audio', 'M4A')).toEqual(['Audio only']);
});
it('does not offer a shared format when its qualities do not overlap', () => {
const format = (quality: string, id: string) => [{
name: `${quality} MP4`,
quality,
selector: id,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
}];
const rows = [
row({ id: 'one', isMedia: true, formats: format('1080p', 'one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: format('720p', 'two'), selectedFormat: 0 })
];
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual([]);
});
it('filters playlist choices by media type and format', () => {
const formats = (qualities: string[], idPrefix: string) => [
...qualities.flatMap((quality, index) => [
{
name: `${quality} MKV`,
quality,
selector: `${idPrefix}-mkv-${index}`,
ext: 'mkv',
formatLabel: 'MKV • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: `${quality} MP4`,
quality,
selector: `${idPrefix}-mp4-${index}`,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
}
]),
{
name: 'Audio only M4A',
quality: 'Audio only',
selector: `${idPrefix}-m4a`,
ext: 'm4a',
formatLabel: 'M4A • AAC',
detail: '4 MB',
type: 'Audio',
bytes: 4
},
{
name: 'Audio only WEBM',
quality: 'Audio only',
selector: `${idPrefix}-webm`,
ext: 'webm',
formatLabel: 'WEBM • Opus',
detail: '4 MB',
type: 'Audio',
bytes: 4
}
];
const rows = [
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p'], 'one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p'], 'two'), selectedFormat: 0 })
];
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual(['MKV', 'MP4']);
expect(commonMediaQualitiesForRows(rows, 'Video', 'MP4')).toEqual(['720p']);
expect(commonMediaFormatsForRows(rows, 'Audio')).toEqual(['M4A', 'WEBM']);
expect(commonMediaQualitiesForRows(rows, 'Audio', 'M4A')).toEqual(['Audio only']);
expect(mediaTypeForFormat(rows[0].formats![4])).toBe('Audio');
expect(mediaFormatForFormat(rows[0].formats![4])).toBe('M4A');
});
it('applies an exact playlist media selection without falling back to another format', () => {
const formats = (id: string) => [
{
name: '720p MKV',
quality: '720p',
selector: `${id}-mkv`,
ext: 'mkv',
formatLabel: 'MKV • H.264',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: '720p MP4',
quality: '720p',
selector: `${id}-mp4`,
ext: 'mp4',
formatLabel: 'MP4 • H.264',
detail: '9 MB',
type: 'Video',
bytes: 9
}
];
const rows = [
row({ id: 'one', isMedia: true, file: 'one.mkv', formats: formats('one'), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, file: 'two.mkv', formats: formats('two'), selectedFormat: 0 })
];
const selected = selectExactMediaSelection(rows, ['one', 'two'], {
mediaType: 'Video',
format: 'MP4',
quality: '720p'
});
expect(selected[0]).toMatchObject({ selectedFormat: 1, file: 'one.mp4' });
expect(selected[1]).toMatchObject({ selectedFormat: 1, file: 'two.mp4' });
expect(selectExactMediaSelection(rows, ['one', 'two'], {
mediaType: 'Video',
format: 'WEBM',
quality: '720p'
})).toEqual(rows);
});
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' })];
+140
View File
@@ -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;
@@ -20,6 +21,14 @@ export interface AddMediaFormat {
isApproximate?: boolean; isApproximate?: boolean;
} }
export type MediaType = 'Audio' | 'Video';
export interface MediaSelection {
mediaType: MediaType;
format: string;
quality: string;
}
export interface AddDownloadDraftRow { export interface AddDownloadDraftRow {
id: string; id: string;
sourceUrl: string; sourceUrl: string;
@@ -331,6 +340,137 @@ 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 mediaTypeForFormat = (
format: Pick<AddMediaFormat, 'type'>
): MediaType => format.type.toLowerCase().includes('audio') ? 'Audio' : 'Video';
export const mediaFormatForFormat = (
format: Pick<AddMediaFormat, 'ext'>
): string => normalizeMediaQualityLabel(format.ext.replace(/^\.+/, '').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'>>,
mediaType?: MediaType,
mediaFormat?: string
): string[] => {
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
if (readyMediaRows.length < 1) return [];
const matches = (format: AddMediaFormat) =>
(!mediaType || mediaTypeForFormat(format) === mediaType)
&& (!mediaFormat || mediaFormatForFormat(format) === mediaFormat);
const firstQualities = Array.from(new Set(
readyMediaRows[0].formats!.filter(matches).map(mediaQualityForFormat)
));
return firstQualities.filter(quality => readyMediaRows.every(row =>
row.formats!.some(format => matches(format) && mediaQualityForFormat(format) === quality)
));
};
export const commonMediaFormatsForRows = (
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
mediaType: MediaType
): string[] => {
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
if (readyMediaRows.length < 1) return [];
const firstFormats = Array.from(new Set(
readyMediaRows[0].formats!
.filter(format => mediaTypeForFormat(format) === mediaType)
.map(mediaFormatForFormat)
));
return firstFormats.filter(mediaFormat =>
readyMediaRows.every(row =>
row.formats!.some(format =>
mediaTypeForFormat(format) === mediaType && mediaFormatForFormat(format) === mediaFormat
)
)
&& commonMediaQualitiesForRows(readyMediaRows, mediaType, mediaFormat).length > 0
);
};
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 selectExactMediaSelection = (
rows: AddDownloadDraftRow[],
selectedIds: ReadonlySet<string> | readonly string[],
selection: MediaSelection
): 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 =>
mediaTypeForFormat(format) === selection.mediaType
&& mediaFormatForFormat(format) === selection.format
&& mediaQualityForFormat(format) === selection.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'>
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_CALENDAR_PREFERENCE,
formatDateTime,
isCalendarPreference
} from './dateTime';
describe('date/time formatting', () => {
const instant = new Date('2026-03-21T14:30:00.000Z');
it('uses Gregorian explicitly by default, including in localized UI languages', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
expect(formatDateTime(instant, { locale: 'fa', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-gregory', options).format(instant)
);
expect(DEFAULT_CALENDAR_PREFERENCE).toBe('gregorian');
});
it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
);
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
);
});
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-');
expect(isCalendarPreference('gregorian')).toBe(true);
expect(isCalendarPreference('lunar')).toBe(false);
expect(isCalendarPreference(null)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { CalendarPreference } from '../bindings/CalendarPreference';
import { resolveAppLocale } from '../i18n/locales';
export type { CalendarPreference } from '../bindings/CalendarPreference';
export const CALENDAR_PREFERENCES = ['gregorian', 'persian', 'hebrew'] as const satisfies readonly CalendarPreference[];
export const DEFAULT_CALENDAR_PREFERENCE: CalendarPreference = 'gregorian';
export const isCalendarPreference = (value: unknown): value is CalendarPreference =>
typeof value === 'string' && (CALENDAR_PREFERENCES as readonly string[]).includes(value);
type DateTimeInput = Date | string | number;
interface DateTimeFormatConfig {
locale?: string | null;
calendar?: CalendarPreference;
options?: Intl.DateTimeFormatOptions;
}
const calendarIdentifier = (calendar: CalendarPreference): string =>
calendar === 'gregorian' ? 'gregory' : calendar;
const localeWithCalendar = (locale: string | null | undefined, calendar: CalendarPreference): string => {
const resolvedLocale = resolveAppLocale(locale);
return `${resolvedLocale}-u-ca-${calendarIdentifier(calendar)}`;
};
const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value);
/**
* Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional
* calendar even when the user has not opted into one.
*/
export const formatDateTime = (
value: DateTimeInput,
config: DateTimeFormatConfig = {}
): string => {
const date = dateFromInput(value);
if (Number.isNaN(date.getTime())) return '-';
const calendar = config.calendar && isCalendarPreference(config.calendar)
? config.calendar
: DEFAULT_CALENDAR_PREFERENCE;
const options = config.options ?? {};
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar),
options
).format(date);
} catch {
// WebViews can have incomplete ICU calendar data. Keep the display useful
// and deterministic rather than exposing a RangeError to the UI.
try {
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, DEFAULT_CALENDAR_PREFERENCE),
options
).format(date);
} catch {
return '-';
}
}
};
+13
View File
@@ -3,6 +3,7 @@ import {
canPauseDownload, canPauseDownload,
canRedownload, canRedownload,
canStartDownload, canStartDownload,
getPauseResumeAction,
isIdentityLocked, isIdentityLocked,
isTransferLocked, isTransferLocked,
startActionLabel, startActionLabel,
@@ -27,6 +28,18 @@ describe('download action policy', () => {
expect(canRedownload('downloading')).toBe(false); expect(canRedownload('downloading')).toBe(false);
}); });
it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
for (const status of ['ready', 'staged', 'completed', 'failed'] as const) {
expect(getPauseResumeAction(status)).toBeNull();
}
});
it('provides consistent labels and edit locks', () => { it('provides consistent labels and edit locks', () => {
expect(startActionLabel('ready')).toBe('Start'); expect(startActionLabel('ready')).toBe('Start');
expect(startActionLabel('failed')).toBe('Start'); expect(startActionLabel('failed')).toBe('Start');
+8
View File
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
export const canPauseDownload = (status: DownloadStatus): boolean => export const canPauseDownload = (status: DownloadStatus): boolean =>
PAUSABLE_STATUSES.has(status); PAUSABLE_STATUSES.has(status);
export type PauseResumeAction = 'pause' | 'resume';
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
return null;
};
export const canRedownload = (status: DownloadStatus): boolean => export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status); REDOWNLOADABLE_STATUSES.has(status);
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { summarizeDownloads } from './downloadSummary';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.com/${id}`,
fileName: `${id}.bin`,
status: 'ready',
category: 'Other',
dateAdded: '2026-01-01T00:00:00.000Z',
...overrides,
});
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
id,
fraction: 0.5,
speed: '1 MiB/s',
eta: '1m',
size: null,
size_is_final: false,
...overrides,
});
describe('download summaries', () => {
it('aggregates exact bytes and transfer-active counts', () => {
expect(summarizeDownloads([
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
item('done', { status: 'completed', totalBytes: 2048 }),
])).toEqual({
itemCount: 2,
activeCount: 1,
downloadedBytes: 3072,
remainingBytes: 3072,
remainingIsEstimated: false,
});
});
it('treats completed downloads as having no remaining bytes despite stale progress', () => {
expect(summarizeDownloads([
item('done', {
status: 'completed',
totalBytes: 40 * 1024 ** 2,
downloadedBytes: 40 * 1024 ** 2,
}),
], {
done: progress('done', {
downloaded_bytes: 40 * 1024 ** 2 - 555 * 1024,
total_bytes: 555 * 1024,
}),
})).toEqual({
itemCount: 1,
activeCount: 0,
downloadedBytes: 40 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not present partial byte totals as complete aggregates', () => {
expect(summarizeDownloads([
item('known', { totalBytes: 100, downloadedBytes: 20 }),
item('unknown', { status: 'failed' }),
])).toMatchObject({
itemCount: 2,
downloadedBytes: null,
remainingBytes: null,
});
});
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
expect(summarizeDownloads([
item('paused', { status: 'paused', totalBytes: 1000 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1000,
remainingIsEstimated: false,
});
expect(summarizeDownloads([
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
]).downloadedBytes).toBeNull();
});
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
expect(summarizeDownloads([
item('media', {
status: 'ready',
isMedia: true,
totalBytes: 1024,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1024,
remainingIsEstimated: true,
});
});
it('keeps non-final media progress on the stored estimated denominator', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
size: '~2 MB',
}),
], {
media: progress('media', {
downloaded_bytes: 512 * 1024,
total_bytes: 1024,
total_is_estimate: true,
}),
})).toEqual({
itemCount: 1,
activeCount: 1,
downloadedBytes: 512 * 1024,
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
remainingIsEstimated: true,
});
});
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
downloadedBytes: 150,
totalBytes: 100,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 150,
remainingBytes: null,
remainingIsEstimated: false,
});
});
it('uses a final media progress total when one is available', () => {
expect(summarizeDownloads([
item('media', {
status: 'processing',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
totalIsEstimate: true,
}),
], {
media: progress('media', {
downloaded_bytes: 3 * 1024 ** 2,
total_bytes: 3 * 1024 ** 2,
size_is_final: true,
total_is_estimate: false,
}),
})).toMatchObject({
downloadedBytes: 3 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not overflow aggregate byte counters', () => {
const huge = Number.MAX_VALUE;
expect(summarizeDownloads([
item('one', { totalBytes: huge, downloadedBytes: huge }),
item('two', { totalBytes: huge, downloadedBytes: huge }),
])).toMatchObject({
downloadedBytes: null,
remainingBytes: 0,
});
expect(summarizeDownloads([
item('three', { totalBytes: huge, downloadedBytes: 0 }),
item('four', { totalBytes: huge, downloadedBytes: 0 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: null,
});
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { isTransferActiveStatus } from './downloads';
export interface DownloadSummary {
itemCount: number;
activeCount: number;
downloadedBytes: number | null;
remainingBytes: number | null;
remainingIsEstimated: boolean;
}
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
const usableBytes = (value: number | null | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'ready' ||
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'processing' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
typeof download.fraction === 'number' &&
Number.isFinite(download.fraction) &&
download.fraction > 0;
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
!hasPositiveProgress(download) &&
(isFreshDownloadStatus(download.status) || download.status === 'paused');
const effectiveByteState = (
download: DownloadItem,
progress: DownloadProgressEvent | undefined
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
const storedTotalIsEstimate = download.totalIsEstimate === true ||
download.size?.trim().startsWith('~') === true;
const totalBytes = usesStoredMediaTotal
? usableBytes(download.totalBytes)
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
const downloadedBytes = download.status === 'completed'
? usableBytes(download.downloadedBytes) ??
usableBytes(progress?.downloaded_bytes) ??
totalBytes
: usableBytes(progress?.downloaded_bytes) ??
usableBytes(download.downloadedBytes) ??
(canInferNoDownloadedBytes(download) ? 0 : undefined);
const totalIsEstimate = usesStoredMediaTotal
? storedTotalIsEstimate
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
return { downloadedBytes, totalBytes, totalIsEstimate };
};
export const summarizeDownloads = (
downloads: readonly DownloadItem[],
progressMap: ProgressMap = {}
): DownloadSummary => {
if (downloads.length === 0) {
return {
itemCount: 0,
activeCount: 0,
downloadedBytes: null,
remainingBytes: null,
remainingIsEstimated: false,
};
}
let downloadedBytes = 0;
let remainingBytes = 0;
let downloadedKnown = true;
let remainingKnown = true;
let remainingIsEstimated = false;
let activeCount = 0;
for (const download of downloads) {
const state = effectiveByteState(download, progressMap[download.id]);
if (isTransferActiveStatus(download.status)) activeCount += 1;
if (state.downloadedBytes === undefined) {
downloadedKnown = false;
} else {
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
if (Number.isFinite(nextDownloadedBytes)) {
downloadedBytes = nextDownloadedBytes;
} else {
downloadedKnown = false;
}
}
if (download.status === 'completed') {
// A completed row is terminal even when a delayed progress event still
// carries an old partial denominator. Never expose that stale value as
// remaining work in the aggregate status bar.
continue;
}
if (
state.totalBytes === undefined ||
state.downloadedBytes === undefined ||
state.downloadedBytes > state.totalBytes
) {
remainingKnown = false;
} else {
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
if (Number.isFinite(nextRemainingBytes)) {
remainingBytes = nextRemainingBytes;
remainingIsEstimated ||= state.totalIsEstimate;
} else {
remainingKnown = false;
}
}
}
return {
itemCount: downloads.length,
activeCount,
downloadedBytes: downloadedKnown ? downloadedBytes : null,
remainingBytes: remainingKnown ? remainingBytes : null,
remainingIsEstimated,
};
};
+36 -4
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_COLUMN_ORDER, DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS, DEFAULT_COLUMN_WIDTHS,
buildColumnGridTemplate, buildColumnGridTemplate,
getDownloadActionPosition,
getColumnGridColumn, getColumnGridColumn,
normalizeColumnAlignments, normalizeColumnAlignments,
normalizeColumnOrder, normalizeColumnOrder,
@@ -54,12 +55,43 @@ describe('download table column preferences', () => {
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS); expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
}); });
it('keeps user widths fixed while reserving a shared spacer before the final column', () => { it('keeps every data column fixed while reserving a flexible fill track after them', () => {
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe( expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px' '340px 100px 220px 100px 80px 170px minmax(0, 1fr)'
); );
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7'); expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('6');
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined(); expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7'); expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('6');
});
it('keeps row actions inside the visible row edge while preserving viewport anchoring for clipped rows', () => {
const viewport = { top: 0, right: 800, bottom: 600, left: 0 };
expect(getDownloadActionPosition(
{ top: 40, right: 1000, bottom: 72, left: -200 },
viewport,
viewport,
800
)).toMatchObject({
right: 8,
height: 30,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: 400, bottom: 72, left: 100 },
viewport,
viewport,
800
)).toMatchObject({
right: 408,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: -20, bottom: 72, left: -300 },
viewport,
viewport,
800
).visibility).toBe('hidden');
}); });
}); });
+48 -6
View File
@@ -14,10 +14,53 @@ export const DEFAULT_COLUMN_ORDER = [
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const; export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const; export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
// Width of the fixed action rail shown while hovering a row. // Width of the compact action rail shown while hovering or focusing a row.
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 120; export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 64;
// Keep the fixed rail clear of the viewport edge and horizontal scrollbar. // Keep the viewport-anchored rail clear of the window edge and scrollbar.
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8; export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 30;
export const DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE = 1;
export interface DownloadActionViewportRect {
top: number;
right: number;
bottom: number;
left: number;
}
export interface DownloadActionPosition {
top: number;
right: number;
height: number;
overflow: 'hidden' | 'visible';
visibility: 'hidden' | 'visible';
}
export const getDownloadActionPosition = (
rowRect: DownloadActionViewportRect,
horizontalViewportRect: DownloadActionViewportRect,
verticalViewportRect: DownloadActionViewportRect,
windowWidth: number
): DownloadActionPosition => {
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const visibleLeft = Math.max(rowRect.left, horizontalViewportRect.left);
const visibleRight = Math.min(rowRect.right, horizontalViewportRect.right);
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
const isVisible = visibleHeight > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE &&
visibleWidth > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE;
const actionHeight = Math.min(DOWNLOAD_ACTIONS_MAX_HEIGHT, rowRect.bottom - rowRect.top, visibleHeight);
const actionRightEdge = Math.min(rowRect.right, horizontalViewportRect.right);
return {
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
right: Math.max(0, windowWidth - actionRightEdge) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.bottom - rowRect.top ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
};
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order'; export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
@@ -99,9 +142,8 @@ export const buildColumnGridTemplate = (
const normalizedWidths = normalizeColumnWidths(widths); const normalizedWidths = normalizeColumnWidths(widths);
const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]); const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]);
return [ return [
...orderedWidths.slice(0, -1).map(width => `${width}px`), ...orderedWidths.map(width => `${width}px`),
'minmax(0, 1fr)', 'minmax(0, 1fr)',
`${orderedWidths[orderedWidths.length - 1]}px`,
].join(' '); ].join(' ');
}; };
@@ -109,5 +151,5 @@ export const getColumnGridColumn = (
key: DownloadTableColumnKey, key: DownloadTableColumnKey,
order: DownloadTableColumnKey[] order: DownloadTableColumnKey[]
): string | undefined => key === order[order.length - 1] ): string | undefined => key === order[order.length - 1]
? `${order.length + 1}` ? `${order.length}`
: undefined; : undefined;
+37
View File
@@ -0,0 +1,37 @@
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('moves a selected block to the end for a downward drop', () => {
expect(moveSelectedBlockToIndex(items, ['b', 'c'], 2).map(item => item.id))
.toEqual(['a', 'd', 'b', '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);
});
});
+61
View File
@@ -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);
};
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { resolveWindowControlStyle } from './windowControlStyle';
describe('resolveWindowControlStyle', () => {
it('uses the platform convention for automatic style', () => {
expect(resolveWindowControlStyle('auto', 'macos')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'windows')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'linux')).toBe('gnome');
});
it('falls back to macOS styling while an unsupported platform is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'android')).toBe('macos');
});
it('uses the desktop user agent while native platform detection is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe('gnome');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Linux; Android 14)')).toBe('macos');
});
it('preserves an explicit style across platforms', () => {
expect(resolveWindowControlStyle('macos', 'windows')).toBe('macos');
expect(resolveWindowControlStyle('windows', 'linux')).toBe('windows');
expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome');
expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
export const resolveWindowControlStyle = (
style: WindowControlStyle,
os: string,
userAgent = ''
): ResolvedWindowControlStyle => {
if (style !== 'auto') return style;
if (os === 'windows') return 'windows';
if (os === 'linux') return 'gnome';
if (os === 'unknown' && /Windows/i.test(userAgent)) return 'windows';
if (os === 'unknown' && /Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return 'gnome';
return 'macos';
};