├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── audit.yml │ └── ci.yml ├── .gitignore ├── COPYING ├── Cargo.lock ├── Cargo.toml ├── Kooha.doap ├── README.md ├── build-aux ├── dist-vendor.sh └── io.github.seadve.Kooha.Devel.json ├── data ├── icons │ ├── io.github.seadve.Kooha-symbolic.svg │ ├── io.github.seadve.Kooha.Devel.svg │ ├── io.github.seadve.Kooha.Source.svg │ ├── io.github.seadve.Kooha.svg │ └── meson.build ├── io.github.seadve.Kooha.desktop.in.in ├── io.github.seadve.Kooha.gschema.xml.in ├── io.github.seadve.Kooha.metainfo.xml.in.in ├── io.github.seadve.Kooha.service.in ├── meson.build ├── resources │ ├── icons │ │ └── scalable │ │ │ └── actions │ │ │ ├── audio-volume-high-symbolic.svg │ │ │ ├── audio-volume-muted-symbolic.svg │ │ │ ├── checkmark-symbolic.svg │ │ │ ├── microphone-disabled-symbolic.svg │ │ │ ├── microphone2-symbolic.svg │ │ │ ├── mouse-wireless-disabled-symbolic.svg │ │ │ ├── mouse-wireless-symbolic.svg │ │ │ ├── refresh-symbolic.svg │ │ │ ├── selection-symbolic.svg │ │ │ ├── source-pick-symbolic.svg │ │ │ └── warning-symbolic.svg │ ├── meson.build │ ├── profiles.yml │ ├── resources.gresource.xml │ ├── style.css │ └── ui │ │ ├── area_selector.ui │ │ ├── item_row.ui │ │ ├── preferences_dialog.ui │ │ ├── shortcuts.ui │ │ ├── view_port.ui │ │ └── window.ui └── screenshots │ ├── preview.png │ ├── screenshot.png │ ├── screenshot2.png │ └── screenshot3.png ├── meson.build ├── meson_options.txt ├── po ├── LINGUAS ├── POTFILES.in ├── ar.po ├── bg.po ├── bqi.po ├── ca.po ├── cs.po ├── da.po ├── de.po ├── el.po ├── eo.po ├── es.po ├── et.po ├── eu.po ├── fa.po ├── fi.po ├── fil.po ├── fr_FR.po ├── ga.po ├── gl.po ├── he.po ├── hi.po ├── hr.po ├── hu.po ├── ia.po ├── id.po ├── it.po ├── ja.po ├── ko.po ├── kooha.pot ├── meson.build ├── nb_NO.po ├── ne.po ├── nl.po ├── oc.po ├── pl.po ├── pt.po ├── pt_BR.po ├── ro.po ├── ru.po ├── si.po ├── sk.po ├── sr.po ├── sr@latin.po ├── sv.po ├── ta.po ├── tr.po ├── uk.po ├── zh_Hans.po └── zh_Hant.po ├── src ├── about.rs ├── application.rs ├── area_selector │ ├── mod.rs │ └── view_port.rs ├── cancelled.rs ├── config.rs.in ├── device.rs ├── experimental.rs ├── format.rs ├── help.rs ├── i18n.rs ├── item_row.rs ├── main.rs ├── meson.build ├── pipeline.rs ├── preferences_dialog.rs ├── profile.rs ├── recording.rs ├── screencast_portal │ ├── handle_token.rs │ ├── mod.rs │ ├── types.rs │ ├── variant_dict.rs │ └── window_identifier.rs ├── settings.rs ├── timer.rs └── window │ ├── mod.rs │ ├── progress_icon.rs │ └── toggle_button.rs └── typos.toml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: SeaDve 4 | custom: ["https://www.buymeacoffee.com/seadve"] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help Kooha improve. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Affected version** 10 | 11 | 17 | 18 | **Bug summary** 19 | 20 | 23 | 24 | **Steps to reproduce** 25 | 26 | 31 | 32 | **Expected behavior** 33 | 34 | 37 | 38 | **Relevant logs, screenshots, screencasts, etc.** 39 | 40 | 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature to help Kooha improve. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Feature summary** 10 | 11 | 15 | 16 | **How would you like it to work** 17 | 18 | 22 | 23 | **Relevant links, screenshots, screencasts, etc.** 24 | 25 | 30 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | paths: 4 | - "**/Cargo.toml" 5 | - "**/Cargo.lock" 6 | 7 | name: Audit 8 | 9 | jobs: 10 | rust-audit: 11 | name: Rust Audit 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: rustsec/audit-check@v1.4.1 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | 6 | name: CI 7 | 8 | jobs: 9 | checks: 10 | name: Checks 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | # FIXME uncomment when we don't --skip ui_files anymore 15 | # - name: Download dependencies 16 | # run: sudo apt -y install libgtk-4-dev 17 | - name: Run checks.py 18 | run: curl https://raw.githubusercontent.com/SeaDve/scripts/main/checks.py | python - --verbose --skip rustfmt typos ui_files 19 | 20 | rustfmt: 21 | name: Rustfmt 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Create blank versions of configured file 26 | run: echo -e "" >> src/config.rs 27 | - name: Run cargo fmt 28 | run: cargo fmt --all -- --check 29 | 30 | typos: 31 | name: Typos 32 | runs-on: ubuntu-22.04 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Check for typos 36 | uses: crate-ci/typos@master 37 | 38 | flatpak: 39 | name: Flatpak 40 | runs-on: ubuntu-22.04 41 | container: 42 | image: bilelmoussaoui/flatpak-github-actions:gnome-nightly 43 | options: --privileged 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 47 | with: 48 | bundle: kooha.flatpak 49 | manifest-path: build-aux/io.github.seadve.Kooha.Devel.json 50 | run-tests: true 51 | cache-key: flatpak-builder-${{ github.sha }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | build/ 3 | _build/ 4 | builddir/ 5 | build-aux/app 6 | build-aux/.flatpak-builder/ 7 | .flatpak-builder/ 8 | src/config.rs 9 | *.ui.in~ 10 | *.ui~ 11 | .flatpak/ 12 | vendor/ 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kooha" 3 | version = "2.3.0" 4 | authors = ["Dave Patrick Caberto "] 5 | license = "GPL-3.0-or-later" 6 | edition = "2021" 7 | 8 | [profile.release] 9 | lto = true 10 | 11 | # Don't compile and link debug info; these reduce build times at the 12 | # cost of not having line numbers in backtraces. 13 | [profile.dev] 14 | debug = 0 15 | strip = "debuginfo" 16 | 17 | [dependencies] 18 | adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] } 19 | anyhow = "1.0.59" 20 | futures-channel = "0.3.19" 21 | futures-util = { version = "0.3", default-features = false } 22 | gdk-wayland = { package = "gdk4-wayland", version = "0.9" } 23 | gdk-x11 = { package = "gdk4-x11", version = "0.9" } 24 | gettext-rs = { version = "0.7.0", features = ["gettext-system"] } 25 | gsettings-macro = "0.2" 26 | gst = { package = "gstreamer", version = "0.23", features = ["v1_20"] } 27 | gst-plugin-gif = "0.13" 28 | gst-plugin-gtk4 = { version = "0.13", features = [ 29 | "dmabuf", 30 | "gtk_v4_14", 31 | "wayland", 32 | "x11egl", 33 | "x11glx", 34 | ] } 35 | gtk = { package = "gtk4", version = "0.9", features = ["gnome_46"] } 36 | once_cell = "1.19.0" 37 | serde_yaml = "0.9.31" 38 | serde = { version = "1.0.196", features = ["derive"] } 39 | tracing = "0.1.36" 40 | tracing-subscriber = "0.3.15" 41 | -------------------------------------------------------------------------------- /Kooha.doap: -------------------------------------------------------------------------------- 1 | 6 | 7 | Kooha 8 | Elegantly record your screen 9 | 10 | 11 | Rust 12 | 13 | 14 | 15 | Dave Patrick Caberto 16 | 17 | 18 | 19 | 20 | SeaDve 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Kooha 3 |
4 | Kooha 5 |

6 | 7 |

8 | Elegantly record your screen 9 |

10 | 11 |

12 | 13 | Download on Flathub 14 | 15 |
16 | 17 | Buy Me a Coffee 18 | 19 |

20 | 21 |
22 | 23 |

24 | 25 | Translation status 26 | 27 | 28 | Flathub downloads 29 | 30 | 31 | CI status 32 | 33 |

34 | 35 |

36 | Preview 37 |

38 | 39 | Capture your screen in an intuitive and straightforward way without distractions. 40 | 41 | Kooha is a simple screen recorder with a minimal interface. You can simply click 42 | the record button without having to configure a bunch of settings. 43 | 44 | The main features of Kooha include the following: 45 | * 🎙️ Record microphone, desktop audio, or both at the same time 46 | * 📼 Support for WebM, MP4, GIF, and Matroska formats 47 | * 🖥️ Select a monitor or a portion of the screen to record 48 | * 🛠️ Configurable saving location, pointer visibility, frame rate, and delay 49 | * 🚀 Experimental hardware-accelerated encoding 50 | 51 | ## 😕 It Doesn't Work 52 | 53 | There are many possibilities on why it may not be working. You may not have 54 | the runtime requirements mentioned below installed, or your distro doesn't 55 | support it. For troubleshooting purposes, the [screencast compatibility page](https://github.com/emersion/xdg-desktop-portal-wlr/wiki/Screencast-Compatibility) 56 | of `xdg-desktop-portal-wlr` wiki may help determine if your distro 57 | has support for it out of the box. If it does, but it still doesn't work, you 58 | can also check for the [troubleshooting checklist](https://github.com/emersion/xdg-desktop-portal-wlr/wiki/%22It-doesn't-work%22-Troubleshooting-Checklist). 59 | 60 | ## ⚙️ Experimental Features 61 | 62 | These features are disabled by default due to stability issues and possible 63 | performance degradation. However, they can be enabled manually by running Kooha 64 | with `KOOHA_EXPERIMENTAL` env var set to `all` (e.g., `KOOHA_EXPERIMENTAL=all flatpak run io.github.seadve.Kooha`), or individually, by setting 65 | `KOOHA_EXPERIMENTAL` to the following keys (e.g., `KOOHA_EXPERIMENTAL=experimental-formats,window-recording`): 66 | 67 | | Feature | Description | Issues | 68 | | ------------------------ | ----------------------------------------------------------------------- | ------------------------- | 69 | | `all` | Enables all experimental features | - | 70 | | `experimental-formats` | Enables other codecs (e.g., hardware-accelerate encoders, VP9, and AV1) | Stability | 71 | | `multiple-video-sources` | Enables recording multiple monitor or windows | Stability and performance | 72 | | `window-recording` | Enables recording a specific window | Flickering | 73 | 74 | ## 📋 Runtime Requirements 75 | 76 | * pipewire 77 | * gstreamer-plugin-pipewire 78 | * xdg-desktop-portal 79 | * xdg-desktop-portal-(e.g., gtk, kde, wlr) 80 | 81 | ## 🏗️ Building from source 82 | 83 | ### GNOME Builder 84 | 85 | GNOME Builder is the environment used for developing this application. 86 | It can use Flatpak manifests to create a consistent building and running 87 | environment cross-distro. Thus, it is highly recommended you use it. 88 | 89 | 1. Download [GNOME Builder](https://flathub.org/apps/details/org.gnome.Builder). 90 | 2. In Builder, click the "Clone Repository" button at the bottom, using `https://github.com/SeaDve/Kooha.git` as the URL. 91 | 3. Click the build button at the top once the project is loaded. 92 | 93 | ### Meson 94 | 95 | #### Prerequisites 96 | 97 | The following packages are required to build Kooha: 98 | 99 | * meson 100 | * ninja 101 | * appstreamcli (for checks) 102 | * cargo 103 | * x264 (for MP4) 104 | * gstreamer 105 | * gstreamer-plugins-base 106 | * gstreamer-plugins-ugly (for MP4) 107 | * gstreamer-plugins-bad (for VA encoders) 108 | * glib2 109 | * gtk4 110 | * libadwaita 111 | 112 | #### Build Instruction 113 | 114 | ```shell 115 | git clone https://github.com/SeaDve/Kooha.git 116 | cd Kooha 117 | meson _build --prefix=/usr/local 118 | ninja -C _build install 119 | ``` 120 | 121 | ## 📦 Third-Party Packages 122 | 123 | Unlike Flatpak, take note that these packages are not officially supported by the developer. 124 | 125 | ### Repology 126 | 127 | You can also check out other third-party packages on [Repology](https://repology.org/project/kooha/versions). 128 | 129 | ## 🙌 Help translate Kooha 130 | 131 | You can help Kooha translate into your native language. If you find any typos 132 | or think you can improve a translation, you can use the [Weblate](https://hosted.weblate.org/engage/seadve/) platform. 133 | 134 | ## ☕ Support me and the project 135 | 136 | Kooha is free and will always be for everyone to use. If you like the project and 137 | would like to support it, you may [buy me a coffee](https://www.buymeacoffee.com/seadve). 138 | 139 | ## 💝 Acknowledgment 140 | 141 | I would like to express my gratitude to the [contributors](https://github.com/SeaDve/Kooha/graphs/contributors) 142 | and [translators](https://hosted.weblate.org/engage/seadve/) of the project. 143 | 144 | I would also like to thank the open-source software projects, libraries, and APIs that were 145 | used in developing this app, such as GStreamer, GTK, LibAdwaita, and many others, for making Kooha possible. 146 | 147 | I would also like to acknowledge [RecApp](https://github.com/amikha1lov/RecApp), which greatly inspired the creation of Kooha, 148 | as well as [GNOME Screenshot](https://gitlab.gnome.org/GNOME/gnome-screenshot), which served as a reference for Kooha's icon 149 | design. 150 | -------------------------------------------------------------------------------- /build-aux/dist-vendor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Since Meson invokes this script as 3 | # "/bin/sh .../dist-vendor.sh DIST SOURCE_ROOT" we can't rely on bash features 4 | set -eu 5 | export DIST="$1" 6 | export SOURCE_ROOT="$2" 7 | 8 | cd "$SOURCE_ROOT" 9 | mkdir "$DIST"/.cargo 10 | # cargo-vendor-filterer can be found at https://github.com/coreos/cargo-vendor-filterer 11 | # It is also part of the Rust SDK extension. 12 | cargo vendor-filterer --platform=x86_64-unknown-linux-gnu --platform=aarch64-unknown-linux-gnu > "$DIST"/.cargo/config 13 | rm -f vendor/gettext-sys/gettext-*.tar.* 14 | # remove the tarball from checksums 15 | echo $(jq -c 'del(.files["gettext-0.21.tar.xz"])' vendor/gettext-sys/.cargo-checksum.json) > vendor/gettext-sys/.cargo-checksum.json 16 | # Don't combine the previous and this line with a pipe because we can't catch 17 | # errors with "set -o pipefail" 18 | sed -i 's/^directory = ".*"/directory = "vendor"/g' "$DIST/.cargo/config" 19 | # Move vendor into dist tarball directory 20 | mv vendor "$DIST" 21 | -------------------------------------------------------------------------------- /build-aux/io.github.seadve.Kooha.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "io.github.seadve.Kooha.Devel", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "master", 5 | "sdk": "org.gnome.Sdk", 6 | "sdk-extensions": [ 7 | "org.freedesktop.Sdk.Extension.rust-stable", 8 | "org.freedesktop.Sdk.Extension.llvm18" 9 | ], 10 | "command": "kooha", 11 | "finish-args": [ 12 | "--device=dri", 13 | "--filesystem=xdg-videos", 14 | "--socket=fallback-x11", 15 | "--socket=pulseaudio", 16 | "--socket=wayland", 17 | "--env=RUST_BACKTRACE=1", 18 | "--env=RUST_LIB_BACKTRACE=0", 19 | "--env=RUST_LOG=kooha=debug", 20 | "--env=G_MESSAGES_DEBUG=none", 21 | "--env=KOOHA_EXPERIMENTAL=all" 22 | ], 23 | "build-options": { 24 | "append-path": "/usr/lib/sdk/llvm18/bin:/usr/lib/sdk/rust-stable/bin", 25 | "build-args": [ 26 | "--share=network" 27 | ], 28 | "env": { 29 | "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang", 30 | "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold" 31 | } 32 | }, 33 | "modules": [ 34 | { 35 | "name": "x264", 36 | "config-opts": [ 37 | "--enable-shared", 38 | "--enable-pic", 39 | "--disable-cli" 40 | ], 41 | "sources": [ 42 | { 43 | "type": "git", 44 | "url": "https://code.videolan.org/videolan/x264.git", 45 | "branch": "stable", 46 | "commit": "31e19f92f00c7003fa115047ce50978bc98c3a0d" 47 | } 48 | ] 49 | }, 50 | { 51 | "name": "gst-plugins-ugly", 52 | "buildsystem": "meson", 53 | "builddir": true, 54 | "config-opts": [ 55 | "-Ddoc=disabled", 56 | "-Dnls=disabled", 57 | "-Dtests=disabled", 58 | "-Dgpl=enabled" 59 | ], 60 | "sources": [ 61 | { 62 | "type": "archive", 63 | "url": "https://gstreamer.freedesktop.org/src/gst-plugins-ugly/gst-plugins-ugly-1.24.5.tar.xz", 64 | "sha256": "333267b6e00770440a4a00402910dd59ed8fd619eaebf402815fbe111da7776d" 65 | } 66 | ] 67 | }, 68 | { 69 | "name": "kooha", 70 | "buildsystem": "meson", 71 | "run-tests": true, 72 | "config-opts": [ 73 | "-Dprofile=development" 74 | ], 75 | "sources": [ 76 | { 77 | "type": "dir", 78 | "path": "../" 79 | } 80 | ] 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /data/icons/io.github.seadve.Kooha-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /data/icons/io.github.seadve.Kooha.Devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /data/icons/io.github.seadve.Kooha.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | '@0@.svg'.format(application_id), 3 | install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps' 4 | ) 5 | 6 | install_data( 7 | '@0@-symbolic.svg'.format(base_id), 8 | install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps', 9 | rename: '@0@-symbolic.svg'.format(application_id) 10 | ) 11 | -------------------------------------------------------------------------------- /data/io.github.seadve.Kooha.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Kooha 3 | Exec=kooha 4 | Comment=Elegantly record your screen 5 | Type=Application 6 | Terminal=false 7 | Categories=GTK;GNOME;Utility;Recorder; 8 | # Translators: These are search terms to find this application. Do NOT translate or localize the semicolons. The list MUST also end with a semicolon. 9 | Keywords=Screencast;Recorder;Screen;Video; 10 | Icon=@icon@ 11 | StartupNotify=true 12 | DBusActivatable=true 13 | X-GNOME-UsesNotifications=true 14 | -------------------------------------------------------------------------------- /data/io.github.seadve.Kooha.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | "monitor-window" 10 | 11 | 12 | true 13 | 14 | 15 | false 16 | 17 | 18 | true 19 | 20 | 21 | 0 22 | 23 | 24 | b"" 25 | 26 | 27 | "webm-vp8" 28 | 29 | 30 | (30, 1) 31 | 32 | 33 | "" 34 | 35 | 36 | (-1, -1, -1, -1) 37 | 38 | 39 | ((-1, -1, -1, -1), (-1, -1)) 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /data/io.github.seadve.Kooha.service.in: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=@app-id@ 3 | Exec=@bindir@/kooha --gapplication-service 4 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | subdir('icons') 2 | subdir('resources') 3 | 4 | # Desktop file 5 | desktop_conf = configuration_data() 6 | desktop_conf.set('icon', application_id) 7 | desktop_file = i18n.merge_file( 8 | type: 'desktop', 9 | input: configure_file( 10 | input: '@0@.desktop.in.in'.format(base_id), 11 | output: '@BASENAME@', 12 | configuration: desktop_conf 13 | ), 14 | output: '@0@.desktop'.format(application_id), 15 | po_dir: podir, 16 | install: true, 17 | install_dir: datadir / 'applications' 18 | ) 19 | # Validate Desktop file 20 | if desktop_file_validate.found() 21 | test( 22 | 'validate-desktop', 23 | desktop_file_validate, 24 | args: [ 25 | desktop_file.full_path() 26 | ], 27 | depends: desktop_file, 28 | ) 29 | endif 30 | 31 | # Appdata 32 | appdata_conf = configuration_data() 33 | appdata_conf.set('app-id', application_id) 34 | appdata_conf.set('gettext-package', gettext_package) 35 | appdata_file = i18n.merge_file( 36 | input: configure_file( 37 | input: '@0@.metainfo.xml.in.in'.format(base_id), 38 | output: '@BASENAME@', 39 | configuration: appdata_conf 40 | ), 41 | output: '@0@.metainfo.xml'.format(application_id), 42 | po_dir: podir, 43 | install: true, 44 | install_dir: datadir / 'metainfo' 45 | ) 46 | # Validate Appdata 47 | if appstreamcli.found() 48 | test( 49 | 'validate-appdata', appstreamcli, 50 | args: [ 51 | 'validate', '--no-net', '--explain', appdata_file.full_path() 52 | ], 53 | depends: appdata_file, 54 | ) 55 | endif 56 | 57 | # GSchema 58 | gschema_conf = configuration_data() 59 | gschema_conf.set('app-id', application_id) 60 | gschema_conf.set('gettext-package', gettext_package) 61 | configure_file( 62 | input: '@0@.gschema.xml.in'.format(base_id), 63 | output: '@0@.gschema.xml'.format(application_id), 64 | configuration: gschema_conf, 65 | install: true, 66 | install_dir: datadir / 'glib-2.0' / 'schemas' 67 | ) 68 | # Validate GSchema 69 | if glib_compile_schemas.found() 70 | test( 71 | 'validate-gschema', glib_compile_schemas, 72 | args: [ 73 | '--strict', '--dry-run', meson.current_build_dir() 74 | ], 75 | ) 76 | endif 77 | 78 | # D-Bus service file 79 | service_conf = configuration_data() 80 | service_conf.set('app-id', application_id) 81 | service_conf.set('bindir', bindir) 82 | configure_file( 83 | input: 'io.github.seadve.Kooha.service.in', 84 | output: '@0@.service'.format(application_id), 85 | configuration: service_conf, 86 | install: true, 87 | install_dir: datadir / 'dbus-1/services', 88 | ) 89 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/audio-volume-high-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/audio-volume-muted-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/checkmark-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/microphone-disabled-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/microphone2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/mouse-wireless-disabled-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/mouse-wireless-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/refresh-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/selection-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/source-pick-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /data/resources/icons/scalable/actions/warning-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/resources/meson.build: -------------------------------------------------------------------------------- 1 | # Resources 2 | resources = gnome.compile_resources( 3 | 'resources', 4 | 'resources.gresource.xml', 5 | gresource_bundle: true, 6 | source_dir: meson.current_build_dir(), 7 | install: true, 8 | install_dir: pkgdatadir, 9 | ) 10 | -------------------------------------------------------------------------------- /data/resources/profiles.yml: -------------------------------------------------------------------------------- 1 | # Note: 2 | # - videoenc and audioenc will be connected directly to the muxer 3 | # - audioenc and muxer are optional, but if audioenc is set, muxer must also be set 4 | # - ${N_THREADS} will be replaced with ideal thread count 5 | # - default suggested-max-fps is 60 6 | 7 | supported: 8 | - id: webm-vp8 9 | name: WebM 10 | extension: webm 11 | videoenc: > 12 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 13 | vp8enc max-quantizer=17 cpu-used=16 deadline=1 static-threshold=100 keyframe-mode=disabled buffer-size=20000 threads=${N_THREADS} ! 14 | queue 15 | audioenc: > 16 | audioconvert ! 17 | opusenc ! 18 | queue 19 | muxer: webmmux 20 | 21 | - id: mp4 22 | name: MP4 23 | extension: mp4 24 | videoenc: > 25 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 26 | x264enc qp-max=17 speed-preset=ultrafast threads=${N_THREADS} ! 27 | capsfilter caps=video/x-h264,profile=baseline ! 28 | queue ! 29 | h264parse 30 | audioenc: > 31 | audioconvert ! 32 | lamemp3enc ! 33 | queue ! 34 | mpegaudioparse 35 | muxer: mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise 36 | 37 | - id: matroska-h264 38 | name: Matroska 39 | extension: mkv 40 | videoenc: > 41 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 42 | x264enc qp-max=17 speed-preset=ultrafast threads=${N_THREADS} ! 43 | capsfilter caps=video/x-h264,profile=baseline ! 44 | queue ! 45 | h264parse 46 | audioenc: > 47 | audioconvert ! 48 | opusenc ! 49 | queue 50 | muxer: matroskamux 51 | 52 | - id: gif 53 | name: GIF 54 | extension: gif 55 | suggested-max-fps: 24 56 | videoenc: > 57 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 58 | gifenc repeat=-1 speed=30 ! 59 | queue 60 | 61 | experimental: 62 | - id: webm-vp9 63 | name: WebM (VP9) 64 | extension: webm 65 | videoenc: > 66 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 67 | vp9enc max-quantizer=17 cpu-used=16 deadline=1 static-threshold=100 keyframe-mode=disabled buffer-size=20000 threads=${N_THREADS} ! 68 | queue 69 | audioenc: > 70 | audioconvert ! 71 | opusenc ! 72 | queue 73 | muxer: webmmux 74 | 75 | - id: webm-av1 76 | name: WebM (AV1) 77 | extension: webm 78 | videoenc: > 79 | videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=${N_THREADS} ! 80 | av1enc usage-profile=realtime max-quantizer=17 cpu-used=5 end-usage=cq buf-sz=20000 threads=${N_THREADS} ! 81 | queue 82 | audioenc: > 83 | audioconvert ! 84 | opusenc ! 85 | queue 86 | muxer: webmmux 87 | 88 | - id: va-h264 89 | name: WebM VA H264 90 | extension: mp4 91 | videoenc: > 92 | vapostproc ! 93 | vah264enc ! 94 | queue ! 95 | h264parse 96 | audioenc: > 97 | audioconvert ! 98 | lamemp3enc ! 99 | queue ! 100 | mpegaudioparse 101 | muxer: mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise 102 | -------------------------------------------------------------------------------- /data/resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icons/scalable/actions/audio-volume-high-symbolic.svg 5 | icons/scalable/actions/audio-volume-muted-symbolic.svg 6 | icons/scalable/actions/checkmark-symbolic.svg 7 | icons/scalable/actions/microphone-disabled-symbolic.svg 8 | icons/scalable/actions/microphone2-symbolic.svg 9 | icons/scalable/actions/mouse-wireless-disabled-symbolic.svg 10 | icons/scalable/actions/mouse-wireless-symbolic.svg 11 | icons/scalable/actions/refresh-symbolic.svg 12 | icons/scalable/actions/selection-symbolic.svg 13 | icons/scalable/actions/source-pick-symbolic.svg 14 | icons/scalable/actions/warning-symbolic.svg 15 | profiles.yml 16 | style.css 17 | ui/area_selector.ui 18 | ui/item_row.ui 19 | ui/preferences_dialog.ui 20 | ui/shortcuts.ui 21 | ui/view_port.ui 22 | ui/window.ui 23 | 24 | 25 | -------------------------------------------------------------------------------- /data/resources/style.css: -------------------------------------------------------------------------------- 1 | row.error-view { 2 | padding: 0px; 3 | } 4 | 5 | label.large-time { 6 | font-size: 450%; 7 | font-weight: 300; 8 | font-feature-settings: "tnum"; 9 | } 10 | 11 | label.recording { 12 | color: var(--destructive-bg-color); 13 | } 14 | 15 | label.paused { 16 | animation-name: blinking; 17 | animation-iteration-count: infinite; 18 | animation-timing-function: cubic-bezier(1.0, 0, 0, 1.0); 19 | animation-duration: 1s; 20 | } 21 | 22 | button.copy-done { 23 | color: var(--accent-bg-color); 24 | } 25 | 26 | window.area-selector .view-port { 27 | padding: 12px; 28 | padding-top: 6px; 29 | } 30 | 31 | @keyframes blinking { 32 | 0% { 33 | color: var(--window-fg-color); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /data/resources/ui/area_selector.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 85 | 86 | -------------------------------------------------------------------------------- /data/resources/ui/item_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | -------------------------------------------------------------------------------- /data/resources/ui/preferences_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 66 | 67 | -------------------------------------------------------------------------------- /data/resources/ui/shortcuts.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | 6 | 7 | shortcuts 8 | 9 | 10 | General 11 | 12 | 13 | F10 14 | Open Menu 15 | 16 | 17 | 18 | 19 | app.show-preferences 20 | Show Preferences 21 | 22 | 23 | 24 | 25 | win.show-help-overlay 26 | Show Shortcuts 27 | 28 | 29 | 30 | 31 | app.quit 32 | Quit 33 | 34 | 35 | 36 | 37 | 38 | 39 | Recording 40 | 41 | 42 | win.toggle-record 43 | Toggle Record 44 | 45 | 46 | 47 | 48 | False 49 | win.toggle-pause 50 | Toggle Pause 51 | 52 | 53 | 54 | 55 | win.cancel-record 56 | Cancel Record 57 | 58 | 59 | 60 | 61 | 62 | 63 | Settings 64 | 65 | 66 | win.record-desktop-audio 67 | Toggle Desktop Audio 68 | 69 | 70 | 71 | 72 | win.record-microphone 73 | Toggle Microphone 74 | 75 | 76 | 77 | 78 | win.show-pointer 79 | Toggle Pointer 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /data/resources/ui/view_port.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 | -------------------------------------------------------------------------------- /data/screenshots/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Kooha/d91919b1f2931940a9d87ef5ed4156c666e33c52/data/screenshots/preview.png -------------------------------------------------------------------------------- /data/screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Kooha/d91919b1f2931940a9d87ef5ed4156c666e33c52/data/screenshots/screenshot.png -------------------------------------------------------------------------------- /data/screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Kooha/d91919b1f2931940a9d87ef5ed4156c666e33c52/data/screenshots/screenshot2.png -------------------------------------------------------------------------------- /data/screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeaDve/Kooha/d91919b1f2931940a9d87ef5ed4156c666e33c52/data/screenshots/screenshot3.png -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'kooha', 3 | 'rust', 4 | version: '2.3.0', 5 | license: 'GPL-3.0-or-later', 6 | meson_version: '>= 0.59', 7 | ) 8 | 9 | i18n = import('i18n') 10 | gnome = import('gnome') 11 | 12 | base_id = 'io.github.seadve.Kooha' 13 | 14 | dependency('glib-2.0', version: '>= 2.80') 15 | dependency('gio-2.0', version: '>= 2.80') 16 | dependency('gtk4', version: '>= 4.15.3') 17 | dependency('libadwaita-1', version: '>= 1.6') 18 | dependency('gstreamer-1.0', version: '>= 1.24') 19 | dependency('gstreamer-plugins-base-1.0', version: '>= 1.24') 20 | 21 | glib_compile_resources = find_program('glib-compile-resources', required: true) 22 | glib_compile_schemas = find_program('glib-compile-schemas', required: true) 23 | desktop_file_validate = find_program('desktop-file-validate', required: false) 24 | appstreamcli = find_program('appstreamcli', required: false) 25 | cargo = find_program('cargo', required: true) 26 | 27 | version = meson.project_version() 28 | 29 | prefix = get_option('prefix') 30 | bindir = prefix / get_option('bindir') 31 | localedir = prefix / get_option('localedir') 32 | 33 | datadir = prefix / get_option('datadir') 34 | pkgdatadir = datadir / meson.project_name() 35 | iconsdir = datadir / 'icons' 36 | podir = meson.project_source_root() / 'po' 37 | gettext_package = meson.project_name() 38 | 39 | if get_option('profile') == 'development' 40 | profile = 'Devel' 41 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip() 42 | if vcs_tag == '' 43 | version_suffix = '-devel' 44 | else 45 | version_suffix = '-@0@'.format(vcs_tag) 46 | endif 47 | application_id = '@0@.@1@'.format(base_id, profile) 48 | else 49 | profile = '' 50 | version_suffix = '' 51 | application_id = base_id 52 | endif 53 | 54 | meson.add_dist_script( 55 | 'build-aux/dist-vendor.sh', 56 | meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + version, 57 | meson.project_source_root() 58 | ) 59 | 60 | subdir('data') 61 | subdir('po') 62 | subdir('src') 63 | 64 | gnome.post_install( 65 | gtk_update_icon_cache: true, 66 | glib_compile_schemas: true, 67 | update_desktop_database: true, 68 | ) 69 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'default', 6 | 'development' 7 | ], 8 | value: 'default', 9 | description: 'The build profile for Kooha. One of "default" or "development".' 10 | ) 11 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | ar 2 | cs 3 | de 4 | es 5 | eo 6 | eu 7 | fa 8 | fi 9 | fil 10 | fr_FR 11 | he 12 | id 13 | it 14 | ja 15 | ko 16 | nb_NO 17 | nl 18 | pl 19 | pt 20 | pt_BR 21 | ru 22 | sk 23 | sr 24 | sr@latin 25 | sv 26 | tr 27 | uk 28 | zh_Hans 29 | hi 30 | gl 31 | oc 32 | hr 33 | hu 34 | da 35 | ne 36 | zh_Hant 37 | et 38 | ta 39 | bg 40 | el 41 | ca 42 | bqi 43 | si 44 | ro 45 | ga 46 | ia 47 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/io.github.seadve.Kooha.desktop.in.in 2 | data/io.github.seadve.Kooha.gschema.xml.in 3 | data/io.github.seadve.Kooha.metainfo.xml.in.in 4 | data/resources/ui/area_selector.ui 5 | data/resources/ui/preferences_dialog.ui 6 | data/resources/ui/shortcuts.ui 7 | data/resources/ui/window.ui 8 | src/about.rs 9 | src/application.rs 10 | src/device.rs 11 | src/format.rs 12 | src/main.rs 13 | src/preferences_dialog.rs 14 | src/recording.rs 15 | src/settings.rs 16 | src/window/mod.rs 17 | -------------------------------------------------------------------------------- /po/bqi.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the kooha package. 4 | # hosseinabaspanah , 2023, 2024. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: kooha\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-09-26 20:06+0800\n" 10 | "PO-Revision-Date: 2024-02-20 02:02+0000\n" 11 | "Last-Translator: hosseinabaspanah \n" 12 | "Language-Team: Luri (Bakhtiari) \n" 14 | "Language: bqi\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 19 | "X-Generator: Weblate 5.5-dev\n" 20 | 21 | #: data/io.github.seadve.Kooha.desktop.in.in:3 22 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:7 src/about.rs:24 23 | #: src/main.rs:61 24 | msgid "Kooha" 25 | msgstr "kohā" 26 | 27 | #: data/io.github.seadve.Kooha.desktop.in.in:5 28 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:8 29 | msgid "Elegantly record your screen" 30 | msgstr "balge nemāyeš xote be xuvi zaft koň" 31 | 32 | #. Translators: These are search terms to find this application. Do NOT translate or localize the semicolons. The list MUST also end with a semicolon. 33 | #: data/io.github.seadve.Kooha.desktop.in.in:10 34 | msgid "Screencast;Recorder;Screen;Video;" 35 | msgstr "eskerin kast;zaft;balge nemāyeš;film" 36 | 37 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:10 38 | msgid "" 39 | "Capture your screen in a intuitive and straightforward way without " 40 | "distractions." 41 | msgstr "" 42 | 43 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:11 44 | msgid "" 45 | "Kooha is a simple screen recorder with a minimalist interface. You can just " 46 | "click the record button without having to configure a bunch of settings." 47 | msgstr "" 48 | 49 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:13 50 | msgid "The main features of Kooha include the following:" 51 | msgstr "qābeliyatā asli kohā yonowên:" 52 | 53 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:15 54 | msgid "🎙️ Record microphone, desktop audio, or both at the same time" 55 | msgstr "" 56 | 57 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:16 58 | msgid "📼 Support for WebM, MP4, GIF, and Matroska formats" 59 | msgstr "" 60 | 61 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:17 62 | msgid "🖥️ Select a monitor or a portion of the screen to record" 63 | msgstr "" 64 | 65 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:18 66 | msgid "" 67 | "🛠️ Configurable saving location, pointer visibility, frame rate, and delay" 68 | msgstr "" 69 | 70 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:19 71 | msgid "🚀 Experimental hardware accelerated encoding" 72 | msgstr "" 73 | 74 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:25 75 | msgid "Recording options and button to start recording" 76 | msgstr "" 77 | 78 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:29 79 | msgid "In-progress recording duration and button to stop recording" 80 | msgstr "" 81 | 82 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:33 83 | msgid "Countdown to start recording and button to cancel recording" 84 | msgstr "" 85 | 86 | #: data/resources/ui/area_selector.ui:15 87 | msgid "Select Area" 88 | msgstr "" 89 | 90 | #: data/resources/ui/area_selector.ui:20 data/resources/ui/window.ui:233 91 | #: data/resources/ui/window.ui:276 src/window/mod.rs:213 92 | msgid "Cancel" 93 | msgstr "" 94 | 95 | #: data/resources/ui/area_selector.ui:26 96 | msgid "Done" 97 | msgstr "" 98 | 99 | #: data/resources/ui/area_selector.ui:35 100 | msgid "Reset Selection" 101 | msgstr "" 102 | 103 | #: data/resources/ui/preferences_dialog.ui:10 data/resources/ui/shortcuts.ui:10 104 | msgid "General" 105 | msgstr "" 106 | 107 | #: data/resources/ui/preferences_dialog.ui:13 108 | msgid "Delay (Seconds)" 109 | msgstr "" 110 | 111 | #: data/resources/ui/preferences_dialog.ui:14 112 | msgid "Time interval before recording begins" 113 | msgstr "" 114 | 115 | #: data/resources/ui/preferences_dialog.ui:27 116 | msgid "Recordings Folder" 117 | msgstr "" 118 | 119 | #: data/resources/ui/preferences_dialog.ui:28 120 | msgid "Destination folder for the recordings" 121 | msgstr "" 122 | 123 | #: data/resources/ui/preferences_dialog.ui:50 124 | msgid "Video" 125 | msgstr "" 126 | 127 | #: data/resources/ui/preferences_dialog.ui:53 128 | msgid "Format" 129 | msgstr "" 130 | 131 | #: data/resources/ui/preferences_dialog.ui:58 132 | msgid "Frame Rate" 133 | msgstr "" 134 | 135 | #: data/resources/ui/shortcuts.ui:14 136 | msgid "Open Menu" 137 | msgstr "" 138 | 139 | #: data/resources/ui/shortcuts.ui:20 140 | msgid "Show Preferences" 141 | msgstr "" 142 | 143 | #: data/resources/ui/shortcuts.ui:26 144 | msgid "Show Shortcuts" 145 | msgstr "" 146 | 147 | #: data/resources/ui/shortcuts.ui:32 src/window/mod.rs:215 148 | msgid "Quit" 149 | msgstr "" 150 | 151 | #: data/resources/ui/shortcuts.ui:39 src/window/mod.rs:501 152 | msgid "Recording" 153 | msgstr "" 154 | 155 | #: data/resources/ui/shortcuts.ui:43 156 | msgid "Toggle Record" 157 | msgstr "" 158 | 159 | #: data/resources/ui/shortcuts.ui:50 160 | msgid "Toggle Pause" 161 | msgstr "" 162 | 163 | #: data/resources/ui/shortcuts.ui:56 164 | msgid "Cancel Record" 165 | msgstr "" 166 | 167 | #: data/resources/ui/shortcuts.ui:63 168 | msgid "Settings" 169 | msgstr "" 170 | 171 | #: data/resources/ui/shortcuts.ui:67 172 | msgid "Toggle Desktop Audio" 173 | msgstr "" 174 | 175 | #: data/resources/ui/shortcuts.ui:73 176 | msgid "Toggle Microphone" 177 | msgstr "" 178 | 179 | #: data/resources/ui/shortcuts.ui:79 180 | msgid "Toggle Pointer" 181 | msgstr "" 182 | 183 | #: data/resources/ui/window.ui:24 184 | msgid "Main Menu" 185 | msgstr "" 186 | 187 | #: data/resources/ui/window.ui:53 188 | msgid "Capture a Monitor or Window" 189 | msgstr "" 190 | 191 | #: data/resources/ui/window.ui:66 192 | msgid "Capture a Selection of Screen" 193 | msgstr "" 194 | 195 | #: data/resources/ui/window.ui:86 196 | msgid "Enable Desktop Audio" 197 | msgstr "" 198 | 199 | #: data/resources/ui/window.ui:87 200 | msgid "Disable Desktop Audio" 201 | msgstr "" 202 | 203 | #: data/resources/ui/window.ui:95 204 | msgid "Enable Microphone" 205 | msgstr "" 206 | 207 | #: data/resources/ui/window.ui:96 208 | msgid "Disable Microphone" 209 | msgstr "" 210 | 211 | #: data/resources/ui/window.ui:104 212 | msgid "Show Pointer" 213 | msgstr "" 214 | 215 | #: data/resources/ui/window.ui:105 216 | msgid "Hide Pointer" 217 | msgstr "" 218 | 219 | #: data/resources/ui/window.ui:115 220 | msgid "Start Recording" 221 | msgstr "" 222 | 223 | #: data/resources/ui/window.ui:116 224 | msgid "Record" 225 | msgstr "" 226 | 227 | #: data/resources/ui/window.ui:129 228 | msgid "Forget Previously Selected Video Sources" 229 | msgstr "" 230 | 231 | #: data/resources/ui/window.ui:180 232 | msgid "Stop Recording" 233 | msgstr "" 234 | 235 | #: data/resources/ui/window.ui:181 236 | msgid "Stop" 237 | msgstr "" 238 | 239 | #: data/resources/ui/window.ui:191 240 | msgid "Pause Recording" 241 | msgstr "" 242 | 243 | #: data/resources/ui/window.ui:214 244 | msgid "Recording in…" 245 | msgstr "" 246 | 247 | #: data/resources/ui/window.ui:232 data/resources/ui/window.ui:275 248 | msgid "Cancel Recording" 249 | msgstr "" 250 | 251 | #: data/resources/ui/window.ui:257 252 | msgid "Flushing…" 253 | msgstr "" 254 | 255 | #: data/resources/ui/window.ui:293 256 | msgid "_Preferences" 257 | msgstr "" 258 | 259 | #: data/resources/ui/window.ui:297 260 | msgid "_Keyboard Shortcuts" 261 | msgstr "" 262 | 263 | #: data/resources/ui/window.ui:301 264 | msgid "_About Kooha" 265 | msgstr "" 266 | 267 | #. Translators: Replace "translator-credits" with your names. Put a comma between. 268 | #: src/about.rs:35 269 | msgid "translator-credits" 270 | msgstr "" 271 | 272 | #: src/about.rs:45 273 | msgid "Donate (Buy Me a Coffee)" 274 | msgstr "" 275 | 276 | #: src/about.rs:48 277 | msgid "GitHub" 278 | msgstr "" 279 | 280 | #: src/about.rs:50 281 | msgid "Translate" 282 | msgstr "" 283 | 284 | #. Translators: This is a message that the user will see when the recording is finished. 285 | #: src/application.rs:137 286 | msgid "Screencast recorded" 287 | msgstr "" 288 | 289 | #: src/application.rs:144 290 | msgid "Show in Files" 291 | msgstr "" 292 | 293 | #: src/device.rs:26 294 | msgid "Failed to find the default audio device" 295 | msgstr "" 296 | 297 | #: src/device.rs:27 298 | msgid "Make sure that you have PulseAudio installed in your system." 299 | msgstr "" 300 | 301 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 302 | #: src/format.rs:36 303 | msgid "{time} hour" 304 | msgid_plural "{time} hours" 305 | msgstr[0] "" 306 | msgstr[1] "" 307 | 308 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 309 | #: src/format.rs:43 310 | msgid "{time} minute" 311 | msgid_plural "{time} minutes" 312 | msgstr[0] "" 313 | msgstr[1] "" 314 | 315 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 316 | #: src/format.rs:50 317 | msgid "{time} second" 318 | msgid_plural "{time} seconds" 319 | msgstr[0] "" 320 | msgstr[1] "" 321 | 322 | #: src/preferences_dialog.rs:77 323 | msgid "Failed to set recordings folder" 324 | msgstr "" 325 | 326 | #: src/preferences_dialog.rs:237 327 | msgid "This frame rate may cause performance issues on the selected format." 328 | msgstr "" 329 | 330 | #: src/preferences_dialog.rs:301 331 | msgid "This format is experimental and unsupported." 332 | msgstr "" 333 | 334 | #: src/preferences_dialog.rs:308 src/window/mod.rs:559 335 | msgid "None" 336 | msgstr "" 337 | 338 | #: src/recording.rs:42 339 | msgid "No active profile" 340 | msgstr "" 341 | 342 | #: src/recording.rs:184 src/recording.rs:221 src/recording.rs:271 343 | msgid "Failed to start recording" 344 | msgstr "" 345 | 346 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 347 | #: src/recording.rs:187 348 | msgid "Check out {link} for help." 349 | msgstr "" 350 | 351 | #: src/recording.rs:222 352 | msgid "A GStreamer plugin may not be installed." 353 | msgstr "" 354 | 355 | #: src/recording.rs:272 src/recording.rs:501 356 | msgid "Make sure that the saving location exists and is accessible." 357 | msgstr "" 358 | 359 | #: src/recording.rs:492 360 | msgid "An error occurred while recording" 361 | msgstr "" 362 | 363 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 364 | #: src/recording.rs:498 365 | msgid "Failed to open “{path}” for writing" 366 | msgstr "" 367 | 368 | #: src/settings.rs:43 369 | msgid "Select Recordings Folder" 370 | msgstr "" 371 | 372 | #: src/window/mod.rs:78 373 | msgid "Failed to toggle pause" 374 | msgstr "" 375 | 376 | #: src/window/mod.rs:199 377 | msgid "" 378 | "A recording is currently in progress. Quitting immediately may cause the " 379 | "recording to be unplayable. Please stop the recording before quitting." 380 | msgstr "" 381 | 382 | #: src/window/mod.rs:202 383 | msgid "" 384 | "Quitting will cancel the processing and may cause the recording to be " 385 | "unplayable." 386 | msgstr "" 387 | 388 | #: src/window/mod.rs:208 389 | msgid "Quit the Application?" 390 | msgstr "" 391 | 392 | #: src/window/mod.rs:256 393 | msgid "Copy to clipboard" 394 | msgstr "" 395 | 396 | #: src/window/mod.rs:261 397 | msgid "Copied to clipboard" 398 | msgstr "" 399 | 400 | #: src/window/mod.rs:267 401 | msgid "Show detailed error" 402 | msgstr "" 403 | 404 | #: src/window/mod.rs:289 405 | msgid "Help" 406 | msgstr "" 407 | 408 | #: src/window/mod.rs:294 409 | msgid "Ok, Got It" 410 | msgstr "" 411 | 412 | #: src/window/mod.rs:303 413 | msgid "Open Preferences?" 414 | msgstr "" 415 | 416 | #: src/window/mod.rs:304 417 | msgid "" 418 | "The previously selected format may have been unavailable. Open preferences " 419 | "and select a format to continue recording." 420 | msgstr "" 421 | 422 | #: src/window/mod.rs:308 423 | msgid "Later" 424 | msgstr "" 425 | 426 | #: src/window/mod.rs:310 427 | msgid "Open" 428 | msgstr "" 429 | 430 | #: src/window/mod.rs:461 431 | msgid "A recording is in progress" 432 | msgstr "" 433 | 434 | #: src/window/mod.rs:509 435 | msgid "Paused" 436 | msgstr "" 437 | 438 | #: src/window/mod.rs:546 439 | msgid "Normal" 440 | msgstr "" 441 | 442 | #: src/window/mod.rs:547 443 | msgid "Selection" 444 | msgstr "" 445 | -------------------------------------------------------------------------------- /po/ia.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the kooha package. 4 | # "Emilio S." , 2025. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: kooha\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-09-26 20:06+0800\n" 10 | "PO-Revision-Date: 2025-02-02 19:14+0000\n" 11 | "Last-Translator: Anonymous \n" 12 | "Language-Team: Interlingua \n" 14 | "Language: ia\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 19 | "X-Generator: Weblate 5.10-dev\n" 20 | 21 | #: data/io.github.seadve.Kooha.desktop.in.in:3 22 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:7 src/about.rs:24 23 | #: src/main.rs:61 24 | msgid "Kooha" 25 | msgstr "" 26 | 27 | #: data/io.github.seadve.Kooha.desktop.in.in:5 28 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:8 29 | msgid "Elegantly record your screen" 30 | msgstr "" 31 | 32 | #. Translators: These are search terms to find this application. Do NOT translate or localize the semicolons. The list MUST also end with a semicolon. 33 | #: data/io.github.seadve.Kooha.desktop.in.in:10 34 | msgid "Screencast;Recorder;Screen;Video;" 35 | msgstr "" 36 | 37 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:10 38 | msgid "" 39 | "Capture your screen in a intuitive and straightforward way without " 40 | "distractions." 41 | msgstr "" 42 | 43 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:11 44 | msgid "" 45 | "Kooha is a simple screen recorder with a minimalist interface. You can just " 46 | "click the record button without having to configure a bunch of settings." 47 | msgstr "" 48 | 49 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:13 50 | msgid "The main features of Kooha include the following:" 51 | msgstr "" 52 | 53 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:15 54 | msgid "🎙️ Record microphone, desktop audio, or both at the same time" 55 | msgstr "" 56 | 57 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:16 58 | msgid "📼 Support for WebM, MP4, GIF, and Matroska formats" 59 | msgstr "" 60 | 61 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:17 62 | msgid "🖥️ Select a monitor or a portion of the screen to record" 63 | msgstr "" 64 | 65 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:18 66 | msgid "" 67 | "🛠️ Configurable saving location, pointer visibility, frame rate, and delay" 68 | msgstr "" 69 | 70 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:19 71 | msgid "🚀 Experimental hardware accelerated encoding" 72 | msgstr "" 73 | 74 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:25 75 | msgid "Recording options and button to start recording" 76 | msgstr "" 77 | 78 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:29 79 | msgid "In-progress recording duration and button to stop recording" 80 | msgstr "" 81 | 82 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:33 83 | msgid "Countdown to start recording and button to cancel recording" 84 | msgstr "" 85 | 86 | #: data/resources/ui/area_selector.ui:15 87 | msgid "Select Area" 88 | msgstr "" 89 | 90 | #: data/resources/ui/area_selector.ui:20 data/resources/ui/window.ui:233 91 | #: data/resources/ui/window.ui:276 src/window/mod.rs:213 92 | msgid "Cancel" 93 | msgstr "Cancellar" 94 | 95 | #: data/resources/ui/area_selector.ui:26 96 | msgid "Done" 97 | msgstr "" 98 | 99 | #: data/resources/ui/area_selector.ui:35 100 | msgid "Reset Selection" 101 | msgstr "" 102 | 103 | #: data/resources/ui/preferences_dialog.ui:10 data/resources/ui/shortcuts.ui:10 104 | msgid "General" 105 | msgstr "General" 106 | 107 | #: data/resources/ui/preferences_dialog.ui:13 108 | msgid "Delay (Seconds)" 109 | msgstr "" 110 | 111 | #: data/resources/ui/preferences_dialog.ui:14 112 | msgid "Time interval before recording begins" 113 | msgstr "" 114 | 115 | #: data/resources/ui/preferences_dialog.ui:27 116 | msgid "Recordings Folder" 117 | msgstr "" 118 | 119 | #: data/resources/ui/preferences_dialog.ui:28 120 | msgid "Destination folder for the recordings" 121 | msgstr "" 122 | 123 | #: data/resources/ui/preferences_dialog.ui:50 124 | msgid "Video" 125 | msgstr "" 126 | 127 | #: data/resources/ui/preferences_dialog.ui:53 128 | msgid "Format" 129 | msgstr "" 130 | 131 | #: data/resources/ui/preferences_dialog.ui:58 132 | msgid "Frame Rate" 133 | msgstr "" 134 | 135 | #: data/resources/ui/shortcuts.ui:14 136 | msgid "Open Menu" 137 | msgstr "Aperir menu" 138 | 139 | #: data/resources/ui/shortcuts.ui:20 140 | msgid "Show Preferences" 141 | msgstr "" 142 | 143 | #: data/resources/ui/shortcuts.ui:26 144 | #, fuzzy 145 | msgid "Show Shortcuts" 146 | msgstr "Monstrar commandos curte" 147 | 148 | #: data/resources/ui/shortcuts.ui:32 src/window/mod.rs:215 149 | msgid "Quit" 150 | msgstr "Quitar" 151 | 152 | #: data/resources/ui/shortcuts.ui:39 src/window/mod.rs:501 153 | msgid "Recording" 154 | msgstr "" 155 | 156 | #: data/resources/ui/shortcuts.ui:43 157 | msgid "Toggle Record" 158 | msgstr "" 159 | 160 | #: data/resources/ui/shortcuts.ui:50 161 | msgid "Toggle Pause" 162 | msgstr "" 163 | 164 | #: data/resources/ui/shortcuts.ui:56 165 | msgid "Cancel Record" 166 | msgstr "" 167 | 168 | #: data/resources/ui/shortcuts.ui:63 169 | msgid "Settings" 170 | msgstr "" 171 | 172 | #: data/resources/ui/shortcuts.ui:67 173 | msgid "Toggle Desktop Audio" 174 | msgstr "" 175 | 176 | #: data/resources/ui/shortcuts.ui:73 177 | msgid "Toggle Microphone" 178 | msgstr "" 179 | 180 | #: data/resources/ui/shortcuts.ui:79 181 | msgid "Toggle Pointer" 182 | msgstr "" 183 | 184 | #: data/resources/ui/window.ui:24 185 | msgid "Main Menu" 186 | msgstr "Menu principal" 187 | 188 | #: data/resources/ui/window.ui:53 189 | msgid "Capture a Monitor or Window" 190 | msgstr "" 191 | 192 | #: data/resources/ui/window.ui:66 193 | msgid "Capture a Selection of Screen" 194 | msgstr "" 195 | 196 | #: data/resources/ui/window.ui:86 197 | msgid "Enable Desktop Audio" 198 | msgstr "" 199 | 200 | #: data/resources/ui/window.ui:87 201 | msgid "Disable Desktop Audio" 202 | msgstr "" 203 | 204 | #: data/resources/ui/window.ui:95 205 | msgid "Enable Microphone" 206 | msgstr "" 207 | 208 | #: data/resources/ui/window.ui:96 209 | msgid "Disable Microphone" 210 | msgstr "" 211 | 212 | #: data/resources/ui/window.ui:104 213 | msgid "Show Pointer" 214 | msgstr "" 215 | 216 | #: data/resources/ui/window.ui:105 217 | msgid "Hide Pointer" 218 | msgstr "" 219 | 220 | #: data/resources/ui/window.ui:115 221 | msgid "Start Recording" 222 | msgstr "" 223 | 224 | #: data/resources/ui/window.ui:116 225 | msgid "Record" 226 | msgstr "" 227 | 228 | #: data/resources/ui/window.ui:129 229 | msgid "Forget Previously Selected Video Sources" 230 | msgstr "" 231 | 232 | #: data/resources/ui/window.ui:180 233 | msgid "Stop Recording" 234 | msgstr "" 235 | 236 | #: data/resources/ui/window.ui:181 237 | msgid "Stop" 238 | msgstr "" 239 | 240 | #: data/resources/ui/window.ui:191 241 | msgid "Pause Recording" 242 | msgstr "" 243 | 244 | #: data/resources/ui/window.ui:214 245 | msgid "Recording in…" 246 | msgstr "" 247 | 248 | #: data/resources/ui/window.ui:232 data/resources/ui/window.ui:275 249 | msgid "Cancel Recording" 250 | msgstr "" 251 | 252 | #: data/resources/ui/window.ui:257 253 | msgid "Flushing…" 254 | msgstr "" 255 | 256 | #: data/resources/ui/window.ui:293 257 | msgid "_Preferences" 258 | msgstr "_Preferentias" 259 | 260 | #: data/resources/ui/window.ui:297 261 | msgid "_Keyboard Shortcuts" 262 | msgstr "_Commandos curte de claviero" 263 | 264 | #: data/resources/ui/window.ui:301 265 | msgid "_About Kooha" 266 | msgstr "" 267 | 268 | #. Translators: Replace "translator-credits" with your names. Put a comma between. 269 | #: src/about.rs:35 270 | msgid "translator-credits" 271 | msgstr "" 272 | 273 | #: src/about.rs:45 274 | msgid "Donate (Buy Me a Coffee)" 275 | msgstr "" 276 | 277 | #: src/about.rs:48 278 | msgid "GitHub" 279 | msgstr "GitHub" 280 | 281 | #: src/about.rs:50 282 | msgid "Translate" 283 | msgstr "Traducer" 284 | 285 | #. Translators: This is a message that the user will see when the recording is finished. 286 | #: src/application.rs:137 287 | msgid "Screencast recorded" 288 | msgstr "" 289 | 290 | #: src/application.rs:144 291 | msgid "Show in Files" 292 | msgstr "" 293 | 294 | #: src/device.rs:26 295 | msgid "Failed to find the default audio device" 296 | msgstr "" 297 | 298 | #: src/device.rs:27 299 | msgid "Make sure that you have PulseAudio installed in your system." 300 | msgstr "" 301 | 302 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 303 | #: src/format.rs:36 304 | msgid "{time} hour" 305 | msgid_plural "{time} hours" 306 | msgstr[0] "" 307 | msgstr[1] "" 308 | 309 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 310 | #: src/format.rs:43 311 | msgid "{time} minute" 312 | msgid_plural "{time} minutes" 313 | msgstr[0] "" 314 | msgstr[1] "" 315 | 316 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 317 | #: src/format.rs:50 318 | msgid "{time} second" 319 | msgid_plural "{time} seconds" 320 | msgstr[0] "" 321 | msgstr[1] "" 322 | 323 | #: src/preferences_dialog.rs:77 324 | msgid "Failed to set recordings folder" 325 | msgstr "" 326 | 327 | #: src/preferences_dialog.rs:237 328 | msgid "This frame rate may cause performance issues on the selected format." 329 | msgstr "" 330 | 331 | #: src/preferences_dialog.rs:301 332 | msgid "This format is experimental and unsupported." 333 | msgstr "" 334 | 335 | #: src/preferences_dialog.rs:308 src/window/mod.rs:559 336 | msgid "None" 337 | msgstr "" 338 | 339 | #: src/recording.rs:42 340 | msgid "No active profile" 341 | msgstr "" 342 | 343 | #: src/recording.rs:184 src/recording.rs:221 src/recording.rs:271 344 | msgid "Failed to start recording" 345 | msgstr "" 346 | 347 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 348 | #: src/recording.rs:187 349 | msgid "Check out {link} for help." 350 | msgstr "" 351 | 352 | #: src/recording.rs:222 353 | msgid "A GStreamer plugin may not be installed." 354 | msgstr "" 355 | 356 | #: src/recording.rs:272 src/recording.rs:501 357 | msgid "Make sure that the saving location exists and is accessible." 358 | msgstr "" 359 | 360 | #: src/recording.rs:492 361 | msgid "An error occurred while recording" 362 | msgstr "" 363 | 364 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 365 | #: src/recording.rs:498 366 | msgid "Failed to open “{path}” for writing" 367 | msgstr "" 368 | 369 | #: src/settings.rs:43 370 | msgid "Select Recordings Folder" 371 | msgstr "" 372 | 373 | #: src/window/mod.rs:78 374 | msgid "Failed to toggle pause" 375 | msgstr "" 376 | 377 | #: src/window/mod.rs:199 378 | msgid "" 379 | "A recording is currently in progress. Quitting immediately may cause the " 380 | "recording to be unplayable. Please stop the recording before quitting." 381 | msgstr "" 382 | 383 | #: src/window/mod.rs:202 384 | msgid "" 385 | "Quitting will cancel the processing and may cause the recording to be " 386 | "unplayable." 387 | msgstr "" 388 | 389 | #: src/window/mod.rs:208 390 | msgid "Quit the Application?" 391 | msgstr "" 392 | 393 | #: src/window/mod.rs:256 394 | msgid "Copy to clipboard" 395 | msgstr "" 396 | 397 | #: src/window/mod.rs:261 398 | msgid "Copied to clipboard" 399 | msgstr "" 400 | 401 | #: src/window/mod.rs:267 402 | msgid "Show detailed error" 403 | msgstr "" 404 | 405 | #: src/window/mod.rs:289 406 | msgid "Help" 407 | msgstr "" 408 | 409 | #: src/window/mod.rs:294 410 | msgid "Ok, Got It" 411 | msgstr "" 412 | 413 | #: src/window/mod.rs:303 414 | msgid "Open Preferences?" 415 | msgstr "" 416 | 417 | #: src/window/mod.rs:304 418 | msgid "" 419 | "The previously selected format may have been unavailable. Open preferences " 420 | "and select a format to continue recording." 421 | msgstr "" 422 | 423 | #: src/window/mod.rs:308 424 | msgid "Later" 425 | msgstr "" 426 | 427 | #: src/window/mod.rs:310 428 | msgid "Open" 429 | msgstr "" 430 | 431 | #: src/window/mod.rs:461 432 | msgid "A recording is in progress" 433 | msgstr "" 434 | 435 | #: src/window/mod.rs:509 436 | msgid "Paused" 437 | msgstr "" 438 | 439 | #: src/window/mod.rs:546 440 | msgid "Normal" 441 | msgstr "" 442 | 443 | #: src/window/mod.rs:547 444 | msgid "Selection" 445 | msgstr "" 446 | -------------------------------------------------------------------------------- /po/kooha.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the kooha package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: kooha\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-09-26 20:06+0800\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" 20 | 21 | #: data/io.github.seadve.Kooha.desktop.in.in:3 22 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:7 src/about.rs:24 23 | #: src/main.rs:61 24 | msgid "Kooha" 25 | msgstr "" 26 | 27 | #: data/io.github.seadve.Kooha.desktop.in.in:5 28 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:8 29 | msgid "Elegantly record your screen" 30 | msgstr "" 31 | 32 | #. Translators: These are search terms to find this application. Do NOT translate or localize the semicolons. The list MUST also end with a semicolon. 33 | #: data/io.github.seadve.Kooha.desktop.in.in:10 34 | msgid "Screencast;Recorder;Screen;Video;" 35 | msgstr "" 36 | 37 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:10 38 | msgid "" 39 | "Capture your screen in a intuitive and straightforward way without " 40 | "distractions." 41 | msgstr "" 42 | 43 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:11 44 | msgid "" 45 | "Kooha is a simple screen recorder with a minimalist interface. You can just " 46 | "click the record button without having to configure a bunch of settings." 47 | msgstr "" 48 | 49 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:13 50 | msgid "The main features of Kooha include the following:" 51 | msgstr "" 52 | 53 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:15 54 | msgid "🎙️ Record microphone, desktop audio, or both at the same time" 55 | msgstr "" 56 | 57 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:16 58 | msgid "📼 Support for WebM, MP4, GIF, and Matroska formats" 59 | msgstr "" 60 | 61 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:17 62 | msgid "🖥️ Select a monitor or a portion of the screen to record" 63 | msgstr "" 64 | 65 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:18 66 | msgid "" 67 | "🛠️ Configurable saving location, pointer visibility, frame rate, and delay" 68 | msgstr "" 69 | 70 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:19 71 | msgid "🚀 Experimental hardware accelerated encoding" 72 | msgstr "" 73 | 74 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:25 75 | msgid "Recording options and button to start recording" 76 | msgstr "" 77 | 78 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:29 79 | msgid "In-progress recording duration and button to stop recording" 80 | msgstr "" 81 | 82 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:33 83 | msgid "Countdown to start recording and button to cancel recording" 84 | msgstr "" 85 | 86 | #: data/resources/ui/area_selector.ui:15 87 | msgid "Select Area" 88 | msgstr "" 89 | 90 | #: data/resources/ui/area_selector.ui:20 data/resources/ui/window.ui:233 91 | #: data/resources/ui/window.ui:276 src/window/mod.rs:213 92 | msgid "Cancel" 93 | msgstr "" 94 | 95 | #: data/resources/ui/area_selector.ui:26 96 | msgid "Done" 97 | msgstr "" 98 | 99 | #: data/resources/ui/area_selector.ui:35 100 | msgid "Reset Selection" 101 | msgstr "" 102 | 103 | #: data/resources/ui/preferences_dialog.ui:10 data/resources/ui/shortcuts.ui:10 104 | msgid "General" 105 | msgstr "" 106 | 107 | #: data/resources/ui/preferences_dialog.ui:13 108 | msgid "Delay (Seconds)" 109 | msgstr "" 110 | 111 | #: data/resources/ui/preferences_dialog.ui:14 112 | msgid "Time interval before recording begins" 113 | msgstr "" 114 | 115 | #: data/resources/ui/preferences_dialog.ui:27 116 | msgid "Recordings Folder" 117 | msgstr "" 118 | 119 | #: data/resources/ui/preferences_dialog.ui:28 120 | msgid "Destination folder for the recordings" 121 | msgstr "" 122 | 123 | #: data/resources/ui/preferences_dialog.ui:50 124 | msgid "Video" 125 | msgstr "" 126 | 127 | #: data/resources/ui/preferences_dialog.ui:53 128 | msgid "Format" 129 | msgstr "" 130 | 131 | #: data/resources/ui/preferences_dialog.ui:58 132 | msgid "Frame Rate" 133 | msgstr "" 134 | 135 | #: data/resources/ui/shortcuts.ui:14 136 | msgid "Open Menu" 137 | msgstr "" 138 | 139 | #: data/resources/ui/shortcuts.ui:20 140 | msgid "Show Preferences" 141 | msgstr "" 142 | 143 | #: data/resources/ui/shortcuts.ui:26 144 | msgid "Show Shortcuts" 145 | msgstr "" 146 | 147 | #: data/resources/ui/shortcuts.ui:32 src/window/mod.rs:215 148 | msgid "Quit" 149 | msgstr "" 150 | 151 | #: data/resources/ui/shortcuts.ui:39 src/window/mod.rs:501 152 | msgid "Recording" 153 | msgstr "" 154 | 155 | #: data/resources/ui/shortcuts.ui:43 156 | msgid "Toggle Record" 157 | msgstr "" 158 | 159 | #: data/resources/ui/shortcuts.ui:50 160 | msgid "Toggle Pause" 161 | msgstr "" 162 | 163 | #: data/resources/ui/shortcuts.ui:56 164 | msgid "Cancel Record" 165 | msgstr "" 166 | 167 | #: data/resources/ui/shortcuts.ui:63 168 | msgid "Settings" 169 | msgstr "" 170 | 171 | #: data/resources/ui/shortcuts.ui:67 172 | msgid "Toggle Desktop Audio" 173 | msgstr "" 174 | 175 | #: data/resources/ui/shortcuts.ui:73 176 | msgid "Toggle Microphone" 177 | msgstr "" 178 | 179 | #: data/resources/ui/shortcuts.ui:79 180 | msgid "Toggle Pointer" 181 | msgstr "" 182 | 183 | #: data/resources/ui/window.ui:24 184 | msgid "Main Menu" 185 | msgstr "" 186 | 187 | #: data/resources/ui/window.ui:53 188 | msgid "Capture a Monitor or Window" 189 | msgstr "" 190 | 191 | #: data/resources/ui/window.ui:66 192 | msgid "Capture a Selection of Screen" 193 | msgstr "" 194 | 195 | #: data/resources/ui/window.ui:86 196 | msgid "Enable Desktop Audio" 197 | msgstr "" 198 | 199 | #: data/resources/ui/window.ui:87 200 | msgid "Disable Desktop Audio" 201 | msgstr "" 202 | 203 | #: data/resources/ui/window.ui:95 204 | msgid "Enable Microphone" 205 | msgstr "" 206 | 207 | #: data/resources/ui/window.ui:96 208 | msgid "Disable Microphone" 209 | msgstr "" 210 | 211 | #: data/resources/ui/window.ui:104 212 | msgid "Show Pointer" 213 | msgstr "" 214 | 215 | #: data/resources/ui/window.ui:105 216 | msgid "Hide Pointer" 217 | msgstr "" 218 | 219 | #: data/resources/ui/window.ui:115 220 | msgid "Start Recording" 221 | msgstr "" 222 | 223 | #: data/resources/ui/window.ui:116 224 | msgid "Record" 225 | msgstr "" 226 | 227 | #: data/resources/ui/window.ui:129 228 | msgid "Forget Previously Selected Video Sources" 229 | msgstr "" 230 | 231 | #: data/resources/ui/window.ui:180 232 | msgid "Stop Recording" 233 | msgstr "" 234 | 235 | #: data/resources/ui/window.ui:181 236 | msgid "Stop" 237 | msgstr "" 238 | 239 | #: data/resources/ui/window.ui:191 240 | msgid "Pause Recording" 241 | msgstr "" 242 | 243 | #: data/resources/ui/window.ui:214 244 | msgid "Recording in…" 245 | msgstr "" 246 | 247 | #: data/resources/ui/window.ui:232 data/resources/ui/window.ui:275 248 | msgid "Cancel Recording" 249 | msgstr "" 250 | 251 | #: data/resources/ui/window.ui:257 252 | msgid "Flushing…" 253 | msgstr "" 254 | 255 | #: data/resources/ui/window.ui:293 256 | msgid "_Preferences" 257 | msgstr "" 258 | 259 | #: data/resources/ui/window.ui:297 260 | msgid "_Keyboard Shortcuts" 261 | msgstr "" 262 | 263 | #: data/resources/ui/window.ui:301 264 | msgid "_About Kooha" 265 | msgstr "" 266 | 267 | #. Translators: Replace "translator-credits" with your names. Put a comma between. 268 | #: src/about.rs:35 269 | msgid "translator-credits" 270 | msgstr "" 271 | 272 | #: src/about.rs:45 273 | msgid "Donate (Buy Me a Coffee)" 274 | msgstr "" 275 | 276 | #: src/about.rs:48 277 | msgid "GitHub" 278 | msgstr "" 279 | 280 | #: src/about.rs:50 281 | msgid "Translate" 282 | msgstr "" 283 | 284 | #. Translators: This is a message that the user will see when the recording is finished. 285 | #: src/application.rs:137 286 | msgid "Screencast recorded" 287 | msgstr "" 288 | 289 | #: src/application.rs:144 290 | msgid "Show in Files" 291 | msgstr "" 292 | 293 | #: src/device.rs:26 294 | msgid "Failed to find the default audio device" 295 | msgstr "" 296 | 297 | #: src/device.rs:27 298 | msgid "Make sure that you have PulseAudio installed in your system." 299 | msgstr "" 300 | 301 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 302 | #: src/format.rs:36 303 | msgid "{time} hour" 304 | msgid_plural "{time} hours" 305 | msgstr[0] "" 306 | msgstr[1] "" 307 | 308 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 309 | #: src/format.rs:43 310 | msgid "{time} minute" 311 | msgid_plural "{time} minutes" 312 | msgstr[0] "" 313 | msgstr[1] "" 314 | 315 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 316 | #: src/format.rs:50 317 | msgid "{time} second" 318 | msgid_plural "{time} seconds" 319 | msgstr[0] "" 320 | msgstr[1] "" 321 | 322 | #: src/preferences_dialog.rs:77 323 | msgid "Failed to set recordings folder" 324 | msgstr "" 325 | 326 | #: src/preferences_dialog.rs:237 327 | msgid "This frame rate may cause performance issues on the selected format." 328 | msgstr "" 329 | 330 | #: src/preferences_dialog.rs:301 331 | msgid "This format is experimental and unsupported." 332 | msgstr "" 333 | 334 | #: src/preferences_dialog.rs:308 src/window/mod.rs:559 335 | msgid "None" 336 | msgstr "" 337 | 338 | #: src/recording.rs:42 339 | msgid "No active profile" 340 | msgstr "" 341 | 342 | #: src/recording.rs:184 src/recording.rs:221 src/recording.rs:271 343 | msgid "Failed to start recording" 344 | msgstr "" 345 | 346 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 347 | #: src/recording.rs:187 348 | msgid "Check out {link} for help." 349 | msgstr "" 350 | 351 | #: src/recording.rs:222 352 | msgid "A GStreamer plugin may not be installed." 353 | msgstr "" 354 | 355 | #: src/recording.rs:272 src/recording.rs:501 356 | msgid "Make sure that the saving location exists and is accessible." 357 | msgstr "" 358 | 359 | #: src/recording.rs:492 360 | msgid "An error occurred while recording" 361 | msgstr "" 362 | 363 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 364 | #: src/recording.rs:498 365 | msgid "Failed to open “{path}” for writing" 366 | msgstr "" 367 | 368 | #: src/settings.rs:43 369 | msgid "Select Recordings Folder" 370 | msgstr "" 371 | 372 | #: src/window/mod.rs:78 373 | msgid "Failed to toggle pause" 374 | msgstr "" 375 | 376 | #: src/window/mod.rs:199 377 | msgid "" 378 | "A recording is currently in progress. Quitting immediately may cause the " 379 | "recording to be unplayable. Please stop the recording before quitting." 380 | msgstr "" 381 | 382 | #: src/window/mod.rs:202 383 | msgid "" 384 | "Quitting will cancel the processing and may cause the recording to be " 385 | "unplayable." 386 | msgstr "" 387 | 388 | #: src/window/mod.rs:208 389 | msgid "Quit the Application?" 390 | msgstr "" 391 | 392 | #: src/window/mod.rs:256 393 | msgid "Copy to clipboard" 394 | msgstr "" 395 | 396 | #: src/window/mod.rs:261 397 | msgid "Copied to clipboard" 398 | msgstr "" 399 | 400 | #: src/window/mod.rs:267 401 | msgid "Show detailed error" 402 | msgstr "" 403 | 404 | #: src/window/mod.rs:289 405 | msgid "Help" 406 | msgstr "" 407 | 408 | #: src/window/mod.rs:294 409 | msgid "Ok, Got It" 410 | msgstr "" 411 | 412 | #: src/window/mod.rs:303 413 | msgid "Open Preferences?" 414 | msgstr "" 415 | 416 | #: src/window/mod.rs:304 417 | msgid "" 418 | "The previously selected format may have been unavailable. Open preferences " 419 | "and select a format to continue recording." 420 | msgstr "" 421 | 422 | #: src/window/mod.rs:308 423 | msgid "Later" 424 | msgstr "" 425 | 426 | #: src/window/mod.rs:310 427 | msgid "Open" 428 | msgstr "" 429 | 430 | #: src/window/mod.rs:461 431 | msgid "A recording is in progress" 432 | msgstr "" 433 | 434 | #: src/window/mod.rs:509 435 | msgid "Paused" 436 | msgstr "" 437 | 438 | #: src/window/mod.rs:546 439 | msgid "Normal" 440 | msgstr "" 441 | 442 | #: src/window/mod.rs:547 443 | msgid "Selection" 444 | msgstr "" 445 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(gettext_package, 2 | args: ['--keyword=gettext_f', '--keyword=ngettext_f:1,2',], 3 | preset: 'glib') 4 | -------------------------------------------------------------------------------- /po/ro.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the kooha package. 4 | # C0sM1n , 2023. 5 | # Gabriel Cozma , 2024. 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: kooha\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2024-09-26 20:06+0800\n" 11 | "PO-Revision-Date: 2024-03-24 21:01+0000\n" 12 | "Last-Translator: Gabriel Cozma \n" 13 | "Language-Team: Romanian \n" 15 | "Language: ro\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < " 20 | "20)) ? 1 : 2;\n" 21 | "X-Generator: Weblate 5.5-dev\n" 22 | 23 | #: data/io.github.seadve.Kooha.desktop.in.in:3 24 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:7 src/about.rs:24 25 | #: src/main.rs:61 26 | msgid "Kooha" 27 | msgstr "Kooha" 28 | 29 | #: data/io.github.seadve.Kooha.desktop.in.in:5 30 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:8 31 | msgid "Elegantly record your screen" 32 | msgstr "Înregistrați elegant ecranul" 33 | 34 | #. Translators: These are search terms to find this application. Do NOT translate or localize the semicolons. The list MUST also end with a semicolon. 35 | #: data/io.github.seadve.Kooha.desktop.in.in:10 36 | msgid "Screencast;Recorder;Screen;Video;" 37 | msgstr "Screencast;Recorder;Ecran;Video;" 38 | 39 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:10 40 | msgid "" 41 | "Capture your screen in a intuitive and straightforward way without " 42 | "distractions." 43 | msgstr "Capturați-vă ecranul într-un mod intuitiv și simplu, fără distrageri." 44 | 45 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:11 46 | msgid "" 47 | "Kooha is a simple screen recorder with a minimalist interface. You can just " 48 | "click the record button without having to configure a bunch of settings." 49 | msgstr "" 50 | "Kooha este un simplu înregistrator de ecran, cu o interfață minimalistă. " 51 | "Puteți doar să faceți un clic pe butonul de înregistrare fără a fi nevoie să " 52 | "configurați o grămadă de setări." 53 | 54 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:13 55 | msgid "The main features of Kooha include the following:" 56 | msgstr "Principalele caracteristici ale lui Kooha includ următoarele:" 57 | 58 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:15 59 | msgid "🎙️ Record microphone, desktop audio, or both at the same time" 60 | msgstr "🎙️ Înregistrați microfon, sunet desktop sau ambele în același timp" 61 | 62 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:16 63 | msgid "📼 Support for WebM, MP4, GIF, and Matroska formats" 64 | msgstr "📼 Suport pentru formatele WebM, MP4, GIF și Matroska" 65 | 66 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:17 67 | msgid "🖥️ Select a monitor or a portion of the screen to record" 68 | msgstr "🖥️ Selectați un monitor sau o porțiune a ecranului pentru a înregistra" 69 | 70 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:18 71 | msgid "" 72 | "🛠️ Configurable saving location, pointer visibility, frame rate, and delay" 73 | msgstr "" 74 | "🛠️ Locație configurabilă de salvare, vizibilitate indicator, rata de cadre și " 75 | "întârziere" 76 | 77 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:19 78 | msgid "🚀 Experimental hardware accelerated encoding" 79 | msgstr "🚀 Codare accelerată hardware experimentală" 80 | 81 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:25 82 | msgid "Recording options and button to start recording" 83 | msgstr "" 84 | 85 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:29 86 | msgid "In-progress recording duration and button to stop recording" 87 | msgstr "" 88 | 89 | #: data/io.github.seadve.Kooha.metainfo.xml.in.in:33 90 | msgid "Countdown to start recording and button to cancel recording" 91 | msgstr "" 92 | 93 | #: data/resources/ui/area_selector.ui:15 94 | msgid "Select Area" 95 | msgstr "Selectați Zona" 96 | 97 | #: data/resources/ui/area_selector.ui:20 data/resources/ui/window.ui:233 98 | #: data/resources/ui/window.ui:276 src/window/mod.rs:213 99 | msgid "Cancel" 100 | msgstr "Renunță" 101 | 102 | #: data/resources/ui/area_selector.ui:26 103 | msgid "Done" 104 | msgstr "Terminat" 105 | 106 | #: data/resources/ui/area_selector.ui:35 107 | msgid "Reset Selection" 108 | msgstr "Resetează selecția" 109 | 110 | #: data/resources/ui/preferences_dialog.ui:10 data/resources/ui/shortcuts.ui:10 111 | msgid "General" 112 | msgstr "General" 113 | 114 | #: data/resources/ui/preferences_dialog.ui:13 115 | msgid "Delay (Seconds)" 116 | msgstr "Întârziere (secunde)" 117 | 118 | #: data/resources/ui/preferences_dialog.ui:14 119 | msgid "Time interval before recording begins" 120 | msgstr "Interval de timp înainte de începerea înregistrării" 121 | 122 | #: data/resources/ui/preferences_dialog.ui:27 123 | msgid "Recordings Folder" 124 | msgstr "Fișier de înregistrări" 125 | 126 | #: data/resources/ui/preferences_dialog.ui:28 127 | msgid "Destination folder for the recordings" 128 | msgstr "Fișierul de destinație pentru înregistrări" 129 | 130 | #: data/resources/ui/preferences_dialog.ui:50 131 | msgid "Video" 132 | msgstr "Video" 133 | 134 | #: data/resources/ui/preferences_dialog.ui:53 135 | msgid "Format" 136 | msgstr "Format" 137 | 138 | #: data/resources/ui/preferences_dialog.ui:58 139 | msgid "Frame Rate" 140 | msgstr "Rata de Cadre" 141 | 142 | #: data/resources/ui/shortcuts.ui:14 143 | msgid "Open Menu" 144 | msgstr "Deschideți Meniu" 145 | 146 | #: data/resources/ui/shortcuts.ui:20 147 | msgid "Show Preferences" 148 | msgstr "Afișați preferințe" 149 | 150 | #: data/resources/ui/shortcuts.ui:26 151 | msgid "Show Shortcuts" 152 | msgstr "Afișați comenzile rapide" 153 | 154 | #: data/resources/ui/shortcuts.ui:32 src/window/mod.rs:215 155 | msgid "Quit" 156 | msgstr "Închide programul" 157 | 158 | #: data/resources/ui/shortcuts.ui:39 src/window/mod.rs:501 159 | msgid "Recording" 160 | msgstr "Înregistrare" 161 | 162 | #: data/resources/ui/shortcuts.ui:43 163 | msgid "Toggle Record" 164 | msgstr "Comutați înregistrarea" 165 | 166 | #: data/resources/ui/shortcuts.ui:50 167 | msgid "Toggle Pause" 168 | msgstr "Comutați întreruperea" 169 | 170 | #: data/resources/ui/shortcuts.ui:56 171 | msgid "Cancel Record" 172 | msgstr "Anulează înregistrarea" 173 | 174 | #: data/resources/ui/shortcuts.ui:63 175 | msgid "Settings" 176 | msgstr "Setări" 177 | 178 | #: data/resources/ui/shortcuts.ui:67 179 | msgid "Toggle Desktop Audio" 180 | msgstr "" 181 | 182 | #: data/resources/ui/shortcuts.ui:73 183 | msgid "Toggle Microphone" 184 | msgstr "" 185 | 186 | #: data/resources/ui/shortcuts.ui:79 187 | msgid "Toggle Pointer" 188 | msgstr "" 189 | 190 | #: data/resources/ui/window.ui:24 191 | msgid "Main Menu" 192 | msgstr "Meniul principal" 193 | 194 | #: data/resources/ui/window.ui:53 195 | msgid "Capture a Monitor or Window" 196 | msgstr "" 197 | 198 | #: data/resources/ui/window.ui:66 199 | msgid "Capture a Selection of Screen" 200 | msgstr "" 201 | 202 | #: data/resources/ui/window.ui:86 203 | msgid "Enable Desktop Audio" 204 | msgstr "" 205 | 206 | #: data/resources/ui/window.ui:87 207 | msgid "Disable Desktop Audio" 208 | msgstr "" 209 | 210 | #: data/resources/ui/window.ui:95 211 | msgid "Enable Microphone" 212 | msgstr "" 213 | 214 | #: data/resources/ui/window.ui:96 215 | msgid "Disable Microphone" 216 | msgstr "" 217 | 218 | #: data/resources/ui/window.ui:104 219 | msgid "Show Pointer" 220 | msgstr "" 221 | 222 | #: data/resources/ui/window.ui:105 223 | msgid "Hide Pointer" 224 | msgstr "" 225 | 226 | #: data/resources/ui/window.ui:115 227 | msgid "Start Recording" 228 | msgstr "" 229 | 230 | #: data/resources/ui/window.ui:116 231 | msgid "Record" 232 | msgstr "" 233 | 234 | #: data/resources/ui/window.ui:129 235 | msgid "Forget Previously Selected Video Sources" 236 | msgstr "" 237 | 238 | #: data/resources/ui/window.ui:180 239 | msgid "Stop Recording" 240 | msgstr "" 241 | 242 | #: data/resources/ui/window.ui:181 243 | msgid "Stop" 244 | msgstr "" 245 | 246 | #: data/resources/ui/window.ui:191 247 | msgid "Pause Recording" 248 | msgstr "" 249 | 250 | #: data/resources/ui/window.ui:214 251 | msgid "Recording in…" 252 | msgstr "" 253 | 254 | #: data/resources/ui/window.ui:232 data/resources/ui/window.ui:275 255 | msgid "Cancel Recording" 256 | msgstr "" 257 | 258 | #: data/resources/ui/window.ui:257 259 | msgid "Flushing…" 260 | msgstr "" 261 | 262 | #: data/resources/ui/window.ui:293 263 | msgid "_Preferences" 264 | msgstr "_Preferințe" 265 | 266 | #: data/resources/ui/window.ui:297 267 | msgid "_Keyboard Shortcuts" 268 | msgstr "Scurtături _tastatură" 269 | 270 | #: data/resources/ui/window.ui:301 271 | msgid "_About Kooha" 272 | msgstr "" 273 | 274 | #. Translators: Replace "translator-credits" with your names. Put a comma between. 275 | #: src/about.rs:35 276 | msgid "translator-credits" 277 | msgstr "" 278 | "Gabriel Cozma\n" 279 | "Danny3" 280 | 281 | #: src/about.rs:45 282 | msgid "Donate (Buy Me a Coffee)" 283 | msgstr "Donează (Cumpără-mi o cafea)" 284 | 285 | #: src/about.rs:48 286 | msgid "GitHub" 287 | msgstr "GitHub" 288 | 289 | #: src/about.rs:50 290 | msgid "Translate" 291 | msgstr "Traduceți" 292 | 293 | #. Translators: This is a message that the user will see when the recording is finished. 294 | #: src/application.rs:137 295 | msgid "Screencast recorded" 296 | msgstr "" 297 | 298 | #: src/application.rs:144 299 | msgid "Show in Files" 300 | msgstr "" 301 | 302 | #: src/device.rs:26 303 | msgid "Failed to find the default audio device" 304 | msgstr "" 305 | 306 | #: src/device.rs:27 307 | msgid "Make sure that you have PulseAudio installed in your system." 308 | msgstr "" 309 | 310 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 311 | #: src/format.rs:36 312 | msgid "{time} hour" 313 | msgid_plural "{time} hours" 314 | msgstr[0] "" 315 | msgstr[1] "" 316 | msgstr[2] "" 317 | 318 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 319 | #: src/format.rs:43 320 | msgid "{time} minute" 321 | msgid_plural "{time} minutes" 322 | msgstr[0] "" 323 | msgstr[1] "" 324 | msgstr[2] "" 325 | 326 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 327 | #: src/format.rs:50 328 | msgid "{time} second" 329 | msgid_plural "{time} seconds" 330 | msgstr[0] "" 331 | msgstr[1] "" 332 | msgstr[2] "" 333 | 334 | #: src/preferences_dialog.rs:77 335 | msgid "Failed to set recordings folder" 336 | msgstr "" 337 | 338 | #: src/preferences_dialog.rs:237 339 | msgid "This frame rate may cause performance issues on the selected format." 340 | msgstr "" 341 | 342 | #: src/preferences_dialog.rs:301 343 | msgid "This format is experimental and unsupported." 344 | msgstr "" 345 | 346 | #: src/preferences_dialog.rs:308 src/window/mod.rs:559 347 | msgid "None" 348 | msgstr "" 349 | 350 | #: src/recording.rs:42 351 | msgid "No active profile" 352 | msgstr "" 353 | 354 | #: src/recording.rs:184 src/recording.rs:221 src/recording.rs:271 355 | msgid "Failed to start recording" 356 | msgstr "" 357 | 358 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 359 | #: src/recording.rs:187 360 | msgid "Check out {link} for help." 361 | msgstr "" 362 | 363 | #: src/recording.rs:222 364 | msgid "A GStreamer plugin may not be installed." 365 | msgstr "" 366 | 367 | #: src/recording.rs:272 src/recording.rs:501 368 | msgid "Make sure that the saving location exists and is accessible." 369 | msgstr "" 370 | 371 | #: src/recording.rs:492 372 | msgid "An error occurred while recording" 373 | msgstr "" 374 | 375 | #. Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 376 | #: src/recording.rs:498 377 | msgid "Failed to open “{path}” for writing" 378 | msgstr "" 379 | 380 | #: src/settings.rs:43 381 | msgid "Select Recordings Folder" 382 | msgstr "" 383 | 384 | #: src/window/mod.rs:78 385 | msgid "Failed to toggle pause" 386 | msgstr "" 387 | 388 | #: src/window/mod.rs:199 389 | msgid "" 390 | "A recording is currently in progress. Quitting immediately may cause the " 391 | "recording to be unplayable. Please stop the recording before quitting." 392 | msgstr "" 393 | 394 | #: src/window/mod.rs:202 395 | msgid "" 396 | "Quitting will cancel the processing and may cause the recording to be " 397 | "unplayable." 398 | msgstr "" 399 | 400 | #: src/window/mod.rs:208 401 | msgid "Quit the Application?" 402 | msgstr "" 403 | 404 | #: src/window/mod.rs:256 405 | msgid "Copy to clipboard" 406 | msgstr "" 407 | 408 | #: src/window/mod.rs:261 409 | msgid "Copied to clipboard" 410 | msgstr "" 411 | 412 | #: src/window/mod.rs:267 413 | msgid "Show detailed error" 414 | msgstr "" 415 | 416 | #: src/window/mod.rs:289 417 | msgid "Help" 418 | msgstr "" 419 | 420 | #: src/window/mod.rs:294 421 | msgid "Ok, Got It" 422 | msgstr "" 423 | 424 | #: src/window/mod.rs:303 425 | msgid "Open Preferences?" 426 | msgstr "" 427 | 428 | #: src/window/mod.rs:304 429 | msgid "" 430 | "The previously selected format may have been unavailable. Open preferences " 431 | "and select a format to continue recording." 432 | msgstr "" 433 | 434 | #: src/window/mod.rs:308 435 | msgid "Later" 436 | msgstr "" 437 | 438 | #: src/window/mod.rs:310 439 | msgid "Open" 440 | msgstr "" 441 | 442 | #: src/window/mod.rs:461 443 | msgid "A recording is in progress" 444 | msgstr "" 445 | 446 | #: src/window/mod.rs:509 447 | msgid "Paused" 448 | msgstr "" 449 | 450 | #: src/window/mod.rs:546 451 | msgid "Normal" 452 | msgstr "" 453 | 454 | #: src/window/mod.rs:547 455 | msgid "Selection" 456 | msgstr "" 457 | 458 | #~ msgid "Dave Patrick Caberto" 459 | #~ msgstr "David Patrick Caberto" 460 | -------------------------------------------------------------------------------- /src/about.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | fs::File, 4 | io::{BufRead, BufReader}, 5 | path::Path, 6 | process::{Command, Stdio}, 7 | }; 8 | 9 | use adw::prelude::*; 10 | use anyhow::anyhow; 11 | use anyhow::Result; 12 | use gettextrs::gettext; 13 | use gst::prelude::*; 14 | use gtk::glib; 15 | 16 | use crate::{ 17 | config::{APP_ID, VERSION}, 18 | experimental, 19 | }; 20 | 21 | pub fn present_dialog(parent: &impl IsA) { 22 | let dialog = adw::AboutDialog::builder() 23 | .application_icon(APP_ID) 24 | .application_name(gettext("Kooha")) 25 | .developer_name("Dave Patrick Caberto") 26 | .version(VERSION) 27 | .copyright("© 2024 Dave Patrick Caberto") 28 | .license_type(gtk::License::Gpl30) 29 | .developers(vec![ 30 | "Dave Patrick Caberto", 31 | "Mathiascode", 32 | "Felix Weilbach", 33 | ]) 34 | // Translators: Replace "translator-credits" with your names. Put a comma between. 35 | .translator_credits(gettext("translator-credits")) 36 | .issue_url("https://github.com/SeaDve/Kooha/issues") 37 | .support_url("https://github.com/SeaDve/Kooha/discussions") 38 | .debug_info(debug_info()) 39 | .debug_info_filename("kooha-debug-info") 40 | .release_notes_version("2.3.0") 41 | .release_notes(release_notes()) 42 | .build(); 43 | 44 | dialog.add_link( 45 | &gettext("Donate (Buy Me a Coffee)"), 46 | "https://www.buymeacoffee.com/seadve", 47 | ); 48 | dialog.add_link(&gettext("GitHub"), "https://github.com/SeaDve/Kooha"); 49 | dialog.add_link( 50 | &gettext("Translate"), 51 | "https://hosted.weblate.org/projects/seadve/kooha", 52 | ); 53 | 54 | dialog.present(Some(parent)); 55 | } 56 | 57 | fn release_notes() -> &'static str { 58 | r#"

This release contains new features and fixes:

59 |
    60 |
  • Area selector window is now resizable
  • 61 |
  • Previous selected area is now remembered
  • 62 |
  • Logout and idle are now inhibited while recording
  • 63 |
  • Video format and FPS are now shown in the main view
  • 64 |
  • Notifications now show the duration and size of the recording
  • 65 |
  • Notification actions now work even when the application is closed
  • 66 |
  • Progress is now shown when flushing the recording
  • 67 |
  • It is now much easier to pick from frame rate options
  • 68 |
  • Actually fixed audio from stuttering and being cut on long recordings
  • 69 |
  • Record audio in stereo rather than mono when possible
  • 70 |
  • Recordings are no longer deleted when flushing is cancelled
  • 71 |
  • Significant improvements in recording performance
  • 72 |
  • Improved preferences dialog UI
  • 73 |
  • Fixed incorrect output video orientation on certain compositors
  • 74 |
  • Fixed incorrect focus on area selector
  • 75 |
  • Fixed too small area selector window default size on HiDPI monitors
  • 76 |
  • Updated translations
  • 77 |
"# 78 | } 79 | 80 | fn cpu_model() -> Result { 81 | let output = Command::new("lscpu") 82 | .stdout(Stdio::piped()) 83 | .spawn()? 84 | .wait_with_output()?; 85 | 86 | for res in output.stdout.lines() { 87 | let line = res?; 88 | 89 | if line.contains("Model name:") { 90 | if let Some((_, value)) = line.split_once(':') { 91 | return Ok(value.trim().to_string()); 92 | } 93 | } 94 | } 95 | 96 | Ok("".into()) 97 | } 98 | 99 | fn gpu_model() -> Result { 100 | let output = Command::new("lspci") 101 | .stdout(Stdio::piped()) 102 | .spawn()? 103 | .wait_with_output()?; 104 | 105 | for res in output.stdout.lines() { 106 | let line = res?; 107 | 108 | if line.contains("VGA") { 109 | if let Some(value) = line.splitn(3, ':').last() { 110 | return Ok(value.trim().to_string()); 111 | } 112 | } 113 | } 114 | 115 | Ok("".into()) 116 | } 117 | 118 | fn is_flatpak() -> bool { 119 | Path::new("/.flatpak-info").exists() 120 | } 121 | 122 | fn debug_info() -> String { 123 | let container = env::var("container") 124 | .ok() 125 | .or_else(|| is_flatpak().then(|| "Flatpak".to_string())) 126 | .unwrap_or_else(|| "none".into()); 127 | let experimental_features = experimental::enabled_features(); 128 | 129 | let language_names = glib::language_names().join(", "); 130 | 131 | let cpu_model = cpu_model().unwrap_or_else(|e| format!("<{}>", e)); 132 | let gpu_model = gpu_model().unwrap_or_else(|e| format!("<{}>", e)); 133 | 134 | let distribution = os_info("PRETTY_NAME").unwrap_or_else(|e| format!("<{}>", e)); 135 | let desktop_session = env::var("DESKTOP_SESSION").unwrap_or_else(|_| "".into()); 136 | let display_server = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "".into()); 137 | 138 | let gtk_version = format!( 139 | "{}.{}.{}", 140 | gtk::major_version(), 141 | gtk::minor_version(), 142 | gtk::micro_version() 143 | ); 144 | let adw_version = format!( 145 | "{}.{}.{}", 146 | adw::major_version(), 147 | adw::minor_version(), 148 | adw::micro_version() 149 | ); 150 | let gst_version_string = gst::version_string(); 151 | let pipewire_version = gst::Registry::get() 152 | .find_feature("pipewiresrc", gst::ElementFactory::static_type()) 153 | .map_or("".into(), |feature| { 154 | feature 155 | .plugin() 156 | .map_or("".into(), |plugin| plugin.version()) 157 | }); 158 | 159 | format!( 160 | r#"- {APP_ID} {VERSION} 161 | - Container: {container} 162 | - Experimental Features: {experimental_features:?} 163 | 164 | - Language: {language_names} 165 | 166 | - CPU: {cpu_model} 167 | - GPU: {gpu_model} 168 | 169 | - Distribution: {distribution} 170 | - Desktop Session: {desktop_session} 171 | - Display Server: {display_server} 172 | 173 | - GTK {gtk_version} 174 | - Libadwaita {adw_version} 175 | - {gst_version_string} 176 | - Pipewire {pipewire_version}"# 177 | ) 178 | } 179 | 180 | fn os_info(key_name: &str) -> Result { 181 | let os_release_path = if is_flatpak() { 182 | "/run/host/etc/os-release" 183 | } else { 184 | "/etc/os-release" 185 | }; 186 | let file = File::open(os_release_path)?; 187 | 188 | for line in BufReader::new(file).lines() { 189 | let line = line?; 190 | let Some((key, value)) = line.split_once('=') else { 191 | continue; 192 | }; 193 | 194 | if key == key_name { 195 | return Ok(value.trim_matches('\"').to_string()); 196 | } 197 | } 198 | 199 | Err(anyhow!("unknown")) 200 | } 201 | -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | use adw::{prelude::*, subclass::prelude::*}; 2 | use gettextrs::gettext; 3 | use gtk::{ 4 | gio, 5 | glib::{self, clone}, 6 | }; 7 | 8 | use crate::{ 9 | about, 10 | config::{APP_ID, PKGDATADIR, PROFILE, VERSION}, 11 | format, 12 | preferences_dialog::PreferencesDialog, 13 | settings::Settings, 14 | window::Window, 15 | }; 16 | 17 | mod imp { 18 | use std::cell::OnceCell; 19 | 20 | use super::*; 21 | 22 | #[derive(Debug, Default)] 23 | pub struct Application { 24 | pub(super) settings: OnceCell, 25 | } 26 | 27 | #[glib::object_subclass] 28 | impl ObjectSubclass for Application { 29 | const NAME: &'static str = "KoohaApplication"; 30 | type Type = super::Application; 31 | type ParentType = adw::Application; 32 | } 33 | 34 | impl ObjectImpl for Application {} 35 | 36 | impl ApplicationImpl for Application { 37 | fn activate(&self) { 38 | self.parent_activate(); 39 | 40 | let obj = self.obj(); 41 | 42 | if let Some(window) = obj.windows().first() { 43 | window.present(); 44 | return; 45 | } 46 | 47 | let window = Window::new(&obj); 48 | window.present(); 49 | } 50 | 51 | fn startup(&self) { 52 | self.parent_startup(); 53 | 54 | gtk::Window::set_default_icon_name(APP_ID); 55 | 56 | let obj = self.obj(); 57 | 58 | obj.setup_gactions(); 59 | obj.setup_accels(); 60 | } 61 | } 62 | 63 | impl GtkApplicationImpl for Application {} 64 | impl AdwApplicationImpl for Application {} 65 | } 66 | 67 | glib::wrapper! { 68 | pub struct Application(ObjectSubclass) 69 | @extends gio::Application, gtk::Application, adw::Application, 70 | @implements gio::ActionMap, gio::ActionGroup; 71 | } 72 | 73 | impl Application { 74 | pub fn new() -> Self { 75 | glib::Object::builder() 76 | .property("application-id", APP_ID) 77 | .property("resource-base-path", "/io/github/seadve/Kooha/") 78 | .build() 79 | } 80 | 81 | /// Returns the global instance of `Application`. 82 | /// 83 | /// # Panics 84 | /// 85 | /// Panics if the app is not running or if this is called on a non-main thread. 86 | pub fn get() -> Self { 87 | debug_assert!( 88 | gtk::is_initialized_main_thread(), 89 | "application must only be accessed in the main thread" 90 | ); 91 | 92 | gio::Application::default().unwrap().downcast().unwrap() 93 | } 94 | 95 | pub fn settings(&self) -> &Settings { 96 | self.imp().settings.get_or_init(|| { 97 | let settings = Settings::default(); 98 | 99 | if tracing::enabled!(tracing::Level::TRACE) { 100 | settings.connect_changed(None, |settings, key| { 101 | tracing::trace!("Settings `{}` changed to `{}`", key, settings.value(key)); 102 | }); 103 | } 104 | 105 | settings 106 | }) 107 | } 108 | 109 | pub fn window(&self) -> Window { 110 | self.active_window() 111 | .map_or_else(|| Window::new(self), |w| w.downcast().unwrap()) 112 | } 113 | 114 | pub async fn send_record_success_notification( 115 | &self, 116 | recording_file: &gio::File, 117 | duration: gst::ClockTime, 118 | ) { 119 | let mut body_fragments = vec![format::duration(duration)]; 120 | 121 | match recording_file 122 | .query_info_future( 123 | gio::FILE_ATTRIBUTE_STANDARD_SIZE, 124 | gio::FileQueryInfoFlags::NONE, 125 | glib::Priority::DEFAULT_IDLE, 126 | ) 127 | .await 128 | { 129 | Ok(file_info) => { 130 | let formatted_size = glib::format_size(file_info.size() as u64); 131 | body_fragments.push(formatted_size.to_string()); 132 | } 133 | Err(err) => tracing::warn!("Failed to get file size: {:?}", err), 134 | } 135 | 136 | // Translators: This is a message that the user will see when the recording is finished. 137 | let notification = gio::Notification::new(&gettext("Screencast recorded")); 138 | notification.set_body(Some(&body_fragments.join(", "))); 139 | notification.set_default_action_and_target_value( 140 | "app.launch-uri", 141 | Some(&recording_file.uri().to_variant()), 142 | ); 143 | notification.add_button_with_target_value( 144 | &gettext("Show in Files"), 145 | "app.show-in-files", 146 | Some(&recording_file.uri().to_variant()), 147 | ); 148 | 149 | self.send_notification(Some("record-success"), ¬ification); 150 | } 151 | 152 | pub fn run(&self) -> glib::ExitCode { 153 | tracing::info!("Kooha ({})", APP_ID); 154 | tracing::info!("Version: {} ({})", VERSION, PROFILE); 155 | tracing::info!("Datadir: {}", PKGDATADIR); 156 | 157 | ApplicationExtManual::run(self) 158 | } 159 | 160 | pub fn quit(&self) { 161 | glib::spawn_future_local(clone!( 162 | #[weak(rename_to = obj)] 163 | self, 164 | async move { 165 | if obj.quit_request().await.is_proceed() { 166 | ApplicationExt::quit(&obj); 167 | } 168 | } 169 | )); 170 | } 171 | 172 | async fn quit_request(&self) -> glib::Propagation { 173 | if let Some(window) = self.active_window() { 174 | let window = window.downcast::().unwrap(); 175 | 176 | if window.is_busy() { 177 | return window.run_quit_confirmation_dialog().await; 178 | } 179 | } 180 | 181 | glib::Propagation::Proceed 182 | } 183 | 184 | fn setup_gactions(&self) { 185 | let launch_uri_action = gio::ActionEntry::builder("launch-uri") 186 | .parameter_type(Some(&String::static_variant_type())) 187 | .activate(|obj: &Self, _, param| { 188 | let uri = param.unwrap().get::().unwrap(); 189 | glib::spawn_future_local(clone!( 190 | #[strong] 191 | obj, 192 | async move { 193 | if let Err(err) = gtk::FileLauncher::new(Some(&gio::File::for_uri(&uri))) 194 | .launch_future(obj.active_window().as_ref()) 195 | .await 196 | { 197 | tracing::error!("Failed to launch uri `{}`: {:?}", uri, err); 198 | } 199 | } 200 | )); 201 | }) 202 | .build(); 203 | let show_in_files_action = gio::ActionEntry::builder("show-in-files") 204 | .parameter_type(Some(&String::static_variant_type())) 205 | .activate(|obj: &Self, _, param| { 206 | let uri = param.unwrap().get::().unwrap(); 207 | glib::spawn_future_local(clone!( 208 | #[strong] 209 | obj, 210 | async move { 211 | if let Err(err) = gtk::FileLauncher::new(Some(&gio::File::for_uri(&uri))) 212 | .open_containing_folder_future(obj.active_window().as_ref()) 213 | .await 214 | { 215 | tracing::warn!("Failed to show `{}` in files: {:?}", uri, err); 216 | } 217 | } 218 | )); 219 | }) 220 | .build(); 221 | let quit_action = gio::ActionEntry::builder("quit") 222 | .activate(|obj: &Self, _, _| { 223 | obj.quit(); 224 | }) 225 | .build(); 226 | let show_preferences_action = gio::ActionEntry::builder("show-preferences") 227 | .activate(|obj: &Self, _, _| { 228 | let dialog = PreferencesDialog::new(obj.settings()); 229 | dialog.present(Some(&obj.window())); 230 | }) 231 | .build(); 232 | let show_about_action = gio::ActionEntry::builder("show-about") 233 | .activate(|obj: &Self, _, _| { 234 | about::present_dialog(&obj.window()); 235 | }) 236 | .build(); 237 | self.add_action_entries([ 238 | launch_uri_action, 239 | show_in_files_action, 240 | quit_action, 241 | show_preferences_action, 242 | show_about_action, 243 | ]); 244 | } 245 | 246 | fn setup_accels(&self) { 247 | self.set_accels_for_action("app.show-preferences", &["comma"]); 248 | self.set_accels_for_action("app.quit", &["q"]); 249 | self.set_accels_for_action("window.close", &["w"]); 250 | self.set_accels_for_action("win.record-desktop-audio", &["a"]); 251 | self.set_accels_for_action("win.record-microphone", &["m"]); 252 | self.set_accels_for_action("win.show-pointer", &["p"]); 253 | self.set_accels_for_action("win.toggle-record", &["r"]); 254 | // self.set_accels_for_action("win.toggle-pause", &["k"]); // See issue #112 in GitHub repo 255 | self.set_accels_for_action("win.cancel-record", &["c"]); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/cancelled.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt}; 2 | 3 | #[derive(Debug)] 4 | pub struct Cancelled { 5 | task: String, 6 | } 7 | 8 | impl Cancelled { 9 | pub fn new(task: impl Into) -> Self { 10 | Cancelled { task: task.into() } 11 | } 12 | } 13 | 14 | impl fmt::Display for Cancelled { 15 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 16 | write!(f, "Cancelled {}", self.task) 17 | } 18 | } 19 | 20 | impl error::Error for Cancelled {} 21 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const APP_ID: &str = @APP_ID@; 2 | pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 3 | pub const LOCALEDIR: &str = @LOCALEDIR@; 4 | pub const PKGDATADIR: &str = @PKGDATADIR@; 5 | pub const PROFILE: &str = @PROFILE@; 6 | pub const RESOURCES_FILE: &str = concat!(@PKGDATADIR@, "/resources.gresource"); 7 | pub const VERSION: &str = @VERSION@; 8 | -------------------------------------------------------------------------------- /src/device.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, ensure, Context, Result}; 2 | use gettextrs::gettext; 3 | use gst::prelude::*; 4 | 5 | use crate::help::ContextWithHelp; 6 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 8 | pub enum DeviceClass { 9 | Source, 10 | Sink, 11 | } 12 | 13 | impl DeviceClass { 14 | fn as_str(self) -> &'static str { 15 | match self { 16 | Self::Source => "Audio/Source", 17 | Self::Sink => "Audio/Sink", 18 | } 19 | } 20 | } 21 | 22 | pub fn find_default(class: DeviceClass) -> Result { 23 | let provider = 24 | gst::DeviceProviderFactory::by_name("pulsedeviceprovider").with_context(|| { 25 | ContextWithHelp::new( 26 | gettext("Failed to find the default audio device"), 27 | gettext("Make sure that you have PulseAudio installed in your system."), 28 | ) 29 | })?; 30 | 31 | provider.start()?; 32 | let devices = provider.devices(); 33 | provider.stop(); 34 | 35 | tracing::debug!("Finding device name for class `{:?}`", class); 36 | 37 | for device in devices { 38 | if let Err(err) = validate_device(&device, class) { 39 | tracing::debug!("Skipping device `{}`: {:?}", device.name(), err); 40 | continue; 41 | } 42 | 43 | return Ok(device); 44 | } 45 | 46 | Err(anyhow!("Failed to find a default device")) 47 | } 48 | 49 | fn validate_device(device: &gst::Device, class: DeviceClass) -> Result<()> { 50 | ensure!( 51 | device.has_classes(class.as_str()), 52 | "Unknown device class `{}`", 53 | device.device_class() 54 | ); 55 | 56 | let is_default = device 57 | .properties() 58 | .context("No properties")? 59 | .get::("is-default") 60 | .context("No `is-default` property")?; 61 | 62 | ensure!(is_default, "Not the default device"); 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /src/experimental.rs: -------------------------------------------------------------------------------- 1 | use std::{env, sync::OnceLock}; 2 | 3 | pub fn enabled_features() -> &'static [Feature] { 4 | static ENABLED_FEATURES: OnceLock> = OnceLock::new(); 5 | 6 | ENABLED_FEATURES.get_or_init(|| { 7 | env::var("KOOHA_EXPERIMENTAL") 8 | .map(|val| { 9 | val.split(',') 10 | .filter_map(|raw_feature_str| { 11 | let feature_str = raw_feature_str.trim().to_lowercase(); 12 | let feature = Feature::from_str(&feature_str); 13 | if feature.is_none() { 14 | tracing::warn!("Unknown `{}` experimental feature", feature_str); 15 | } 16 | feature 17 | }) 18 | .collect::>() 19 | }) 20 | .unwrap_or_default() 21 | }) 22 | } 23 | 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 25 | pub enum Feature { 26 | All, 27 | ExperimentalFormats, 28 | MultipleVideoSources, 29 | WindowRecording, 30 | } 31 | 32 | impl Feature { 33 | fn from_str(string: &str) -> Option { 34 | match string { 35 | "all" | "1" => Some(Self::All), 36 | "experimental-formats" => Some(Self::ExperimentalFormats), 37 | "multiple-video-sources" => Some(Self::MultipleVideoSources), 38 | "window-recording" => Some(Self::WindowRecording), 39 | _ => None, 40 | } 41 | } 42 | 43 | pub fn is_enabled(self) -> bool { 44 | let enabled_features = enabled_features(); 45 | 46 | enabled_features.contains(&Self::All) || enabled_features.contains(&self) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/format.rs: -------------------------------------------------------------------------------- 1 | use crate::i18n::ngettext_f; 2 | 3 | /// Formats a framerate in a more human-readable format. 4 | pub fn framerate(framerate: gst::Fraction) -> String { 5 | let reduced = framerate.reduced(); 6 | 7 | if reduced.is_integer() { 8 | return reduced.numer().to_string(); 9 | } 10 | 11 | let float = *reduced.numer() as f64 / *reduced.denom() as f64; 12 | format!("{:.2}", float) 13 | } 14 | 15 | /// Formats time in MM:SS. 16 | /// 17 | /// The MM part will be more than 2 digits if the time is >= 100 minutes. 18 | pub fn digital_clock(clock_time: gst::ClockTime) -> String { 19 | let secs = clock_time.seconds(); 20 | 21 | let seconds_display = secs % 60; 22 | let minutes_display = secs / 60; 23 | format!("{:02}∶{:02}", minutes_display, seconds_display) 24 | } 25 | 26 | /// Formats time as duration. 27 | pub fn duration(clock_time: gst::ClockTime) -> String { 28 | let secs = clock_time.seconds(); 29 | 30 | let hours_display = secs / 3600; 31 | let minutes_display = (secs % 3600) / 60; 32 | let seconds_display = secs % 60; 33 | 34 | let hours_display_str = ngettext_f( 35 | // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 36 | "{time} hour", 37 | "{time} hours", 38 | hours_display as u32, 39 | &[("time", &hours_display.to_string())], 40 | ); 41 | let minutes_display_str = ngettext_f( 42 | // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 43 | "{time} minute", 44 | "{time} minutes", 45 | minutes_display as u32, 46 | &[("time", &minutes_display.to_string())], 47 | ); 48 | let seconds_display_str = ngettext_f( 49 | // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. 50 | "{time} second", 51 | "{time} seconds", 52 | seconds_display as u32, 53 | &[("time", &seconds_display.to_string())], 54 | ); 55 | 56 | if hours_display > 0 { 57 | // 4 hours 5 minutes 6 seconds 58 | format!( 59 | "{} {} {}", 60 | hours_display_str, minutes_display_str, seconds_display_str 61 | ) 62 | } else if minutes_display > 0 { 63 | // 5 minutes 6 seconds 64 | format!("{} {}", minutes_display_str, seconds_display_str) 65 | } else { 66 | // 6 seconds 67 | seconds_display_str 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use super::*; 74 | 75 | #[test] 76 | fn test_framerate() { 77 | assert_eq!(framerate(gst::Fraction::from_integer(24)), "24"); 78 | assert_eq!(framerate(gst::Fraction::new(30_000, 1001)), "29.97"); 79 | assert_eq!(framerate(gst::Fraction::new(60_000, 1001)), "59.94"); 80 | } 81 | 82 | #[test] 83 | fn test_duration() { 84 | assert_eq!(duration(gst::ClockTime::ZERO), "0 seconds"); 85 | assert_eq!(duration(gst::ClockTime::from_seconds(1)), "1 second"); 86 | assert_eq!( 87 | duration(gst::ClockTime::from_seconds(3 * 60 + 4)), 88 | "3 minutes 4 seconds" 89 | ); 90 | assert_eq!( 91 | duration(gst::ClockTime::from_seconds(60 * 60 + 6)), 92 | "1 hour 0 minutes 6 seconds" 93 | ); 94 | assert_eq!( 95 | duration(gst::ClockTime::from_seconds(2 * 60 * 60)), 96 | "2 hours 0 minutes 0 seconds" 97 | ); 98 | } 99 | 100 | #[test] 101 | fn digital_clock_less_than_1_hour() { 102 | assert_eq!(digital_clock(gst::ClockTime::ZERO), "00∶00"); 103 | assert_eq!(digital_clock(gst::ClockTime::from_seconds(31)), "00∶31"); 104 | assert_eq!( 105 | digital_clock(gst::ClockTime::from_seconds(8 * 60 + 1)), 106 | "08∶01" 107 | ); 108 | assert_eq!( 109 | digital_clock(gst::ClockTime::from_seconds(33 * 60 + 3)), 110 | "33∶03" 111 | ); 112 | assert_eq!( 113 | digital_clock(gst::ClockTime::from_seconds(59 * 60 + 59)), 114 | "59∶59" 115 | ); 116 | } 117 | 118 | #[test] 119 | fn digital_clock_more_than_1_hour() { 120 | assert_eq!( 121 | digital_clock(gst::ClockTime::from_seconds(60 * 60)), 122 | "60∶00" 123 | ); 124 | assert_eq!( 125 | digital_clock(gst::ClockTime::from_seconds(60 * 60 + 9)), 126 | "60∶09" 127 | ); 128 | assert_eq!( 129 | digital_clock(gst::ClockTime::from_seconds(60 * 60 + 31)), 130 | "60∶31" 131 | ); 132 | assert_eq!( 133 | digital_clock(gst::ClockTime::from_seconds(100 * 60 + 20)), 134 | "100∶20" 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/help.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug)] 4 | pub struct ContextWithHelp { 5 | context: String, 6 | help_message: String, 7 | } 8 | 9 | impl ContextWithHelp { 10 | pub fn new(context: impl Into, help_message: impl Into) -> Self { 11 | ContextWithHelp { 12 | context: context.into(), 13 | help_message: help_message.into(), 14 | } 15 | } 16 | 17 | pub fn help_message(&self) -> &str { 18 | &self.help_message 19 | } 20 | } 21 | 22 | impl fmt::Display for ContextWithHelp { 23 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 24 | f.write_str(&self.context) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/i18n.rs: -------------------------------------------------------------------------------- 1 | // Copied from Fractal GPLv3 2 | // See https://gitlab.gnome.org/GNOME/fractal/-/blob/c0bc4078bb2cdd511c89fdf41a51275db90bb7ab/src/i18n.rs 3 | 4 | use gettextrs::{gettext, ngettext}; 5 | 6 | /// Like `gettext`, but replaces named variables using the given key-value tuples. 7 | /// 8 | /// The expected format to replace is `{name}`, where `name` is the first string 9 | /// in a key-value tuple. 10 | pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { 11 | let s = gettext(msgid); 12 | freplace(s, args) 13 | } 14 | 15 | /// Like `ngettext`, but replaces named variables using the given key-value tuples. 16 | /// 17 | /// The expected format to replace is `{name}`, where `name` is the first string 18 | /// in a key-value tuple. 19 | pub fn ngettext_f(msgid: &str, msgid_plural: &str, n: u32, args: &[(&str, &str)]) -> String { 20 | let s = ngettext(msgid, msgid_plural, n); 21 | freplace(s, args) 22 | } 23 | 24 | /// Replace variables in the given string using the given key-value tuples. 25 | /// 26 | /// The expected format to replace is `{name}`, where `name` is the first string 27 | /// in a key-value tuple. 28 | fn freplace(s: String, args: &[(&str, &str)]) -> String { 29 | // This function is useless if there are no arguments 30 | debug_assert!(!args.is_empty(), "atleast one key-value pair must be given"); 31 | 32 | // We could check here if all keys were used, but some translations might 33 | // not use all variables, so we don't do that. 34 | 35 | let mut s = s; 36 | for (key, val) in args { 37 | s = s.replace(&format!("{{{key}}}"), val); 38 | } 39 | 40 | debug_assert!(!s.contains('{'), "all format variables must be replaced"); 41 | 42 | if tracing::enabled!(tracing::Level::WARN) && s.contains('{') { 43 | tracing::warn!( 44 | "all format variables must be replaced, but some were not: {}", 45 | s 46 | ); 47 | } 48 | 49 | s 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::*; 55 | 56 | #[test] 57 | #[should_panic = "atleast one key-value pair must be given"] 58 | fn freplace_no_args() { 59 | gettext_f("no args", &[]); 60 | } 61 | 62 | #[test] 63 | #[should_panic = "all format variables must be replaced"] 64 | fn freplace_missing_key() { 65 | gettext_f("missing {one}", &[("two", "2")]); 66 | } 67 | 68 | #[test] 69 | fn gettext_f_simple() { 70 | assert_eq!(gettext_f("no replace", &[("one", "1")]), "no replace"); 71 | assert_eq!(gettext_f("{one} param", &[("one", "1")]), "1 param"); 72 | assert_eq!( 73 | gettext_f("middle {one} param", &[("one", "1")]), 74 | "middle 1 param" 75 | ); 76 | assert_eq!(gettext_f("end {one}", &[("one", "1")]), "end 1"); 77 | } 78 | 79 | #[test] 80 | fn gettext_f_multiple() { 81 | assert_eq!( 82 | gettext_f("multiple {one} and {two}", &[("one", "1"), ("two", "2")]), 83 | "multiple 1 and 2" 84 | ); 85 | assert_eq!( 86 | gettext_f("multiple {two} and {one}", &[("one", "1"), ("two", "2")]), 87 | "multiple 2 and 1" 88 | ); 89 | assert_eq!( 90 | gettext_f("multiple {one} and {one}", &[("one", "1"), ("two", "2")]), 91 | "multiple 1 and 1" 92 | ); 93 | } 94 | 95 | #[test] 96 | fn ngettext_f_multiple() { 97 | assert_eq!( 98 | ngettext_f( 99 | "singular {one} and {two}", 100 | "plural {one} and {two}", 101 | 1, 102 | &[("one", "1"), ("two", "two")], 103 | ), 104 | "singular 1 and two" 105 | ); 106 | assert_eq!( 107 | ngettext_f( 108 | "singular {one} and {two}", 109 | "plural {one} and {two}", 110 | 2, 111 | &[("one", "1"), ("two", "two")], 112 | ), 113 | "plural 1 and two" 114 | ); 115 | } 116 | 117 | #[test] 118 | fn ngettext_f_unused_on_singular() { 119 | assert_eq!( 120 | ngettext_f( 121 | "singular {one}", 122 | "plural {one} and {two}", 123 | 1, 124 | &[("one", "1"), ("two", "2")], 125 | ), 126 | "singular 1" 127 | ); 128 | assert_eq!( 129 | ngettext_f( 130 | "singular {one}", 131 | "plural {one} and {two}", 132 | 2, 133 | &[("one", "1"), ("two", "2")], 134 | ), 135 | "plural 1 and 2" 136 | ); 137 | } 138 | 139 | #[test] 140 | fn ngettext_f_unused_on_plural() { 141 | assert_eq!( 142 | ngettext_f( 143 | "singular {one} and {two}", 144 | "plural {one}", 145 | 1, 146 | &[("one", "1"), ("two", "2")], 147 | ), 148 | "singular 1 and 2" 149 | ); 150 | assert_eq!( 151 | ngettext_f( 152 | "singular {one} and {two}", 153 | "plural {one}", 154 | 2, 155 | &[("one", "1"), ("two", "2")], 156 | ), 157 | "plural 1" 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/item_row.rs: -------------------------------------------------------------------------------- 1 | use gtk::{glib, prelude::*, subclass::prelude::*}; 2 | 3 | mod imp { 4 | use std::cell::{Cell, RefCell}; 5 | 6 | use super::*; 7 | 8 | #[derive(Default, glib::Properties, gtk::CompositeTemplate)] 9 | #[properties(wrapper_type = super::ItemRow)] 10 | #[template(resource = "/io/github/seadve/Kooha/ui/item_row.ui")] 11 | pub struct ItemRow { 12 | #[property(get, set = Self::set_title, explicit_notify)] 13 | pub(super) title: RefCell, 14 | #[property(get, set = Self::set_warning_tooltip_text, explicit_notify)] 15 | pub(super) warning_tooltip_text: RefCell, 16 | #[property(get, set = Self::set_shows_warning_icon, explicit_notify)] 17 | pub(super) shows_warning_icon: Cell, 18 | #[property(get, set = Self::set_is_on_popover, explicit_notify)] 19 | pub(super) is_on_popover: Cell, 20 | #[property(get, set = Self::set_is_selected, explicit_notify)] 21 | pub(super) is_selected: Cell, 22 | 23 | #[template_child] 24 | pub(super) start_warning_icon: TemplateChild, 25 | #[template_child] 26 | pub(super) title_label: TemplateChild, 27 | #[template_child] 28 | pub(super) selected_icon: TemplateChild, 29 | #[template_child] 30 | pub(super) end_warning_icon: TemplateChild, 31 | } 32 | 33 | #[glib::object_subclass] 34 | impl ObjectSubclass for ItemRow { 35 | const NAME: &'static str = "KoohaItemRow"; 36 | type Type = super::ItemRow; 37 | type ParentType = gtk::Widget; 38 | 39 | fn class_init(klass: &mut Self::Class) { 40 | klass.bind_template(); 41 | } 42 | 43 | fn instance_init(obj: &glib::subclass::InitializingObject) { 44 | obj.init_template(); 45 | } 46 | } 47 | 48 | #[glib::derived_properties] 49 | impl ObjectImpl for ItemRow { 50 | fn constructed(&self) { 51 | self.parent_constructed(); 52 | 53 | let obj = self.obj(); 54 | 55 | obj.update_title_label(); 56 | obj.update_warning_icon_tooltip_text(); 57 | obj.update_warning_icons_visibility(); 58 | obj.update_selected_icon_visibility(); 59 | obj.update_selected_icon_opacity(); 60 | } 61 | 62 | fn dispose(&self) { 63 | self.dispose_template(); 64 | } 65 | } 66 | 67 | impl WidgetImpl for ItemRow {} 68 | 69 | impl ItemRow { 70 | fn set_title(&self, title: String) { 71 | let obj = self.obj(); 72 | 73 | if title == obj.title() { 74 | return; 75 | } 76 | 77 | self.title.replace(title); 78 | obj.update_title_label(); 79 | obj.notify_title(); 80 | } 81 | 82 | fn set_warning_tooltip_text(&self, warning_tooltip_text: String) { 83 | let obj = self.obj(); 84 | 85 | if warning_tooltip_text == obj.warning_tooltip_text() { 86 | return; 87 | } 88 | 89 | self.warning_tooltip_text.replace(warning_tooltip_text); 90 | obj.update_warning_icon_tooltip_text(); 91 | obj.notify_warning_tooltip_text(); 92 | } 93 | 94 | fn set_shows_warning_icon(&self, shows_warning_icon: bool) { 95 | let obj = self.obj(); 96 | 97 | if shows_warning_icon == obj.shows_warning_icon() { 98 | return; 99 | } 100 | 101 | self.shows_warning_icon.set(shows_warning_icon); 102 | obj.update_warning_icons_visibility(); 103 | obj.notify_shows_warning_icon(); 104 | } 105 | 106 | fn set_is_on_popover(&self, is_on_popover: bool) { 107 | let obj = self.obj(); 108 | 109 | if is_on_popover == obj.is_on_popover() { 110 | return; 111 | } 112 | 113 | self.is_on_popover.set(is_on_popover); 114 | obj.update_selected_icon_visibility(); 115 | obj.update_warning_icons_visibility(); 116 | obj.notify_is_on_popover(); 117 | } 118 | 119 | fn set_is_selected(&self, is_selected: bool) { 120 | let obj = self.obj(); 121 | 122 | if is_selected == obj.is_selected() { 123 | return; 124 | } 125 | 126 | self.is_selected.set(is_selected); 127 | obj.update_selected_icon_opacity(); 128 | obj.notify_is_selected(); 129 | } 130 | } 131 | } 132 | 133 | glib::wrapper! { 134 | pub struct ItemRow(ObjectSubclass) 135 | @extends gtk::Widget; 136 | } 137 | 138 | impl ItemRow { 139 | pub fn new() -> Self { 140 | glib::Object::new() 141 | } 142 | 143 | fn update_title_label(&self) { 144 | let imp = self.imp(); 145 | imp.title_label.set_label(&self.title()); 146 | } 147 | 148 | fn update_warning_icon_tooltip_text(&self) { 149 | let imp = self.imp(); 150 | 151 | let warning_tooltip_text = Some(self.warning_tooltip_text()).filter(|s| !s.is_empty()); 152 | imp.start_warning_icon 153 | .set_tooltip_text(warning_tooltip_text.as_deref()); 154 | imp.end_warning_icon 155 | .set_tooltip_text(warning_tooltip_text.as_deref()); 156 | } 157 | 158 | fn update_warning_icons_visibility(&self) { 159 | let imp = self.imp(); 160 | 161 | let is_on_popover = self.is_on_popover(); 162 | let shows_warning_icon = self.shows_warning_icon(); 163 | imp.start_warning_icon 164 | .set_visible(shows_warning_icon && !is_on_popover); 165 | imp.end_warning_icon 166 | .set_visible(shows_warning_icon && is_on_popover); 167 | } 168 | 169 | fn update_selected_icon_visibility(&self) { 170 | let imp = self.imp(); 171 | imp.selected_icon.set_visible(self.is_on_popover()); 172 | } 173 | 174 | fn update_selected_icon_opacity(&self) { 175 | let imp = self.imp(); 176 | let opacity = if self.is_selected() { 1.0 } else { 0.0 }; 177 | imp.selected_icon.set_opacity(opacity); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::new_without_default)] 2 | #![warn( 3 | rust_2018_idioms, 4 | clippy::items_after_statements, 5 | clippy::needless_pass_by_value, 6 | clippy::explicit_iter_loop, 7 | clippy::semicolon_if_nothing_returned, 8 | clippy::match_wildcard_for_single_variants, 9 | clippy::inefficient_to_string, 10 | clippy::map_unwrap_or, 11 | clippy::implicit_clone, 12 | clippy::struct_excessive_bools, 13 | clippy::trivially_copy_pass_by_ref, 14 | clippy::unreadable_literal, 15 | clippy::if_not_else, 16 | clippy::doc_markdown, 17 | clippy::unused_async, 18 | clippy::default_trait_access, 19 | clippy::unnecessary_wraps, 20 | clippy::unused_self, 21 | clippy::dbg_macro, 22 | clippy::todo, 23 | clippy::print_stdout 24 | )] 25 | 26 | mod about; 27 | mod application; 28 | mod area_selector; 29 | mod cancelled; 30 | mod config; 31 | mod device; 32 | mod experimental; 33 | mod format; 34 | mod help; 35 | mod i18n; 36 | mod item_row; 37 | mod pipeline; 38 | mod preferences_dialog; 39 | mod profile; 40 | mod recording; 41 | mod screencast_portal; 42 | mod settings; 43 | mod timer; 44 | mod window; 45 | 46 | use gettextrs::{gettext, LocaleCategory}; 47 | use gtk::{gio, glib}; 48 | 49 | use self::{ 50 | application::Application, 51 | config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE}, 52 | }; 53 | 54 | fn main() -> glib::ExitCode { 55 | tracing_subscriber::fmt::init(); 56 | 57 | gettextrs::setlocale(LocaleCategory::LcAll, ""); 58 | gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain."); 59 | gettextrs::textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain."); 60 | 61 | glib::set_application_name(&gettext("Kooha")); 62 | 63 | gst::init().expect("Unable to start gstreamer."); 64 | gstgif::plugin_register_static().expect("Failed to register gif plugin."); 65 | gstgtk4::plugin_register_static().expect("Failed to register gtk4 plugin."); 66 | 67 | let res = gio::Resource::load(RESOURCES_FILE).expect("Could not load gresource file."); 68 | gio::resources_register(&res); 69 | 70 | let app = Application::new(); 71 | app.run() 72 | } 73 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | global_conf.set_quoted('APP_ID', application_id) 3 | global_conf.set_quoted('PKGDATADIR', pkgdatadir) 4 | global_conf.set_quoted('PROFILE', profile) 5 | global_conf.set_quoted('VERSION', version + version_suffix) 6 | global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package) 7 | global_conf.set_quoted('LOCALEDIR', localedir) 8 | config = configure_file( 9 | input: 'config.rs.in', 10 | output: 'config.rs', 11 | configuration: global_conf 12 | ) 13 | # Copy the config.rs output to the source directory. 14 | run_command( 15 | 'cp', 16 | meson.project_build_root() / 'src' / 'config.rs', 17 | meson.project_source_root() / 'src' / 'config.rs', 18 | check: true 19 | ) 20 | 21 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ] 22 | cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ] 23 | 24 | if get_option('profile') == 'default' 25 | cargo_options += [ '--release' ] 26 | rust_target = 'release' 27 | message('Building in release mode') 28 | else 29 | rust_target = 'debug' 30 | message('Building in debug mode') 31 | endif 32 | 33 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] 34 | 35 | cargo_build = custom_target( 36 | 'cargo-build', 37 | build_by_default: true, 38 | build_always_stale: true, 39 | output: meson.project_name(), 40 | console: true, 41 | install: true, 42 | install_dir: bindir, 43 | depends: resources, 44 | command: [ 45 | 'env', 46 | cargo_env, 47 | cargo, 'build', 48 | cargo_options, 49 | '&&', 50 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 51 | ] 52 | ) 53 | 54 | test( 55 | 'cargo-test', 56 | cargo, 57 | args: [ 58 | 'test', 59 | cargo_options, 60 | '--', 61 | '--nocapture', 62 | ], 63 | env: [ 64 | 'RUST_BACKTRACE=1', 65 | cargo_env 66 | ], 67 | timeout: 300, # give cargo more time 68 | ) 69 | 70 | test( 71 | 'cargo-clippy', 72 | cargo, 73 | args: [ 74 | 'clippy', 75 | cargo_options, 76 | '--', 77 | '-D', 78 | 'warnings' 79 | ], 80 | env: [ 81 | cargo_env 82 | ], 83 | timeout: 300, # give cargo more time 84 | ) 85 | -------------------------------------------------------------------------------- /src/profile.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context, Result}; 2 | use gst::prelude::*; 3 | use gtk::{ 4 | gio, 5 | glib::{self, subclass::prelude::*}, 6 | }; 7 | use once_cell::sync::OnceCell as OnceLock; 8 | use serde::Deserialize; 9 | 10 | const DEFAULT_SUGGESTED_MAX_FRAMERATE: gst::Fraction = gst::Fraction::from_integer(60); 11 | const MAX_THREAD_COUNT: u32 = 64; 12 | 13 | #[derive(Debug, Deserialize)] 14 | struct Profiles { 15 | supported: Vec, 16 | experimental: Vec, 17 | } 18 | 19 | #[derive(Debug, Deserialize)] 20 | struct ProfileData { 21 | id: String, 22 | #[serde(default)] 23 | is_experimental: bool, 24 | name: String, 25 | #[serde(rename = "suggested-max-fps")] 26 | suggested_max_framerate: Option, 27 | #[serde(rename = "extension")] 28 | file_extension: String, 29 | #[serde(rename = "videoenc")] 30 | videoenc_bin_str: String, 31 | #[serde(rename = "audioenc")] 32 | audioenc_bin_str: Option, 33 | #[serde(rename = "muxer")] 34 | muxer_bin_str: Option, 35 | } 36 | 37 | mod imp { 38 | use super::*; 39 | 40 | #[derive(Debug, Default)] 41 | pub struct Profile { 42 | pub(super) data: OnceLock, 43 | } 44 | 45 | #[glib::object_subclass] 46 | impl ObjectSubclass for Profile { 47 | const NAME: &'static str = "KoohaProfile"; 48 | type Type = super::Profile; 49 | } 50 | 51 | impl ObjectImpl for Profile {} 52 | } 53 | 54 | glib::wrapper! { 55 | pub struct Profile(ObjectSubclass); 56 | } 57 | 58 | impl Profile { 59 | fn from_data(data: ProfileData) -> Self { 60 | let this = glib::Object::new::(); 61 | this.imp().data.set(data).unwrap(); 62 | this 63 | } 64 | 65 | fn data(&self) -> &ProfileData { 66 | self.imp().data.get().unwrap() 67 | } 68 | 69 | pub fn all() -> Result<&'static [Self]> { 70 | static ALL: OnceLock> = OnceLock::new(); 71 | 72 | ALL.get_or_try_init(|| { 73 | let bytes = gio::resources_lookup_data( 74 | "/io/github/seadve/Kooha/profiles.yml", 75 | gio::ResourceLookupFlags::NONE, 76 | )?; 77 | let profiles = serde_yaml::from_slice::(&bytes)?; 78 | 79 | let supported = profiles.supported.into_iter().map(|mut data| { 80 | data.is_experimental = false; 81 | Self::from_data(data) 82 | }); 83 | let experimental = profiles.experimental.into_iter().map(|mut data| { 84 | data.is_experimental = true; 85 | Self::from_data(data) 86 | }); 87 | Ok(supported.chain(experimental).collect()) 88 | }) 89 | .map(|v| v.as_slice()) 90 | } 91 | 92 | pub fn from_id(id: &str) -> Result<&'static Self> { 93 | let profile = Self::all()? 94 | .iter() 95 | .find(|p| p.id() == id) 96 | .with_context(|| format!("Profile `{}` not found", id))?; 97 | Ok(profile) 98 | } 99 | 100 | pub fn id(&self) -> &str { 101 | &self.data().id 102 | } 103 | 104 | pub fn name(&self) -> &str { 105 | &self.data().name 106 | } 107 | 108 | pub fn file_extension(&self) -> &str { 109 | &self.data().file_extension 110 | } 111 | 112 | pub fn supports_audio(&self) -> bool { 113 | self.data().audioenc_bin_str.is_some() 114 | } 115 | 116 | pub fn suggested_max_framerate(&self) -> gst::Fraction { 117 | self.data().suggested_max_framerate.map_or_else( 118 | || DEFAULT_SUGGESTED_MAX_FRAMERATE, 119 | |raw| gst::Fraction::approximate_f64(raw).unwrap(), 120 | ) 121 | } 122 | 123 | pub fn is_experimental(&self) -> bool { 124 | self.data().is_experimental 125 | } 126 | 127 | pub fn is_available(&self) -> bool { 128 | self.is_available_inner() 129 | .inspect_err(|err| { 130 | tracing::debug!("Profile `{}` is not available: {:?}", self.id(), err); 131 | }) 132 | .is_ok() 133 | } 134 | 135 | fn is_available_inner(&self) -> Result<()> { 136 | parse_bin_test(&self.data().videoenc_bin_str).context("Failed to parse videoenc bin")?; 137 | 138 | if let Some(audioenc_bin_str) = &self.data().audioenc_bin_str { 139 | parse_bin_test(audioenc_bin_str).context("Failed to parse audioenc bin")?; 140 | } 141 | 142 | if let Some(muxer_bin_str) = &self.data().muxer_bin_str { 143 | parse_bin_test(muxer_bin_str).context("Failed to parse muxer bin")?; 144 | } 145 | 146 | Ok(()) 147 | } 148 | 149 | pub fn attach( 150 | &self, 151 | pipeline: &gst::Pipeline, 152 | video_src: &gst::Element, 153 | audio_srcs: Option<&gst::Element>, 154 | sink: &gst::Element, 155 | ) -> Result<()> { 156 | let videoenc_bin = parse_bin("kooha-videoenc-bin", &self.data().videoenc_bin_str)?; 157 | debug_assert!(videoenc_bin.iterate_elements().into_iter().any(|element| { 158 | let factory = element.unwrap().factory().unwrap(); 159 | factory.has_type(gst::ElementFactoryType::VIDEO_ENCODER) 160 | })); 161 | 162 | pipeline.add(&videoenc_bin)?; 163 | video_src.link(&videoenc_bin)?; 164 | 165 | match (&self.data().audioenc_bin_str, &self.data().muxer_bin_str) { 166 | (None, None) => { 167 | // Special case for gifenc 168 | 169 | if audio_srcs.is_some() { 170 | tracing::error!("Audio srcs ignored: Profile does not support audio"); 171 | } 172 | 173 | videoenc_bin.link(sink)?; 174 | } 175 | (audioenc_str, Some(muxer_bin_str)) => { 176 | let muxer_bin = parse_bin("kooha-muxer-bin", muxer_bin_str)?; 177 | let muxer = muxer_bin 178 | .iterate_elements() 179 | .find(|element| { 180 | element 181 | .factory() 182 | .is_some_and(|f| f.has_type(gst::ElementFactoryType::MUXER)) 183 | }) 184 | .context("Can't find the muxer in muxer bin")?; 185 | 186 | pipeline.add(&muxer_bin)?; 187 | videoenc_bin.link_pads(None, &muxer, Some("video_%u"))?; 188 | muxer_bin.link(sink)?; 189 | 190 | if let Some(audio_srcs) = audio_srcs { 191 | let audioenc_str = audioenc_str 192 | .as_ref() 193 | .context("Failed to handle audio srcs: Profile has no audio encoder")?; 194 | let audioenc_bin = parse_bin("kooha-audioenc-bin", audioenc_str)?; 195 | debug_assert!(audioenc_bin.iterate_elements().into_iter().any(|element| { 196 | let factory = element.unwrap().factory().unwrap(); 197 | factory.has_type(gst::ElementFactoryType::AUDIO_ENCODER) 198 | })); 199 | 200 | pipeline.add(&audioenc_bin)?; 201 | audio_srcs.link(&audioenc_bin)?; 202 | audioenc_bin.link_pads(None, &muxer, Some("audio_%u"))?; 203 | } 204 | } 205 | (Some(_), None) => { 206 | bail!("Unexpected audioenc without muxer") 207 | } 208 | } 209 | 210 | Ok(()) 211 | } 212 | } 213 | 214 | fn parse_bin_test(description: &str) -> Result<(), glib::Error> { 215 | // Empty names are ignored in implementation details of `gst::parse::bin_from_description_with_name_full` 216 | parse_bin_inner("", description, false)?; 217 | 218 | Ok(()) 219 | } 220 | 221 | fn parse_bin(name: &str, description: &str) -> Result { 222 | parse_bin_inner(name, description, true) 223 | } 224 | 225 | fn parse_bin_inner( 226 | name: &str, 227 | description: &str, 228 | add_ghost_pads: bool, 229 | ) -> Result { 230 | let ideal_n_threads = glib::num_processors().min(MAX_THREAD_COUNT); 231 | let formatted_description = description.replace("${N_THREADS}", &ideal_n_threads.to_string()); 232 | let bin = gst::parse::bin_from_description_with_name_full( 233 | &formatted_description, 234 | add_ghost_pads, 235 | name, 236 | None, 237 | gst::ParseFlags::FATAL_ERRORS, 238 | )? 239 | .downcast() 240 | .unwrap(); 241 | Ok(bin) 242 | } 243 | 244 | #[cfg(test)] 245 | mod tests { 246 | use super::*; 247 | 248 | use std::{collections::HashSet, sync::Once}; 249 | 250 | use crate::config::RESOURCES_FILE; 251 | 252 | fn init_gresources() { 253 | static INIT: Once = Once::new(); 254 | 255 | INIT.call_once(|| { 256 | let res = gio::Resource::load(RESOURCES_FILE).unwrap(); 257 | gio::resources_register(&res); 258 | }); 259 | } 260 | 261 | #[test] 262 | fn profiles_fields_validity() { 263 | init_gresources(); 264 | 265 | let mut unique = HashSet::new(); 266 | 267 | for profile in Profile::all().unwrap() { 268 | assert!(!profile.id().is_empty()); 269 | 270 | assert!(!profile.name().is_empty()); 271 | assert!(!profile.file_extension().is_empty()); 272 | assert_ne!( 273 | profile.suggested_max_framerate(), 274 | gst::Fraction::from_integer(0) 275 | ); 276 | 277 | assert!( 278 | unique.insert(profile.id().to_string()), 279 | "Duplicate id `{}`", 280 | profile.id() 281 | ); 282 | } 283 | } 284 | 285 | #[test] 286 | fn profiles_validity() { 287 | init_gresources(); 288 | gst::init().unwrap(); 289 | gstgif::plugin_register_static().unwrap(); 290 | 291 | for profile in Profile::all().unwrap() { 292 | // These profiles are not supported by the CI runner. 293 | if matches!(profile.id(), "va-h264") { 294 | continue; 295 | } 296 | 297 | // FIXME Remove this. This is needed as x264enc is somehow not found. 298 | if matches!(profile.id(), "mp4" | "matroska-h264") { 299 | continue; 300 | } 301 | 302 | let pipeline = gst::Pipeline::new(); 303 | 304 | let dummy_video_src = gst::ElementFactory::make("fakesrc").build().unwrap(); 305 | let dummy_sink = gst::ElementFactory::make("fakesink").build().unwrap(); 306 | pipeline.add_many([&dummy_video_src, &dummy_sink]).unwrap(); 307 | 308 | let dummy_audio_src = if profile.supports_audio() { 309 | let dummy_audio_src = gst::ElementFactory::make("fakesrc").build().unwrap(); 310 | pipeline.add(&dummy_audio_src).unwrap(); 311 | Some(dummy_audio_src) 312 | } else { 313 | None 314 | }; 315 | 316 | if let Err(err) = profile.attach( 317 | &pipeline, 318 | &dummy_video_src, 319 | dummy_audio_src.as_ref(), 320 | &dummy_sink, 321 | ) { 322 | panic!("can't attach profile `{}`: {:?}", profile.id(), err); 323 | } 324 | 325 | assert!(pipeline 326 | .find_unlinked_pad(gst::PadDirection::Sink) 327 | .is_none()); 328 | assert!(pipeline.find_unlinked_pad(gst::PadDirection::Src).is_none()); 329 | 330 | assert!(profile.is_available()); 331 | } 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/screencast_portal/handle_token.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib::{self, prelude::*}; 2 | 3 | use std::sync::atomic::{AtomicUsize, Ordering}; 4 | 5 | static COUNTER: AtomicUsize = AtomicUsize::new(0); 6 | 7 | #[derive(Debug)] 8 | pub struct HandleToken(String); 9 | 10 | impl HandleToken { 11 | pub fn new() -> Self { 12 | Self(format!("kooha_{}", COUNTER.fetch_add(1, Ordering::Relaxed))) 13 | } 14 | 15 | pub fn as_str(&self) -> &str { 16 | &self.0 17 | } 18 | } 19 | 20 | impl ToVariant for HandleToken { 21 | fn to_variant(&self) -> glib::Variant { 22 | self.0.to_variant() 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::*; 29 | 30 | #[test] 31 | fn uniqueness() { 32 | let a = HandleToken::new(); 33 | let b = HandleToken::new(); 34 | let c = HandleToken::new(); 35 | 36 | assert_ne!(a.as_str(), b.as_str()); 37 | assert_ne!(b.as_str(), c.as_str()); 38 | assert_ne!(a.as_str(), c.as_str()); 39 | } 40 | 41 | #[test] 42 | fn to_variant() { 43 | let ht = HandleToken::new(); 44 | assert_eq!(ht.to_variant().type_(), String::static_variant_type()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/screencast_portal/mod.rs: -------------------------------------------------------------------------------- 1 | mod handle_token; 2 | mod types; 3 | mod variant_dict; 4 | mod window_identifier; 5 | 6 | use anyhow::{anyhow, bail, Context, Result}; 7 | use futures_channel::oneshot; 8 | use futures_util::future::{self, Either}; 9 | use gtk::{ 10 | gio, 11 | glib::{ 12 | self, 13 | variant::{Handle, ObjectPath}, 14 | }, 15 | prelude::*, 16 | }; 17 | 18 | use std::{cell::RefCell, os::unix::io::RawFd, time::Duration}; 19 | 20 | use self::{handle_token::HandleToken, variant_dict::VariantDict}; 21 | pub use self::{ 22 | types::{CursorMode, PersistMode, SourceType, Stream}, 23 | window_identifier::WindowIdentifier, 24 | }; 25 | use crate::cancelled::Cancelled; 26 | 27 | const DESKTOP_BUS_NAME: &str = "org.freedesktop.portal.Desktop"; 28 | const DESKTOP_OBJECT_PATH: &str = "/org/freedesktop/portal/desktop"; 29 | 30 | const SESSION_IFACE_NAME: &str = "org.freedesktop.portal.Session"; 31 | const REQUEST_IFACE_NAME: &str = "org.freedesktop.portal.Request"; 32 | const SCREENCAST_IFACE_NAME: &str = "org.freedesktop.portal.ScreenCast"; 33 | 34 | const PROXY_CALL_TIMEOUT: Duration = Duration::from_secs(5); 35 | 36 | pub struct Proxy(gio::DBusProxy); 37 | 38 | impl Proxy { 39 | pub async fn new() -> Result { 40 | let inner = gio::DBusProxy::for_bus_future( 41 | gio::BusType::Session, 42 | gio::DBusProxyFlags::NONE, 43 | None, 44 | DESKTOP_BUS_NAME, 45 | DESKTOP_OBJECT_PATH, 46 | SCREENCAST_IFACE_NAME, 47 | ) 48 | .await?; 49 | 50 | Ok(Self(inner)) 51 | } 52 | 53 | pub async fn create_session(&self) -> Result { 54 | let session_handle_token = HandleToken::new(); 55 | let handle_token = HandleToken::new(); 56 | 57 | let session_options = VariantDict::builder() 58 | .entry("handle_token", &handle_token) 59 | .entry("session_handle_token", &session_handle_token) 60 | .build(); 61 | let response = 62 | screencast_request_call(&self.0, &handle_token, "CreateSession", &(session_options,)) 63 | .await?; 64 | 65 | tracing::trace!(?response, "Created screencast session"); 66 | 67 | // FIXME this must be an ObjectPath not a String 68 | let session_handle = response.get_flatten::("session_handle")?; 69 | debug_assert!(session_handle.ends_with(&session_handle_token.as_str())); 70 | 71 | Ok(Session { 72 | proxy: self.0.clone(), 73 | session_handle: ObjectPath::try_from(session_handle)?, 74 | }) 75 | } 76 | 77 | pub fn version(&self) -> Result { 78 | self.property("version") 79 | } 80 | 81 | pub fn available_cursor_modes(&self) -> Result { 82 | let value = self.property::("AvailableCursorModes")?; 83 | 84 | CursorMode::from_bits(value).ok_or_else(|| anyhow!("Invalid cursor mode: {}", value)) 85 | } 86 | 87 | pub fn available_source_types(&self) -> Result { 88 | let value = self.property::("AvailableSourceTypes")?; 89 | 90 | SourceType::from_bits(value).ok_or_else(|| anyhow!("Invalid source type: {}", value)) 91 | } 92 | 93 | fn property(&self, name: &str) -> Result { 94 | let variant = self 95 | .0 96 | .cached_property(name) 97 | .ok_or_else(|| anyhow!("No cached property named `{}`", name))?; 98 | 99 | variant_get::(&variant) 100 | } 101 | } 102 | 103 | #[derive(Debug)] 104 | pub struct Session { 105 | proxy: gio::DBusProxy, 106 | session_handle: ObjectPath, 107 | } 108 | 109 | impl Session { 110 | pub async fn select_sources( 111 | &self, 112 | source_type: SourceType, 113 | multiple: bool, 114 | cursor_mode: CursorMode, 115 | restore_token: Option<&str>, 116 | persist_mode: PersistMode, 117 | ) -> Result<()> { 118 | let handle_token = HandleToken::new(); 119 | 120 | let mut options = VariantDict::builder() 121 | .entry("handle_token", &handle_token) 122 | .entry("types", source_type.bits()) 123 | .entry("multiple", multiple) 124 | .entry("cursor_mode", cursor_mode.bits()) 125 | .entry("persist_mode", persist_mode as u32) 126 | .build(); 127 | 128 | if let Some(restore_token) = restore_token.filter(|t| !t.is_empty()) { 129 | options.insert("restore_token", restore_token); 130 | } 131 | 132 | let response = screencast_request_call( 133 | &self.proxy, 134 | &handle_token, 135 | "SelectSources", 136 | &(&self.session_handle, options), 137 | ) 138 | .await?; 139 | debug_assert!(response.is_empty()); 140 | 141 | tracing::trace!(?response, "Selected sources"); 142 | 143 | Ok(()) 144 | } 145 | 146 | pub async fn start( 147 | &self, 148 | window_identifier: WindowIdentifier, 149 | ) -> Result<(Vec, Option)> { 150 | let handle_token = HandleToken::new(); 151 | 152 | let options = VariantDict::builder() 153 | .entry("handle_token", &handle_token) 154 | .build(); 155 | 156 | let response = screencast_request_call( 157 | &self.proxy, 158 | &handle_token, 159 | "Start", 160 | &(&self.session_handle, window_identifier, options), 161 | ) 162 | .await?; 163 | 164 | tracing::trace!(?response, "Started screencast session"); 165 | 166 | let streams = response.get_flatten("streams")?; 167 | let restore_token = response.get("restore_token")?; 168 | 169 | Ok((streams, restore_token)) 170 | } 171 | 172 | pub async fn open_pipe_wire_remote(&self) -> Result { 173 | let (response, fd_list) = self 174 | .proxy 175 | .call_with_unix_fd_list_future( 176 | "OpenPipeWireRemote", 177 | Some(&(&self.session_handle, VariantDict::default()).to_variant()), 178 | gio::DBusCallFlags::NONE, 179 | PROXY_CALL_TIMEOUT.as_millis() as i32, 180 | gio::UnixFDList::NONE, 181 | ) 182 | .await?; 183 | let fd_list = fd_list.context("No given fd list")?; 184 | 185 | tracing::trace!(%response, fd_list = ?fd_list.peek_fds(), "Opened pipe wire remote"); 186 | 187 | let (fd_index,) = variant_get::<(Handle,)>(&response)?; 188 | 189 | debug_assert_eq!(fd_list.length(), 1); 190 | 191 | let fd = fd_list 192 | .get(fd_index.0) 193 | .with_context(|| format!("Failed to get fd at index `{}`", fd_index.0))?; 194 | 195 | Ok(fd) 196 | } 197 | 198 | pub async fn close(self) -> Result<()> { 199 | let response = self 200 | .proxy 201 | .connection() 202 | .call_future( 203 | Some(DESKTOP_BUS_NAME), 204 | self.session_handle.as_str(), 205 | SESSION_IFACE_NAME, 206 | "Close", 207 | None, 208 | None, 209 | gio::DBusCallFlags::NONE, 210 | PROXY_CALL_TIMEOUT.as_millis() as i32, 211 | ) 212 | .await 213 | .context("Failed to invoke Close on the session")?; 214 | debug_assert!(variant_get::<()>(&response).is_ok()); 215 | 216 | tracing::trace!(%response, "Closed screencast session"); 217 | 218 | Ok(()) 219 | } 220 | } 221 | 222 | async fn screencast_request_call( 223 | proxy: &gio::DBusProxy, 224 | handle_token: &HandleToken, 225 | method: &str, 226 | params: impl ToVariant, 227 | ) -> Result { 228 | let connection = proxy.connection(); 229 | 230 | let unique_identifier = connection 231 | .unique_name() 232 | .expect("Connection has no unique name") 233 | .trim_start_matches(':') 234 | .replace('.', "_"); 235 | 236 | let request_path = { 237 | let path = format!( 238 | "/org/freedesktop/portal/desktop/request/{}/{}", 239 | unique_identifier, 240 | handle_token.as_str() 241 | ); 242 | ObjectPath::try_from(path.as_str()) 243 | .with_context(|| format!("Failed to create object path from `{}`", path))? 244 | }; 245 | 246 | let request_proxy = gio::DBusProxy::for_bus_future( 247 | gio::BusType::Session, 248 | gio::DBusProxyFlags::DO_NOT_AUTO_START 249 | | gio::DBusProxyFlags::DO_NOT_CONNECT_SIGNALS 250 | | gio::DBusProxyFlags::DO_NOT_LOAD_PROPERTIES, 251 | None, 252 | DESKTOP_BUS_NAME, 253 | request_path.as_str(), 254 | REQUEST_IFACE_NAME, 255 | ) 256 | .await?; 257 | 258 | let (name_owner_lost_tx, name_owner_lost_rx) = oneshot::channel(); 259 | let name_owner_lost_tx = RefCell::new(Some(name_owner_lost_tx)); 260 | 261 | let handler_id = request_proxy.connect_notify_local(Some("g-name-owner"), move |proxy, _| { 262 | if proxy.g_name_owner().is_none() { 263 | tracing::warn!("Lost request name owner"); 264 | 265 | if let Some(tx) = name_owner_lost_tx.take() { 266 | let _ = tx.send(()); 267 | } else { 268 | tracing::warn!("Received another g name owner notify"); 269 | } 270 | } 271 | }); 272 | 273 | let (response_tx, response_rx) = oneshot::channel(); 274 | let response_tx = RefCell::new(Some(response_tx)); 275 | 276 | let subscription_id = connection.signal_subscribe( 277 | Some(DESKTOP_BUS_NAME), 278 | Some(REQUEST_IFACE_NAME), 279 | Some("Response"), 280 | Some(request_path.as_str()), 281 | None, 282 | gio::DBusSignalFlags::NONE, 283 | move |_connection, _sender_name, _object_path, _interface_name, _signal_name, output| { 284 | if let Some(tx) = response_tx.take() { 285 | let _ = tx.send(output.clone()); 286 | } else { 287 | tracing::warn!("Received another response for already finished request"); 288 | } 289 | }, 290 | ); 291 | 292 | let params = params.to_variant(); 293 | let path = proxy 294 | .call_future( 295 | method, 296 | Some(¶ms), 297 | gio::DBusCallFlags::NONE, 298 | PROXY_CALL_TIMEOUT.as_millis() as i32, 299 | ) 300 | .await 301 | .with_context(|| format!("Failed to call `{}` with parameters: {:?}", method, params))?; 302 | debug_assert_eq!(variant_get::<(ObjectPath,)>(&path).unwrap().0, request_path); 303 | 304 | tracing::trace!("Waiting request response for method `{}`", method); 305 | 306 | let response = match future::select(response_rx, name_owner_lost_rx).await { 307 | Either::Left((res, _)) => res 308 | .with_context(|| Cancelled::new(method)) 309 | .context("Sender dropped")?, 310 | Either::Right(_) => bail!("Lost name owner for request"), 311 | }; 312 | request_proxy.disconnect(handler_id); 313 | connection.signal_unsubscribe(subscription_id); 314 | 315 | tracing::trace!("Request response received for method `{}`", method); 316 | 317 | let (response_no, response) = variant_get::<(u32, VariantDict)>(&response)?; 318 | 319 | match response_no { 320 | 0 => Ok(response), 321 | 1 => Err(Cancelled::new(method)).context("Cancelled by user"), 322 | 2 => Err(anyhow!( 323 | "Interaction was ended in some other way with response {:?}", 324 | response 325 | )), 326 | no => Err(anyhow!("Unknown response number of {}", no)), 327 | } 328 | } 329 | 330 | /// Provides error messages on incorrect variant type. 331 | fn variant_get(variant: &glib::Variant) -> Result { 332 | variant.get::().ok_or_else(|| { 333 | anyhow!( 334 | "Expected type `{}`; got `{}` with value `{}`", 335 | T::static_variant_type(), 336 | variant.type_(), 337 | variant 338 | ) 339 | }) 340 | } 341 | 342 | #[cfg(test)] 343 | mod tests { 344 | use super::*; 345 | 346 | #[test] 347 | fn get_variant_ok() { 348 | let variant = ("foo",).to_variant(); 349 | let (value,) = variant_get::<(String,)>(&variant).unwrap(); 350 | assert_eq!(value, "foo"); 351 | } 352 | 353 | #[test] 354 | fn get_variant_wrong_type() { 355 | let variant = "foo".to_variant(); 356 | let err = variant_get::(&variant).unwrap_err(); 357 | assert_eq!( 358 | "Expected type `u`; got `s` with value `'foo'`", 359 | err.to_string() 360 | ); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/screencast_portal/types.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib::{self, bitflags::bitflags, prelude::*}; 2 | 3 | use std::borrow::Cow; 4 | 5 | use super::VariantDict; 6 | 7 | bitflags! { 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 9 | pub struct CursorMode: u32 { 10 | const HIDDEN = 1; 11 | const EMBEDDED = 2; 12 | const METADATA = 4; 13 | } 14 | } 15 | 16 | bitflags! { 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 18 | pub struct SourceType: u32 { 19 | const MONITOR = 1; 20 | const WINDOW = 2; 21 | const VIRTUAL = 4; 22 | } 23 | } 24 | 25 | #[allow(dead_code)] 26 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 27 | #[repr(u32)] 28 | pub enum PersistMode { 29 | /// Do not persist. 30 | None = 0, 31 | /// Persist as long as the application is alive. 32 | Transient = 1, 33 | /// Persist until the user revokes this permission. 34 | Persistent = 2, 35 | } 36 | 37 | type StreamVariantType = (u32, VariantDict); 38 | 39 | #[derive(Debug, Clone)] 40 | pub struct Stream { 41 | node_id: u32, 42 | id: Option, 43 | position: Option<(i32, i32)>, 44 | size: Option<(i32, i32)>, 45 | source_type: Option, 46 | } 47 | 48 | impl Stream { 49 | pub fn node_id(&self) -> u32 { 50 | self.node_id 51 | } 52 | 53 | pub fn id(&self) -> Option<&str> { 54 | self.id.as_deref() 55 | } 56 | 57 | pub fn position(&self) -> Option<(i32, i32)> { 58 | self.position 59 | } 60 | 61 | pub fn size(&self) -> Option<(i32, i32)> { 62 | self.size 63 | } 64 | 65 | pub fn source_type(&self) -> Option { 66 | self.source_type 67 | } 68 | } 69 | 70 | impl StaticVariantType for Stream { 71 | fn static_variant_type() -> Cow<'static, glib::VariantTy> { 72 | ::static_variant_type() 73 | } 74 | } 75 | 76 | impl FromVariant for Stream { 77 | fn from_variant(variant: &glib::Variant) -> Option { 78 | let (node_id, props) = variant.get::()?; 79 | Some(Self { 80 | node_id, 81 | id: props.get_flatten("id").ok(), 82 | position: props.get_flatten("position").ok(), 83 | size: props.get_flatten("size").ok(), 84 | source_type: props 85 | .get_flatten::("source_type") 86 | .ok() 87 | .and_then(SourceType::from_bits), 88 | }) 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use super::*; 95 | 96 | #[test] 97 | fn stream_static_variant_type() { 98 | assert_eq!( 99 | Stream::static_variant_type(), 100 | glib::VariantTy::new("(ua{sv})").unwrap() 101 | ); 102 | } 103 | 104 | #[test] 105 | fn stream_from_variant() { 106 | let variant = glib::Variant::parse(None, "(uint32 63, {'id': <'0'>, 'source_type': , 'position': <(2, 2)>, 'size': <(1680, 1050)>})").unwrap(); 107 | assert_eq!(variant.type_(), Stream::static_variant_type()); 108 | 109 | let stream = variant.get::().unwrap(); 110 | assert_eq!(stream.node_id(), 63); 111 | assert_eq!(stream.id(), Some("0")); 112 | assert_eq!(stream.position(), Some((2, 2))); 113 | assert_eq!(stream.size(), Some((1680, 1050))); 114 | assert_eq!(stream.source_type(), Some(SourceType::MONITOR)); 115 | } 116 | 117 | #[test] 118 | fn stream_from_variant_optional() { 119 | let variant = 120 | glib::Variant::parse(Some(&Stream::static_variant_type()), "(uint32 63, {})").unwrap(); 121 | 122 | let stream = variant.get::().unwrap(); 123 | assert_eq!(stream.node_id(), 63); 124 | assert_eq!(stream.id(), None); 125 | assert_eq!(stream.position(), None); 126 | assert_eq!(stream.size(), None); 127 | assert_eq!(stream.source_type(), None); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/screencast_portal/variant_dict.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use gtk::glib::{self, prelude::*}; 3 | 4 | use std::{borrow::Cow, collections::HashMap, fmt}; 5 | 6 | #[derive(Default)] 7 | pub struct VariantDict(HashMap); 8 | 9 | impl fmt::Debug for VariantDict { 10 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 11 | fmt::Debug::fmt(&self.to_variant(), f) 12 | } 13 | } 14 | 15 | impl VariantDict { 16 | pub fn builder() -> VariantDictBuilder { 17 | VariantDictBuilder { h: HashMap::new() } 18 | } 19 | 20 | pub fn is_empty(&self) -> bool { 21 | self.0.is_empty() 22 | } 23 | 24 | pub fn insert(&mut self, key: &str, value: impl ToVariant) { 25 | self.0.insert(key.to_string(), value.to_variant()); 26 | } 27 | 28 | pub fn get_flatten(&self, key: &str) -> Result { 29 | let variant = self 30 | .0 31 | .get(key) 32 | .ok_or_else(|| anyhow!("Key `{}` not found", key))?; 33 | 34 | variant.get::().ok_or_else(|| { 35 | anyhow!( 36 | "Expected key `{}` of type `{}`; got `{}` with value `{}`", 37 | key, 38 | T::static_variant_type(), 39 | variant.type_(), 40 | variant 41 | ) 42 | }) 43 | } 44 | 45 | pub fn get(&self, key: &str) -> Result> { 46 | let Some(variant) = self.0.get(key) else { 47 | return Ok(None); 48 | }; 49 | 50 | let value = variant.get::().ok_or_else(|| { 51 | anyhow!( 52 | "Expected key `{}` of type `{}`; got `{}` with value `{}`", 53 | key, 54 | T::static_variant_type(), 55 | variant.type_(), 56 | variant 57 | ) 58 | })?; 59 | 60 | Ok(Some(value)) 61 | } 62 | } 63 | 64 | impl StaticVariantType for VariantDict { 65 | fn static_variant_type() -> Cow<'static, glib::VariantTy> { 66 | glib::VariantDict::static_variant_type() 67 | } 68 | } 69 | 70 | impl FromVariant for VariantDict { 71 | fn from_variant(value: &glib::Variant) -> Option { 72 | Some(Self(value.get::>()?)) 73 | } 74 | } 75 | 76 | impl ToVariant for VariantDict { 77 | fn to_variant(&self) -> glib::Variant { 78 | self.0.to_variant() 79 | } 80 | } 81 | 82 | pub struct VariantDictBuilder { 83 | h: HashMap, 84 | } 85 | 86 | impl VariantDictBuilder { 87 | pub fn entry(mut self, key: &str, value: impl ToVariant) -> Self { 88 | self.h.insert(key.into(), value.to_variant()); 89 | self 90 | } 91 | 92 | pub fn build(self) -> VariantDict { 93 | VariantDict(self.h) 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | 101 | #[test] 102 | fn empty() { 103 | let var_dict_a = VariantDict::default(); 104 | assert!(var_dict_a.is_empty()); 105 | 106 | let var_dict_b = VariantDict::builder().entry("test", "value").build(); 107 | assert!(!var_dict_b.is_empty()); 108 | } 109 | 110 | #[test] 111 | fn get_flatten_ok() { 112 | let var_dict = VariantDict::builder().entry("test", "value").build(); 113 | assert_eq!(var_dict.get_flatten::("test").unwrap(), "value"); 114 | } 115 | 116 | #[test] 117 | fn get_flatten_missing() { 118 | let var_dict = VariantDict::builder().entry("test", "value").build(); 119 | assert_eq!( 120 | var_dict 121 | .get_flatten::("test2") 122 | .unwrap_err() 123 | .to_string(), 124 | "Key `test2` not found" 125 | ); 126 | } 127 | 128 | #[test] 129 | fn get_flatten_wrong_type() { 130 | let var_dict = VariantDict::builder().entry("test", "value").build(); 131 | assert_eq!( 132 | var_dict.get_flatten::("test").unwrap_err().to_string(), 133 | "Expected key `test` of type `u`; got `s` with value `'value'`" 134 | ); 135 | } 136 | 137 | #[test] 138 | fn get_ok() { 139 | let var_dict = VariantDict::builder().entry("test", "value").build(); 140 | assert_eq!( 141 | var_dict.get::("test").unwrap().as_deref(), 142 | Some("value") 143 | ); 144 | } 145 | 146 | #[test] 147 | fn get_missing() { 148 | let var_dict = VariantDict::builder().entry("test", "value").build(); 149 | assert_eq!(var_dict.get::("test2").unwrap(), None); 150 | } 151 | 152 | #[test] 153 | fn get_wrong_type() { 154 | let var_dict = VariantDict::builder().entry("test", "value").build(); 155 | assert_eq!( 156 | var_dict.get::("test").unwrap_err().to_string(), 157 | "Expected key `test` of type `u`; got `s` with value `'value'`" 158 | ); 159 | } 160 | 161 | #[test] 162 | fn static_variant_type() { 163 | assert_eq!( 164 | VariantDict::default().to_variant().type_(), 165 | glib::VariantDict::static_variant_type() 166 | ); 167 | } 168 | 169 | #[test] 170 | fn from_variant() { 171 | let var = glib::Variant::parse(None, "{'test': <'value'>}").unwrap(); 172 | let var_dict = VariantDict::from_variant(&var).unwrap(); 173 | assert_eq!(var_dict.get_flatten::("test").unwrap(), "value"); 174 | } 175 | 176 | #[test] 177 | fn to_variant() { 178 | assert_eq!(VariantDict::static_variant_type(), glib::VariantTy::VARDICT); 179 | 180 | let var_dict = VariantDict::builder().entry("test", "value").build(); 181 | assert_eq!(var_dict.to_variant().to_string(), "{'test': <'value'>}"); 182 | } 183 | 184 | #[test] 185 | fn builder() { 186 | let var_dict = VariantDict::builder() 187 | .entry("test", "value") 188 | .entry("test2", "value2") 189 | .build(); 190 | assert_eq!(var_dict.get_flatten::("test").unwrap(), "value"); 191 | assert_eq!(var_dict.get_flatten::("test2").unwrap(), "value2"); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/screencast_portal/window_identifier.rs: -------------------------------------------------------------------------------- 1 | // Based on ashpd (MIT) 2 | // Source: https://github.com/bilelmoussaoui/ashpd/blob/49aca6ff0f20c68fc2ddb09763ed9937b002ded6/src/window_identifier/gtk4.rs 3 | 4 | use futures_channel::oneshot; 5 | use gtk::{ 6 | glib::{self, WeakRef}, 7 | prelude::*, 8 | }; 9 | 10 | use std::{cell::RefCell, fmt}; 11 | 12 | const WINDOW_HANDLE_KEY: &str = "kooha-wayland-window-handle"; 13 | 14 | type WindowHandleData = (Option, u8); 15 | 16 | #[derive(Debug)] 17 | pub enum WindowIdentifier { 18 | Wayland { 19 | top_level: WeakRef, 20 | handle: Option, 21 | }, 22 | X11(gdk_x11::XWindow), 23 | None, 24 | } 25 | 26 | impl fmt::Display for WindowIdentifier { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | match self { 29 | WindowIdentifier::Wayland { handle, .. } => { 30 | write!(f, "wayland:{}", handle.as_deref().unwrap_or_default()) 31 | } 32 | WindowIdentifier::X11(xid) => write!(f, "x11:{:#x}", xid), 33 | WindowIdentifier::None => f.write_str(""), 34 | } 35 | } 36 | } 37 | 38 | impl ToVariant for WindowIdentifier { 39 | fn to_variant(&self) -> glib::Variant { 40 | self.to_string().to_variant() 41 | } 42 | } 43 | 44 | impl WindowIdentifier { 45 | pub async fn new(native: &impl IsA) -> Self { 46 | let Some(surface) = native.surface() else { 47 | return Self::None; 48 | }; 49 | 50 | if let Some(top_level) = surface.downcast_ref::() { 51 | let handle = unsafe { 52 | if let Some(mut handle) = top_level.data::(WINDOW_HANDLE_KEY) { 53 | let (handle, ref_count) = handle.as_mut(); 54 | *ref_count += 1; 55 | handle.clone() 56 | } else { 57 | let (tx, rx) = oneshot::channel(); 58 | let tx = RefCell::new(Some(tx)); 59 | 60 | let result = top_level.export_handle(move |_, handle| { 61 | let tx = tx.take().expect("callback called twice"); 62 | 63 | match handle { 64 | Ok(handle) => { 65 | let _ = tx.send(Some(handle.to_string())); 66 | } 67 | Err(err) => { 68 | tracing::warn!("Failed to export handle: {:?}", err); 69 | let _ = tx.send(None); 70 | } 71 | } 72 | }); 73 | 74 | if !result { 75 | return Self::None; 76 | } 77 | 78 | let handle = rx.await.unwrap(); 79 | top_level.set_data::(WINDOW_HANDLE_KEY, (handle.clone(), 1)); 80 | handle 81 | } 82 | }; 83 | 84 | Self::Wayland { 85 | top_level: top_level.downgrade(), 86 | handle, 87 | } 88 | } else if let Some(surface) = surface.downcast_ref::() { 89 | Self::X11(surface.xid()) 90 | } else { 91 | tracing::warn!( 92 | "Unhandled surface backend type: {:?}", 93 | surface.display().backend() 94 | ); 95 | Self::None 96 | } 97 | } 98 | } 99 | 100 | impl Drop for WindowIdentifier { 101 | fn drop(&mut self) { 102 | if let WindowIdentifier::Wayland { top_level, handle } = self { 103 | if handle.is_none() { 104 | return; 105 | } 106 | 107 | if let Some(top_level) = top_level.upgrade() { 108 | unsafe { 109 | let (handle, ref_count) = top_level 110 | .data::(WINDOW_HANDLE_KEY) 111 | .unwrap() 112 | .as_mut(); 113 | 114 | if *ref_count > 1 { 115 | *ref_count -= 1; 116 | return; 117 | } 118 | 119 | top_level.unexport_handle(); 120 | tracing::trace!("Unexported handle: {:?}", handle); 121 | 122 | let _ = top_level.steal_data::(WINDOW_HANDLE_KEY); 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf, time::Duration}; 2 | 3 | use adw::prelude::*; 4 | use anyhow::{Context, Result}; 5 | use gettextrs::gettext; 6 | use gsettings_macro::gen_settings; 7 | use gtk::{gio, glib}; 8 | 9 | use crate::{ 10 | area_selector::{Selection, SelectionContext}, 11 | config::APP_ID, 12 | profile::Profile, 13 | }; 14 | 15 | #[gen_settings(file = "./data/io.github.seadve.Kooha.gschema.xml.in")] 16 | #[gen_settings_define(key_name = "selection", arg_type = "Selection", ret_type = "Selection")] 17 | #[gen_settings_define( 18 | key_name = "selection-context", 19 | arg_type = "SelectionContext", 20 | ret_type = "SelectionContext" 21 | )] 22 | #[gen_settings_skip(key_name = "saving-location")] 23 | #[gen_settings_skip(key_name = "framerate")] 24 | #[gen_settings_skip(key_name = "record-delay")] 25 | #[gen_settings_skip(key_name = "profile-id")] 26 | pub struct Settings; 27 | 28 | impl Default for Settings { 29 | fn default() -> Self { 30 | Self::new(APP_ID) 31 | } 32 | } 33 | 34 | impl Settings { 35 | /// Opens a `FileDialog` to select a folder and updates 36 | /// the settings with the selected folder. 37 | pub async fn select_saving_location( 38 | &self, 39 | parent: Option<&impl IsA>, 40 | ) -> Result<()> { 41 | let dialog = gtk::FileDialog::builder() 42 | .modal(true) 43 | .title(gettext("Select Recordings Folder")) 44 | .initial_folder(&gio::File::for_path(self.saving_location())) 45 | .build(); 46 | 47 | let folder = dialog.select_folder_future(parent).await?; 48 | let path = folder.path().context("Folder does not have a path")?; 49 | self.0.set("saving-location", path).unwrap(); 50 | 51 | Ok(()) 52 | } 53 | 54 | pub fn saving_location(&self) -> PathBuf { 55 | let stored_saving_location: PathBuf = self.0.get("saving-location"); 56 | 57 | if !stored_saving_location.as_os_str().is_empty() { 58 | return stored_saving_location; 59 | } 60 | 61 | let saving_location = 62 | glib::user_special_dir(glib::UserDirectory::Videos).unwrap_or_else(glib::home_dir); 63 | 64 | let kooha_saving_location = saving_location.join("Kooha"); 65 | 66 | if let Err(err) = fs::create_dir_all(&kooha_saving_location) { 67 | tracing::warn!( 68 | "Failed to create dir at `{}`: {:?}", 69 | kooha_saving_location.display(), 70 | err 71 | ); 72 | return saving_location; 73 | } 74 | 75 | kooha_saving_location 76 | } 77 | 78 | pub fn connect_saving_location_changed( 79 | &self, 80 | f: impl Fn(&Self) + 'static, 81 | ) -> glib::SignalHandlerId { 82 | self.0 83 | .connect_changed(Some("saving-location"), move |settings, _| { 84 | f(&Self(settings.clone())); 85 | }) 86 | } 87 | 88 | pub fn framerate(&self) -> gst::Fraction { 89 | self.0.get::<(i32, i32)>("framerate").into() 90 | } 91 | 92 | pub fn set_framerate(&self, framerate: gst::Fraction) { 93 | let raw = <(i32, i32)>::from(framerate); 94 | self.0.set("framerate", raw).unwrap(); 95 | } 96 | 97 | pub fn connect_framerate_changed(&self, f: impl Fn(&Self) + 'static) -> glib::SignalHandlerId { 98 | self.0 99 | .connect_changed(Some("framerate"), move |settings, _| { 100 | f(&Self(settings.clone())); 101 | }) 102 | } 103 | 104 | pub fn record_delay(&self) -> Duration { 105 | Duration::from_secs(self.0.get::("record-delay") as u64) 106 | } 107 | 108 | pub fn create_record_delay_action(&self) -> gio::Action { 109 | self.0.create_action("record-delay") 110 | } 111 | 112 | pub fn bind_record_delay<'a>( 113 | &'a self, 114 | object: &'a impl IsA, 115 | property: &'a str, 116 | ) -> gio::BindingBuilder<'a> { 117 | self.0.bind("record-delay", object, property) 118 | } 119 | 120 | pub fn set_profile(&self, profile: Option<&Profile>) { 121 | self.0 122 | .set_string("profile-id", profile.map_or("", |profile| profile.id())) 123 | .unwrap(); 124 | } 125 | 126 | pub fn profile(&self) -> Option<&'static Profile> { 127 | let profile_id = self.0.get::("profile-id"); 128 | 129 | if profile_id.is_empty() { 130 | return None; 131 | } 132 | 133 | Profile::from_id(&profile_id) 134 | .inspect_err(|err| { 135 | tracing::warn!("Failed to get profile with id `{}`: {:?}", profile_id, err); 136 | }) 137 | .ok() 138 | .filter(|profile| profile.is_available()) 139 | } 140 | 141 | pub fn connect_profile_changed(&self, f: impl Fn(&Self) + 'static) -> glib::SignalHandlerId { 142 | self.0 143 | .connect_changed(Some("profile-id"), move |settings, _| { 144 | f(&Self(settings.clone())); 145 | }) 146 | } 147 | 148 | pub fn reset_profile(&self) { 149 | self.0.reset("profile-id"); 150 | } 151 | } 152 | 153 | #[cfg(test)] 154 | mod tests { 155 | use super::*; 156 | 157 | use std::{env, process::Command, sync::Once}; 158 | 159 | fn setup_schema() { 160 | static INIT: Once = Once::new(); 161 | 162 | INIT.call_once(|| { 163 | let schema_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/data"); 164 | 165 | let output = Command::new("glib-compile-schemas") 166 | .arg(schema_dir) 167 | .output() 168 | .unwrap(); 169 | 170 | if !output.status.success() { 171 | panic!( 172 | "Failed to compile GSchema for tests; stdout: {}; stderr: {}", 173 | String::from_utf8_lossy(&output.stdout), 174 | String::from_utf8_lossy(&output.stderr) 175 | ); 176 | } 177 | 178 | env::set_var("GSETTINGS_SCHEMA_DIR", schema_dir); 179 | env::set_var("GSETTINGS_BACKEND", "memory"); 180 | }); 181 | } 182 | 183 | #[test] 184 | fn default_profile() { 185 | setup_schema(); 186 | gst::init().unwrap(); 187 | 188 | assert!(Settings::default().profile().is_some()); 189 | assert!(Settings::default().profile().unwrap().supports_audio()); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/timer.rs: -------------------------------------------------------------------------------- 1 | use futures_util::future::FusedFuture; 2 | use gtk::glib::{self, clone}; 3 | 4 | use std::{ 5 | cell::{Cell, RefCell}, 6 | fmt, 7 | future::Future, 8 | pin::Pin, 9 | rc::Rc, 10 | task::{Context, Poll, Waker}, 11 | time::{Duration, Instant}, 12 | }; 13 | 14 | use crate::cancelled::Cancelled; 15 | 16 | const SECS_LEFT_UPDATE_INTERVAL: Duration = Duration::from_millis(200); 17 | 18 | /// A reference counted cancellable timed future 19 | /// 20 | /// The timer will only start when it gets polled. 21 | #[derive(Clone)] 22 | pub struct Timer { 23 | inner: Rc, 24 | } 25 | 26 | impl fmt::Debug for Timer { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | f.debug_struct("Timer") 29 | .field("duration", &self.inner.duration) 30 | .field("state", &self.inner.state.get()) 31 | .field("elapsed", &self.inner.instant.get().map(|i| i.elapsed())) 32 | .finish() 33 | } 34 | } 35 | 36 | #[derive(Debug, Clone, Copy)] 37 | enum State { 38 | Waiting, 39 | Cancelled, 40 | Done, 41 | } 42 | 43 | impl State { 44 | fn to_poll(self) -> Poll<::Output> { 45 | match self { 46 | State::Waiting => Poll::Pending, 47 | State::Cancelled => Poll::Ready(Err(Cancelled::new("timer"))), 48 | State::Done => Poll::Ready(Ok(())), 49 | } 50 | } 51 | } 52 | 53 | struct Inner { 54 | duration: Duration, 55 | 56 | secs_left_changed_cb: Box, 57 | secs_left_changed_source_id: RefCell>, 58 | 59 | state: Cell, 60 | 61 | instant: Cell>, 62 | waker: RefCell>, 63 | source_id: RefCell>, 64 | } 65 | 66 | impl Inner { 67 | fn secs_left(&self) -> u64 { 68 | if self.is_terminated() { 69 | return 0; 70 | } 71 | 72 | let elapsed_secs = self 73 | .instant 74 | .get() 75 | .map_or(Duration::ZERO, |instant| instant.elapsed()) 76 | .as_secs(); 77 | 78 | self.duration.as_secs() - elapsed_secs 79 | } 80 | 81 | fn is_terminated(&self) -> bool { 82 | matches!(self.state.get(), State::Done | State::Cancelled) 83 | } 84 | } 85 | 86 | impl Timer { 87 | /// The timer will start as soon as it gets polled 88 | pub fn new(duration: Duration, secs_left_changed_cb: impl Fn(u64) + 'static) -> Self { 89 | Self { 90 | inner: Rc::new(Inner { 91 | duration, 92 | secs_left_changed_cb: Box::new(secs_left_changed_cb), 93 | secs_left_changed_source_id: RefCell::new(None), 94 | state: Cell::new(State::Waiting), 95 | instant: Cell::new(None), 96 | waker: RefCell::new(None), 97 | source_id: RefCell::new(None), 98 | }), 99 | } 100 | } 101 | 102 | pub fn cancel(&self) { 103 | if self.inner.is_terminated() { 104 | return; 105 | } 106 | 107 | self.inner.state.set(State::Cancelled); 108 | 109 | if let Some(source_id) = self.inner.source_id.take() { 110 | source_id.remove(); 111 | } 112 | 113 | if let Some(source_id) = self.inner.secs_left_changed_source_id.take() { 114 | source_id.remove(); 115 | } 116 | 117 | if let Some(waker) = self.inner.waker.take() { 118 | waker.wake(); 119 | } 120 | } 121 | } 122 | 123 | impl Future for Timer { 124 | type Output = Result<(), Cancelled>; 125 | 126 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 127 | match self.inner.state.get().to_poll() { 128 | ready @ Poll::Ready(_) => return ready, 129 | Poll::Pending => {} 130 | } 131 | 132 | if self.inner.duration.is_zero() { 133 | self.inner.state.set(State::Done); 134 | return Poll::Ready(Ok(())); 135 | } 136 | 137 | let waker = cx.waker().clone(); 138 | self.inner.waker.replace(Some(waker)); 139 | 140 | self.inner 141 | .secs_left_changed_source_id 142 | .replace(Some(glib::timeout_add_local( 143 | SECS_LEFT_UPDATE_INTERVAL, 144 | clone!( 145 | #[weak(rename_to = inner)] 146 | self.inner, 147 | #[upgrade_or_panic] 148 | move || { 149 | (inner.secs_left_changed_cb)(inner.secs_left()); 150 | glib::ControlFlow::Continue 151 | } 152 | ), 153 | ))); 154 | 155 | self.inner 156 | .source_id 157 | .replace(Some(glib::timeout_add_local_once( 158 | self.inner.duration, 159 | clone!( 160 | #[weak(rename_to = inner)] 161 | self.inner, 162 | move || { 163 | inner.state.set(State::Done); 164 | 165 | if let Some(source_id) = inner.secs_left_changed_source_id.take() { 166 | source_id.remove(); 167 | } 168 | 169 | if let Some(waker) = inner.waker.take() { 170 | waker.wake(); 171 | } 172 | } 173 | ), 174 | ))); 175 | self.inner.instant.set(Some(Instant::now())); 176 | (self.inner.secs_left_changed_cb)(self.inner.secs_left()); 177 | 178 | self.inner.state.get().to_poll() 179 | } 180 | } 181 | 182 | impl FusedFuture for Timer { 183 | fn is_terminated(&self) -> bool { 184 | self.inner.is_terminated() 185 | } 186 | } 187 | 188 | impl Drop for Timer { 189 | fn drop(&mut self) { 190 | self.cancel(); 191 | } 192 | } 193 | 194 | #[cfg(test)] 195 | mod tests { 196 | use super::*; 197 | 198 | use futures_util::FutureExt; 199 | 200 | #[gtk::test] 201 | async fn normal() { 202 | let timer = Timer::new(Duration::from_nanos(10), |_| {}); 203 | assert_eq!(timer.inner.duration, Duration::from_nanos(10)); 204 | assert!(matches!(timer.inner.state.get(), State::Waiting)); 205 | 206 | assert!(timer.clone().await.is_ok()); 207 | assert!(matches!(timer.inner.state.get(), State::Done)); 208 | assert_eq!(timer.inner.secs_left(), 0); 209 | } 210 | 211 | #[gtk::test] 212 | async fn cancelled() { 213 | let timer = Timer::new(Duration::from_nanos(10), |_| {}); 214 | assert!(matches!(timer.inner.state.get(), State::Waiting)); 215 | 216 | timer.cancel(); 217 | 218 | assert!(timer.clone().await.is_err()); 219 | assert!(matches!(timer.inner.state.get(), State::Cancelled)); 220 | assert_eq!(timer.inner.secs_left(), 0); 221 | } 222 | 223 | #[gtk::test] 224 | fn zero_duration() { 225 | let control = Timer::new(Duration::from_nanos(10), |_| {}); 226 | assert!(control.now_or_never().is_none()); 227 | 228 | let timer = Timer::new(Duration::ZERO, |_| {}); 229 | 230 | assert!(timer.clone().now_or_never().unwrap().is_ok()); 231 | assert!(matches!(timer.inner.state.get(), State::Done)); 232 | assert_eq!(timer.inner.secs_left(), 0); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/window/progress_icon.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::Cell, 3 | f64::consts::{FRAC_PI_2, TAU}, 4 | }; 5 | 6 | use adw::prelude::*; 7 | use gtk::{ 8 | cairo, 9 | glib::{self, clone}, 10 | graphene::Rect, 11 | subclass::prelude::*, 12 | }; 13 | 14 | const LINE_WIDTH: f64 = 4.0; 15 | 16 | const ANIMATION_DURATION_MS: u32 = 300; 17 | 18 | mod imp { 19 | use std::cell::OnceCell; 20 | 21 | use super::*; 22 | 23 | #[derive(Default, glib::Properties)] 24 | #[properties(wrapper_type = super::ProgressIcon)] 25 | pub struct ProgressIcon { 26 | #[property(get, set = Self::set_progress, minimum = 0.0, maximum = 1.0, explicit_notify)] 27 | pub(super) progress: Cell, 28 | 29 | pub(super) animation: OnceCell, 30 | pub(super) display_progress: Cell, 31 | } 32 | 33 | #[glib::object_subclass] 34 | impl ObjectSubclass for ProgressIcon { 35 | const NAME: &'static str = "KoohaProgressIcon"; 36 | type Type = super::ProgressIcon; 37 | type ParentType = gtk::Widget; 38 | } 39 | 40 | #[glib::derived_properties] 41 | impl ObjectImpl for ProgressIcon { 42 | fn constructed(&self) { 43 | self.parent_constructed(); 44 | 45 | let obj = self.obj(); 46 | 47 | let animation_target = adw::CallbackAnimationTarget::new(clone!( 48 | #[weak] 49 | obj, 50 | move |value| { 51 | let imp = obj.imp(); 52 | imp.display_progress.set(value); 53 | obj.queue_draw(); 54 | } 55 | )); 56 | let animation = adw::TimedAnimation::builder() 57 | .widget(&*obj) 58 | .duration(ANIMATION_DURATION_MS) 59 | .target(&animation_target) 60 | .build(); 61 | self.animation.set(animation).unwrap(); 62 | } 63 | } 64 | 65 | impl WidgetImpl for ProgressIcon { 66 | fn snapshot(&self, snapshot: >k::Snapshot) { 67 | let obj = self.obj(); 68 | 69 | let width = obj.width(); 70 | let height = obj.height(); 71 | let color = obj.color(); 72 | 73 | let cx = width as f64 / 2.0; 74 | let cy = height as f64 / 2.0; 75 | let radius = width as f64 / 2.0 - LINE_WIDTH / 2.0; 76 | let arc_end = self.display_progress.get() * TAU - FRAC_PI_2; 77 | 78 | let ctx = snapshot.append_cairo(&Rect::new(0.0, 0.0, width as f32, height as f32)); 79 | ctx.set_line_width(LINE_WIDTH); 80 | ctx.set_line_cap(cairo::LineCap::Round); 81 | 82 | ctx.set_source_color(&color); 83 | ctx.move_to(cx, cy - radius); 84 | ctx.arc(cx, cy, radius, -FRAC_PI_2, arc_end); 85 | ctx.stroke().unwrap(); 86 | 87 | ctx.set_source_color(&color.with_alpha(color.alpha() * 0.15)); 88 | ctx.move_to(cx + radius * arc_end.cos(), cy + radius * arc_end.sin()); 89 | ctx.arc(cx, cy, radius, arc_end, 3.0 * FRAC_PI_2); 90 | ctx.stroke().unwrap(); 91 | } 92 | } 93 | 94 | impl ProgressIcon { 95 | fn set_progress(&self, progress: f64) { 96 | if (progress - self.progress.get()).abs() < f64::EPSILON { 97 | return; 98 | } 99 | 100 | let obj = self.obj(); 101 | 102 | self.progress.set(progress); 103 | 104 | let animation = self.animation.get().unwrap(); 105 | animation.set_value_from(animation.value()); 106 | animation.set_value_to(progress); 107 | animation.play(); 108 | 109 | obj.notify_progress(); 110 | } 111 | } 112 | } 113 | 114 | glib::wrapper! { 115 | pub struct ProgressIcon(ObjectSubclass) 116 | @extends gtk::Widget; 117 | } 118 | 119 | impl ProgressIcon { 120 | pub fn new() -> Self { 121 | glib::Object::new() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/window/toggle_button.rs: -------------------------------------------------------------------------------- 1 | use gtk::{glib, prelude::*, subclass::prelude::*}; 2 | 3 | use std::cell::RefCell; 4 | 5 | mod imp { 6 | use super::*; 7 | 8 | #[derive(Debug, Default, glib::Properties)] 9 | #[properties(wrapper_type = super::ToggleButton)] 10 | pub struct ToggleButton { 11 | /// Icon name to show on un-toggled state 12 | #[property(get, set = Self::set_default_icon_name, explicit_notify)] 13 | pub(super) default_icon_name: RefCell, 14 | /// Icon name to show on toggled state 15 | #[property(get, set = Self::set_toggled_icon_name, explicit_notify)] 16 | pub(super) toggled_icon_name: RefCell, 17 | /// Tooltip text to show on un-toggled state 18 | #[property(get, set = Self::set_default_tooltip_text, explicit_notify)] 19 | pub(super) default_tooltip_text: RefCell, 20 | /// Tooltip text to show on toggled state 21 | #[property(get, set = Self::set_toggled_tooltip_text, explicit_notify)] 22 | pub(super) toggled_tooltip_text: RefCell, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for ToggleButton { 27 | const NAME: &'static str = "KoohaToggleButton"; 28 | type Type = super::ToggleButton; 29 | type ParentType = gtk::ToggleButton; 30 | } 31 | 32 | #[glib::derived_properties] 33 | impl ObjectImpl for ToggleButton {} 34 | 35 | impl WidgetImpl for ToggleButton {} 36 | impl ButtonImpl for ToggleButton {} 37 | 38 | impl ToggleButtonImpl for ToggleButton { 39 | fn toggled(&self) { 40 | let obj = self.obj(); 41 | 42 | obj.update_icon_name(); 43 | obj.update_tooltip_text(); 44 | 45 | self.parent_toggled(); 46 | } 47 | } 48 | 49 | impl ToggleButton { 50 | fn set_default_icon_name(&self, default_icon_name: String) { 51 | let obj = self.obj(); 52 | 53 | if default_icon_name == obj.default_icon_name().as_str() { 54 | return; 55 | } 56 | 57 | self.default_icon_name.replace(default_icon_name); 58 | obj.update_icon_name(); 59 | obj.notify_default_icon_name(); 60 | } 61 | 62 | fn set_toggled_icon_name(&self, toggled_icon_name: String) { 63 | let obj = self.obj(); 64 | 65 | if toggled_icon_name == obj.toggled_icon_name().as_str() { 66 | return; 67 | } 68 | 69 | self.toggled_icon_name.replace(toggled_icon_name); 70 | obj.update_icon_name(); 71 | obj.notify_toggled_icon_name(); 72 | } 73 | 74 | fn set_default_tooltip_text(&self, default_tooltip_text: String) { 75 | let obj = self.obj(); 76 | 77 | if default_tooltip_text == obj.default_tooltip_text().as_str() { 78 | return; 79 | } 80 | 81 | self.default_tooltip_text.replace(default_tooltip_text); 82 | obj.update_tooltip_text(); 83 | obj.notify_default_tooltip_text(); 84 | } 85 | 86 | fn set_toggled_tooltip_text(&self, toggled_tooltip_text: String) { 87 | let obj = self.obj(); 88 | 89 | if toggled_tooltip_text == obj.toggled_tooltip_text().as_str() { 90 | return; 91 | } 92 | 93 | self.toggled_tooltip_text.replace(toggled_tooltip_text); 94 | obj.update_tooltip_text(); 95 | obj.notify_toggled_tooltip_text(); 96 | } 97 | } 98 | } 99 | 100 | glib::wrapper! { 101 | /// A toggle button that shows different icons and tooltips depending on the state. 102 | /// 103 | /// Note: `icon-name` and `tooltip-text` must not be set directly. 104 | pub struct ToggleButton(ObjectSubclass) 105 | @extends gtk::Widget, gtk::Button, gtk::ToggleButton; 106 | } 107 | 108 | impl ToggleButton { 109 | pub fn new() -> Self { 110 | glib::Object::new() 111 | } 112 | 113 | fn update_icon_name(&self) { 114 | let icon_name = if self.is_active() { 115 | self.toggled_icon_name() 116 | } else { 117 | self.default_icon_name() 118 | }; 119 | self.set_icon_name(&icon_name); 120 | } 121 | 122 | fn update_tooltip_text(&self) { 123 | let tooltip_text = if self.is_active() { 124 | self.toggled_tooltip_text() 125 | } else { 126 | self.default_tooltip_text() 127 | }; 128 | self.set_tooltip_text(if tooltip_text.is_empty() { 129 | None 130 | } else { 131 | Some(&tooltip_text) 132 | }); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | numer = "numer" 3 | 4 | [files] 5 | extend-exclude = ["data/icons/*.svg", "po/*.po"] 6 | --------------------------------------------------------------------------------