├── .cargo └── config.toml ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── feature.yaml │ └── question.yaml └── workflows │ └── main.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── MaterialIcons-Regular.ttf ├── NotoSans-Regular.ttf ├── icon.ico ├── icon.kra ├── icon.png ├── icon.svg ├── linux │ ├── com.mtkennerly.madamiru.desktop │ └── com.mtkennerly.madamiru.metainfo.xml └── windows │ └── manifest.xml ├── build.rs ├── clippy.toml ├── crowdin.yml ├── docs ├── cli.md ├── demo-gui.gif ├── help │ ├── application-folder.md │ ├── command-line.md │ ├── comparison-with-other-projects.md │ ├── configuration-file.md │ ├── environment-variables.md │ ├── installation.md │ ├── logging.md │ ├── media-sources.md │ └── troubleshooting.md ├── sample-gui.png └── schema │ ├── config.yaml │ └── playlist.yaml ├── lang ├── de-DE.ftl ├── en-US.ftl ├── fr-FR.ftl ├── pl-PL.ftl └── pt-BR.ftl ├── rustfmt.toml ├── src ├── cli.rs ├── cli │ └── parse.rs ├── gui.rs ├── gui │ ├── app.rs │ ├── button.rs │ ├── common.rs │ ├── dropdown.rs │ ├── font.rs │ ├── grid.rs │ ├── icon.rs │ ├── modal.rs │ ├── player.rs │ ├── shortcuts.rs │ ├── style.rs │ ├── undoable.rs │ └── widget.rs ├── lang.rs ├── main.rs ├── media.rs ├── metadata.rs ├── path.rs ├── prelude.rs ├── resource.rs ├── resource │ ├── cache.rs │ ├── config.rs │ └── playlist.rs └── testing.rs └── tasks.py /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-Ctarget-feature=+crt-static"] 3 | 4 | [target.i686-pc-windows-msvc] 5 | rustflags = ["-Ctarget-feature=+crt-static"] 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{feature,json,md,yaml,yml}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Problem 2 | description: Report a problem. 3 | labels: ["bug"] 4 | body: 5 | - type: dropdown 6 | attributes: 7 | label: Application version 8 | description: If you're not using the latest version, please update and make sure the problem still occurs. 9 | options: 10 | - v0.2.0 11 | - v0.1.0 12 | - Other 13 | validations: 14 | required: true 15 | - type: dropdown 16 | attributes: 17 | label: Operating system 18 | options: 19 | - Windows 20 | - Mac 21 | - Linux 22 | - Linux (Steam Deck) 23 | validations: 24 | required: true 25 | - type: dropdown 26 | attributes: 27 | label: Installation method 28 | options: 29 | - Standalone 30 | - Cargo 31 | - Flatpak 32 | - Scoop 33 | - Other 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Description 39 | description: What happened? 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: logs 44 | attributes: 45 | label: Logs 46 | description: >- 47 | Please provide any relevant screenshots, CLI output, or log files. 48 | Refer to the documentation to 49 | [find your config file](https://github.com/mtkennerly/madamiru/blob/master/docs/help/configuration-file.md) 50 | and/or [enable verbose logging](https://github.com/mtkennerly/madamiru/blob/master/docs/help/logging.md). 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature 2 | description: Suggest a new feature or change. 3 | labels: ["enhancement"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What's your idea? 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: Ask a question. 3 | labels: ["question"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What's your question? 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | name: Main 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | include: 12 | - os: windows-latest 13 | rust-target: x86_64-pc-windows-msvc 14 | artifact-name: win64 15 | artifact-file: madamiru.exe 16 | tar: false 17 | - os: windows-latest 18 | rust-target: i686-pc-windows-msvc 19 | artifact-name: win32 20 | artifact-file: madamiru.exe 21 | tar: false 22 | - os: ubuntu-22.04 23 | rust-target: x86_64-unknown-linux-gnu 24 | artifact-name: linux 25 | artifact-file: madamiru 26 | tar: true 27 | - os: macos-13 28 | rust-target: x86_64-apple-darwin 29 | artifact-name: mac 30 | artifact-file: madamiru 31 | tar: true 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.7' 37 | - uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | - uses: mtkennerly/dunamai-action@v1 41 | with: 42 | env-var: MADAMIRU_VERSION 43 | args: --style semver 44 | - uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: stable-${{ matrix.rust-target }} 47 | - uses: Swatinem/rust-cache@v2 48 | with: 49 | key: ${{ matrix.os }}-${{ matrix.rust-target }} 50 | - if: ${{ startsWith(matrix.os, 'ubuntu-') }} 51 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev libasound2-dev 52 | - if: ${{ matrix.artifact-name == 'win32' }} 53 | uses: blinemedical/setup-gstreamer@v1 54 | with: 55 | version: '1.22.12' 56 | arch: 'x86' 57 | - if: ${{ matrix.artifact-name != 'win32' }} 58 | uses: blinemedical/setup-gstreamer@v1 59 | with: 60 | version: '1.22.12' 61 | - run: cargo build --release 62 | - if: ${{ matrix.tar }} 63 | run: | 64 | cd target/release 65 | tar --create --gzip --file=madamiru-v${{ env.MADAMIRU_VERSION }}-${{ matrix.artifact-name }}.tar.gz ${{ matrix.artifact-file }} 66 | - if: ${{ matrix.tar }} 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: madamiru-v${{ env.MADAMIRU_VERSION }}-${{ matrix.artifact-name }} 70 | path: target/release/madamiru-v${{ env.MADAMIRU_VERSION }}-${{ matrix.artifact-name }}.tar.gz 71 | - if: ${{ !matrix.tar }} 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: madamiru-v${{ env.MADAMIRU_VERSION }}-${{ matrix.artifact-name }} 75 | path: target/release/${{ matrix.artifact-file }} 76 | 77 | test: 78 | strategy: 79 | matrix: 80 | os: 81 | - windows-latest 82 | - ubuntu-22.04 83 | - macos-13 84 | runs-on: ${{ matrix.os }} 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: dtolnay/rust-toolchain@stable 88 | - uses: Swatinem/rust-cache@v2 89 | with: 90 | key: ${{ matrix.os }} 91 | - if: ${{ startsWith(matrix.os, 'ubuntu-') }} 92 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev libasound2-dev 93 | - uses: blinemedical/setup-gstreamer@v1 94 | with: 95 | version: '1.22.12' 96 | - run: cargo build --no-default-features 97 | - run: cargo test 98 | 99 | lint: 100 | strategy: 101 | matrix: 102 | os: 103 | - windows-latest 104 | - ubuntu-22.04 105 | runs-on: ${{ matrix.os }} 106 | steps: 107 | - uses: actions/checkout@v4 108 | - uses: dtolnay/rust-toolchain@stable 109 | with: 110 | components: rustfmt, clippy 111 | - uses: Swatinem/rust-cache@v2 112 | with: 113 | key: ${{ matrix.os }} 114 | - if: ${{ startsWith(matrix.os, 'ubuntu-') }} 115 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev libasound2-dev 116 | - uses: blinemedical/setup-gstreamer@v1 117 | with: 118 | version: '1.22.12' 119 | - run: cargo fmt --all -- --check 120 | - run: cargo clippy --workspace -- --deny warnings 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | /tmp 4 | tarpaulin-report.html 5 | *~ 6 | *.log 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude: ^tests/ 8 | - repo: https://github.com/Lucas-C/pre-commit-hooks 9 | rev: v1.1.7 10 | hooks: 11 | - id: forbid-tabs 12 | - repo: https://github.com/mtkennerly/pre-commit-hooks 13 | rev: v0.2.0 14 | hooks: 15 | - id: cargo-fmt 16 | - id: cargo-clippy 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | * Changed: 4 | * If the `WGPU_POWER_PREF` environment variable is not set, 5 | then Madamiru will automatically set it to `high` while running. 6 | This has fixed application crashes on several users' systems, 7 | but is ultimately dependent on graphics hardware and drivers. 8 | If you experience any issues with this, please report it. 9 | * The standalone Linux release is now compiled on Ubuntu 22.04 instead of Ubuntu 20.04 10 | because of [a change by GitHub](https://github.com/actions/runner-images/issues/11101). 11 | 12 | ## v0.2.0 (2025-03-26) 13 | 14 | * Added: 15 | * When the app can't detect a file's type, 16 | it will try checking the system's shared MIME database (if available on Linux/Mac), 17 | and then further fall back to guessing based on the file extension. 18 | * Partial translations into Brazilian Portuguese, French, German, and Polish. 19 | (Thanks to contributors on the [Crowdin project](https://crowdin.com/project/madamiru)) 20 | * Changed: 21 | * The app previously used a known set of supported video formats and ignored other video files. 22 | However, since the exact set depends on which GStreamer plugins you've installed, 23 | the app will now simply try loading any video file. 24 | * Application crash and CLI parse errors are now logged. 25 | * Fixed: 26 | * The `crop` content fit now works correctly for videos. 27 | Previously, it acted the same as `stretch`. 28 | * If you drag-and-dropped multiple files into the window 29 | while there was more than one grid open, 30 | only one of the files would be inserted into the grid that you selected. 31 | * If a video is still being downloaded while you watch it, 32 | the video duration will update as the download continues. 33 | 34 | ## v0.1.0 (2024-12-12) 35 | 36 | * Initial release. 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | ### Prerequisites 3 | Use the latest version of Rust. 4 | 5 | On Linux, you'll need some additional system packages. 6 | Refer to [the installation guide](/docs/help/installation.md) for the list. 7 | 8 | You'll also need to install GStreamer (tested with 1.22.12). 9 | You can follow the instructions here: 10 | https://github.com/sdroege/gstreamer-rs#installation 11 | 12 | ### Commands 13 | * Run program: 14 | * `cargo run` 15 | * Run tests: 16 | * `cargo test` 17 | * Activate pre-commit hooks (requires Python) to handle formatting/linting: 18 | ``` 19 | pip install --user pre-commit 20 | pre-commit install 21 | ``` 22 | 23 | ### Environment variables 24 | These are optional: 25 | 26 | * `MADAMIRU_VERSION`: 27 | * If set, shown in the window title instead of the Cargo.toml version. 28 | * Intended for CI. 29 | 30 | ### Icon 31 | The master icon is `assets/icon.kra`, which you can edit using 32 | [Krita](https://krita.org/en) and then export into the other formats. 33 | 34 | ### Release preparation 35 | Commands assume you are using [Git Bash](https://git-scm.com) on Windows. 36 | 37 | #### Dependencies (one-time) 38 | ```bash 39 | pip install invoke 40 | cargo install cargo-lichking 41 | 42 | # Verified with commit ba58a5c44ccb7d2e0ca0238d833d17de17c2b53b: 43 | curl -o /c/opt/flatpak-cargo-generator.py https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py 44 | pip install aiohttp toml 45 | ``` 46 | 47 | Also install the Crowdin CLI tool manually. 48 | 49 | #### Process 50 | * Run `invoke prerelease` 51 | * If you already updated the translations separately, 52 | then run `invoke prerelease --no-update-lang` 53 | * Update the translation percentages in `src/lang.rs` 54 | * Update the documentation if necessary for any new features. 55 | Check for any new content that needs to be uncommented (` 29 | 30 | * For Linux, Madamiru is available on [Flathub](https://flathub.org/apps/details/com.mtkennerly.madamiru). 31 | Note that it has limited file system access by default (`~`, `/media`, `/run/media`). 32 | If you'd like to enable broader access, you can do so using a tool like Flatseal. 33 | 34 | * If you have [Rust](https://www.rust-lang.org), you can use Cargo. 35 | 36 | * To install or update: `cargo install --locked madamiru` 37 | 38 | However, note that some features are not yet fully functional in this version. 39 | The prebuilt binaries uses a pre-release version of some crates, 40 | which enables more functionality than a regular Cargo install currently does. 41 | Specifically, video volume/mute controls will not work, 42 | and the content fit setting will be ignored for videos. 43 | 44 | On Linux, this requires the following system packages, or their equivalents 45 | for your distribution: 46 | 47 | * Ubuntu: `sudo apt-get install -y gcc cmake libx11-dev libxcb-composite0-dev libfreetype6-dev libexpat1-dev libfontconfig1-dev libgtk-3-dev libasound2-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-libav` 48 | 49 | ## Notes 50 | If you are on Windows: 51 | 52 | * When you first run Madamiru, you may see a popup that says 53 | "Windows protected your PC", 54 | because Windows does not recognize the program's publisher. 55 | Click "more info" and then "run anyway" to start the program. 56 | 57 | If you are on Mac: 58 | 59 | * When you first run Madamiru, you may see a popup that says 60 | "Madamiru can't be opened because it is from an unidentified developer". 61 | To allow Madamiru to run, please refer to [this article](https://support.apple.com/en-us/102445), 62 | specifically the section on `If you want to open an app [...] from an unidentified developer`. 63 | -------------------------------------------------------------------------------- /docs/help/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | Log files are stored in the [application folder](/docs/help/application-folder.md). 3 | The latest log file is named `madamiru_rCURRENT.log`, 4 | and any other log files will be named with a timestamp (e.g., `madamiru_r2000-01-02_03-04-05.log`). 5 | 6 | By default, only warnings and errors are logged, 7 | but you can customize this by setting the `RUST_LOG` environment variable 8 | (e.g., `RUST_LOG=madamiru=debug`). 9 | The most recent 5 log files are kept, rotating on app launch or when a log reaches 10 MiB. 10 | 11 | You can also enable logging for GStreamer by setting these environment variables: 12 | `GST_DEBUG=3` and `GST_DEBUG_FILE=gst.log`. 13 | -------------------------------------------------------------------------------- /docs/help/media-sources.md: -------------------------------------------------------------------------------- 1 | # Media sources 2 | To add media to a group, 3 | you can specify the group's sources 4 | using the gear icon in the group's header controls. 5 | 6 | You can configure different kinds of sources: 7 | 8 | * A `path` source is the path to a specific file or folder on your computer. 9 | For folders, the application will look for media directly inside of that folder, 10 | but not in any of its subfolders. 11 | * A `glob` source lets you specify many files/folders at once using 12 | [glob patterns](https://en.wikipedia.org/wiki/Glob_(programming)). 13 | For example, `C:\media\**\*.mp4` would select all MP4 files in any subfolder of `C:\media`. 14 | 15 | Tips: 16 | 17 | * Relative paths are supported and resolve to the current working directory. 18 | * Sources may begin with a `` placeholder, 19 | which resolves to the location of the active playlist. 20 | If the playlist is not yet saved, then it resolves to the current working directory. 21 | * Sources may begin with `~`, 22 | which resolves to your user folder (e.g., `C:\Users\your-name` on Windows). 23 | * For globs, if your file/folder name contains special glob characters, 24 | you can escape them by wrapping them in brackets. 25 | For example, to select all MP4 files starting with `[prefix]` (because `[` and `]` are special), 26 | you can write `[[]prefix[]] *.mp4`. 27 | -------------------------------------------------------------------------------- /docs/help/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | * The window content is way too big and goes off screen. 3 | * **Linux:** Try setting the `WINIT_X11_SCALE_FACTOR` environment variable to `1`. 4 | Flatpak installs will have this set automatically. 5 | * The file/folder picker doesn't work. 6 | * **Steam Deck:** Use desktop mode instead of game mode. 7 | * **Flatpak:** The `DISPLAY` environment variable may not be getting passed through to the container. 8 | This has been observed on GNOME systems. 9 | Try running `flatpak run --nosocket=fallback-x11 --socket=x11 com.mtkennerly.madamiru`. 10 | * The GUI won't launch. 11 | * There may be an issue with your graphics drivers/support. 12 | Try using the software renderer instead by setting the `ICED_BACKEND` environment variable to `tiny-skia`. 13 | * Try forcing the application to use your dedicated GPU instead of the integrated graphics. 14 | One way to do this is by setting the `WGPU_POWER_PREF` environment variable to `high`. 15 | Alternatively, on Windows 11, go to: Settings app -> System -> Display -> Graphics. 16 | * You can try prioritizing different hardware renderers 17 | by setting the `WGPU_BACKEND` environment variable to `dx12`, `vulkan`, `metal`, or `gl`. 18 | * **Flatpak:** You can try forcing X11 instead of Wayland: 19 | `flatpak run --nosocket=wayland --socket=x11 com.mtkennerly.madamiru` 20 | * On Windows, I can't load really long folder/file paths. 21 | * The application supports long paths, 22 | but you also need to enable that feature in Windows itself: 23 | https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry#registry-setting-to-enable-long-paths 24 | * When I try to play a video, it says `Element failed to change its state`. 25 | * This probably means that GStreamer is installed, 26 | but doesn't have the codec necessary for the video. 27 | You can confirm this by setting two environment variables when you run the application: 28 | `GST_DEBUG=3` and `GST_DEBUG_FILE=gst.log`, 29 | and then checking the `gst.log` file for more information. 30 | 31 | If it is indeed a missing codec, 32 | then you can try installing GStreamer with additional codecs enabled: 33 | * Windows: You can do this by enabling more features in the GStreamer installer. 34 | * Ubuntu: `sudo apt-get install -y gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly` 35 | * When I try to play an audio file, it says `Unable to determine media duration` or `end of stream`. 36 | * This means that the audio backend was unable to handle the file. 37 | Please check back over time as support for more files may be added/improved 38 | 39 | ## Environment variables on Windows 40 | Some of the instructions above mention setting environment variables. 41 | If you're using Windows and not familiar with how to do this, 42 | you can follow these instructions: 43 | 44 | * Open the Start Menu, 45 | search for `edit the system environment variables`, 46 | and select the matching result. 47 | * In the new window, click the `environment variables...` button. 48 | * In the upper `user variables` section, click the `new...` button, 49 | then enter the variable name and value. 50 | If the variable already exists, select it and click `edit...`. 51 | -------------------------------------------------------------------------------- /docs/sample-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/madamiru/67fef34f670ca46f8510e817423a25edd722a9a1/docs/sample-gui.png -------------------------------------------------------------------------------- /docs/schema/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | $schema: "http://json-schema.org/draft-07/schema#" 3 | title: Config 4 | description: "Settings for `config.yaml`" 5 | type: object 6 | properties: 7 | playback: 8 | default: 9 | image_duration: 10 10 | muted: false 11 | pause_on_unfocus: false 12 | volume: 1.0 13 | allOf: 14 | - $ref: "#/definitions/Playback" 15 | release: 16 | default: 17 | check: true 18 | allOf: 19 | - $ref: "#/definitions/Release" 20 | view: 21 | default: 22 | confirm_discard_playlist: true 23 | language: en-US 24 | theme: dark 25 | allOf: 26 | - $ref: "#/definitions/View" 27 | definitions: 28 | Language: 29 | description: Display language. 30 | oneOf: 31 | - description: English 32 | type: string 33 | enum: 34 | - en-US 35 | - description: French 36 | type: string 37 | enum: 38 | - fr-FR 39 | - description: German 40 | type: string 41 | enum: 42 | - de-DE 43 | - description: Polish 44 | type: string 45 | enum: 46 | - pl-PL 47 | - description: Brazilian Portuguese 48 | type: string 49 | enum: 50 | - pt-BR 51 | Playback: 52 | type: object 53 | properties: 54 | image_duration: 55 | description: "How long to show images, in seconds." 56 | default: 10 57 | type: integer 58 | format: uint 59 | minimum: 1.0 60 | muted: 61 | description: Whether all players are muted. 62 | default: false 63 | type: boolean 64 | pause_on_unfocus: 65 | description: Whether to pause when window loses focus. 66 | default: false 67 | type: boolean 68 | volume: 69 | description: "Volume level when not muted. 1.0 is 100%, 0.01 is 1%." 70 | default: 1.0 71 | type: number 72 | format: float 73 | Release: 74 | type: object 75 | properties: 76 | check: 77 | description: "Whether to check for new releases. If enabled, the application will check at most once every 24 hours." 78 | default: true 79 | type: boolean 80 | Theme: 81 | description: Visual theme. 82 | type: string 83 | enum: 84 | - light 85 | - dark 86 | View: 87 | type: object 88 | properties: 89 | confirm_discard_playlist: 90 | default: true 91 | type: boolean 92 | language: 93 | default: en-US 94 | allOf: 95 | - $ref: "#/definitions/Language" 96 | theme: 97 | default: dark 98 | allOf: 99 | - $ref: "#/definitions/Theme" 100 | -------------------------------------------------------------------------------- /docs/schema/playlist.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | $schema: "http://json-schema.org/draft-07/schema#" 3 | title: Playlist 4 | description: Settings for a playlist 5 | type: object 6 | properties: 7 | layout: 8 | default: 9 | group: 10 | content_fit: scale 11 | max_media: 1 12 | orientation: horizontal 13 | orientation_limit: automatic 14 | sources: [] 15 | allOf: 16 | - $ref: "#/definitions/Layout" 17 | definitions: 18 | ContentFit: 19 | oneOf: 20 | - description: "Scale the media up or down to fill as much of the available space as possible while maintaining the media's aspect ratio." 21 | type: string 22 | enum: 23 | - scale 24 | - description: "Scale the media down to fill as much of the available space as possible while maintaining the media's aspect ratio. Don't scale up if it's smaller than the available space." 25 | type: string 26 | enum: 27 | - scale_down 28 | - description: "Crop the media to fill all of the available space. Maintain the aspect ratio, cutting off parts of the media as needed to fit." 29 | type: string 30 | enum: 31 | - crop 32 | - description: "Stretch the media to fill all of the available space. Preserve the whole media, disregarding the aspect ratio." 33 | type: string 34 | enum: 35 | - stretch 36 | FilePath: 37 | type: string 38 | Group: 39 | type: object 40 | properties: 41 | content_fit: 42 | default: scale 43 | allOf: 44 | - $ref: "#/definitions/ContentFit" 45 | max_media: 46 | default: 1 47 | type: integer 48 | format: uint 49 | minimum: 0.0 50 | orientation: 51 | default: horizontal 52 | allOf: 53 | - $ref: "#/definitions/Orientation" 54 | orientation_limit: 55 | default: automatic 56 | allOf: 57 | - $ref: "#/definitions/OrientationLimit" 58 | sources: 59 | default: [] 60 | type: array 61 | items: 62 | $ref: "#/definitions/Source" 63 | Layout: 64 | oneOf: 65 | - type: object 66 | required: 67 | - split 68 | properties: 69 | split: 70 | $ref: "#/definitions/Split" 71 | additionalProperties: false 72 | - type: object 73 | required: 74 | - group 75 | properties: 76 | group: 77 | $ref: "#/definitions/Group" 78 | additionalProperties: false 79 | Orientation: 80 | type: string 81 | enum: 82 | - horizontal 83 | - vertical 84 | OrientationLimit: 85 | oneOf: 86 | - type: string 87 | enum: 88 | - automatic 89 | - type: object 90 | required: 91 | - fixed 92 | properties: 93 | fixed: 94 | type: integer 95 | format: uint 96 | minimum: 1.0 97 | additionalProperties: false 98 | Source: 99 | oneOf: 100 | - type: object 101 | required: 102 | - path 103 | properties: 104 | path: 105 | type: object 106 | required: 107 | - path 108 | properties: 109 | path: 110 | $ref: "#/definitions/FilePath" 111 | additionalProperties: false 112 | - type: object 113 | required: 114 | - glob 115 | properties: 116 | glob: 117 | type: object 118 | required: 119 | - pattern 120 | properties: 121 | pattern: 122 | type: string 123 | additionalProperties: false 124 | Split: 125 | type: object 126 | properties: 127 | axis: 128 | default: horizontal 129 | allOf: 130 | - $ref: "#/definitions/SplitAxis" 131 | first: 132 | default: 133 | group: 134 | content_fit: scale 135 | max_media: 1 136 | orientation: horizontal 137 | orientation_limit: automatic 138 | sources: [] 139 | allOf: 140 | - $ref: "#/definitions/Layout" 141 | ratio: 142 | default: 0.5 143 | type: number 144 | format: float 145 | second: 146 | default: 147 | group: 148 | content_fit: scale 149 | max_media: 1 150 | orientation: horizontal 151 | orientation_limit: automatic 152 | sources: [] 153 | allOf: 154 | - $ref: "#/definitions/Layout" 155 | SplitAxis: 156 | type: string 157 | enum: 158 | - horizontal 159 | - vertical 160 | -------------------------------------------------------------------------------- /lang/de-DE.ftl: -------------------------------------------------------------------------------- 1 | thing-application = Anwendung 2 | thing-audio = Audio 3 | # How media will be fit into the available space (scale/crop/etc). 4 | thing-content-fit = Content fit 5 | thing-error = Fehler 6 | # https://en.wikipedia.org/wiki/Glob_(programming) 7 | thing-glob = Glob 8 | thing-image = Image 9 | thing-items-per-line = Items per line 10 | thing-key-shift = Shift 11 | thing-language = Sprache 12 | thing-layout = Layout 13 | thing-orientation = Orientation 14 | # Path to a file/folder on the system. 15 | thing-path = Path 16 | thing-playlist = Playlist 17 | thing-settings = Settings 18 | # Locations to find media. 19 | thing-sources = Quellen 20 | # Visual theme for the application. 21 | thing-theme = Theme 22 | action-add-player = Add player 23 | action-cancel = Cancel 24 | action-check-for-updates = Check for application updates automatically 25 | action-close = Close 26 | action-confirm = Confirm 27 | action-confirm-when-discarding-unsaved-playlist = Confirm when discarding unsaved playlist 28 | action-crop = Crop 29 | action-exit-app = Exit application 30 | action-jump-position = Jump to random position 31 | action-mute = Mute 32 | action-open-file = Open file 33 | action-open-folder = Open folder 34 | action-open-playlist = Open playlist 35 | action-pause = Pause 36 | # This happens if the user switches to another app or minimizes this app. 37 | action-pause-when-window-loses-focus = Pause when window loses focus 38 | action-play = Play 39 | action-play-for-this-many-seconds = Play for this many seconds 40 | action-save-playlist = Save playlist 41 | action-save-playlist-as-new-file = Save playlist as new file 42 | action-scale = Scale 43 | action-scale-down = Scale down 44 | action-select-folder = Select folder 45 | action-select-file = Select file 46 | action-shuffle = Shuffle 47 | action-split-horizontally = Split horizontally 48 | action-split-vertically = Split vertically 49 | action-start-new-playlist = Start new playlist 50 | action-stretch = Stretch 51 | action-unmute = Unmute 52 | action-view-releases = View releases 53 | # This refers to the dark-colored theme. 54 | state-dark = Dark 55 | state-horizontal = Horizontal 56 | # This refers to the light-colored theme. 57 | state-light = Light 58 | state-vertical = Vertical 59 | tell-config-is-invalid = The config file is invalid. 60 | tell-player-will-loop = Player will loop 61 | tell-player-will-shuffle = Player will shuffle 62 | tell-playlist-has-unsaved-changes = Your playlist has unsaved changes. 63 | tell-playlist-is-invalid = The playlist file is invalid. 64 | tell-new-version-available = An application update is available: { $version }. 65 | tell-no-media-found-in-sources = No more media found in the configured sources. 66 | tell-unable-to-determine-media-duration = Unable to determine media duration. 67 | tell-unable-to-open-path = Unable to open path. 68 | tell-unable-to-open-url = Unable to open URL. 69 | tell-unable-to-save-playlist = Unable to save playlist. 70 | ask-discard-changes = Discard changes? 71 | ask-load-new-playlist-anyway = Load a new playlist anyway? 72 | ask-view-release-notes = Would you like to view the release notes? 73 | -------------------------------------------------------------------------------- /lang/en-US.ftl: -------------------------------------------------------------------------------- 1 | thing-application = Application 2 | thing-audio = Audio 3 | # How media will be fit into the available space (scale/crop/etc). 4 | thing-content-fit = Content fit 5 | thing-error = Error 6 | # https://en.wikipedia.org/wiki/Glob_(programming) 7 | thing-glob = Glob 8 | thing-image = Image 9 | thing-items-per-line = Items per line 10 | thing-key-shift = Shift 11 | thing-language = Language 12 | thing-layout = Layout 13 | thing-orientation = Orientation 14 | # Path to a file/folder on the system. 15 | thing-path = Path 16 | thing-playlist = Playlist 17 | thing-settings = Settings 18 | # Locations to find media. 19 | thing-sources = Sources 20 | # Visual theme for the application. 21 | thing-theme = Theme 22 | 23 | action-add-player = Add player 24 | action-cancel = Cancel 25 | action-check-for-updates = Check for application updates automatically 26 | action-close = Close 27 | action-confirm = Confirm 28 | action-confirm-when-discarding-unsaved-playlist = Confirm when discarding unsaved playlist 29 | action-crop = Crop 30 | action-exit-app = Exit application 31 | action-jump-position = Jump to random position 32 | action-mute = Mute 33 | action-open-file = Open file 34 | action-open-folder = Open folder 35 | action-open-playlist = Open playlist 36 | action-pause = Pause 37 | # This happens if the user switches to another app or minimizes this app. 38 | action-pause-when-window-loses-focus = Pause when window loses focus 39 | action-play = Play 40 | action-play-for-this-many-seconds = Play for this many seconds 41 | action-save-playlist = Save playlist 42 | action-save-playlist-as-new-file = Save playlist as new file 43 | action-scale = Scale 44 | action-scale-down = Scale down 45 | action-select-folder = Select folder 46 | action-select-file = Select file 47 | action-shuffle = Shuffle 48 | action-split-horizontally = Split horizontally 49 | action-split-vertically = Split vertically 50 | action-start-new-playlist = Start new playlist 51 | action-stretch = Stretch 52 | action-unmute = Unmute 53 | action-view-releases = View releases 54 | 55 | # This refers to the dark-colored theme. 56 | state-dark = Dark 57 | state-horizontal = Horizontal 58 | # This refers to the light-colored theme. 59 | state-light = Light 60 | state-vertical = Vertical 61 | 62 | tell-config-is-invalid = The config file is invalid. 63 | tell-player-will-loop = Player will loop 64 | tell-player-will-shuffle = Player will shuffle 65 | tell-playlist-has-unsaved-changes = Your playlist has unsaved changes. 66 | tell-playlist-is-invalid = The playlist file is invalid. 67 | tell-new-version-available = An application update is available: {$version}. 68 | tell-no-media-found-in-sources = No more media found in the configured sources. 69 | tell-unable-to-determine-media-duration = Unable to determine media duration. 70 | tell-unable-to-open-path = Unable to open path. 71 | tell-unable-to-open-url = Unable to open URL. 72 | tell-unable-to-save-playlist = Unable to save playlist. 73 | 74 | ask-discard-changes = Discard changes? 75 | ask-load-new-playlist-anyway = Load a new playlist anyway? 76 | ask-view-release-notes = Would you like to view the release notes? 77 | -------------------------------------------------------------------------------- /lang/fr-FR.ftl: -------------------------------------------------------------------------------- 1 | thing-application = Application 2 | thing-audio = Audio 3 | # How media will be fit into the available space (scale/crop/etc). 4 | thing-content-fit = Content fit 5 | thing-error = Error 6 | # https://en.wikipedia.org/wiki/Glob_(programming) 7 | thing-glob = Glob 8 | thing-image = Image 9 | thing-items-per-line = Items per line 10 | thing-key-shift = Shift 11 | thing-language = Language 12 | thing-layout = Layout 13 | thing-orientation = Orientation 14 | # Path to a file/folder on the system. 15 | thing-path = Chemin 16 | thing-playlist = Playlist 17 | thing-settings = Settings 18 | # Locations to find media. 19 | thing-sources = Sources 20 | # Visual theme for the application. 21 | thing-theme = Theme 22 | action-add-player = Add player 23 | action-cancel = Cancel 24 | action-check-for-updates = Check for application updates automatically 25 | action-close = Close 26 | action-confirm = Confirm 27 | action-confirm-when-discarding-unsaved-playlist = Confirm when discarding unsaved playlist 28 | action-crop = Crop 29 | action-exit-app = Exit application 30 | action-jump-position = Jump to random position 31 | action-mute = Mute 32 | action-open-file = Open file 33 | action-open-folder = Open folder 34 | action-open-playlist = Open playlist 35 | action-pause = Pause 36 | # This happens if the user switches to another app or minimizes this app. 37 | action-pause-when-window-loses-focus = Pause when window loses focus 38 | action-play = Play 39 | action-play-for-this-many-seconds = Play for this many seconds 40 | action-save-playlist = Save playlist 41 | action-save-playlist-as-new-file = Save playlist as new file 42 | action-scale = Scale 43 | action-scale-down = Scale down 44 | action-select-folder = Sélectionner un dossier 45 | action-select-file = Select file 46 | action-shuffle = Shuffle 47 | action-split-horizontally = Split horizontally 48 | action-split-vertically = Split vertically 49 | action-start-new-playlist = Start new playlist 50 | action-stretch = Stretch 51 | action-unmute = Unmute 52 | action-view-releases = View releases 53 | # This refers to the dark-colored theme. 54 | state-dark = Dark 55 | state-horizontal = Horizontal 56 | # This refers to the light-colored theme. 57 | state-light = Light 58 | state-vertical = Vertical 59 | tell-config-is-invalid = The config file is invalid. 60 | tell-player-will-loop = Player will loop 61 | tell-player-will-shuffle = Player will shuffle 62 | tell-playlist-has-unsaved-changes = Your playlist has unsaved changes. 63 | tell-playlist-is-invalid = The playlist file is invalid. 64 | tell-new-version-available = An application update is available: { $version }. 65 | tell-no-media-found-in-sources = No more media found in the configured sources. 66 | tell-unable-to-determine-media-duration = Unable to determine media duration. 67 | tell-unable-to-open-path = Unable to open path. 68 | tell-unable-to-open-url = Unable to open URL. 69 | tell-unable-to-save-playlist = Unable to save playlist. 70 | ask-discard-changes = Discard changes? 71 | ask-load-new-playlist-anyway = Load a new playlist anyway? 72 | ask-view-release-notes = Would you like to view the release notes? 73 | -------------------------------------------------------------------------------- /lang/pl-PL.ftl: -------------------------------------------------------------------------------- 1 | thing-application = Aplikacja 2 | thing-audio = Audio 3 | # How media will be fit into the available space (scale/crop/etc). 4 | thing-content-fit = Dopasowanie treści 5 | thing-error = Błąd 6 | # https://en.wikipedia.org/wiki/Glob_(programming) 7 | thing-glob = Glob 8 | thing-image = Zdjęcie 9 | thing-items-per-line = Pozycji na linię 10 | thing-key-shift = Shift 11 | thing-language = Język 12 | thing-layout = Układ 13 | thing-orientation = Orientacja 14 | # Path to a file/folder on the system. 15 | thing-path = Ścieżka 16 | thing-playlist = Lista odtwarzania 17 | thing-settings = Ustawienia 18 | # Locations to find media. 19 | thing-sources = Źródła 20 | # Visual theme for the application. 21 | thing-theme = Motyw 22 | action-add-player = Dodaj odtwarzacz 23 | action-cancel = Anuluj 24 | action-check-for-updates = Automatycznie sprawdzaj aktualizacje aplikacji 25 | action-close = Zamknij 26 | action-confirm = Potwierdź 27 | action-confirm-when-discarding-unsaved-playlist = Confirm when discarding unsaved playlist 28 | action-crop = Przytnij 29 | action-exit-app = Wyjdź z aplikacji 30 | action-jump-position = Skocz do losowej pozycji 31 | action-mute = Wycisz 32 | action-open-file = Open file 33 | action-open-folder = Otwórz folder 34 | action-open-playlist = Otwórz listę odtwarzania 35 | action-pause = Wstrzymaj 36 | # This happens if the user switches to another app or minimizes this app. 37 | action-pause-when-window-loses-focus = Wstrzymaj, gdy okno nie będzie w centrum 38 | action-play = Odtwórz 39 | action-play-for-this-many-seconds = Graj przez wiele sekund 40 | action-save-playlist = Zapisz listę odtwarzania 41 | action-save-playlist-as-new-file = Zapisz listę odtwarzania jako nowy plik 42 | action-scale = Skala 43 | action-scale-down = Zmniejsz 44 | action-select-folder = Wybierz folder 45 | action-select-file = Wybierz plik 46 | action-shuffle = Losuj 47 | action-split-horizontally = Podziel poziomo 48 | action-split-vertically = Podziel pionowo 49 | action-start-new-playlist = Rozpocznij nową listę odtwarzania 50 | action-stretch = Rozciągnij 51 | action-unmute = Odcisz 52 | action-view-releases = Pokaż wydania 53 | # This refers to the dark-colored theme. 54 | state-dark = Ciemny 55 | state-horizontal = Poziomy 56 | # This refers to the light-colored theme. 57 | state-light = Jasny 58 | state-vertical = Pionowy 59 | tell-config-is-invalid = Plik konfiguracyjny jest nieprawidłowy. 60 | tell-player-will-loop = Odtwarzacz będzie zapętlony 61 | tell-player-will-shuffle = Odtwarzacz będzie losował 62 | tell-playlist-has-unsaved-changes = Twoja lista odtwarzania ma niezapisane zmiany. 63 | tell-playlist-is-invalid = Plik list odtwarzania jest nieprawidłowy. 64 | tell-new-version-available = Dostępna jest aktualizacja aplikacji: { $version }. 65 | tell-no-media-found-in-sources = No more media found in the configured sources. 66 | tell-unable-to-determine-media-duration = Nie można określić czasu trwania multimediów. 67 | tell-unable-to-open-path = Unable to open path. 68 | tell-unable-to-open-url = Nie można otworzyć adresu URL. 69 | tell-unable-to-save-playlist = Nie można zapisać listy odtwarzania. 70 | ask-discard-changes = Porzucić zmiany? 71 | ask-load-new-playlist-anyway = Wczytać mimo to nową listę odtwarzania? 72 | ask-view-release-notes = Czy chcesz zobaczyć informacje o wydaniu? 73 | -------------------------------------------------------------------------------- /lang/pt-BR.ftl: -------------------------------------------------------------------------------- 1 | thing-application = Application 2 | thing-audio = Audio 3 | # How media will be fit into the available space (scale/crop/etc). 4 | thing-content-fit = Content fit 5 | thing-error = Error 6 | # https://en.wikipedia.org/wiki/Glob_(programming) 7 | thing-glob = Glob 8 | thing-image = Image 9 | thing-items-per-line = Items per line 10 | thing-key-shift = Shift 11 | thing-language = Language 12 | thing-layout = Layout 13 | thing-orientation = Orientation 14 | # Path to a file/folder on the system. 15 | thing-path = Path 16 | thing-playlist = Playlist 17 | thing-settings = Settings 18 | # Locations to find media. 19 | thing-sources = Sources 20 | # Visual theme for the application. 21 | thing-theme = Theme 22 | action-add-player = Add player 23 | action-cancel = Cancel 24 | action-check-for-updates = Check for application updates automatically 25 | action-close = Close 26 | action-confirm = Confirm 27 | action-confirm-when-discarding-unsaved-playlist = Confirm when discarding unsaved playlist 28 | action-crop = Crop 29 | action-exit-app = Exit application 30 | action-jump-position = Jump to random position 31 | action-mute = Mute 32 | action-open-file = Abrir arquivo 33 | action-open-folder = Open folder 34 | action-open-playlist = Open playlist 35 | action-pause = Pause 36 | # This happens if the user switches to another app or minimizes this app. 37 | action-pause-when-window-loses-focus = Pause when window loses focus 38 | action-play = Play 39 | action-play-for-this-many-seconds = Play for this many seconds 40 | action-save-playlist = Save playlist 41 | action-save-playlist-as-new-file = Save playlist as new file 42 | action-scale = Scale 43 | action-scale-down = Scale down 44 | action-select-folder = Select folder 45 | action-select-file = Select file 46 | action-shuffle = Shuffle 47 | action-split-horizontally = Split horizontally 48 | action-split-vertically = Split vertically 49 | action-start-new-playlist = Start new playlist 50 | action-stretch = Stretch 51 | action-unmute = Unmute 52 | action-view-releases = View releases 53 | # This refers to the dark-colored theme. 54 | state-dark = Dark 55 | state-horizontal = Horizontal 56 | # This refers to the light-colored theme. 57 | state-light = Light 58 | state-vertical = Vertical 59 | tell-config-is-invalid = The config file is invalid. 60 | tell-player-will-loop = Player will loop 61 | tell-player-will-shuffle = Player will shuffle 62 | tell-playlist-has-unsaved-changes = Your playlist has unsaved changes. 63 | tell-playlist-is-invalid = The playlist file is invalid. 64 | tell-new-version-available = An application update is available: { $version }. 65 | tell-no-media-found-in-sources = No more media found in the configured sources. 66 | tell-unable-to-determine-media-duration = Unable to determine media duration. 67 | tell-unable-to-open-path = Unable to open path. 68 | tell-unable-to-open-url = Unable to open URL. 69 | tell-unable-to-save-playlist = Unable to save playlist. 70 | ask-discard-changes = Discard changes? 71 | ask-load-new-playlist-anyway = Load a new playlist anyway? 72 | ask-view-release-notes = Would you like to view the release notes? 73 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | mod parse; 2 | 3 | use clap::CommandFactory; 4 | 5 | use crate::{ 6 | cli::parse::{Cli, CompletionShell, Subcommand}, 7 | lang, media, 8 | path::StrictPath, 9 | prelude::Error, 10 | resource::{cache::Cache, config::Config, playlist::Playlist, ResourceFile}, 11 | }; 12 | 13 | pub fn parse_sources(sources: Vec) -> Vec { 14 | if !sources.is_empty() { 15 | sources 16 | .into_iter() 17 | .filter_map(|path| (!path.is_blank()).then(|| media::Source::new_path(path))) 18 | .collect() 19 | } else { 20 | use std::io::IsTerminal; 21 | 22 | let stdin = std::io::stdin(); 23 | if stdin.is_terminal() { 24 | vec![] 25 | } else { 26 | let sources: Vec<_> = stdin 27 | .lines() 28 | .map_while(Result::ok) 29 | .filter_map(|raw| (!raw.trim().is_empty()).then(|| media::Source::new_path(StrictPath::new(raw)))) 30 | .collect(); 31 | log::debug!("Sources from stdin: {:?}", &sources); 32 | if sources.is_empty() { 33 | vec![] 34 | } else { 35 | sources 36 | } 37 | } 38 | } 39 | } 40 | 41 | pub fn parse() -> Result { 42 | use clap::Parser; 43 | Cli::try_parse() 44 | } 45 | 46 | pub fn run(sub: Subcommand) -> Result<(), Error> { 47 | let mut config = Config::load()?; 48 | Cache::load().unwrap_or_default().migrate_config(&mut config); 49 | lang::set(config.view.language); 50 | 51 | log::debug!("Config on startup: {config:?}"); 52 | 53 | match sub { 54 | Subcommand::Complete { shell } => { 55 | let clap_shell = match shell { 56 | CompletionShell::Bash => clap_complete::Shell::Bash, 57 | CompletionShell::Fish => clap_complete::Shell::Fish, 58 | CompletionShell::Zsh => clap_complete::Shell::Zsh, 59 | CompletionShell::PowerShell => clap_complete::Shell::PowerShell, 60 | CompletionShell::Elvish => clap_complete::Shell::Elvish, 61 | }; 62 | clap_complete::generate( 63 | clap_shell, 64 | &mut Cli::command(), 65 | env!("CARGO_PKG_NAME"), 66 | &mut std::io::stdout(), 67 | ) 68 | } 69 | Subcommand::Schema { format, kind } => { 70 | let format = format.unwrap_or_default(); 71 | let schema = match kind { 72 | parse::SchemaSubcommand::Config => schemars::schema_for!(Config), 73 | parse::SchemaSubcommand::Playlist => schemars::schema_for!(Playlist), 74 | }; 75 | 76 | let serialized = match format { 77 | parse::SerializationFormat::Json => serde_json::to_string_pretty(&schema).unwrap(), 78 | parse::SerializationFormat::Yaml => serde_yaml::to_string(&schema).unwrap(), 79 | }; 80 | println!("{serialized}"); 81 | } 82 | } 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/cli/parse.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::prelude::StrictPath; 4 | 5 | use clap::ValueEnum; 6 | 7 | fn parse_strict_path(path: &str) -> Result { 8 | let cwd = StrictPath::cwd(); 9 | Ok(StrictPath::relative(path.to_owned(), Some(cwd.raw()))) 10 | } 11 | 12 | fn styles() -> clap::builder::styling::Styles { 13 | use clap::builder::styling::{AnsiColor, Effects, Styles}; 14 | 15 | Styles::styled() 16 | .header(AnsiColor::Yellow.on_default() | Effects::BOLD) 17 | .usage(AnsiColor::Yellow.on_default() | Effects::BOLD) 18 | .literal(AnsiColor::Green.on_default() | Effects::BOLD) 19 | .placeholder(AnsiColor::Green.on_default()) 20 | } 21 | 22 | #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] 23 | pub enum CompletionShell { 24 | #[clap(about = "Completions for Bash")] 25 | Bash, 26 | #[clap(about = "Completions for Fish")] 27 | Fish, 28 | #[clap(about = "Completions for Zsh")] 29 | Zsh, 30 | #[clap(name = "powershell", about = "Completions for PowerShell")] 31 | PowerShell, 32 | #[clap(about = "Completions for Elvish")] 33 | Elvish, 34 | } 35 | 36 | /// Serialization format 37 | #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 38 | pub enum SerializationFormat { 39 | #[default] 40 | Json, 41 | Yaml, 42 | } 43 | 44 | #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] 45 | pub enum Subcommand { 46 | /// Generate shell completion scripts 47 | Complete { 48 | #[clap(subcommand)] 49 | shell: CompletionShell, 50 | }, 51 | /// Display schemas that the application uses 52 | Schema { 53 | #[clap(long, value_enum, value_name = "FORMAT")] 54 | format: Option, 55 | 56 | #[clap(subcommand)] 57 | kind: SchemaSubcommand, 58 | }, 59 | } 60 | 61 | #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] 62 | pub enum SchemaSubcommand { 63 | #[clap(about = "Schema for config.yaml")] 64 | Config, 65 | #[clap(about = "Schema for playlist.madamiru")] 66 | Playlist, 67 | } 68 | 69 | /// Play multiple videos at once 70 | #[derive(clap::Parser, Clone, Debug, PartialEq, Eq)] 71 | #[clap(name = "madamiru", version, max_term_width = 100, next_line_help = true, styles = styles())] 72 | pub struct Cli { 73 | /// Use configuration found in DIRECTORY 74 | #[clap(long, value_name = "DIRECTORY")] 75 | pub config: Option, 76 | 77 | /// Files and folders to load. 78 | /// Alternatively supports stdin (one value per line). 79 | #[clap(value_parser = parse_strict_path)] 80 | pub sources: Vec, 81 | 82 | /// Glob patterns to load. 83 | #[clap(long)] 84 | pub glob: Vec, 85 | 86 | #[clap(subcommand)] 87 | pub sub: Option, 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | use clap::Parser; 93 | 94 | use super::*; 95 | 96 | fn check_args(args: &[&str], expected: Cli) { 97 | assert_eq!(expected, Cli::parse_from(args)); 98 | } 99 | 100 | #[test] 101 | fn accepts_cli_without_arguments() { 102 | check_args( 103 | &["madamiru"], 104 | Cli { 105 | config: None, 106 | sources: vec![], 107 | glob: vec![], 108 | sub: None, 109 | }, 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/gui.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod button; 3 | mod common; 4 | mod dropdown; 5 | mod font; 6 | mod grid; 7 | mod icon; 8 | mod modal; 9 | mod player; 10 | mod shortcuts; 11 | mod style; 12 | mod undoable; 13 | mod widget; 14 | 15 | use self::app::App; 16 | pub use self::common::Flags; 17 | 18 | pub fn run(flags: Flags) { 19 | let app = iced::application(App::title, App::update, App::view) 20 | .subscription(App::subscription) 21 | .theme(App::theme) 22 | .settings(iced::Settings { 23 | default_font: font::TEXT, 24 | ..Default::default() 25 | }) 26 | .window(iced::window::Settings { 27 | min_size: Some(iced::Size::new(480.0, 360.0)), 28 | exit_on_close_request: false, 29 | #[cfg(target_os = "linux")] 30 | platform_specific: iced::window::settings::PlatformSpecific { 31 | application_id: crate::prelude::LINUX_APP_ID.to_string(), 32 | ..Default::default() 33 | }, 34 | icon: match image::load_from_memory(include_bytes!("../assets/icon.png")) { 35 | Ok(buffer) => { 36 | let buffer = buffer.to_rgba8(); 37 | let width = buffer.width(); 38 | let height = buffer.height(); 39 | let dynamic_image = image::DynamicImage::ImageRgba8(buffer); 40 | iced::window::icon::from_rgba(dynamic_image.into_bytes(), width, height).ok() 41 | } 42 | Err(_) => None, 43 | }, 44 | ..Default::default() 45 | }); 46 | 47 | if let Err(e) = app.run_with(move || app::App::new(flags)) { 48 | log::error!("Failed to initialize GUI: {e:?}"); 49 | eprintln!("Failed to initialize GUI: {e:?}"); 50 | 51 | rfd::MessageDialog::new() 52 | .set_level(rfd::MessageLevel::Error) 53 | .set_description(e.to_string()) 54 | .set_buttons(rfd::MessageButtons::Ok) 55 | .show(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/gui/button.rs: -------------------------------------------------------------------------------- 1 | use iced::{alignment, keyboard, widget::tooltip, Padding}; 2 | 3 | use crate::{ 4 | gui::{ 5 | common::{BrowseFileSubject, BrowseSubject, EditAction, Message}, 6 | icon::Icon, 7 | style, 8 | widget::{text, Button, Container, Element, Tooltip}, 9 | }, 10 | lang, 11 | path::StrictPath, 12 | }; 13 | 14 | pub struct CustomButton<'a> { 15 | content: Element<'a>, 16 | on_press: Option, 17 | enabled: bool, 18 | class: style::Button, 19 | padding: Option, 20 | tooltip: Option, 21 | tooltip_position: tooltip::Position, 22 | obscured: bool, 23 | } 24 | 25 | impl CustomButton<'_> { 26 | pub fn on_press(mut self, message: Message) -> Self { 27 | self.on_press = Some(message); 28 | self 29 | } 30 | 31 | pub fn on_press_maybe(mut self, message: Option) -> Self { 32 | self.on_press = message; 33 | self 34 | } 35 | 36 | pub fn enabled(mut self, enabled: bool) -> Self { 37 | self.enabled = enabled; 38 | self 39 | } 40 | 41 | pub fn tooltip(mut self, tooltip: String) -> Self { 42 | self.tooltip = Some(tooltip); 43 | self 44 | } 45 | 46 | pub fn tooltip_below(mut self, tooltip: String) -> Self { 47 | self.tooltip = Some(tooltip); 48 | self.tooltip_position = tooltip::Position::Bottom; 49 | self 50 | } 51 | 52 | pub fn obscured(mut self, obscured: bool) -> Self { 53 | self.obscured = obscured; 54 | self 55 | } 56 | 57 | pub fn padding(mut self, padding: impl Into) -> Self { 58 | self.padding = Some(padding.into()); 59 | self 60 | } 61 | } 62 | 63 | impl<'a> From> for Element<'a> { 64 | fn from(value: CustomButton<'a>) -> Self { 65 | let mut button = Button::new(value.content).class(value.class); 66 | 67 | if !value.obscured && value.enabled { 68 | button = button.on_press_maybe(value.on_press); 69 | } 70 | 71 | if let Some(padding) = value.padding { 72 | button = button.padding(padding); 73 | } 74 | 75 | match value.tooltip { 76 | Some(tooltip) if !value.obscured && value.enabled => Tooltip::new( 77 | button, 78 | Container::new(text(tooltip).size(14)).padding([2, 4]), 79 | value.tooltip_position, 80 | ) 81 | .gap(5) 82 | .class(style::Container::Tooltip) 83 | .into(), 84 | _ => button.into(), 85 | } 86 | } 87 | } 88 | 89 | pub fn bare<'a>(content: String) -> CustomButton<'a> { 90 | CustomButton { 91 | content: text(content).align_x(alignment::Horizontal::Center).into(), 92 | on_press: None, 93 | enabled: true, 94 | class: style::Button::Bare, 95 | padding: Some([0, 5].into()), 96 | tooltip: None, 97 | tooltip_position: tooltip::Position::Top, 98 | obscured: false, 99 | } 100 | } 101 | 102 | pub fn primary<'a>(content: String) -> CustomButton<'a> { 103 | CustomButton { 104 | content: text(content).align_x(alignment::Horizontal::Center).into(), 105 | on_press: None, 106 | enabled: true, 107 | class: style::Button::Primary, 108 | padding: Some([5, 40].into()), 109 | tooltip: None, 110 | tooltip_position: tooltip::Position::Top, 111 | obscured: false, 112 | } 113 | } 114 | 115 | pub fn negative<'a>(content: String) -> CustomButton<'a> { 116 | CustomButton { 117 | content: text(content).align_x(alignment::Horizontal::Center).into(), 118 | on_press: None, 119 | enabled: true, 120 | class: style::Button::Negative, 121 | padding: Some([5, 40].into()), 122 | tooltip: None, 123 | tooltip_position: tooltip::Position::Top, 124 | obscured: false, 125 | } 126 | } 127 | 128 | pub fn icon<'a>(icon: Icon) -> CustomButton<'a> { 129 | CustomButton { 130 | content: icon.small_control().into(), 131 | on_press: None, 132 | enabled: true, 133 | class: style::Button::Icon, 134 | padding: None, 135 | tooltip: None, 136 | tooltip_position: tooltip::Position::Top, 137 | obscured: false, 138 | } 139 | } 140 | 141 | pub fn big_icon<'a>(icon: Icon) -> CustomButton<'a> { 142 | CustomButton { 143 | content: icon.big_control().into(), 144 | on_press: None, 145 | enabled: true, 146 | class: style::Button::Icon, 147 | padding: None, 148 | tooltip: None, 149 | tooltip_position: tooltip::Position::Top, 150 | obscured: false, 151 | } 152 | } 153 | 154 | pub fn mini_icon<'a>(icon: Icon) -> CustomButton<'a> { 155 | CustomButton { 156 | content: icon.mini_control().into(), 157 | on_press: None, 158 | enabled: true, 159 | class: style::Button::Icon, 160 | padding: None, 161 | tooltip: None, 162 | tooltip_position: tooltip::Position::Top, 163 | obscured: false, 164 | } 165 | } 166 | 167 | pub fn max_icon<'a>(icon: Icon) -> CustomButton<'a> { 168 | CustomButton { 169 | content: icon.max_control().into(), 170 | on_press: None, 171 | enabled: true, 172 | class: style::Button::Icon, 173 | padding: None, 174 | tooltip: None, 175 | tooltip_position: tooltip::Position::Top, 176 | obscured: false, 177 | } 178 | } 179 | 180 | pub fn choose_folder<'a>(subject: BrowseSubject, raw: StrictPath, modifiers: &keyboard::Modifiers) -> CustomButton<'a> { 181 | if modifiers.shift() { 182 | icon(Icon::OpenInNew).on_press(Message::OpenDir { path: raw }) 183 | } else { 184 | icon(Icon::FolderOpen).on_press(Message::BrowseDir(subject)) 185 | } 186 | .tooltip(format!( 187 | "{}\n{} {}", 188 | lang::action::select_folder(), 189 | lang::field(&lang::thing::key::shift()), 190 | lang::action::open_folder() 191 | )) 192 | } 193 | 194 | pub fn choose_file<'a>( 195 | subject: BrowseFileSubject, 196 | raw: StrictPath, 197 | modifiers: &keyboard::Modifiers, 198 | ) -> CustomButton<'a> { 199 | if modifiers.shift() { 200 | icon(Icon::FileOpen).on_press(Message::OpenFile { path: raw }) 201 | } else { 202 | icon(Icon::File).on_press(Message::BrowseFile(subject)) 203 | } 204 | .tooltip(format!( 205 | "{}\n{} {}", 206 | lang::action::select_file(), 207 | lang::field(&lang::thing::key::shift()), 208 | lang::action::open_file() 209 | )) 210 | } 211 | 212 | pub fn move_up<'a>(action: fn(EditAction) -> Message, index: usize) -> CustomButton<'a> { 213 | icon(Icon::ArrowUpward).on_press_maybe((index > 0).then(|| action(EditAction::move_up(index)))) 214 | } 215 | 216 | pub fn move_down<'a>(action: fn(EditAction) -> Message, index: usize, max: usize) -> CustomButton<'a> { 217 | icon(Icon::ArrowDownward).on_press_maybe((index < max - 1).then(|| action(EditAction::move_down(index)))) 218 | } 219 | -------------------------------------------------------------------------------- /src/gui/common.rs: -------------------------------------------------------------------------------- 1 | use std::{num::NonZeroUsize, time::Instant}; 2 | 3 | use iced::{ 4 | widget::{pane_grid, text_input}, 5 | Length, 6 | }; 7 | 8 | use crate::{ 9 | gui::{ 10 | grid, modal, player, 11 | shortcuts::TextHistories, 12 | style, 13 | widget::{Element, TextInput, Undoable}, 14 | }, 15 | media, 16 | prelude::StrictPath, 17 | resource::config, 18 | }; 19 | 20 | const ERROR_ICON: text_input::Icon = text_input::Icon { 21 | font: crate::gui::font::ICONS, 22 | code_point: crate::gui::icon::Icon::Error.as_char(), 23 | size: None, 24 | spacing: 5.0, 25 | side: text_input::Side::Right, 26 | }; 27 | 28 | #[derive(Clone, Debug, Default)] 29 | pub struct Flags { 30 | pub sources: Vec, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub enum Message { 35 | Ignore, 36 | Exit { 37 | force: bool, 38 | }, 39 | Tick(Instant), 40 | #[cfg(feature = "audio")] 41 | CheckAudio, 42 | Save, 43 | CloseModal, 44 | Config { 45 | event: config::Event, 46 | }, 47 | CheckAppRelease, 48 | AppReleaseChecked(Result), 49 | BrowseDir(BrowseSubject), 50 | BrowseFile(BrowseFileSubject), 51 | OpenDir { 52 | path: StrictPath, 53 | }, 54 | OpenFile { 55 | path: StrictPath, 56 | }, 57 | OpenPathFailure { 58 | path: StrictPath, 59 | }, 60 | OpenUrlFailure { 61 | url: String, 62 | }, 63 | KeyboardEvent(iced::keyboard::Event), 64 | UndoRedo(crate::gui::undoable::Action, UndoSubject), 65 | OpenUrl(String), 66 | OpenUrlAndCloseModal(String), 67 | Refresh, 68 | SetPause(bool), 69 | SetMute(bool), 70 | SetVolume { 71 | volume: f32, 72 | }, 73 | Player { 74 | grid_id: grid::Id, 75 | player_id: player::Id, 76 | event: player::Event, 77 | }, 78 | AllPlayers { 79 | event: player::Event, 80 | }, 81 | Modal { 82 | event: modal::Event, 83 | }, 84 | ShowSettings, 85 | FindMedia, 86 | MediaFound { 87 | context: media::RefreshContext, 88 | media: media::SourceMap, 89 | }, 90 | FileDragDrop(StrictPath), 91 | FileDragDropGridSelected(grid::Id), 92 | WindowFocused, 93 | WindowUnfocused, 94 | Pane { 95 | event: PaneEvent, 96 | }, 97 | PlaylistReset { 98 | force: bool, 99 | }, 100 | PlaylistSelect { 101 | force: bool, 102 | }, 103 | PlaylistLoad { 104 | path: StrictPath, 105 | }, 106 | PlaylistSave, 107 | PlaylistSaveAs, 108 | PlaylistSavedAs { 109 | path: StrictPath, 110 | }, 111 | } 112 | 113 | impl Message { 114 | pub fn browsed_dir(subject: BrowseSubject, choice: Option) -> Self { 115 | match choice { 116 | Some(path) => match subject { 117 | BrowseSubject::Source { index } => Self::Modal { 118 | event: modal::Event::EditedSource { 119 | action: EditAction::Change(index, crate::path::render_pathbuf(&path)), 120 | }, 121 | }, 122 | }, 123 | None => Self::Ignore, 124 | } 125 | } 126 | 127 | pub fn browsed_file(subject: BrowseFileSubject, choice: Option) -> Self { 128 | match choice { 129 | Some(path) => match subject { 130 | BrowseFileSubject::Source { index } => Self::Modal { 131 | event: modal::Event::EditedSource { 132 | action: EditAction::Change(index, crate::path::render_pathbuf(&path)), 133 | }, 134 | }, 135 | BrowseFileSubject::Playlist { save } => { 136 | if save { 137 | Self::PlaylistSavedAs { 138 | path: StrictPath::from(path), 139 | } 140 | } else { 141 | Self::PlaylistLoad { 142 | path: StrictPath::from(path), 143 | } 144 | } 145 | } 146 | }, 147 | None => Self::Ignore, 148 | } 149 | } 150 | } 151 | 152 | #[derive(Debug, Clone, PartialEq, Eq)] 153 | pub enum EditAction { 154 | Add, 155 | Change(usize, String), 156 | Remove(usize), 157 | Move(usize, EditDirection), 158 | } 159 | 160 | impl EditAction { 161 | pub fn move_up(index: usize) -> Self { 162 | Self::Move(index, EditDirection::Up) 163 | } 164 | 165 | pub fn move_down(index: usize) -> Self { 166 | Self::Move(index, EditDirection::Down) 167 | } 168 | } 169 | 170 | #[derive(Debug, Clone, PartialEq, Eq)] 171 | pub enum EditDirection { 172 | Up, 173 | Down, 174 | } 175 | 176 | impl EditDirection { 177 | pub fn shift(&self, index: usize) -> usize { 178 | match self { 179 | Self::Up => index - 1, 180 | Self::Down => index + 1, 181 | } 182 | } 183 | } 184 | 185 | #[derive(Debug, Clone, PartialEq, Eq)] 186 | pub enum BrowseSubject { 187 | Source { index: usize }, 188 | } 189 | 190 | #[derive(Debug, Clone, PartialEq, Eq)] 191 | pub enum BrowseFileSubject { 192 | Source { index: usize }, 193 | Playlist { save: bool }, 194 | } 195 | 196 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 197 | pub enum UndoSubject { 198 | ImageDuration, 199 | Source { index: usize }, 200 | OrientationLimit, 201 | } 202 | 203 | impl UndoSubject { 204 | pub fn view_with<'a>(self, histories: &TextHistories) -> Element<'a> { 205 | match self { 206 | Self::ImageDuration => self.view(&histories.image_duration.current()), 207 | Self::Source { .. } => self.view(""), 208 | Self::OrientationLimit { .. } => self.view(""), 209 | } 210 | } 211 | 212 | pub fn view<'a>(self, current: &str) -> Element<'a> { 213 | let event: Box Message> = match self { 214 | UndoSubject::ImageDuration => Box::new(move |value| Message::Config { 215 | event: config::Event::ImageDurationRaw(value), 216 | }), 217 | UndoSubject::Source { index } => Box::new(move |value| Message::Modal { 218 | event: modal::Event::EditedSource { 219 | action: EditAction::Change(index, value), 220 | }, 221 | }), 222 | UndoSubject::OrientationLimit => Box::new(move |value| Message::Modal { 223 | event: modal::Event::EditedGridOrientationLimit { raw_limit: value }, 224 | }), 225 | }; 226 | 227 | let placeholder = ""; 228 | 229 | let icon = match self { 230 | UndoSubject::ImageDuration => (current.parse::().is_err()).then_some(ERROR_ICON), 231 | UndoSubject::Source { .. } => (!path_appears_valid(current)).then_some(ERROR_ICON), 232 | UndoSubject::OrientationLimit => (current.parse::().is_err()).then_some(ERROR_ICON), 233 | }; 234 | 235 | let width = match self { 236 | UndoSubject::ImageDuration => Length::Fixed(80.0), 237 | UndoSubject::Source { .. } => Length::Fill, 238 | UndoSubject::OrientationLimit => Length::Fixed(80.0), 239 | }; 240 | 241 | Undoable::new( 242 | { 243 | let mut input = TextInput::new(placeholder, current) 244 | .on_input(event) 245 | .class(style::TextInput) 246 | .padding(5) 247 | .width(width); 248 | 249 | if let Some(icon) = icon { 250 | input = input.icon(icon); 251 | } 252 | 253 | input 254 | }, 255 | move |action| Message::UndoRedo(action, self), 256 | ) 257 | .into() 258 | } 259 | } 260 | 261 | fn path_appears_valid(path: &str) -> bool { 262 | !path.contains("://") 263 | } 264 | 265 | #[derive(Debug, Clone)] 266 | pub enum PaneEvent { 267 | Drag(pane_grid::DragEvent), 268 | Resize(pane_grid::ResizeEvent), 269 | Split { grid_id: grid::Id, axis: pane_grid::Axis }, 270 | Close { grid_id: grid::Id }, 271 | AddPlayer { grid_id: grid::Id }, 272 | ShowSettings { grid_id: grid::Id }, 273 | ShowControls { grid_id: grid::Id }, 274 | CloseControls, 275 | SetMute { grid_id: grid::Id, muted: bool }, 276 | SetPause { grid_id: grid::Id, paused: bool }, 277 | SeekRandom { grid_id: grid::Id }, 278 | Refresh { grid_id: grid::Id }, 279 | } 280 | -------------------------------------------------------------------------------- /src/gui/dropdown.rs: -------------------------------------------------------------------------------- 1 | // Based on: 2 | // https://github.com/iced-rs/iced_aw/blob/3485f3adcb28df105807f153c4e74c122585131a/src/widget/drop_down.rs 3 | // Notable changes: 4 | // * Adjust overlay position based on available horizontal space. 5 | // * Invoke nested overlays. 6 | 7 | use iced::{ 8 | advanced::{ 9 | layout::{Limits, Node}, 10 | overlay, renderer, 11 | widget::{Operation, Tree}, 12 | Clipboard, Layout, Shell, Widget, 13 | }, 14 | event, 15 | keyboard::{self, key::Named}, 16 | mouse::{self, Cursor}, 17 | touch, Element, Event, Length, Point, Rectangle, Size, Vector, 18 | }; 19 | 20 | /// Customizable drop down menu widget 21 | pub struct DropDown<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> 22 | where 23 | Message: Clone, 24 | Renderer: renderer::Renderer, 25 | { 26 | underlay: Element<'a, Message, Theme, Renderer>, 27 | overlay: Element<'a, Message, Theme, Renderer>, 28 | on_dismiss: Option, 29 | width: Option, 30 | height: Length, 31 | expanded: bool, 32 | } 33 | 34 | impl<'a, Message, Theme, Renderer> DropDown<'a, Message, Theme, Renderer> 35 | where 36 | Message: Clone, 37 | Renderer: renderer::Renderer, 38 | { 39 | /// Create a new [`DropDown`] 40 | pub fn new(underlay: U, overlay: B, expanded: bool) -> Self 41 | where 42 | U: Into>, 43 | B: Into>, 44 | { 45 | DropDown { 46 | underlay: underlay.into(), 47 | overlay: overlay.into(), 48 | expanded, 49 | on_dismiss: None, 50 | width: None, 51 | height: Length::Shrink, 52 | } 53 | } 54 | 55 | /// The width of the overlay 56 | #[must_use] 57 | pub fn width(mut self, width: impl Into) -> Self { 58 | self.width = Some(width.into()); 59 | self 60 | } 61 | 62 | /// The height of the overlay 63 | #[must_use] 64 | pub fn height(mut self, height: impl Into) -> Self { 65 | self.height = height.into(); 66 | self 67 | } 68 | 69 | /// Send a message when a click occur outside of the overlay when expanded 70 | #[must_use] 71 | pub fn on_dismiss(mut self, message: Message) -> Self { 72 | self.on_dismiss = Some(message); 73 | self 74 | } 75 | } 76 | 77 | impl<'a, Message, Theme, Renderer> Widget for DropDown<'a, Message, Theme, Renderer> 78 | where 79 | Message: 'a + Clone, 80 | Renderer: 'a + renderer::Renderer, 81 | { 82 | fn size(&self) -> Size { 83 | self.underlay.as_widget().size() 84 | } 85 | 86 | fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node { 87 | self.underlay 88 | .as_widget() 89 | .layout(&mut tree.children[0], renderer, limits) 90 | } 91 | 92 | fn draw( 93 | &self, 94 | state: &Tree, 95 | renderer: &mut Renderer, 96 | theme: &Theme, 97 | style: &renderer::Style, 98 | layout: Layout<'_>, 99 | cursor: Cursor, 100 | viewport: &Rectangle, 101 | ) { 102 | self.underlay 103 | .as_widget() 104 | .draw(&state.children[0], renderer, theme, style, layout, cursor, viewport); 105 | } 106 | 107 | fn children(&self) -> Vec { 108 | vec![Tree::new(&self.underlay), Tree::new(&self.overlay)] 109 | } 110 | 111 | fn diff(&self, tree: &mut Tree) { 112 | tree.diff_children(&[&self.underlay, &self.overlay]); 113 | } 114 | 115 | fn operate<'b>( 116 | &'b self, 117 | state: &'b mut Tree, 118 | layout: Layout<'_>, 119 | renderer: &Renderer, 120 | operation: &mut dyn Operation<()>, 121 | ) { 122 | self.underlay 123 | .as_widget() 124 | .operate(&mut state.children[0], layout, renderer, operation); 125 | } 126 | 127 | fn on_event( 128 | &mut self, 129 | state: &mut Tree, 130 | event: Event, 131 | layout: Layout<'_>, 132 | cursor: Cursor, 133 | renderer: &Renderer, 134 | clipboard: &mut dyn Clipboard, 135 | shell: &mut Shell<'_, Message>, 136 | viewport: &Rectangle, 137 | ) -> event::Status { 138 | self.underlay.as_widget_mut().on_event( 139 | &mut state.children[0], 140 | event, 141 | layout, 142 | cursor, 143 | renderer, 144 | clipboard, 145 | shell, 146 | viewport, 147 | ) 148 | } 149 | 150 | fn mouse_interaction( 151 | &self, 152 | state: &Tree, 153 | layout: Layout<'_>, 154 | cursor: Cursor, 155 | viewport: &Rectangle, 156 | renderer: &Renderer, 157 | ) -> mouse::Interaction { 158 | self.underlay 159 | .as_widget() 160 | .mouse_interaction(&state.children[0], layout, cursor, viewport, renderer) 161 | } 162 | 163 | fn overlay<'b>( 164 | &'b mut self, 165 | state: &'b mut Tree, 166 | layout: Layout<'_>, 167 | renderer: &Renderer, 168 | translation: Vector, 169 | ) -> Option> { 170 | if !self.expanded { 171 | return self 172 | .underlay 173 | .as_widget_mut() 174 | .overlay(&mut state.children[0], layout, renderer, translation); 175 | } 176 | 177 | Some(overlay::Element::new(Box::new(DropDownOverlay::new( 178 | &mut state.children[1], 179 | &mut self.overlay, 180 | &self.on_dismiss, 181 | &self.width, 182 | &self.height, 183 | layout.bounds(), 184 | layout.position(), 185 | )))) 186 | } 187 | } 188 | 189 | impl<'a, Message, Theme: 'a, Renderer> From> 190 | for Element<'a, Message, Theme, Renderer> 191 | where 192 | Message: 'a + Clone, 193 | Renderer: 'a + renderer::Renderer, 194 | { 195 | fn from(drop_down: DropDown<'a, Message, Theme, Renderer>) -> Self { 196 | Element::new(drop_down) 197 | } 198 | } 199 | 200 | struct DropDownOverlay<'a, 'b, Message, Theme = iced::Theme, Renderer = iced::Renderer> 201 | where 202 | Message: Clone, 203 | { 204 | state: &'b mut Tree, 205 | element: &'b mut Element<'a, Message, Theme, Renderer>, 206 | on_dismiss: &'b Option, 207 | width: &'b Option, 208 | height: &'b Length, 209 | underlay_bounds: Rectangle, 210 | position: Point, 211 | } 212 | 213 | impl<'a, 'b, Message, Theme, Renderer> DropDownOverlay<'a, 'b, Message, Theme, Renderer> 214 | where 215 | Message: Clone, 216 | Renderer: renderer::Renderer, 217 | { 218 | #[allow(clippy::too_many_arguments)] 219 | fn new( 220 | state: &'b mut Tree, 221 | element: &'b mut Element<'a, Message, Theme, Renderer>, 222 | on_dismiss: &'b Option, 223 | width: &'b Option, 224 | height: &'b Length, 225 | underlay_bounds: Rectangle, 226 | position: Point, 227 | ) -> Self { 228 | DropDownOverlay { 229 | state, 230 | element, 231 | on_dismiss, 232 | width, 233 | height, 234 | underlay_bounds, 235 | position, 236 | } 237 | } 238 | } 239 | 240 | impl overlay::Overlay 241 | for DropDownOverlay<'_, '_, Message, Theme, Renderer> 242 | where 243 | Message: Clone, 244 | Renderer: renderer::Renderer, 245 | { 246 | fn layout(&mut self, renderer: &Renderer, bounds: Size) -> Node { 247 | let space_right = bounds.width - self.position.x - self.underlay_bounds.width - self.underlay_bounds.width; 248 | let space_left = self.position.x; 249 | 250 | let mut limits = Limits::new( 251 | Size::ZERO, 252 | Size::new( 253 | if space_right > space_left { 254 | space_right 255 | } else { 256 | space_left 257 | }, 258 | bounds.height - self.position.y, 259 | ), 260 | ) 261 | .height(*self.height); 262 | 263 | if let Some(width) = self.width { 264 | limits = limits.width(*width); 265 | } 266 | 267 | let node = self.element.as_widget().layout(self.state, renderer, &limits); 268 | 269 | let previous_position = self.position; 270 | 271 | let position = if space_left > space_right { 272 | Point::new(previous_position.x - node.bounds().width, previous_position.y) 273 | } else { 274 | Point::new(previous_position.x + self.underlay_bounds.width, previous_position.y) 275 | }; 276 | 277 | node.move_to(position) 278 | } 279 | 280 | fn draw( 281 | &self, 282 | renderer: &mut Renderer, 283 | theme: &Theme, 284 | style: &renderer::Style, 285 | layout: Layout<'_>, 286 | cursor: Cursor, 287 | ) { 288 | let bounds = layout.bounds(); 289 | self.element 290 | .as_widget() 291 | .draw(self.state, renderer, theme, style, layout, cursor, &bounds); 292 | } 293 | 294 | fn on_event( 295 | &mut self, 296 | event: Event, 297 | layout: Layout<'_>, 298 | cursor: Cursor, 299 | renderer: &Renderer, 300 | clipboard: &mut dyn Clipboard, 301 | shell: &mut Shell, 302 | ) -> event::Status { 303 | if let Some(on_dismiss) = self.on_dismiss { 304 | match &event { 305 | Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { 306 | if key == &keyboard::Key::Named(Named::Escape) { 307 | shell.publish(on_dismiss.clone()); 308 | } 309 | } 310 | 311 | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left | mouse::Button::Right)) 312 | | Event::Touch(touch::Event::FingerPressed { .. }) => { 313 | if !cursor.is_over(layout.bounds()) && !cursor.is_over(self.underlay_bounds) { 314 | shell.publish(on_dismiss.clone()); 315 | } 316 | } 317 | 318 | _ => {} 319 | } 320 | } 321 | 322 | self.element.as_widget_mut().on_event( 323 | self.state, 324 | event, 325 | layout, 326 | cursor, 327 | renderer, 328 | clipboard, 329 | shell, 330 | &layout.bounds(), 331 | ) 332 | } 333 | 334 | fn mouse_interaction( 335 | &self, 336 | layout: Layout<'_>, 337 | cursor: Cursor, 338 | viewport: &Rectangle, 339 | renderer: &Renderer, 340 | ) -> mouse::Interaction { 341 | self.element 342 | .as_widget() 343 | .mouse_interaction(self.state, layout, cursor, viewport, renderer) 344 | } 345 | 346 | fn overlay<'a>( 347 | &'a mut self, 348 | layout: Layout<'_>, 349 | renderer: &Renderer, 350 | ) -> Option> { 351 | self.element 352 | .as_widget_mut() 353 | .overlay(self.state, layout, renderer, Vector::ZERO) 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/gui/font.rs: -------------------------------------------------------------------------------- 1 | use iced::{font, Font}; 2 | 3 | pub const TEXT_DATA: &[u8] = include_bytes!("../../assets/NotoSans-Regular.ttf"); 4 | pub const TEXT: Font = Font { 5 | family: font::Family::Name("Noto Sans"), 6 | weight: font::Weight::Normal, 7 | stretch: font::Stretch::Normal, 8 | style: font::Style::Normal, 9 | }; 10 | 11 | pub const ICONS_DATA: &[u8] = include_bytes!("../../assets/MaterialIcons-Regular.ttf"); 12 | pub const ICONS: Font = Font { 13 | family: font::Family::Name("Material Icons"), 14 | weight: font::Weight::Normal, 15 | stretch: font::Stretch::Normal, 16 | style: font::Style::Normal, 17 | }; 18 | -------------------------------------------------------------------------------- /src/gui/icon.rs: -------------------------------------------------------------------------------- 1 | use iced::{alignment, Length}; 2 | 3 | use crate::gui::{ 4 | font, 5 | widget::{text, Text}, 6 | }; 7 | 8 | pub enum Icon { 9 | Add, 10 | ArrowDownward, 11 | ArrowUpward, 12 | Close, 13 | Error, 14 | File, 15 | FileOpen, 16 | FolderOpen, 17 | Image, 18 | LogOut, 19 | Loop, 20 | MoreVert, 21 | #[cfg(feature = "video")] 22 | Movie, 23 | #[cfg(feature = "audio")] 24 | Music, 25 | Mute, 26 | OpenInBrowser, 27 | OpenInNew, 28 | Pause, 29 | Play, 30 | PlaylistAdd, 31 | PlaylistRemove, 32 | Refresh, 33 | Save, 34 | SaveAs, 35 | Settings, 36 | Shuffle, 37 | SplitHorizontal, 38 | SplitVertical, 39 | TimerRefresh, 40 | VolumeHigh, 41 | } 42 | 43 | impl Icon { 44 | pub const fn as_char(&self) -> char { 45 | match self { 46 | Self::Add => '\u{E145}', 47 | Self::ArrowDownward => '\u{E5DB}', 48 | Self::ArrowUpward => '\u{E5D8}', 49 | Self::Close => '\u{e14c}', 50 | Self::Error => '\u{e000}', 51 | Self::File => '\u{e24d}', 52 | Self::FileOpen => '\u{eaf3}', 53 | Self::FolderOpen => '\u{E2C8}', 54 | Self::Image => '\u{e3f4}', 55 | Self::LogOut => '\u{e9ba}', 56 | Self::Loop => '\u{e040}', 57 | Self::MoreVert => '\u{E5D4}', 58 | #[cfg(feature = "video")] 59 | Self::Movie => '\u{e02c}', 60 | #[cfg(feature = "audio")] 61 | Self::Music => '\u{e405}', 62 | Self::Mute => '\u{e04f}', 63 | Self::OpenInBrowser => '\u{e89d}', 64 | Self::OpenInNew => '\u{E89E}', 65 | Self::Pause => '\u{e034}', 66 | Self::Play => '\u{e037}', 67 | Self::Refresh => '\u{E5D5}', 68 | Self::Save => '\u{e161}', 69 | Self::SaveAs => '\u{eb60}', 70 | Self::Settings => '\u{E8B8}', 71 | Self::Shuffle => '\u{e043}', 72 | Self::SplitHorizontal => '\u{e8d4}', 73 | Self::SplitVertical => '\u{e8d5}', 74 | Self::TimerRefresh => '\u{e889}', 75 | Self::VolumeHigh => '\u{e050}', 76 | Self::PlaylistAdd => '\u{e03b}', 77 | Self::PlaylistRemove => '\u{eb80}', 78 | } 79 | } 80 | 81 | pub fn big_control(self) -> Text<'static> { 82 | text(self.as_char().to_string()) 83 | .font(font::ICONS) 84 | .size(40) 85 | .width(40) 86 | .height(40) 87 | .align_x(alignment::Horizontal::Center) 88 | .align_y(iced::alignment::Vertical::Center) 89 | .line_height(1.0) 90 | } 91 | 92 | pub fn small_control(self) -> Text<'static> { 93 | text(self.as_char().to_string()) 94 | .font(font::ICONS) 95 | .size(20) 96 | .width(20) 97 | .height(20) 98 | .align_x(alignment::Horizontal::Center) 99 | .align_y(iced::alignment::Vertical::Center) 100 | .line_height(1.0) 101 | } 102 | 103 | pub fn mini_control(self) -> Text<'static> { 104 | text(self.as_char().to_string()) 105 | .font(font::ICONS) 106 | .size(14) 107 | .width(14) 108 | .height(14) 109 | .align_x(alignment::Horizontal::Center) 110 | .align_y(iced::alignment::Vertical::Center) 111 | .line_height(1.0) 112 | } 113 | 114 | pub fn max_control(self) -> Text<'static> { 115 | text(self.as_char().to_string()) 116 | .font(font::ICONS) 117 | .size(40) 118 | .width(Length::Fill) 119 | .height(Length::Fill) 120 | .align_x(alignment::Horizontal::Center) 121 | .align_y(iced::alignment::Vertical::Center) 122 | .line_height(1.0) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/gui/shortcuts.rs: -------------------------------------------------------------------------------- 1 | // Iced has built-in support for some keyboard shortcuts. This module provides 2 | // support for implementing other shortcuts until Iced provides its own support. 3 | 4 | use std::collections::VecDeque; 5 | 6 | use crate::{prelude::StrictPath, resource::config::Config}; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub enum Shortcut { 10 | Undo, 11 | Redo, 12 | } 13 | 14 | impl From for Shortcut { 15 | fn from(source: crate::gui::undoable::Action) -> Self { 16 | match source { 17 | crate::gui::undoable::Action::Undo => Self::Undo, 18 | crate::gui::undoable::Action::Redo => Self::Redo, 19 | } 20 | } 21 | } 22 | 23 | #[derive(Debug, Clone, PartialEq, Eq)] 24 | pub struct TextHistory { 25 | history: VecDeque, 26 | limit: usize, 27 | position: usize, 28 | } 29 | 30 | impl Default for TextHistory { 31 | fn default() -> Self { 32 | Self::new("", 100) 33 | } 34 | } 35 | 36 | impl TextHistory { 37 | pub fn new(initial: &str, limit: usize) -> Self { 38 | let mut history = VecDeque::::new(); 39 | history.push_back(initial.to_string()); 40 | Self { 41 | history, 42 | limit, 43 | position: 0, 44 | } 45 | } 46 | 47 | pub fn raw(initial: &str) -> Self { 48 | Self::new(initial, 100) 49 | } 50 | 51 | pub fn path(initial: &StrictPath) -> Self { 52 | Self::raw(&initial.raw()) 53 | } 54 | 55 | pub fn push(&mut self, text: &str) { 56 | if self.current() == text { 57 | return; 58 | } 59 | if self.position + 1 < self.history.len() { 60 | self.history.truncate(self.position + 1); 61 | } 62 | if self.position + 1 >= self.limit { 63 | self.history.pop_front(); 64 | } 65 | self.history.push_back(text.to_string()); 66 | self.position = self.history.len() - 1; 67 | } 68 | 69 | pub fn current(&self) -> String { 70 | match self.history.get(self.position) { 71 | Some(x) => x.to_string(), 72 | None => "".to_string(), 73 | } 74 | } 75 | 76 | pub fn undo(&mut self) -> String { 77 | self.position = if self.position == 0 { 0 } else { self.position - 1 }; 78 | self.current() 79 | } 80 | 81 | pub fn redo(&mut self) -> String { 82 | self.position = std::cmp::min(self.position + 1, self.history.len() - 1); 83 | self.current() 84 | } 85 | 86 | pub fn apply(&mut self, shortcut: Shortcut) -> String { 87 | match shortcut { 88 | Shortcut::Undo => self.undo(), 89 | Shortcut::Redo => self.redo(), 90 | } 91 | } 92 | } 93 | 94 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 95 | pub struct TextHistories { 96 | pub image_duration: TextHistory, 97 | } 98 | 99 | impl TextHistories { 100 | pub fn new(config: &Config) -> Self { 101 | Self { 102 | image_duration: TextHistory::raw(&config.playback.image_duration.to_string()), 103 | } 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use super::*; 110 | 111 | #[test] 112 | fn text_history() { 113 | let mut ht = TextHistory::new("initial", 3); 114 | 115 | assert_eq!(ht.current(), "initial"); 116 | assert_eq!(ht.undo(), "initial"); 117 | assert_eq!(ht.redo(), "initial"); 118 | 119 | ht.push("a"); 120 | assert_eq!(ht.current(), "a"); 121 | assert_eq!(ht.undo(), "initial"); 122 | assert_eq!(ht.undo(), "initial"); 123 | assert_eq!(ht.redo(), "a"); 124 | assert_eq!(ht.redo(), "a"); 125 | 126 | // Duplicates are ignored: 127 | ht.push("a"); 128 | ht.push("a"); 129 | ht.push("a"); 130 | assert_eq!(ht.undo(), "initial"); 131 | 132 | // History is clipped at the limit: 133 | ht.push("b"); 134 | ht.push("c"); 135 | ht.push("d"); 136 | assert_eq!(ht.undo(), "c"); 137 | assert_eq!(ht.undo(), "b"); 138 | assert_eq!(ht.undo(), "b"); 139 | 140 | // Redos are lost on push: 141 | ht.push("e"); 142 | assert_eq!(ht.current(), "e"); 143 | assert_eq!(ht.redo(), "e"); 144 | assert_eq!(ht.undo(), "b"); 145 | assert_eq!(ht.undo(), "b"); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/gui/style.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | border::Radius, 3 | widget::{button, checkbox, container, pane_grid, pick_list, rule, scrollable, slider, svg, text_input}, 4 | Background, Border, Color, Shadow, Vector, 5 | }; 6 | 7 | use crate::resource::config; 8 | 9 | macro_rules! rgb8 { 10 | ($r:expr, $g:expr, $b:expr) => { 11 | Color::from_rgb($r as f32 / 255.0, $g as f32 / 255.0, $b as f32 / 255.0) 12 | }; 13 | } 14 | 15 | trait ColorExt { 16 | fn alpha(self, alpha: f32) -> Color; 17 | } 18 | 19 | impl ColorExt for Color { 20 | fn alpha(mut self, alpha: f32) -> Self { 21 | self.a = alpha; 22 | self 23 | } 24 | } 25 | 26 | #[derive(Debug, Clone)] 27 | pub struct Theme { 28 | background: Color, 29 | field: Color, 30 | text: Color, 31 | text_button: Color, 32 | text_selection: Color, 33 | positive: Color, 34 | negative: Color, 35 | disabled: Color, 36 | } 37 | 38 | impl Default for Theme { 39 | fn default() -> Self { 40 | Self::from(config::Theme::Light) 41 | } 42 | } 43 | 44 | impl From for Theme { 45 | fn from(source: config::Theme) -> Self { 46 | match source { 47 | config::Theme::Light => Self { 48 | background: Color::WHITE, 49 | field: rgb8!(230, 230, 230), 50 | text: Color::BLACK, 51 | text_button: Color::WHITE, 52 | text_selection: Color::from_rgb(0.8, 0.8, 1.0), 53 | positive: rgb8!(28, 107, 223), 54 | negative: rgb8!(255, 0, 0), 55 | disabled: rgb8!(169, 169, 169), 56 | }, 57 | config::Theme::Dark => Self { 58 | background: rgb8!(41, 41, 41), 59 | field: rgb8!(74, 74, 74), 60 | text: Color::WHITE, 61 | ..Self::from(config::Theme::Light) 62 | }, 63 | } 64 | } 65 | } 66 | 67 | impl iced::application::DefaultStyle for Theme { 68 | fn default_style(&self) -> iced::daemon::Appearance { 69 | iced::application::Appearance { 70 | background_color: self.background, 71 | text_color: self.text, 72 | } 73 | } 74 | } 75 | 76 | #[derive(Clone, Copy, Debug, Default)] 77 | pub struct Text; 78 | impl iced::widget::text::Catalog for Theme { 79 | type Class<'a> = Text; 80 | 81 | fn default<'a>() -> Self::Class<'a> { 82 | Default::default() 83 | } 84 | 85 | fn style(&self, _item: &Self::Class<'_>) -> iced::widget::text::Style { 86 | iced::widget::text::Style { color: None } 87 | } 88 | } 89 | 90 | #[derive(Clone, Copy, Debug, Default)] 91 | pub struct Menu; 92 | impl iced::widget::overlay::menu::Catalog for Theme { 93 | type Class<'a> = Menu; 94 | 95 | fn default<'a>() -> ::Class<'a> { 96 | Default::default() 97 | } 98 | 99 | fn style(&self, _class: &::Class<'_>) -> iced::overlay::menu::Style { 100 | iced::overlay::menu::Style { 101 | background: self.field.into(), 102 | border: Border { 103 | color: self.text.alpha(0.5), 104 | width: 1.0, 105 | radius: 5.0.into(), 106 | }, 107 | text_color: self.text, 108 | selected_background: self.positive.into(), 109 | selected_text_color: Color::WHITE, 110 | } 111 | } 112 | } 113 | 114 | #[derive(Clone, Copy, Debug, Default)] 115 | pub enum Button { 116 | #[default] 117 | Primary, 118 | Negative, 119 | Bare, 120 | Icon, 121 | } 122 | impl button::Catalog for Theme { 123 | type Class<'a> = Button; 124 | 125 | fn default<'a>() -> Self::Class<'a> { 126 | Default::default() 127 | } 128 | 129 | fn style(&self, class: &Self::Class<'_>, status: button::Status) -> button::Style { 130 | let active = button::Style { 131 | background: match class { 132 | Button::Primary => Some(self.positive.into()), 133 | Button::Negative => Some(self.negative.into()), 134 | Button::Bare | Button::Icon => None, 135 | }, 136 | border: Border { 137 | color: Color::TRANSPARENT, 138 | width: 0.0, 139 | radius: 10.0.into(), 140 | }, 141 | text_color: match class { 142 | Button::Bare | Button::Icon => self.text, 143 | _ => self.text_button, 144 | }, 145 | shadow: Shadow { 146 | offset: Vector::new(1.0, 1.0), 147 | ..Default::default() 148 | }, 149 | }; 150 | 151 | match status { 152 | button::Status::Active => active, 153 | button::Status::Hovered => button::Style { 154 | background: match class { 155 | Button::Primary => Some(self.positive.alpha(0.8).into()), 156 | Button::Negative => Some(self.negative.alpha(0.8).into()), 157 | Button::Bare | Button::Icon => Some(self.text.alpha(0.2).into()), 158 | }, 159 | border: active.border, 160 | text_color: match class { 161 | Button::Bare | Button::Icon => self.text.alpha(0.9), 162 | _ => self.text_button.alpha(0.9), 163 | }, 164 | shadow: Shadow { 165 | offset: Vector::new(1.0, 2.0), 166 | ..Default::default() 167 | }, 168 | }, 169 | button::Status::Pressed => button::Style { 170 | shadow: Shadow { 171 | offset: Vector::default(), 172 | ..active.shadow 173 | }, 174 | ..active 175 | }, 176 | button::Status::Disabled => button::Style { 177 | shadow: Shadow { 178 | offset: Vector::default(), 179 | ..active.shadow 180 | }, 181 | background: active.background.map(|background| match background { 182 | Background::Color(color) => Background::Color(Color { 183 | a: color.a * 0.5, 184 | ..color 185 | }), 186 | Background::Gradient(gradient) => Background::Gradient(gradient.scale_alpha(0.5)), 187 | }), 188 | text_color: Color { 189 | a: active.text_color.a * 0.5, 190 | ..active.text_color 191 | }, 192 | ..active 193 | }, 194 | } 195 | } 196 | } 197 | 198 | #[derive(Clone, Copy, Debug, Default)] 199 | pub enum Container { 200 | #[default] 201 | Wrapper, 202 | Primary, 203 | ModalForeground, 204 | ModalBackground, 205 | Player, 206 | PlayerGroup, 207 | PlayerGroupControls, 208 | PlayerGroupTitle, 209 | Tooltip, 210 | FileDrag, 211 | } 212 | impl container::Catalog for Theme { 213 | type Class<'a> = Container; 214 | 215 | fn default<'a>() -> Self::Class<'a> { 216 | Default::default() 217 | } 218 | 219 | fn style(&self, class: &Self::Class<'_>) -> container::Style { 220 | container::Style { 221 | background: Some(match class { 222 | Container::Wrapper => Color::TRANSPARENT.into(), 223 | Container::Player => self.field.alpha(0.15).into(), 224 | Container::PlayerGroup => self.field.alpha(0.3).into(), 225 | Container::PlayerGroupControls => self.field.into(), 226 | Container::PlayerGroupTitle => self.field.alpha(0.45).into(), 227 | Container::ModalBackground => self.field.alpha(0.5).into(), 228 | Container::Tooltip => self.field.into(), 229 | Container::FileDrag => self.field.alpha(0.9).into(), 230 | _ => self.background.into(), 231 | }), 232 | border: Border { 233 | color: match class { 234 | Container::Wrapper => Color::TRANSPARENT, 235 | Container::Player => self.field.alpha(0.8), 236 | Container::PlayerGroup | Container::PlayerGroupTitle => self.field, 237 | Container::PlayerGroupControls => self.disabled, 238 | Container::ModalForeground => self.disabled, 239 | _ => self.text, 240 | }, 241 | width: match class { 242 | Container::Player 243 | | Container::PlayerGroup 244 | | Container::PlayerGroupControls 245 | | Container::PlayerGroupTitle 246 | | Container::ModalForeground => 1.0, 247 | _ => 0.0, 248 | }, 249 | radius: match class { 250 | Container::ModalForeground | Container::Player | Container::PlayerGroupControls => 10.0.into(), 251 | Container::PlayerGroup => Radius::new(10.0).top(0.0), 252 | Container::PlayerGroupTitle => Radius::new(10.0).bottom(0.0), 253 | Container::ModalBackground => 5.0.into(), 254 | Container::Tooltip => 20.0.into(), 255 | _ => 0.0.into(), 256 | }, 257 | }, 258 | text_color: match class { 259 | Container::Wrapper => None, 260 | _ => Some(self.text), 261 | }, 262 | shadow: Shadow { 263 | color: Color::TRANSPARENT, 264 | offset: Vector::ZERO, 265 | blur_radius: 0.0, 266 | }, 267 | } 268 | } 269 | } 270 | 271 | #[derive(Clone, Copy, Debug, Default)] 272 | pub struct Scrollable; 273 | impl scrollable::Catalog for Theme { 274 | type Class<'a> = Scrollable; 275 | 276 | fn default<'a>() -> Self::Class<'a> { 277 | Default::default() 278 | } 279 | 280 | fn style(&self, _class: &Self::Class<'_>, status: scrollable::Status) -> scrollable::Style { 281 | let active = scrollable::Style { 282 | container: container::Style::default(), 283 | vertical_rail: scrollable::Rail { 284 | background: Some(Color::TRANSPARENT.into()), 285 | border: Border { 286 | color: Color::TRANSPARENT, 287 | width: 0.0, 288 | radius: 5.0.into(), 289 | }, 290 | scroller: scrollable::Scroller { 291 | color: self.text.alpha(0.7), 292 | border: Border { 293 | color: Color::TRANSPARENT, 294 | width: 0.0, 295 | radius: 5.0.into(), 296 | }, 297 | }, 298 | }, 299 | horizontal_rail: scrollable::Rail { 300 | background: Some(Color::TRANSPARENT.into()), 301 | border: Border { 302 | color: Color::TRANSPARENT, 303 | width: 0.0, 304 | radius: 5.0.into(), 305 | }, 306 | scroller: scrollable::Scroller { 307 | color: self.text.alpha(0.7), 308 | border: Border { 309 | color: Color::TRANSPARENT, 310 | width: 0.0, 311 | radius: 5.0.into(), 312 | }, 313 | }, 314 | }, 315 | gap: None, 316 | }; 317 | 318 | match status { 319 | scrollable::Status::Active => active, 320 | scrollable::Status::Hovered { 321 | is_horizontal_scrollbar_hovered, 322 | is_vertical_scrollbar_hovered, 323 | } => { 324 | if !is_horizontal_scrollbar_hovered && !is_vertical_scrollbar_hovered { 325 | return active; 326 | } 327 | 328 | scrollable::Style { 329 | vertical_rail: scrollable::Rail { 330 | background: Some(self.text.alpha(0.4).into()), 331 | border: Border { 332 | color: self.text.alpha(0.8), 333 | ..active.vertical_rail.border 334 | }, 335 | ..active.vertical_rail 336 | }, 337 | horizontal_rail: scrollable::Rail { 338 | background: Some(self.text.alpha(0.4).into()), 339 | border: Border { 340 | color: self.text.alpha(0.8), 341 | ..active.horizontal_rail.border 342 | }, 343 | ..active.horizontal_rail 344 | }, 345 | ..active 346 | } 347 | } 348 | scrollable::Status::Dragged { .. } => self.style( 349 | _class, 350 | scrollable::Status::Hovered { 351 | is_horizontal_scrollbar_hovered: true, 352 | is_vertical_scrollbar_hovered: true, 353 | }, 354 | ), 355 | } 356 | } 357 | } 358 | 359 | #[derive(Clone, Copy, Debug, Default)] 360 | pub struct PickList; 361 | impl pick_list::Catalog for Theme { 362 | type Class<'a> = PickList; 363 | 364 | fn default<'a>() -> ::Class<'a> { 365 | Default::default() 366 | } 367 | 368 | fn style(&self, _class: &::Class<'_>, status: pick_list::Status) -> pick_list::Style { 369 | let active = pick_list::Style { 370 | border: Border { 371 | color: self.text.alpha(0.7), 372 | width: 1.0, 373 | radius: 5.0.into(), 374 | }, 375 | background: self.field.alpha(0.6).into(), 376 | text_color: self.text, 377 | placeholder_color: iced::Color::BLACK, 378 | handle_color: self.text, 379 | }; 380 | 381 | match status { 382 | pick_list::Status::Active => active, 383 | pick_list::Status::Hovered => pick_list::Style { 384 | background: self.field.into(), 385 | ..active 386 | }, 387 | pick_list::Status::Opened => active, 388 | } 389 | } 390 | } 391 | 392 | #[derive(Clone, Copy, Debug, Default)] 393 | pub struct Checkbox; 394 | impl checkbox::Catalog for Theme { 395 | type Class<'a> = Checkbox; 396 | 397 | fn default<'a>() -> Self::Class<'a> { 398 | Default::default() 399 | } 400 | 401 | fn style(&self, _class: &Self::Class<'_>, status: checkbox::Status) -> checkbox::Style { 402 | let active = checkbox::Style { 403 | background: self.field.alpha(0.6).into(), 404 | icon_color: self.text, 405 | border: Border { 406 | color: self.text.alpha(0.6), 407 | width: 1.0, 408 | radius: 5.0.into(), 409 | }, 410 | text_color: Some(self.text), 411 | }; 412 | 413 | match status { 414 | checkbox::Status::Active { .. } => active, 415 | checkbox::Status::Hovered { .. } => checkbox::Style { 416 | background: self.field.into(), 417 | ..active 418 | }, 419 | checkbox::Status::Disabled { .. } => checkbox::Style { 420 | background: match active.background { 421 | Background::Color(color) => Background::Color(Color { 422 | a: color.a * 0.5, 423 | ..color 424 | }), 425 | Background::Gradient(gradient) => Background::Gradient(gradient.scale_alpha(0.5)), 426 | }, 427 | ..active 428 | }, 429 | } 430 | } 431 | } 432 | 433 | #[derive(Clone, Copy, Debug, Default)] 434 | pub struct TextInput; 435 | impl text_input::Catalog for Theme { 436 | type Class<'a> = TextInput; 437 | 438 | fn default<'a>() -> Self::Class<'a> { 439 | Default::default() 440 | } 441 | 442 | fn style(&self, _class: &Self::Class<'_>, status: text_input::Status) -> text_input::Style { 443 | let active = text_input::Style { 444 | background: Color::TRANSPARENT.into(), 445 | border: Border { 446 | color: self.text.alpha(0.8), 447 | width: 1.0, 448 | radius: 5.0.into(), 449 | }, 450 | icon: self.negative, 451 | placeholder: self.text.alpha(0.5), 452 | value: self.text, 453 | selection: self.text_selection, 454 | }; 455 | 456 | match status { 457 | text_input::Status::Active => active, 458 | text_input::Status::Hovered | text_input::Status::Focused => text_input::Style { 459 | border: Border { 460 | color: self.text, 461 | ..active.border 462 | }, 463 | ..active 464 | }, 465 | text_input::Status::Disabled => text_input::Style { 466 | background: self.disabled.into(), 467 | value: self.text.alpha(0.5), 468 | ..active 469 | }, 470 | } 471 | } 472 | } 473 | 474 | #[derive(Clone, Copy, Debug, Default)] 475 | pub struct Slider; 476 | impl iced::widget::slider::Catalog for Theme { 477 | type Class<'a> = Slider; 478 | 479 | fn default<'a>() -> Self::Class<'a> { 480 | Default::default() 481 | } 482 | 483 | fn style(&self, _class: &Self::Class<'_>, status: slider::Status) -> slider::Style { 484 | let fade = 0.75; 485 | 486 | let active = slider::Style { 487 | rail: slider::Rail { 488 | backgrounds: (self.positive.alpha(fade).into(), self.field.alpha(fade).into()), 489 | width: 5.0, 490 | border: Border { 491 | color: self.field.alpha(fade), 492 | width: 1.0, 493 | radius: 5.0.into(), 494 | }, 495 | }, 496 | handle: slider::Handle { 497 | shape: slider::HandleShape::Circle { radius: 5.0 }, 498 | background: self.positive.alpha(fade).into(), 499 | border_width: 1.0, 500 | border_color: self.field.alpha(fade), 501 | }, 502 | }; 503 | 504 | match status { 505 | slider::Status::Active => active, 506 | slider::Status::Hovered | slider::Status::Dragged => slider::Style { 507 | rail: slider::Rail { 508 | backgrounds: (self.positive.into(), self.field.into()), 509 | ..active.rail 510 | }, 511 | handle: slider::Handle { 512 | background: self.positive.into(), 513 | border_color: self.field, 514 | ..active.handle 515 | }, 516 | }, 517 | } 518 | } 519 | } 520 | 521 | #[derive(Clone, Copy, Debug, Default)] 522 | pub struct Svg; 523 | impl svg::Catalog for Theme { 524 | type Class<'a> = Svg; 525 | 526 | fn default<'a>() -> Self::Class<'a> { 527 | Default::default() 528 | } 529 | 530 | fn style(&self, _class: &Self::Class<'_>, _status: svg::Status) -> svg::Style { 531 | svg::Style { color: None } 532 | } 533 | } 534 | 535 | #[derive(Clone, Copy, Debug, Default)] 536 | pub struct PaneGrid; 537 | impl pane_grid::Catalog for Theme { 538 | type Class<'a> = PaneGrid; 539 | 540 | fn default<'a>() -> ::Class<'a> { 541 | Default::default() 542 | } 543 | 544 | fn style(&self, _class: &::Class<'_>) -> pane_grid::Style { 545 | pane_grid::Style { 546 | hovered_region: pane_grid::Highlight { 547 | background: self.positive.alpha(0.5).into(), 548 | border: Border { 549 | color: Color::TRANSPARENT, 550 | width: 0.0, 551 | radius: 5.0.into(), 552 | }, 553 | }, 554 | hovered_split: pane_grid::Line { 555 | color: self.disabled, 556 | width: 2.0, 557 | }, 558 | picked_split: pane_grid::Line { 559 | color: self.disabled.alpha(0.8), 560 | width: 2.0, 561 | }, 562 | } 563 | } 564 | } 565 | 566 | #[derive(Clone, Copy, Debug, Default)] 567 | pub struct Rule; 568 | impl rule::Catalog for Theme { 569 | type Class<'a> = Rule; 570 | 571 | fn default<'a>() -> Self::Class<'a> { 572 | Default::default() 573 | } 574 | 575 | fn style(&self, _class: &Self::Class<'_>) -> rule::Style { 576 | rule::Style { 577 | color: self.disabled, 578 | width: 1, 579 | radius: 0.0.into(), 580 | fill_mode: rule::FillMode::Full, 581 | } 582 | } 583 | } 584 | -------------------------------------------------------------------------------- /src/gui/undoable.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | advanced::{ 3 | layout, renderer, 4 | widget::{Operation, Tree}, 5 | Clipboard, Layout, Shell, Widget, 6 | }, 7 | event::{self, Event}, 8 | keyboard::Key, 9 | mouse, overlay, Element, Length, Rectangle, 10 | }; 11 | 12 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 | pub enum Action { 14 | Undo, 15 | Redo, 16 | } 17 | 18 | #[allow(missing_debug_implementations)] 19 | pub struct Undoable<'a, Message, Theme, Renderer, F> 20 | where 21 | Message: Clone, 22 | F: Fn(Action) -> Message + 'a, 23 | { 24 | content: Element<'a, Message, Theme, Renderer>, 25 | on_change: F, 26 | } 27 | 28 | impl<'a, Message, Theme, Renderer, F> Undoable<'a, Message, Theme, Renderer, F> 29 | where 30 | Message: Clone, 31 | F: Fn(Action) -> Message + 'a, 32 | { 33 | pub fn new(content: T, on_change: F) -> Self 34 | where 35 | T: Into>, 36 | { 37 | Self { 38 | content: content.into(), 39 | on_change, 40 | } 41 | } 42 | } 43 | 44 | impl<'a, Message, Theme, Renderer, F> Widget for Undoable<'a, Message, Theme, Renderer, F> 45 | where 46 | Message: Clone, 47 | Renderer: iced::advanced::text::Renderer, 48 | F: Fn(Action) -> Message + 'a, 49 | { 50 | fn diff(&self, tree: &mut Tree) { 51 | self.content.as_widget().diff(tree) 52 | } 53 | 54 | fn size(&self) -> iced::Size { 55 | self.content.as_widget().size() 56 | } 57 | 58 | fn size_hint(&self) -> iced::Size { 59 | self.content.as_widget().size_hint() 60 | } 61 | 62 | fn state(&self) -> iced::advanced::widget::tree::State { 63 | self.content.as_widget().state() 64 | } 65 | 66 | fn tag(&self) -> iced::advanced::widget::tree::Tag { 67 | self.content.as_widget().tag() 68 | } 69 | 70 | fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { 71 | self.content.as_widget().layout(tree, renderer, limits) 72 | } 73 | 74 | fn operate(&self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) { 75 | self.content.as_widget().operate(tree, layout, renderer, operation) 76 | } 77 | 78 | fn on_event( 79 | &mut self, 80 | tree: &mut Tree, 81 | event: Event, 82 | layout: Layout<'_>, 83 | cursor: mouse::Cursor, 84 | renderer: &Renderer, 85 | clipboard: &mut dyn Clipboard, 86 | shell: &mut Shell<'_, Message>, 87 | viewport: &Rectangle, 88 | ) -> event::Status { 89 | if let Event::Keyboard(iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = &event { 90 | let focused = tree 91 | .state 92 | .downcast_ref::>() 93 | .is_focused(); 94 | if focused { 95 | match (key.as_ref(), modifiers.command(), modifiers.shift()) { 96 | (Key::Character("z"), true, false) => { 97 | shell.publish((self.on_change)(Action::Undo)); 98 | return event::Status::Captured; 99 | } 100 | (Key::Character("y"), true, false) | (Key::Character("z"), true, true) => { 101 | shell.publish((self.on_change)(Action::Redo)); 102 | return event::Status::Captured; 103 | } 104 | _ => (), 105 | }; 106 | } 107 | } 108 | 109 | self.content 110 | .as_widget_mut() 111 | .on_event(tree, event, layout, cursor, renderer, clipboard, shell, viewport) 112 | } 113 | 114 | fn mouse_interaction( 115 | &self, 116 | tree: &Tree, 117 | layout: Layout<'_>, 118 | cursor_position: mouse::Cursor, 119 | viewport: &Rectangle, 120 | renderer: &Renderer, 121 | ) -> mouse::Interaction { 122 | self.content 123 | .as_widget() 124 | .mouse_interaction(tree, layout, cursor_position, viewport, renderer) 125 | } 126 | 127 | fn draw( 128 | &self, 129 | tree: &Tree, 130 | renderer: &mut Renderer, 131 | theme: &Theme, 132 | style: &renderer::Style, 133 | layout: Layout<'_>, 134 | cursor: mouse::Cursor, 135 | viewport: &Rectangle, 136 | ) { 137 | self.content 138 | .as_widget() 139 | .draw(tree, renderer, theme, style, layout, cursor, viewport) 140 | } 141 | 142 | fn overlay<'b>( 143 | &'b mut self, 144 | tree: &'b mut Tree, 145 | layout: Layout<'_>, 146 | renderer: &Renderer, 147 | translation: iced::Vector, 148 | ) -> Option> { 149 | self.content 150 | .as_widget_mut() 151 | .overlay(tree, layout, renderer, translation) 152 | } 153 | } 154 | 155 | impl<'a, Message, Theme, Renderer, F> From> 156 | for Element<'a, Message, Theme, Renderer> 157 | where 158 | Message: 'a + Clone, 159 | Theme: 'a, 160 | Renderer: iced::advanced::text::Renderer + 'a, 161 | F: Fn(Action) -> Message + 'a, 162 | { 163 | fn from(undoable: Undoable<'a, Message, Theme, Renderer, F>) -> Self { 164 | Self::new(undoable) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/gui/widget.rs: -------------------------------------------------------------------------------- 1 | use iced::widget as w; 2 | 3 | use crate::gui::{common::Message, style::Theme}; 4 | 5 | pub type Renderer = iced::Renderer; 6 | 7 | pub type Element<'a> = iced::Element<'a, Message, Theme, Renderer>; 8 | 9 | pub type Button<'a> = w::Button<'a, Message, Theme, Renderer>; 10 | pub type Checkbox<'a> = w::Checkbox<'a, Message, Theme, Renderer>; 11 | pub type Column<'a> = w::Column<'a, Message, Theme, Renderer>; 12 | pub type Container<'a> = w::Container<'a, Message, Theme, Renderer>; 13 | pub type DropDown<'a> = crate::gui::dropdown::DropDown<'a, Message, Theme, Renderer>; 14 | pub type PaneGrid<'a> = w::PaneGrid<'a, Message, Theme, Renderer>; 15 | pub type PickList<'a, T, L, V> = w::PickList<'a, T, L, V, Message, Theme, Renderer>; 16 | pub type Responsive<'a> = w::Responsive<'a, Message, Theme, Renderer>; 17 | pub type Row<'a> = w::Row<'a, Message, Theme, Renderer>; 18 | pub type Scrollable<'a> = w::Scrollable<'a, Message, Theme, Renderer>; 19 | pub type Stack<'a> = w::Stack<'a, Message, Theme, Renderer>; 20 | pub type Text<'a> = w::Text<'a, Theme, Renderer>; 21 | pub type TextInput<'a> = w::TextInput<'a, Message, Theme, Renderer>; 22 | pub type Tooltip<'a> = w::Tooltip<'a, Message, Theme, Renderer>; 23 | pub type Undoable<'a, F> = crate::gui::undoable::Undoable<'a, Message, Theme, Renderer, F>; 24 | 25 | pub use w::Space; 26 | 27 | pub fn checkbox<'a>(label: impl Into, is_checked: bool, f: impl Fn(bool) -> Message + 'a) -> Checkbox<'a> { 28 | Checkbox::new(label, is_checked) 29 | .on_toggle(f) 30 | .size(20) 31 | .text_shaping(w::text::Shaping::Advanced) 32 | } 33 | 34 | pub fn pick_list<'a, T, L, V>( 35 | options: L, 36 | selected: Option, 37 | on_selected: impl Fn(T) -> Message + 'a, 38 | ) -> PickList<'a, T, L, V> 39 | where 40 | T: ToString + PartialEq + Clone, 41 | L: std::borrow::Borrow<[T]> + 'a, 42 | V: std::borrow::Borrow + 'a, 43 | Message: Clone, 44 | Renderer: iced::advanced::text::Renderer, 45 | { 46 | PickList::new(options, selected, on_selected) 47 | .text_shaping(w::text::Shaping::Advanced) 48 | .padding(5) 49 | } 50 | 51 | pub fn text<'a>(content: impl iced::widget::text::IntoFragment<'a>) -> Text<'a> { 52 | Text::new(content).shaping(w::text::Shaping::Advanced) 53 | } 54 | -------------------------------------------------------------------------------- /src/lang.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use fluent::{bundle::FluentBundle, FluentArgs, FluentResource}; 4 | use intl_memoizer::concurrent::IntlLangMemoizer; 5 | use regex::Regex; 6 | use std::sync::LazyLock; 7 | use unic_langid::LanguageIdentifier; 8 | 9 | use crate::prelude::Error; 10 | 11 | const VERSION: &str = "version"; 12 | 13 | /// Display language. 14 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 15 | pub enum Language { 16 | /// English 17 | #[default] 18 | #[serde(rename = "en-US")] 19 | English, 20 | 21 | /// French 22 | #[serde(rename = "fr-FR")] 23 | French, 24 | 25 | /// German 26 | #[serde(rename = "de-DE")] 27 | German, 28 | 29 | /// Polish 30 | #[serde(rename = "pl-PL")] 31 | Polish, 32 | 33 | /// Brazilian Portuguese 34 | #[serde(rename = "pt-BR")] 35 | PortugueseBrazilian, 36 | } 37 | 38 | impl Language { 39 | pub const ALL: &'static [Self] = &[ 40 | Self::German, 41 | Self::English, 42 | Self::French, 43 | Self::Polish, 44 | Self::PortugueseBrazilian, 45 | ]; 46 | 47 | pub fn id(&self) -> LanguageIdentifier { 48 | let id = match self { 49 | Self::English => "en-US", 50 | Self::French => "fr-FR", 51 | Self::German => "de-DE", 52 | Self::Polish => "pl-PL", 53 | Self::PortugueseBrazilian => "pt-BR", 54 | }; 55 | id.parse().unwrap() 56 | } 57 | 58 | fn name(&self) -> &'static str { 59 | match self { 60 | Self::English => "English", 61 | Self::French => "Français", 62 | Self::German => "Deutsch", 63 | Self::Polish => "Polski", 64 | Self::PortugueseBrazilian => "Português brasileiro", 65 | } 66 | } 67 | 68 | fn completion(&self) -> u8 { 69 | match self { 70 | Self::English => 100, 71 | Self::French => 2, 72 | Self::German => 2, 73 | Self::Polish => 87, 74 | Self::PortugueseBrazilian => 1, 75 | } 76 | } 77 | } 78 | 79 | impl ToString for Language { 80 | fn to_string(&self) -> String { 81 | match self { 82 | Self::English => self.name().to_string(), 83 | _ => format!("{} ({}%)", self.name(), self.completion()), 84 | } 85 | } 86 | } 87 | 88 | static LANGUAGE: Mutex = Mutex::new(Language::English); 89 | 90 | static BUNDLE: LazyLock>> = LazyLock::new(|| { 91 | let ftl = include_str!("../lang/en-US.ftl").to_owned(); 92 | let res = FluentResource::try_new(ftl).expect("Failed to parse Fluent file content."); 93 | 94 | let mut bundle = FluentBundle::new_concurrent(vec![Language::English.id()]); 95 | bundle.set_use_isolating(false); 96 | 97 | bundle 98 | .add_resource(res) 99 | .expect("Failed to add Fluent resources to the bundle."); 100 | 101 | Mutex::new(bundle) 102 | }); 103 | 104 | fn set_language(language: Language) { 105 | let mut bundle = BUNDLE.lock().unwrap(); 106 | 107 | let ftl = match language { 108 | Language::English => include_str!("../lang/en-US.ftl"), 109 | Language::French => include_str!("../lang/fr-FR.ftl"), 110 | Language::German => include_str!("../lang/de-DE.ftl"), 111 | Language::Polish => include_str!("../lang/pl-PL.ftl"), 112 | Language::PortugueseBrazilian => include_str!("../lang/pt-BR.ftl"), 113 | } 114 | .to_owned(); 115 | 116 | let res = FluentResource::try_new(ftl).expect("Failed to parse Fluent file content."); 117 | bundle.locales = vec![language.id()]; 118 | 119 | bundle.add_resource_overriding(res); 120 | 121 | let mut last_language = LANGUAGE.lock().unwrap(); 122 | *last_language = language; 123 | } 124 | 125 | static RE_EXTRA_SPACES: LazyLock = LazyLock::new(|| Regex::new(r"([^\r\n ]) {2,}").unwrap()); 126 | static RE_EXTRA_LINES: LazyLock = LazyLock::new(|| Regex::new(r"([^\r\n ])[\r\n]([^\r\n ])").unwrap()); 127 | static RE_EXTRA_PARAGRAPHS: LazyLock = LazyLock::new(|| Regex::new(r"([^\r\n ])[\r\n]{2,}([^\r\n ])").unwrap()); 128 | 129 | fn translate(id: &str) -> String { 130 | translate_args(id, &FluentArgs::new()) 131 | } 132 | 133 | fn translate_args(id: &str, args: &FluentArgs) -> String { 134 | let bundle = match BUNDLE.lock() { 135 | Ok(x) => x, 136 | Err(_) => return "fluent-cannot-lock".to_string(), 137 | }; 138 | 139 | let parts: Vec<&str> = id.splitn(2, '.').collect(); 140 | let (name, attr) = if parts.len() < 2 { 141 | (id, None) 142 | } else { 143 | (parts[0], Some(parts[1])) 144 | }; 145 | 146 | let message = match bundle.get_message(name) { 147 | Some(x) => x, 148 | None => return format!("fluent-no-message={}", name), 149 | }; 150 | 151 | let pattern = match attr { 152 | None => match message.value() { 153 | Some(x) => x, 154 | None => return format!("fluent-no-message-value={}", id), 155 | }, 156 | Some(attr) => match message.get_attribute(attr) { 157 | Some(x) => x.value(), 158 | None => return format!("fluent-no-attr={}", id), 159 | }, 160 | }; 161 | let mut errors = vec![]; 162 | let value = bundle.format_pattern(pattern, Some(args), &mut errors); 163 | 164 | RE_EXTRA_PARAGRAPHS 165 | .replace_all( 166 | &RE_EXTRA_LINES.replace_all(&RE_EXTRA_SPACES.replace_all(&value, "${1} "), "${1} ${2}"), 167 | "${1}\n\n${2}", 168 | ) 169 | .to_string() 170 | } 171 | 172 | pub fn set(language: Language) { 173 | set_language(Language::English); 174 | if language != Language::English { 175 | set_language(language); 176 | } 177 | } 178 | 179 | pub fn app_name() -> String { 180 | "Madamiru".to_string() 181 | } 182 | 183 | pub fn window_title() -> String { 184 | let name = app_name(); 185 | format!("{} v{}", name, *crate::prelude::VERSION) 186 | } 187 | 188 | pub fn field(text: &str) -> String { 189 | let language = LANGUAGE.lock().unwrap(); 190 | match *language { 191 | Language::French => format!("{} :", text), 192 | _ => format!("{}:", text), 193 | } 194 | } 195 | 196 | pub fn handle_error(error: &Error) -> String { 197 | let error = match error { 198 | Error::ConfigInvalid { why } => format!("{}\n\n{why}", tell::config_is_invalid()), 199 | Error::NoMediaFound => tell::no_media_found_in_sources(), 200 | Error::PlaylistInvalid { why } => format!("{}\n\n{why}", tell::playlist_is_invalid()), 201 | Error::UnableToOpenPath(path) => format!("{}\n\n{}", tell::unable_to_open_path(), path.render()), 202 | Error::UnableToOpenUrl(url) => format!("{}\n\n{}", tell::unable_to_open_url(), url), 203 | Error::UnableToSavePlaylist { why } => format!("{}\n\n{why}", tell::unable_to_save_playlist()), 204 | }; 205 | 206 | format!("{} {}", field(&thing::error()), error) 207 | } 208 | 209 | macro_rules! join { 210 | ($a:expr, $b:expr) => { 211 | format!("{} {}", $a, $b) 212 | }; 213 | } 214 | 215 | pub(crate) use join; 216 | 217 | pub mod thing { 218 | use super::*; 219 | 220 | pub fn application() -> String { 221 | translate("thing-application") 222 | } 223 | 224 | pub fn audio() -> String { 225 | translate("thing-audio") 226 | } 227 | 228 | pub fn content_fit() -> String { 229 | translate("thing-content-fit") 230 | } 231 | 232 | pub fn error() -> String { 233 | translate("thing-error") 234 | } 235 | 236 | pub fn glob() -> String { 237 | translate("thing-glob") 238 | } 239 | 240 | pub fn image() -> String { 241 | translate("thing-image") 242 | } 243 | 244 | pub fn items_per_line() -> String { 245 | translate("thing-items-per-line") 246 | } 247 | 248 | pub fn language() -> String { 249 | translate("thing-language") 250 | } 251 | 252 | pub fn layout() -> String { 253 | translate("thing-layout") 254 | } 255 | 256 | pub fn orientation() -> String { 257 | translate("thing-orientation") 258 | } 259 | 260 | pub fn path() -> String { 261 | translate("thing-path") 262 | } 263 | 264 | pub fn playlist() -> String { 265 | translate("thing-playlist") 266 | } 267 | 268 | pub fn settings() -> String { 269 | translate("thing-settings") 270 | } 271 | 272 | pub fn sources() -> String { 273 | translate("thing-sources") 274 | } 275 | 276 | pub fn theme() -> String { 277 | translate("thing-theme") 278 | } 279 | 280 | pub mod key { 281 | use super::*; 282 | 283 | pub fn shift() -> String { 284 | translate("thing-key-shift") 285 | } 286 | } 287 | } 288 | 289 | pub mod action { 290 | use super::*; 291 | 292 | pub fn add_player() -> String { 293 | translate("action-add-player") 294 | } 295 | 296 | pub fn cancel() -> String { 297 | translate("action-cancel") 298 | } 299 | 300 | pub fn check_for_updates() -> String { 301 | translate("action-check-for-updates") 302 | } 303 | 304 | pub fn close() -> String { 305 | translate("action-close") 306 | } 307 | 308 | pub fn confirm() -> String { 309 | translate("action-confirm") 310 | } 311 | 312 | pub fn confirm_when_discarding_unsaved_playlist() -> String { 313 | translate("action-confirm-when-discarding-unsaved-playlist") 314 | } 315 | 316 | pub fn crop() -> String { 317 | translate("action-crop") 318 | } 319 | 320 | pub fn exit_app() -> String { 321 | translate("action-exit-app") 322 | } 323 | 324 | pub fn jump_position() -> String { 325 | translate("action-jump-position") 326 | } 327 | 328 | pub fn mute() -> String { 329 | translate("action-mute") 330 | } 331 | 332 | pub fn open_folder() -> String { 333 | translate("action-open-folder") 334 | } 335 | 336 | pub fn open_file() -> String { 337 | translate("action-open-file") 338 | } 339 | 340 | pub fn open_playlist() -> String { 341 | translate("action-open-playlist") 342 | } 343 | 344 | pub fn pause() -> String { 345 | translate("action-pause") 346 | } 347 | 348 | pub fn pause_when_window_loses_focus() -> String { 349 | translate("action-pause-when-window-loses-focus") 350 | } 351 | 352 | pub fn play() -> String { 353 | translate("action-play") 354 | } 355 | 356 | pub fn play_for_this_many_seconds() -> String { 357 | translate("action-play-for-this-many-seconds") 358 | } 359 | 360 | pub fn save_playlist() -> String { 361 | translate("action-save-playlist") 362 | } 363 | 364 | pub fn save_playlist_as_new_file() -> String { 365 | translate("action-save-playlist-as-new-file") 366 | } 367 | 368 | pub fn scale() -> String { 369 | translate("action-scale") 370 | } 371 | 372 | pub fn scale_down() -> String { 373 | translate("action-scale-down") 374 | } 375 | 376 | pub fn select_folder() -> String { 377 | translate("action-select-folder") 378 | } 379 | 380 | pub fn select_file() -> String { 381 | translate("action-select-file") 382 | } 383 | 384 | pub fn shuffle() -> String { 385 | translate("action-shuffle") 386 | } 387 | 388 | pub fn split_horizontally() -> String { 389 | translate("action-split-horizontally") 390 | } 391 | 392 | pub fn split_vertically() -> String { 393 | translate("action-split-vertically") 394 | } 395 | 396 | pub fn start_new_playlist() -> String { 397 | translate("action-start-new-playlist") 398 | } 399 | 400 | pub fn stretch() -> String { 401 | translate("action-stretch") 402 | } 403 | 404 | pub fn unmute() -> String { 405 | translate("action-unmute") 406 | } 407 | 408 | pub fn view_releases() -> String { 409 | translate("action-view-releases") 410 | } 411 | } 412 | 413 | pub mod state { 414 | use super::*; 415 | 416 | pub fn dark() -> String { 417 | translate("state-dark") 418 | } 419 | 420 | pub fn horizontal() -> String { 421 | translate("state-horizontal") 422 | } 423 | 424 | pub fn light() -> String { 425 | translate("state-light") 426 | } 427 | 428 | pub fn vertical() -> String { 429 | translate("state-vertical") 430 | } 431 | } 432 | 433 | pub mod tell { 434 | use super::*; 435 | 436 | pub fn config_is_invalid() -> String { 437 | translate("tell-config-is-invalid") 438 | } 439 | 440 | pub fn player_will_loop() -> String { 441 | translate("tell-player-will-loop") 442 | } 443 | 444 | pub fn player_will_shuffle() -> String { 445 | translate("tell-player-will-shuffle") 446 | } 447 | 448 | pub fn playlist_has_unsaved_changes() -> String { 449 | translate("tell-playlist-has-unsaved-changes") 450 | } 451 | 452 | pub fn playlist_is_invalid() -> String { 453 | translate("tell-playlist-is-invalid") 454 | } 455 | 456 | pub fn new_version_available(version: &str) -> String { 457 | let mut args = FluentArgs::new(); 458 | args.set(VERSION, version); 459 | translate_args("tell-new-version-available", &args) 460 | } 461 | 462 | pub fn no_media_found_in_sources() -> String { 463 | translate("tell-no-media-found-in-sources") 464 | } 465 | 466 | #[allow(unused)] 467 | pub fn unable_to_determine_media_duration() -> String { 468 | translate("tell-unable-to-determine-media-duration") 469 | } 470 | 471 | pub fn unable_to_open_path() -> String { 472 | translate("tell-unable-to-open-path") 473 | } 474 | 475 | pub fn unable_to_open_url() -> String { 476 | translate("tell-unable-to-open-url") 477 | } 478 | 479 | pub fn unable_to_save_playlist() -> String { 480 | translate("tell-unable-to-save-playlist") 481 | } 482 | } 483 | 484 | pub mod ask { 485 | use super::*; 486 | 487 | pub fn discard_changes() -> String { 488 | translate("ask-discard-changes") 489 | } 490 | 491 | pub fn load_new_playlist_anyway() -> String { 492 | translate("ask-load-new-playlist-anyway") 493 | } 494 | 495 | pub fn view_release_notes() -> String { 496 | translate("ask-view-release-notes") 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::too_many_arguments, clippy::to_string_trait_impl)] 2 | 3 | mod cli; 4 | mod gui; 5 | mod lang; 6 | mod media; 7 | mod metadata; 8 | mod path; 9 | mod prelude; 10 | mod resource; 11 | 12 | #[cfg(test)] 13 | mod testing; 14 | 15 | use crate::{ 16 | gui::Flags, 17 | prelude::{app_dir, CONFIG_DIR, VERSION}, 18 | }; 19 | 20 | /// The logger handle must be retained until the application closes. 21 | /// https://docs.rs/flexi_logger/0.23.1/flexi_logger/error_info/index.html#write 22 | fn prepare_logging() -> Result { 23 | flexi_logger::Logger::try_with_env_or_str("madamiru=warn") 24 | .unwrap() 25 | .log_to_file(flexi_logger::FileSpec::default().directory(app_dir().as_std_path_buf().unwrap())) 26 | .write_mode(flexi_logger::WriteMode::BufferAndFlush) 27 | .rotate( 28 | flexi_logger::Criterion::Size(1024 * 1024 * 10), 29 | flexi_logger::Naming::Timestamps, 30 | flexi_logger::Cleanup::KeepLogFiles(4), 31 | ) 32 | .use_utc() 33 | .format_for_files(|w, now, record| { 34 | write!( 35 | w, 36 | "[{}] {} [{}] {}", 37 | now.format("%Y-%m-%dT%H:%M:%S%.3fZ"), 38 | record.level(), 39 | record.module_path().unwrap_or(""), 40 | &record.args(), 41 | ) 42 | }) 43 | .start() 44 | } 45 | 46 | /// Based on: https://github.com/Traverse-Research/panic-log/blob/874a61b24a8bc8f9b07f9c26dc10b13cbc2622f9/src/lib.rs#L26 47 | /// Modified to flush a provided log handle. 48 | fn prepare_panic_hook(handle: Option) { 49 | let original_hook = std::panic::take_hook(); 50 | std::panic::set_hook(Box::new(move |info| { 51 | let thread_name = std::thread::current().name().unwrap_or("").to_owned(); 52 | 53 | let location = if let Some(panic_location) = info.location() { 54 | format!( 55 | "{}:{}:{}", 56 | panic_location.file(), 57 | panic_location.line(), 58 | panic_location.column() 59 | ) 60 | } else { 61 | "".to_owned() 62 | }; 63 | let message = info.payload().downcast_ref::<&str>().unwrap_or(&""); 64 | 65 | let backtrace = std::backtrace::Backtrace::force_capture(); 66 | 67 | log::error!("thread '{thread_name}' panicked at {location}:\n{message}\nstack backtrace:\n{backtrace}"); 68 | 69 | if let Some(handle) = handle.clone() { 70 | handle.flush(); 71 | } 72 | 73 | original_hook(info); 74 | })); 75 | } 76 | 77 | fn prepare_winit() { 78 | if std::env::var("WGPU_POWER_PREF").is_err() { 79 | std::env::set_var("WGPU_POWER_PREF", "high"); 80 | } 81 | } 82 | 83 | /// Detach the current process from its console on Windows. 84 | /// 85 | /// ## Testing 86 | /// This has several edge cases and has been the source of multiple bugs. 87 | /// If you change this, be careful and make sure to test this matrix: 88 | /// 89 | /// * Arguments: 90 | /// * None (double click in Windows Explorer) 91 | /// * None (from console) 92 | /// * `--help` (has output, but before this function is called) 93 | /// * `schema config` (has output, after this function is called) 94 | /// * Consoles: 95 | /// * Command Prompt 96 | /// * PowerShell 97 | /// * Git Bash 98 | /// * Console host for double clicking in Windows Explorer: 99 | /// * Windows Console Host 100 | /// * Windows Terminal 101 | /// 102 | /// ## Alternatives 103 | /// We have tried `#![windows_subsystem = "windows"]` plus `AttachConsole`/`AllocConsole`, 104 | /// but that messes up the console output in Command Prompt and PowerShell 105 | /// (a new prompt line is shown, and then the output bleeds into that line). 106 | /// 107 | /// We have tried relaunching the program with a special environment variable, 108 | /// but that eventually raised a false positive from Windows Defender (`Win32/Wacapew.C!ml`). 109 | /// 110 | /// We may eventually want to try using a manifest to set ``, 111 | /// but that is not yet widely available: 112 | /// https://github.com/microsoft/terminal/blob/5383cb3a1bb8095e214f7d4da085ea4646db8868/doc/specs/%237335%20-%20Console%20Allocation%20Policy.md 113 | /// 114 | /// ## Considerations 115 | /// The current approach is to let the console appear and then immediately `FreeConsole`. 116 | /// Previously, Windows Terminal wouldn't remove the console in that case, 117 | /// but that has been fixed: https://github.com/microsoft/terminal/issues/16174 118 | /// 119 | /// There was also an issue where asynchronous Rclone commands would fail to spawn 120 | /// ("The request is not supported (os error 50)"), 121 | /// but that has been solved by resetting the standard device handles: 122 | /// https://github.com/rust-lang/rust/issues/113277 123 | /// 124 | /// Watch out for non-obvious code paths that may defeat detachment. 125 | /// flexi_logger's `colors` feature would cause the console to stick around 126 | /// if logging was enabled before detaching. 127 | #[cfg(target_os = "windows")] 128 | unsafe fn detach_console() { 129 | use windows::Win32::{ 130 | Foundation::HANDLE, 131 | System::Console::{FreeConsole, SetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}, 132 | }; 133 | 134 | fn tell(msg: &str) { 135 | eprintln!("{}", msg); 136 | log::error!("{}", msg); 137 | } 138 | 139 | if FreeConsole().is_err() { 140 | tell("Unable to detach the console"); 141 | std::process::exit(1); 142 | } 143 | if SetStdHandle(STD_INPUT_HANDLE, HANDLE::default()).is_err() { 144 | tell("Unable to reset stdin handle"); 145 | std::process::exit(1); 146 | } 147 | if SetStdHandle(STD_OUTPUT_HANDLE, HANDLE::default()).is_err() { 148 | tell("Unable to reset stdout handle"); 149 | std::process::exit(1); 150 | } 151 | if SetStdHandle(STD_ERROR_HANDLE, HANDLE::default()).is_err() { 152 | tell("Unable to reset stderr handle"); 153 | std::process::exit(1); 154 | } 155 | } 156 | 157 | fn main() { 158 | let mut failed = false; 159 | let args = cli::parse(); 160 | 161 | if let Some(config_dir) = args.as_ref().ok().and_then(|args| args.config.as_ref()) { 162 | *CONFIG_DIR.lock().unwrap() = Some(config_dir.clone()); 163 | } 164 | 165 | prepare_winit(); 166 | let logger = prepare_logging(); 167 | #[allow(clippy::useless_asref)] 168 | prepare_panic_hook(logger.as_ref().map(|x| x.clone()).ok()); 169 | let flush_logger = || { 170 | if let Ok(logger) = &logger { 171 | logger.flush(); 172 | } 173 | }; 174 | 175 | log::debug!("Version: {}", *VERSION); 176 | log::debug!("Invocation: {:?}", std::env::args()); 177 | 178 | let args = match args { 179 | Ok(x) => x, 180 | Err(e) => { 181 | match e.kind() { 182 | clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {} 183 | _ => { 184 | log::error!("CLI failed to parse: {e}"); 185 | } 186 | } 187 | flush_logger(); 188 | e.exit() 189 | } 190 | }; 191 | 192 | match args.sub { 193 | None => { 194 | // Do any extra CLI parsing before we detach the console. 195 | let mut sources = cli::parse_sources(args.sources); 196 | sources.extend(args.glob.into_iter().map(media::Source::new_glob)); 197 | 198 | #[cfg(target_os = "windows")] 199 | if std::env::var(crate::prelude::ENV_DEBUG).is_err() { 200 | unsafe { 201 | detach_console(); 202 | } 203 | } 204 | 205 | let flags = Flags { sources }; 206 | gui::run(flags); 207 | } 208 | Some(sub) => { 209 | if let Err(e) = cli::run(sub) { 210 | failed = true; 211 | eprintln!("{}", lang::handle_error(&e)); 212 | } 213 | } 214 | }; 215 | 216 | flush_logger(); 217 | 218 | if failed { 219 | std::process::exit(1); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/media.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use itertools::Itertools; 4 | 5 | use crate::{lang, path::StrictPath}; 6 | 7 | pub const MAX_INITIAL: usize = 1; 8 | 9 | mod placeholder { 10 | pub const PLAYLIST: &str = ""; 11 | } 12 | 13 | pub fn fill_placeholders_in_path(path: &StrictPath, playlist: Option<&StrictPath>) -> StrictPath { 14 | let playlist = playlist 15 | .and_then(|x| x.parent_if_file().ok()) 16 | .unwrap_or_else(StrictPath::cwd); 17 | path.replace_raw_prefix(placeholder::PLAYLIST, playlist.raw_ref()) 18 | } 19 | 20 | #[derive(Debug, Clone, Copy)] 21 | pub enum RefreshContext { 22 | Launch, 23 | Edit, 24 | Playlist, 25 | Automatic, 26 | } 27 | 28 | #[derive( 29 | Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema, 30 | )] 31 | #[serde(rename_all = "snake_case")] 32 | pub enum Source { 33 | Path { path: StrictPath }, 34 | Glob { pattern: String }, 35 | } 36 | 37 | impl Source { 38 | pub fn new_path(path: StrictPath) -> Self { 39 | Self::Path { path } 40 | } 41 | 42 | pub fn new_glob(pattern: String) -> Self { 43 | Self::Glob { pattern } 44 | } 45 | 46 | pub fn kind(&self) -> SourceKind { 47 | match self { 48 | Self::Path { .. } => SourceKind::Path, 49 | Self::Glob { .. } => SourceKind::Glob, 50 | } 51 | } 52 | 53 | pub fn set_kind(&mut self, kind: SourceKind) { 54 | let raw = self.raw(); 55 | 56 | match kind { 57 | SourceKind::Path => { 58 | *self = Self::new_path(StrictPath::new(raw)); 59 | } 60 | SourceKind::Glob => { 61 | *self = Self::new_glob(raw.to_string()); 62 | } 63 | } 64 | } 65 | 66 | pub fn path(&self) -> Option<&StrictPath> { 67 | match self { 68 | Self::Path { path } => Some(path), 69 | Self::Glob { .. } => None, 70 | } 71 | } 72 | 73 | pub fn is_empty(&self) -> bool { 74 | match self { 75 | Self::Path { path } => path.raw_ref().trim().is_empty(), 76 | Self::Glob { pattern } => pattern.trim().is_empty(), 77 | } 78 | } 79 | 80 | pub fn raw(&self) -> &str { 81 | match self { 82 | Self::Path { path } => path.raw_ref(), 83 | Self::Glob { pattern } => pattern, 84 | } 85 | } 86 | 87 | pub fn reset(&mut self, raw: String) { 88 | match self { 89 | Self::Path { path } => { 90 | path.reset(raw); 91 | } 92 | Self::Glob { pattern } => { 93 | *pattern = raw; 94 | } 95 | } 96 | } 97 | 98 | pub fn fill_placeholders(&self, playlist: &StrictPath) -> Self { 99 | match self { 100 | Self::Path { path } => Self::Path { 101 | path: fill_placeholders_in_path(path, Some(playlist)), 102 | }, 103 | Self::Glob { pattern } => Self::Glob { 104 | pattern: match pattern.strip_prefix(placeholder::PLAYLIST) { 105 | Some(suffix) => format!("{}{}", playlist.render(), suffix), 106 | None => pattern.clone(), 107 | }, 108 | }, 109 | } 110 | } 111 | 112 | pub fn has_playlist_placeholder(&self) -> bool { 113 | self.raw().contains(placeholder::PLAYLIST) 114 | } 115 | } 116 | 117 | impl Default for Source { 118 | fn default() -> Self { 119 | Self::Path { 120 | path: Default::default(), 121 | } 122 | } 123 | } 124 | 125 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] 126 | pub enum SourceKind { 127 | #[default] 128 | Path, 129 | Glob, 130 | } 131 | 132 | impl SourceKind { 133 | pub const ALL: &'static [Self] = &[Self::Path, Self::Glob]; 134 | } 135 | 136 | impl ToString for SourceKind { 137 | fn to_string(&self) -> String { 138 | match self { 139 | Self::Path => lang::thing::path(), 140 | Self::Glob => lang::thing::glob(), 141 | } 142 | } 143 | } 144 | 145 | #[derive(Debug)] 146 | enum Mime { 147 | /// From the `infer` crate. 148 | /// Based on magic bytes without system dependencies, but not exhaustive. 149 | Pure(&'static str), 150 | /// From the `tree_magic_mini` crate. 151 | /// Uses the system's shared database on Linux and Mac, 152 | /// but not viable for Windows without bundling GPL data. 153 | #[allow(unused)] 154 | Database(&'static str), 155 | /// From the `mime_guess` crate. 156 | /// Guesses based on the file extension. 157 | Extension(mime_guess::Mime), 158 | } 159 | 160 | impl Mime { 161 | fn essence(&self) -> &str { 162 | match self { 163 | Self::Pure(raw) => raw, 164 | Self::Database(raw) => raw, 165 | Self::Extension(mime) => mime.essence_str(), 166 | } 167 | } 168 | } 169 | 170 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 171 | pub enum Media { 172 | Image { 173 | path: StrictPath, 174 | }, 175 | Svg { 176 | path: StrictPath, 177 | }, 178 | Gif { 179 | path: StrictPath, 180 | }, 181 | #[cfg(feature = "audio")] 182 | Audio { 183 | path: StrictPath, 184 | }, 185 | #[cfg(feature = "video")] 186 | Video { 187 | path: StrictPath, 188 | }, 189 | } 190 | 191 | impl Media { 192 | pub fn path(&self) -> &StrictPath { 193 | match self { 194 | Self::Image { path } => path, 195 | Self::Svg { path } => path, 196 | Self::Gif { path } => path, 197 | #[cfg(feature = "audio")] 198 | Self::Audio { path } => path, 199 | #[cfg(feature = "video")] 200 | Self::Video { path } => path, 201 | } 202 | } 203 | 204 | fn identify(path: &StrictPath) -> Option { 205 | let inferrable = match path.as_std_path_buf() { 206 | Ok(pb) => pb, 207 | Err(e) => { 208 | log::error!("Unable to parse path: {path:?} | {e:?}"); 209 | return None; 210 | } 211 | }; 212 | 213 | #[allow(clippy::unnecessary_lazy_evaluations)] 214 | let mime = infer::get_from_path(&inferrable) 215 | .map_err(|e| { 216 | log::error!("Error inferring file type: {path:?} | {e:?}"); 217 | e 218 | }) 219 | .ok() 220 | .flatten() 221 | .map(|x| Mime::Pure(x.mime_type())) 222 | .or_else(|| { 223 | #[cfg(target_os = "windows")] 224 | { 225 | None 226 | } 227 | #[cfg(not(target_os = "windows"))] 228 | { 229 | tree_magic_mini::from_filepath(&inferrable).map(Mime::Database) 230 | } 231 | }) 232 | .or_else(|| mime_guess::from_path(&inferrable).first().map(Mime::Extension)); 233 | 234 | log::debug!("Inferred file type '{:?}': {path:?}", mime); 235 | 236 | mime.and_then(|mime| { 237 | let mime = mime.essence(); 238 | 239 | #[cfg(feature = "video")] 240 | if mime.starts_with("video/") { 241 | // The exact formats supported will depend on the user's GStreamer plugins, 242 | // so just go ahead and try it. Some that work by default on Windows: 243 | // * video/mp4 244 | // * video/mpeg 245 | // * video/quicktime 246 | // * video/webm 247 | // * video/x-m4v 248 | // * video/x-matroska 249 | // * video/x-msvideo 250 | return Some(Self::Video { 251 | path: path.normalized(), 252 | }); 253 | } 254 | 255 | let extension = path.file_extension().map(|x| x.to_lowercase()); 256 | 257 | match mime { 258 | #[cfg(feature = "audio")] 259 | "audio/mpeg" | "audio/m4a" | "audio/x-flac" | "audio/x-wav" => Some(Self::Audio { 260 | path: path.normalized(), 261 | }), 262 | "image/bmp" | "image/jpeg" | "image/png" | "image/tiff" | "image/vnd.microsoft.icon" | "image/webp" => { 263 | Some(Self::Image { 264 | path: path.normalized(), 265 | }) 266 | } 267 | "image/gif" => Some(Self::Gif { 268 | path: path.normalized(), 269 | }), 270 | "image/svg+xml" => Some(Self::Svg { 271 | path: path.normalized(), 272 | }), 273 | "text/xml" if extension.is_some_and(|ext| ext == "svg") => Some(Self::Svg { 274 | path: path.normalized(), 275 | }), 276 | _ => None, 277 | } 278 | }) 279 | } 280 | } 281 | 282 | pub type SourceMap = HashMap>; 283 | 284 | #[derive(Debug, Default, Clone)] 285 | pub struct Collection { 286 | media: SourceMap, 287 | errored: HashSet, 288 | } 289 | 290 | impl Collection { 291 | pub fn clear(&mut self) { 292 | self.media.clear(); 293 | } 294 | 295 | pub fn mark_error(&mut self, media: &Media) { 296 | self.errored.insert(media.clone()); 297 | } 298 | 299 | pub fn is_outdated(&self, media: &Media, sources: &[Source]) -> bool { 300 | if sources.is_empty() { 301 | return true; 302 | } 303 | 304 | sources 305 | .iter() 306 | .filter_map(|source| self.media.get(source)) 307 | .all(|known| !known.contains(media)) 308 | } 309 | 310 | pub fn find(sources: &[Source], playlist: Option) -> SourceMap { 311 | let mut media = SourceMap::new(); 312 | let playlist = playlist 313 | .and_then(|x| x.parent_if_file().ok()) 314 | .unwrap_or_else(StrictPath::cwd); 315 | 316 | for source in sources { 317 | media.insert(source.clone(), Self::find_in_source(source, Some(&playlist))); 318 | } 319 | 320 | media 321 | } 322 | 323 | fn find_in_source(source: &Source, playlist: Option<&StrictPath>) -> HashSet { 324 | log::debug!("Finding media in source: {source:?}, playlist: {playlist:?}"); 325 | 326 | let source = match playlist { 327 | Some(playlist) => source.fill_placeholders(playlist), 328 | None => source.clone(), 329 | }; 330 | log::debug!("Source with placeholders filled: {source:?}"); 331 | 332 | match &source { 333 | Source::Path { path } => { 334 | if path.is_file() { 335 | log::debug!("Source is file"); 336 | match Media::identify(path) { 337 | Some(source) => HashSet::from_iter([source]), 338 | None => HashSet::new(), 339 | } 340 | } else if path.is_dir() { 341 | log::debug!("Source is directory"); 342 | path.joined("*") 343 | .glob() 344 | .into_iter() 345 | .filter(|x| x.is_file()) 346 | .filter_map(|path| Media::identify(&path)) 347 | .collect() 348 | } else if path.is_symlink() { 349 | log::debug!("Source is symlink"); 350 | match path.interpreted() { 351 | Ok(path) => Self::find_in_source(&Source::new_path(path), None), 352 | Err(_) => HashSet::new(), 353 | } 354 | } else { 355 | log::debug!("Source is unknown path"); 356 | HashSet::new() 357 | } 358 | } 359 | Source::Glob { pattern } => { 360 | log::debug!("Source is glob"); 361 | let mut media = HashSet::new(); 362 | for path in StrictPath::new(pattern).glob() { 363 | media.extend(Self::find_in_source(&Source::new_path(path), None)); 364 | } 365 | media 366 | } 367 | } 368 | } 369 | 370 | pub fn update(&mut self, new: SourceMap, context: RefreshContext) { 371 | match context { 372 | RefreshContext::Launch | RefreshContext::Playlist | RefreshContext::Automatic => { 373 | self.media = new; 374 | } 375 | RefreshContext::Edit => { 376 | self.media.extend(new); 377 | } 378 | } 379 | } 380 | 381 | pub fn new_first(&self, sources: &[Source], take: usize, old: HashSet<&Media>) -> Option> { 382 | use rand::seq::SliceRandom; 383 | 384 | let mut media: Vec<_> = sources 385 | .iter() 386 | .filter_map(|source| self.media.get(source)) 387 | .flatten() 388 | .unique() 389 | .collect(); 390 | media.shuffle(&mut rand::rng()); 391 | 392 | let media: Vec<_> = media 393 | .iter() 394 | .filter(|media| !self.errored.contains(media) && !old.contains(*media)) 395 | .chain( 396 | media 397 | .iter() 398 | .filter(|media| !self.errored.contains(media) && old.contains(*media)), 399 | ) 400 | .take(take) 401 | .cloned() 402 | .cloned() 403 | .collect(); 404 | 405 | (!media.is_empty()).then_some(media) 406 | } 407 | 408 | pub fn one_new(&self, sources: &[Source], old: HashSet<&Media>) -> Option { 409 | use rand::seq::SliceRandom; 410 | 411 | let mut media: Vec<_> = sources 412 | .iter() 413 | .filter_map(|source| self.media.get(source)) 414 | .flatten() 415 | .unique() 416 | .collect(); 417 | media.shuffle(&mut rand::rng()); 418 | 419 | media 420 | .into_iter() 421 | .find(|media| !self.errored.contains(media) && !old.contains(media)) 422 | .cloned() 423 | } 424 | } 425 | 426 | #[cfg(test)] 427 | mod tests { 428 | use super::*; 429 | use pretty_assertions::assert_eq; 430 | 431 | #[test] 432 | fn can_fill_placeholders_in_path_with_match() { 433 | let source = Source::new_path(StrictPath::new(format!("{}/foo", placeholder::PLAYLIST))); 434 | let playlist = StrictPath::new("/tmp"); 435 | let filled = Source::new_path(StrictPath::new("/tmp/foo")); 436 | assert_eq!(filled, source.fill_placeholders(&playlist)) 437 | } 438 | 439 | #[test] 440 | fn can_fill_placeholders_in_path_without_match() { 441 | let source = Source::new_path(StrictPath::new(format!("/{}/foo", placeholder::PLAYLIST))); 442 | let playlist = StrictPath::new("/tmp"); 443 | assert_eq!(source, source.fill_placeholders(&playlist)) 444 | } 445 | 446 | #[test] 447 | fn can_fill_placeholders_in_glob_with_match() { 448 | let source = Source::new_glob(format!("{}/foo", placeholder::PLAYLIST)); 449 | let playlist = StrictPath::new("/tmp"); 450 | let filled = Source::new_glob("/tmp/foo".to_string()); 451 | assert_eq!(filled, source.fill_placeholders(&playlist)) 452 | } 453 | 454 | #[test] 455 | fn can_fill_placeholders_in_glob_without_match() { 456 | let source = Source::new_glob(format!("/{}/foo", placeholder::PLAYLIST)); 457 | let playlist = StrictPath::new("/tmp"); 458 | assert_eq!(source, source.fill_placeholders(&playlist)) 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /src/metadata.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 2 | pub struct Release { 3 | pub version: semver::Version, 4 | pub url: String, 5 | } 6 | 7 | impl Release { 8 | const URL: &'static str = "https://api.github.com/repos/mtkennerly/madamiru/releases/latest"; 9 | 10 | pub async fn fetch() -> Result { 11 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 12 | pub struct Response { 13 | pub html_url: String, 14 | pub tag_name: String, 15 | } 16 | 17 | let req = reqwest::Client::new() 18 | .get(Self::URL) 19 | .header(reqwest::header::USER_AGENT, &*crate::prelude::USER_AGENT); 20 | let res = req.send().await?; 21 | 22 | match res.status() { 23 | reqwest::StatusCode::OK => { 24 | let bytes = res.bytes().await?.to_vec(); 25 | let raw = String::from_utf8(bytes)?; 26 | let parsed = serde_json::from_str::(&raw)?; 27 | 28 | Ok(Self { 29 | version: semver::Version::parse(parsed.tag_name.trim_start_matches('v'))?, 30 | url: parsed.html_url, 31 | }) 32 | } 33 | code => Err(format!("status code: {code:?}").into()), 34 | } 35 | } 36 | 37 | pub fn fetch_sync() -> Result { 38 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 39 | pub struct Response { 40 | pub html_url: String, 41 | pub tag_name: String, 42 | } 43 | 44 | let req = reqwest::blocking::Client::new() 45 | .get(Self::URL) 46 | .header(reqwest::header::USER_AGENT, &*crate::prelude::USER_AGENT); 47 | let res = req.send()?; 48 | 49 | match res.status() { 50 | reqwest::StatusCode::OK => { 51 | let bytes = res.bytes()?.to_vec(); 52 | let raw = String::from_utf8(bytes)?; 53 | let parsed = serde_json::from_str::(&raw)?; 54 | 55 | Ok(Self { 56 | version: semver::Version::parse(parsed.tag_name.trim_start_matches('v'))?, 57 | url: parsed.html_url, 58 | }) 59 | } 60 | code => Err(format!("status code: {code:?}").into()), 61 | } 62 | } 63 | 64 | pub fn is_update(&self) -> bool { 65 | if let Ok(current) = semver::Version::parse(*crate::VERSION) { 66 | self.version > current 67 | } else { 68 | false 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::Mutex}; 2 | 3 | use std::sync::LazyLock; 4 | 5 | use crate::path::CommonPath; 6 | pub use crate::path::StrictPath; 7 | 8 | pub static VERSION: LazyLock<&'static str> = 9 | LazyLock::new(|| option_env!("MADAMIRU_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); 10 | pub static USER_AGENT: LazyLock = LazyLock::new(|| format!("madamiru/{}", *VERSION)); 11 | pub static CANONICAL_VERSION: LazyLock<(u32, u32, u32)> = LazyLock::new(|| { 12 | let version_parts: Vec = env!("CARGO_PKG_VERSION") 13 | .split('.') 14 | .map(|x| x.parse().unwrap_or(0)) 15 | .collect(); 16 | if version_parts.len() != 3 { 17 | (0, 0, 0) 18 | } else { 19 | (version_parts[0], version_parts[1], version_parts[2]) 20 | } 21 | }); 22 | 23 | pub type AnyError = Box; 24 | 25 | pub const APP_DIR_NAME: &str = "com.mtkennerly.madamiru"; 26 | #[allow(unused)] 27 | pub const LINUX_APP_ID: &str = "com.mtkennerly.madamiru"; 28 | const PORTABLE_FLAG_FILE_NAME: &str = "madamiru.portable"; 29 | 30 | pub static STEAM_DECK: LazyLock = 31 | LazyLock::new(|| cfg!(target_os = "linux") && StrictPath::new("/home/deck".to_string()).exists()); 32 | 33 | pub static CONFIG_DIR: Mutex> = Mutex::new(None); 34 | 35 | #[allow(unused)] 36 | pub const ENV_DEBUG: &str = "MADAMIRU_DEBUG"; 37 | 38 | #[derive(Clone, Debug, PartialEq, Eq)] 39 | pub enum Error { 40 | ConfigInvalid { why: String }, 41 | NoMediaFound, 42 | PlaylistInvalid { why: String }, 43 | UnableToOpenPath(StrictPath), 44 | UnableToOpenUrl(String), 45 | UnableToSavePlaylist { why: String }, 46 | } 47 | 48 | pub fn app_dir() -> StrictPath { 49 | if let Some(dir) = CONFIG_DIR.lock().unwrap().as_ref() { 50 | return StrictPath::from(dir.clone()); 51 | } 52 | 53 | if let Ok(mut flag) = std::env::current_exe() { 54 | flag.pop(); 55 | flag.push(PORTABLE_FLAG_FILE_NAME); 56 | if flag.exists() { 57 | flag.pop(); 58 | return StrictPath::from(flag); 59 | } 60 | } 61 | 62 | StrictPath::new(format!("{}/{}", CommonPath::Config.get().unwrap(), APP_DIR_NAME)) 63 | } 64 | 65 | pub fn timestamp_mmss(seconds: u64) -> String { 66 | let minutes = seconds / 60; 67 | let seconds = seconds % 60; 68 | 69 | format!("{minutes:02}:{seconds:02}") 70 | } 71 | 72 | pub fn timestamp_hhmmss(mut seconds: u64) -> String { 73 | let hours = seconds / (60 * 60); 74 | seconds %= 60 * 60; 75 | 76 | let minutes = seconds / 60; 77 | seconds %= 60; 78 | 79 | format!("{hours:02}:{minutes:02}:{seconds:02}") 80 | } 81 | 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 83 | pub enum Change { 84 | Same, 85 | Different, 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use super::*; 91 | use pretty_assertions::assert_eq; 92 | use test_case::test_case; 93 | 94 | #[test_case(0, "00:00")] 95 | #[test_case(9, "00:09")] 96 | #[test_case(10, "00:10")] 97 | #[test_case(60, "01:00")] 98 | #[test_case(60 * 60 + 1, "60:01")] 99 | pub fn can_format_timestamp_mmss(seconds: u64, formatted: &str) { 100 | assert_eq!(formatted, timestamp_mmss(seconds)); 101 | } 102 | 103 | #[test_case(0, "00:00:00")] 104 | #[test_case(9, "00:00:09")] 105 | #[test_case(10, "00:00:10")] 106 | #[test_case(60, "00:01:00")] 107 | #[test_case(60 * 60, "01:00:00")] 108 | #[test_case(60 * 60 + 1, "01:00:01")] 109 | #[test_case(60 * 60 * 2 - 1, "01:59:59")] 110 | pub fn can_format_timestamp_hhmmss(seconds: u64, formatted: &str) { 111 | assert_eq!(formatted, timestamp_hhmmss(seconds)); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/resource.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod config; 3 | pub mod playlist; 4 | 5 | use crate::prelude::{app_dir, AnyError, StrictPath}; 6 | 7 | pub trait ResourceFile 8 | where 9 | Self: Default + serde::de::DeserializeOwned, 10 | { 11 | const FILE_NAME: &'static str; 12 | 13 | fn path() -> StrictPath { 14 | app_dir().joined(Self::FILE_NAME) 15 | } 16 | 17 | /// If the resource file does not exist, use default data and apply these modifications. 18 | fn initialize(self) -> Self { 19 | self 20 | } 21 | 22 | /// Update any legacy settings on load. 23 | fn migrate(self) -> Self { 24 | self 25 | } 26 | 27 | fn load() -> Result { 28 | Self::load_from(&Self::path()) 29 | } 30 | 31 | fn load_from(path: &StrictPath) -> Result { 32 | if !path.exists() { 33 | return Ok(Self::default().initialize()); 34 | } 35 | let content = Self::load_raw(path)?; 36 | Self::load_from_string(&content) 37 | } 38 | 39 | fn load_raw(path: &StrictPath) -> Result { 40 | path.try_read() 41 | } 42 | 43 | fn load_from_string(content: &str) -> Result { 44 | Ok(ResourceFile::migrate(serde_yaml::from_str(content)?)) 45 | } 46 | } 47 | 48 | pub trait SaveableResourceFile 49 | where 50 | Self: ResourceFile + serde::Serialize, 51 | { 52 | fn save(&self) { 53 | let new_content = serde_yaml::to_string(&self).unwrap(); 54 | 55 | if let Ok(old_content) = Self::load_raw(&Self::path()) { 56 | if old_content == new_content { 57 | return; 58 | } 59 | } 60 | 61 | if Self::path().create_parent_dir().is_ok() { 62 | let _ = Self::path().write_with_content(&new_content); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/resource/cache.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | prelude::CANONICAL_VERSION, 3 | resource::{config::Config, ResourceFile, SaveableResourceFile}, 4 | }; 5 | 6 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 7 | #[serde(default)] 8 | pub struct Cache { 9 | pub version: Option<(u32, u32, u32)>, 10 | pub release: Release, 11 | } 12 | 13 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 14 | #[serde(default)] 15 | pub struct Release { 16 | pub checked: chrono::DateTime, 17 | pub latest: Option, 18 | } 19 | 20 | impl ResourceFile for Cache { 21 | const FILE_NAME: &'static str = "cache.yaml"; 22 | } 23 | 24 | impl SaveableResourceFile for Cache {} 25 | 26 | impl Cache { 27 | pub fn migrate_config(mut self, config: &mut Config) -> Self { 28 | let mut updated = false; 29 | 30 | if self.version != Some(*CANONICAL_VERSION) { 31 | self.version = Some(*CANONICAL_VERSION); 32 | updated = true; 33 | } 34 | 35 | if updated { 36 | self.save(); 37 | config.save(); 38 | } 39 | 40 | self 41 | } 42 | 43 | pub fn should_check_app_update(&self) -> bool { 44 | let now = chrono::offset::Utc::now(); 45 | now.signed_duration_since(self.release.checked).num_hours() >= 24 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/resource/config.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use crate::{ 4 | lang::{self, Language}, 5 | prelude::{app_dir, Error, StrictPath}, 6 | resource::{ResourceFile, SaveableResourceFile}, 7 | }; 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum Event { 11 | Theme(Theme), 12 | Language(Language), 13 | CheckRelease(bool), 14 | ImageDurationRaw(String), 15 | PauseWhenWindowLosesFocus(bool), 16 | ConfirmWhenDiscardingUnsavedPlaylist(bool), 17 | } 18 | 19 | /// Settings for `config.yaml` 20 | #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 21 | #[serde(default)] 22 | pub struct Config { 23 | pub release: Release, 24 | pub view: View, 25 | pub playback: Playback, 26 | } 27 | 28 | impl ResourceFile for Config { 29 | const FILE_NAME: &'static str = "config.yaml"; 30 | } 31 | 32 | impl SaveableResourceFile for Config {} 33 | 34 | impl Config { 35 | fn file_archived_invalid() -> StrictPath { 36 | app_dir().joined("config.invalid.yaml") 37 | } 38 | 39 | pub fn load() -> Result { 40 | ResourceFile::load().map_err(|e| Error::ConfigInvalid { why: format!("{}", e) }) 41 | } 42 | 43 | pub fn archive_invalid() -> Result<(), Box> { 44 | Self::path().move_to(&Self::file_archived_invalid())?; 45 | Ok(()) 46 | } 47 | } 48 | 49 | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 50 | #[serde(default)] 51 | pub struct Release { 52 | /// Whether to check for new releases. 53 | /// If enabled, the application will check at most once every 24 hours. 54 | pub check: bool, 55 | } 56 | 57 | impl Default for Release { 58 | fn default() -> Self { 59 | Self { check: true } 60 | } 61 | } 62 | 63 | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 64 | #[serde(default)] 65 | pub struct View { 66 | pub language: Language, 67 | pub theme: Theme, 68 | pub confirm_discard_playlist: bool, 69 | } 70 | 71 | impl Default for View { 72 | fn default() -> Self { 73 | Self { 74 | language: Default::default(), 75 | theme: Default::default(), 76 | confirm_discard_playlist: true, 77 | } 78 | } 79 | } 80 | 81 | /// Visual theme. 82 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 83 | #[serde(rename_all = "snake_case")] 84 | pub enum Theme { 85 | Light, 86 | #[default] 87 | Dark, 88 | } 89 | 90 | impl Theme { 91 | pub const ALL: &'static [Self] = &[Self::Light, Self::Dark]; 92 | } 93 | 94 | impl ToString for Theme { 95 | fn to_string(&self) -> String { 96 | match self { 97 | Self::Light => lang::state::light(), 98 | Self::Dark => lang::state::dark(), 99 | } 100 | } 101 | } 102 | 103 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 104 | #[serde(default)] 105 | pub struct Playback { 106 | #[serde(skip)] 107 | pub paused: bool, 108 | /// Whether all players are muted. 109 | pub muted: bool, 110 | /// Volume level when not muted. 1.0 is 100%, 0.01 is 1%. 111 | pub volume: f32, 112 | /// How long to show images, in seconds. 113 | pub image_duration: NonZeroUsize, 114 | /// Whether to pause when window loses focus. 115 | pub pause_on_unfocus: bool, 116 | } 117 | 118 | impl Playback { 119 | pub fn with_paused(&self, paused: bool) -> Self { 120 | Self { paused, ..self.clone() } 121 | } 122 | 123 | pub fn with_paused_maybe(&self, paused: Option) -> Self { 124 | Self { 125 | paused: paused.unwrap_or(self.paused), 126 | ..self.clone() 127 | } 128 | } 129 | 130 | pub fn with_muted(&self, muted: bool) -> Self { 131 | Self { muted, ..self.clone() } 132 | } 133 | 134 | pub fn with_muted_maybe(&self, muted: Option) -> Self { 135 | Self { 136 | muted: muted.unwrap_or(self.muted), 137 | ..self.clone() 138 | } 139 | } 140 | } 141 | 142 | impl Default for Playback { 143 | fn default() -> Self { 144 | Self { 145 | paused: false, 146 | muted: false, 147 | volume: 1.0, 148 | image_duration: NonZeroUsize::new(10).unwrap(), 149 | pause_on_unfocus: false, 150 | } 151 | } 152 | } 153 | 154 | #[cfg(test)] 155 | mod tests { 156 | use pretty_assertions::assert_eq; 157 | 158 | use super::*; 159 | 160 | #[test] 161 | fn can_parse_minimal_config() { 162 | let config = Config::load_from_string("{}").unwrap(); 163 | 164 | assert_eq!(Config::default(), config); 165 | } 166 | 167 | #[test] 168 | fn can_parse_optional_fields_when_present_in_config() { 169 | let config = Config::load_from_string( 170 | r#" 171 | release: 172 | check: false 173 | view: 174 | theme: light 175 | confirm_discard_playlist: false 176 | playback: 177 | muted: true 178 | volume: 0.5 179 | image_duration: 2 180 | pause_on_unfocus: true 181 | "#, 182 | ) 183 | .unwrap(); 184 | 185 | assert_eq!( 186 | Config { 187 | release: Release { check: false }, 188 | view: View { 189 | language: Language::English, 190 | theme: Theme::Light, 191 | confirm_discard_playlist: false 192 | }, 193 | playback: Playback { 194 | paused: false, 195 | muted: true, 196 | volume: 0.5, 197 | image_duration: NonZeroUsize::new(2).unwrap(), 198 | pause_on_unfocus: true, 199 | }, 200 | }, 201 | config, 202 | ); 203 | } 204 | 205 | #[test] 206 | fn can_be_serialized() { 207 | assert_eq!( 208 | r#" 209 | --- 210 | release: 211 | check: true 212 | view: 213 | language: en-US 214 | theme: dark 215 | confirm_discard_playlist: true 216 | playback: 217 | muted: false 218 | volume: 1.0 219 | image_duration: 10 220 | pause_on_unfocus: false 221 | "# 222 | .trim(), 223 | serde_yaml::to_string(&Config::default()).unwrap().trim(), 224 | ); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/resource/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use itertools::Itertools; 4 | 5 | use crate::{ 6 | lang, media, 7 | prelude::{Error, StrictPath}, 8 | resource::ResourceFile, 9 | }; 10 | 11 | const HINT: &str = "# madamiru-playlist"; 12 | 13 | /// Settings for a playlist 14 | #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 15 | #[serde(default)] 16 | pub struct Playlist { 17 | pub layout: Layout, 18 | } 19 | 20 | impl ResourceFile for Playlist { 21 | const FILE_NAME: &'static str = "playlist.madamiru"; 22 | } 23 | 24 | impl Playlist { 25 | pub const EXTENSION: &'static str = "madamiru"; 26 | 27 | pub fn new(layout: Layout) -> Self { 28 | Self { layout } 29 | } 30 | 31 | pub fn load_from(path: &StrictPath) -> Result { 32 | let content = Self::load_raw(path).map_err(|e| Error::PlaylistInvalid { why: e.to_string() })?; 33 | let parsed = Self::load_from_string(&content).map_err(|e| Error::PlaylistInvalid { why: e.to_string() })?; 34 | Ok(parsed) 35 | } 36 | 37 | pub fn save_to(&self, path: &StrictPath) -> Result<(), Error> { 38 | let new_content = self.serialize(); 39 | 40 | if let Ok(old_content) = Self::load_raw(path) { 41 | if old_content == new_content { 42 | return Ok(()); 43 | } 44 | } 45 | 46 | path.create_parent_dir() 47 | .map_err(|e| Error::UnableToSavePlaylist { why: e.to_string() })?; 48 | path.write_with_content(&self.serialize()) 49 | .map_err(|e| Error::UnableToSavePlaylist { why: e.to_string() })?; 50 | 51 | Ok(()) 52 | } 53 | 54 | pub fn serialize(&self) -> String { 55 | serde_yaml::to_string(&self) 56 | .unwrap() 57 | .replacen("---", &format!("---\n{HINT}"), 1) 58 | } 59 | 60 | pub fn sources(&self) -> Vec { 61 | self.layout.sources() 62 | } 63 | } 64 | 65 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 66 | #[serde(rename_all = "snake_case")] 67 | pub enum Layout { 68 | Split(Split), 69 | Group(Group), 70 | } 71 | 72 | impl Layout { 73 | pub fn sources(&self) -> Vec { 74 | match self { 75 | Layout::Split(split) => split 76 | .first 77 | .sources() 78 | .into_iter() 79 | .chain(split.second.sources()) 80 | .unique() 81 | .collect(), 82 | Layout::Group(group) => group.sources.iter().unique().cloned().collect(), 83 | } 84 | } 85 | } 86 | 87 | impl Default for Layout { 88 | fn default() -> Self { 89 | Self::Group(Group::default()) 90 | } 91 | } 92 | 93 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 94 | #[serde(default)] 95 | pub struct Split { 96 | pub axis: SplitAxis, 97 | pub ratio: f32, 98 | pub first: Box, 99 | pub second: Box, 100 | } 101 | 102 | impl Default for Split { 103 | fn default() -> Self { 104 | Self { 105 | axis: Default::default(), 106 | ratio: 0.5, 107 | first: Default::default(), 108 | second: Default::default(), 109 | } 110 | } 111 | } 112 | 113 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 114 | #[serde(rename_all = "snake_case")] 115 | pub enum SplitAxis { 116 | #[default] 117 | Horizontal, 118 | Vertical, 119 | } 120 | 121 | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 122 | #[serde(default)] 123 | pub struct Group { 124 | pub sources: Vec, 125 | pub max_media: usize, 126 | pub content_fit: ContentFit, 127 | pub orientation: Orientation, 128 | pub orientation_limit: OrientationLimit, 129 | } 130 | 131 | impl Default for Group { 132 | fn default() -> Self { 133 | Self { 134 | sources: Default::default(), 135 | max_media: 1, 136 | content_fit: Default::default(), 137 | orientation: Default::default(), 138 | orientation_limit: Default::default(), 139 | } 140 | } 141 | } 142 | 143 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 144 | #[serde(rename_all = "snake_case")] 145 | pub enum Orientation { 146 | #[default] 147 | Horizontal, 148 | Vertical, 149 | } 150 | 151 | impl Orientation { 152 | pub const ALL: &'static [Self] = &[Self::Horizontal, Self::Vertical]; 153 | } 154 | 155 | impl ToString for Orientation { 156 | fn to_string(&self) -> String { 157 | match self { 158 | Self::Horizontal => lang::state::horizontal(), 159 | Self::Vertical => lang::state::vertical(), 160 | } 161 | } 162 | } 163 | 164 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 165 | #[serde(rename_all = "snake_case")] 166 | pub enum OrientationLimit { 167 | #[default] 168 | Automatic, 169 | Fixed(NonZeroUsize), 170 | } 171 | 172 | impl OrientationLimit { 173 | pub const DEFAULT_FIXED: usize = 4; 174 | 175 | pub fn default_fixed() -> NonZeroUsize { 176 | NonZeroUsize::new(Self::DEFAULT_FIXED).unwrap() 177 | } 178 | 179 | pub fn is_fixed(&self) -> bool { 180 | match self { 181 | Self::Automatic => false, 182 | Self::Fixed(_) => true, 183 | } 184 | } 185 | } 186 | 187 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] 188 | #[serde(rename_all = "snake_case")] 189 | pub enum ContentFit { 190 | /// Scale the media up or down to fill as much of the available space as possible 191 | /// while maintaining the media's aspect ratio. 192 | #[default] 193 | Scale, 194 | 195 | /// Scale the media down to fill as much of the available space as possible 196 | /// while maintaining the media's aspect ratio. 197 | /// Don't scale up if it's smaller than the available space. 198 | ScaleDown, 199 | 200 | /// Crop the media to fill all of the available space. 201 | /// Maintain the aspect ratio, cutting off parts of the media as needed to fit. 202 | Crop, 203 | 204 | /// Stretch the media to fill all of the available space. 205 | /// Preserve the whole media, disregarding the aspect ratio. 206 | Stretch, 207 | } 208 | 209 | impl ContentFit { 210 | pub const ALL: &'static [Self] = &[Self::Scale, Self::ScaleDown, Self::Crop, Self::Stretch]; 211 | } 212 | 213 | impl ToString for ContentFit { 214 | fn to_string(&self) -> String { 215 | match self { 216 | ContentFit::Scale => lang::action::scale(), 217 | ContentFit::ScaleDown => lang::action::scale_down(), 218 | ContentFit::Crop => lang::action::crop(), 219 | ContentFit::Stretch => lang::action::stretch(), 220 | } 221 | } 222 | } 223 | 224 | impl From for iced::ContentFit { 225 | fn from(value: ContentFit) -> Self { 226 | match value { 227 | ContentFit::Scale => iced::ContentFit::Contain, 228 | ContentFit::ScaleDown => iced::ContentFit::ScaleDown, 229 | ContentFit::Crop => iced::ContentFit::Cover, 230 | ContentFit::Stretch => iced::ContentFit::Fill, 231 | } 232 | } 233 | } 234 | 235 | #[cfg(test)] 236 | mod tests { 237 | use pretty_assertions::assert_eq; 238 | 239 | use super::*; 240 | 241 | #[test] 242 | fn can_parse_minimal_config() { 243 | let playlist = Playlist::load_from_string("{}").unwrap(); 244 | 245 | assert_eq!(Playlist::default(), playlist); 246 | } 247 | 248 | #[test] 249 | fn can_parse_optional_fields_when_present_in_config() { 250 | let playlist = Playlist::load_from_string( 251 | r#" 252 | layout: 253 | group: 254 | sources: 255 | - path: 256 | path: tmp 257 | max_media: 4 258 | content_fit: crop 259 | orientation: vertical 260 | orientation_limit: 261 | fixed: 2 262 | "#, 263 | ) 264 | .unwrap(); 265 | 266 | assert_eq!( 267 | Playlist { 268 | layout: Layout::Group(Group { 269 | sources: vec![media::Source::new_path(StrictPath::new("tmp"))], 270 | max_media: 4, 271 | content_fit: ContentFit::Crop, 272 | orientation: Orientation::Vertical, 273 | orientation_limit: OrientationLimit::Fixed(NonZeroUsize::new(2).unwrap()) 274 | }) 275 | }, 276 | playlist, 277 | ); 278 | } 279 | 280 | #[test] 281 | fn can_be_serialized() { 282 | assert_eq!( 283 | r#" 284 | --- 285 | # madamiru-playlist 286 | layout: 287 | group: 288 | sources: [] 289 | max_media: 1 290 | content_fit: scale 291 | orientation: horizontal 292 | orientation_limit: automatic 293 | "# 294 | .trim(), 295 | Playlist::default().serialize().trim(), 296 | ); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/testing.rs: -------------------------------------------------------------------------------- 1 | pub fn repo() -> String { 2 | repo_raw().replace('\\', "/") 3 | } 4 | 5 | pub fn repo_raw() -> String { 6 | env!("CARGO_MANIFEST_DIR").to_string() 7 | } 8 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import re 3 | import shutil 4 | import sys 5 | import textwrap 6 | import zipfile 7 | from pathlib import Path 8 | 9 | from invoke import task 10 | 11 | ROOT = Path(__file__).parent 12 | DIST = ROOT / "dist" 13 | LANG = ROOT / "lang" 14 | 15 | 16 | def get_version() -> str: 17 | for line in (ROOT / "Cargo.toml").read_text("utf-8").splitlines(): 18 | if line.startswith("version ="): 19 | return line.replace("version = ", "").strip('"') 20 | 21 | raise RuntimeError("Could not determine version") 22 | 23 | 24 | def replace_pattern_in_file(file: Path, old: str, new: str, count: int = 1): 25 | content = file.read_text("utf-8") 26 | updated = re.sub(old, new, content, count=count) 27 | file.write_text(updated, "utf-8") 28 | 29 | 30 | def confirm(prompt: str): 31 | response = input(f"Confirm by typing '{prompt}': ") 32 | if response.lower() != prompt.lower(): 33 | sys.exit(1) 34 | 35 | 36 | @task 37 | def version(ctx): 38 | print(get_version()) 39 | 40 | 41 | @task 42 | def legal(ctx): 43 | version = get_version() 44 | txt_name = f"madamiru-v{version}-legal.txt" 45 | txt_path = ROOT / "dist" / txt_name 46 | try: 47 | ctx.run(f'cargo lichking bundle --file "{txt_path}"', hide=True) 48 | except Exception: 49 | pass 50 | raw = txt_path.read_text("utf8") 51 | normalized = re.sub(r"C:\\Users\\[^\\]+", "~", raw) 52 | txt_path.write_text(normalized, "utf8") 53 | 54 | zip_path = ROOT / "dist" / f"madamiru-v{version}-legal.zip" 55 | with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip: 56 | zip.write(txt_path, txt_name) 57 | 58 | 59 | @task 60 | def flatpak(ctx, generator="/opt/flatpak-cargo-generator.py"): 61 | target = Path(f"{DIST}/generated-sources.json") 62 | 63 | ctx.run(f'python "{generator}" "{ROOT}/Cargo.lock" -o "{target}"', hide=True) 64 | 65 | content = target.read_text("utf-8") 66 | updated = re.sub(r"flatpak-cargo/git\\\\(.+)\\\\.", "flatpak-cargo/git/\\1/.", content) 67 | target.write_text(updated, "utf-8") 68 | 69 | 70 | @task 71 | def lang(ctx, jar="/opt/crowdin-cli/crowdin-cli.jar"): 72 | ctx.run(f'java -jar "{jar}" pull --export-only-approved') 73 | 74 | mapping = {} 75 | for file in LANG.glob("*.ftl"): 76 | if "en-US.ftl" in file.name: 77 | continue 78 | content = file.read_text("utf8") 79 | if content not in mapping: 80 | mapping[content] = set() 81 | mapping[content].add(file) 82 | 83 | for group in mapping.values(): 84 | if len(group) > 1: 85 | for file in group: 86 | file.unlink() 87 | 88 | 89 | @task 90 | def clean(ctx): 91 | if DIST.exists(): 92 | shutil.rmtree(DIST, ignore_errors=True) 93 | DIST.mkdir() 94 | 95 | 96 | @task 97 | def docs(ctx): 98 | docs_cli(ctx) 99 | docs_schema(ctx) 100 | 101 | 102 | @task 103 | def docs_cli(ctx): 104 | docs = Path(__file__).parent / "docs" 105 | if not docs.exists(): 106 | docs.mkdir(parents=True) 107 | doc = docs / "cli.md" 108 | 109 | commands = [ 110 | "--help", 111 | "complete --help", 112 | "schema --help", 113 | ] 114 | 115 | lines = [ 116 | "This is the raw help text for the command line interface.", 117 | ] 118 | for command in commands: 119 | print(f"cli.md: {command}") 120 | output = ctx.run(f"cargo run -- {command}", hide=True) 121 | lines.append("") 122 | lines.append(f"## `{command}`") 123 | lines.append("```") 124 | for line in output.stdout.splitlines(): 125 | lines.append(line.rstrip()) 126 | lines.append("```") 127 | 128 | with doc.open("w") as f: 129 | for line in lines: 130 | f.write(line + "\n") 131 | 132 | 133 | @task 134 | def docs_schema(ctx): 135 | docs = Path(__file__).parent / "docs" / "schema" 136 | if not docs.exists(): 137 | docs.mkdir(parents=True) 138 | 139 | commands = [ 140 | "config", 141 | "playlist", 142 | ] 143 | 144 | for command in commands: 145 | doc = docs / f"{command}.yaml" 146 | print(f"schema: {command}") 147 | output = ctx.run(f"cargo run -- schema --format yaml {command}", hide=True) 148 | 149 | with doc.open("w") as f: 150 | f.write(output.stdout.strip() + "\n") 151 | 152 | 153 | @task 154 | def prerelease(ctx, new_version, update_lang=True): 155 | date = dt.datetime.now().strftime("%Y-%m-%d") 156 | 157 | replace_pattern_in_file( 158 | ROOT / "Cargo.toml", 159 | 'version = ".+"', 160 | f'version = "{new_version}"', 161 | ) 162 | 163 | replace_pattern_in_file( 164 | ROOT / "CHANGELOG.md", 165 | "## Unreleased", 166 | f"## v{new_version} ({date})", 167 | ) 168 | 169 | replace_pattern_in_file( 170 | ROOT / ".github/ISSUE_TEMPLATE/bug.yaml", 171 | r"(options:)(\n - v\d+\.\d+\.\d+)", 172 | fr"\g<1>\n - v{new_version}\g<2>", 173 | ) 174 | 175 | replace_pattern_in_file( 176 | ROOT / "assets/linux/com.mtkennerly.madamiru.metainfo.xml", 177 | "(madamiru/v).+(/docs/sample-gui.png)", 178 | fr"\g<1>{new_version}\g<2>", 179 | ) 180 | 181 | replace_pattern_in_file( 182 | ROOT / "assets/linux/com.mtkennerly.madamiru.metainfo.xml", 183 | "", 184 | f'\n ', 185 | ) 186 | 187 | # Update version in Cargo.lock 188 | ctx.run("cargo build") 189 | 190 | clean(ctx) 191 | legal(ctx) 192 | flatpak(ctx) 193 | docs(ctx) 194 | if update_lang: 195 | lang(ctx) 196 | 197 | 198 | @task 199 | def release(ctx): 200 | version = get_version() 201 | 202 | confirm(f"release {version}") 203 | 204 | ctx.run(f'git commit -m "Release v{version}"') 205 | ctx.run(f'git tag v{version} -m "Release"') 206 | ctx.run("git push") 207 | ctx.run(f"git push origin tag v{version}") 208 | 209 | 210 | @task 211 | def release_flatpak(ctx, target="/git/com.mtkennerly.madamiru"): 212 | target = Path(target) 213 | spec = target / "com.mtkennerly.madamiru.yaml" 214 | version = get_version() 215 | 216 | with ctx.cd(target): 217 | ctx.run("git checkout master") 218 | ctx.run("git pull") 219 | ctx.run(f"git checkout -b release/v{version}") 220 | 221 | shutil.copy(DIST / "generated-sources.json", target / "generated-sources.json") 222 | spec_content = spec.read_bytes().decode("utf-8") 223 | spec_content = re.sub(r"( tag:) (.*)", fr"\1 v{version}", spec_content) 224 | spec.write_bytes(spec_content.encode("utf-8")) 225 | 226 | ctx.run("git add .") 227 | ctx.run(f'git commit -m "Update for v{version}"') 228 | ctx.run("git push origin HEAD") 229 | 230 | 231 | @task 232 | def release_winget(ctx, target="/git/_forks/winget-pkgs"): 233 | target = Path(target) 234 | version = get_version() 235 | changelog = textwrap.indent(latest_changelog(), " ") 236 | 237 | with ctx.cd(target): 238 | ctx.run("git checkout master") 239 | ctx.run("git pull upstream master") 240 | ctx.run(f"git checkout -b mtkennerly.madamiru-{version}") 241 | ctx.run(f"wingetcreate update mtkennerly.madamiru --version {version} --urls https://github.com/mtkennerly/madamiru/releases/download/v{version}/madamiru-v{version}-win64.zip https://github.com/mtkennerly/madamiru/releases/download/v{version}/madamiru-v{version}-win32.zip") 242 | 243 | spec = target / f"manifests/m/mtkennerly/madamiru/{version}/mtkennerly.madamiru.locale.en-US.yaml" 244 | spec_content = spec.read_bytes().decode("utf-8") 245 | spec_content = spec_content.replace("Moniker: madamiru", f"Moniker: madamiru\nReleaseNotes: |-\n{changelog}\nReleaseNotesUrl: https://github.com/mtkennerly/madamiru/releases/tag/v{version}") 246 | spec.write_bytes(spec_content.encode("utf-8")) 247 | 248 | ctx.run(f"winget validate --manifest manifests/m/mtkennerly/madamiru/{version}") 249 | ctx.run("git add .") 250 | ctx.run(f'git commit -m "mtkennerly.madamiru version {version}"') 251 | ctx.run("git push origin HEAD") 252 | 253 | 254 | def latest_changelog() -> str: 255 | changelog = ROOT / "CHANGELOG.md" 256 | content = changelog.read_bytes().decode("utf-8") 257 | 258 | lines = [] 259 | header = False 260 | for line in content.splitlines(): 261 | if line.startswith("#"): 262 | if header: 263 | break 264 | header = True 265 | continue 266 | 267 | lines.append(line) 268 | 269 | return "\n".join(lines).strip() 270 | --------------------------------------------------------------------------------