mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d895fb1b73 | |||
| 5372a76eda | |||
| 54693456b1 | |||
| edc0c1c6ad | |||
| c10b889a41 | |||
| 530324ab9c | |||
| 240055497d | |||
| 7787f4e6c0 | |||
| cf09bfa2e4 | |||
| 931e1f2f1e | |||
| 956b70dc10 | |||
| dd7c2196fe | |||
| d62e0429a4 | |||
| f3af5b0584 | |||
| 5614022dbd | |||
| b1659fa3d0 | |||
| 9a40b30a0f | |||
| 4f89a6c79d | |||
| f5d918ec1e | |||
| b579c35e42 | |||
| 1f1ec828f9 | |||
| 228db1eaa2 | |||
| 9a790073f5 | |||
| f3eff02069 | |||
| 4374219685 | |||
| 4e3ceb21a5 | |||
| 6adbccdf43 | |||
| 454fad712b | |||
| 7f450a802f | |||
| f2b1fb9d4a | |||
| 9302911ac7 | |||
| 18e9200b74 | |||
| 3559c69968 | |||
| 3587fb0c0d | |||
| b60818d3af | |||
| 5463d27ed3 | |||
| 3f298715a6 | |||
| ceab8a5fdf | |||
| f4e5e211cc | |||
| 9d737598c6 | |||
| d3e5e8c208 | |||
| 17eb8e5d39 |
+5
-5
@@ -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.
|
||||||
|
|||||||
+1
-1
Submodule Extensions/Browser updated: 8a9a4c4a63...b5118f1009
@@ -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.
|
||||||
|
|
||||||
[](https://github.com/nimbold/Firelink/releases)
|
[](https://github.com/nimbold/Firelink/releases/latest)
|
||||||
[](#supported-platforms)
|
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
||||||
[](#supported-platforms)
|
[](LICENSE)
|
||||||
[](#supported-platforms)
|
[](#installation)
|
||||||
[](LICENSE)
|
[](#installation)
|
||||||
[](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
|
[](#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>
|
||||||
|
<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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+80
-29
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+15
-24
@@ -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.
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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} />}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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,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, };
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+1070
-77
File diff suppressed because it is too large
Load Diff
@@ -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' : ''}`}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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: 'دانلودکننده رسانه و موتورها',
|
||||||
|
|||||||
@@ -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
@@ -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: 'Загрузчик медиа и движки',
|
||||||
|
|||||||
@@ -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: 'Завантажувач медіа та рушії',
|
||||||
|
|||||||
@@ -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: '媒体下载器和引擎',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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: [{
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' })];
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user