├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build_image.yaml │ ├── build_linux.yml │ ├── build_macos.yml │ ├── build_release.yml │ ├── build_windows.yml │ ├── build_windows_release.yml │ ├── cargo_clippy_check.yaml │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── docker ├── .dockerignore ├── Dockerfile ├── README.md └── entrypoint.sh ├── docs ├── build_on_linux.md ├── tsukimi1.png ├── tsukimi2.png ├── tsukimi3.png ├── tsukimi4.png └── tsukimi5.png ├── flatpak └── moe.tsuna.tsukimi.yml ├── installer ├── LICENSE.txt ├── tsukimi_installer.nsi └── version.txt ├── meson.build ├── meson.options ├── po ├── LINGUAS ├── POTFILES ├── ar.po ├── de.po ├── fr.po ├── ja.po ├── meson.build ├── nb_NO.po ├── pt_BR.po ├── ta.po ├── tsukimi.pot ├── zh_CN.po └── zh_Hant.po ├── resources ├── icons │ ├── meson.build │ ├── moe.tsuna.tsukimi.png │ ├── scalable │ │ └── actions │ │ │ ├── arrow-pointing-at-line-down-symbolic.svg │ │ │ ├── arrow4-left-symbolic.svg │ │ │ ├── axes-one-quadrant-symbolic.svg │ │ │ ├── card-bulleted-symbolic.svg │ │ │ ├── chain-link-loose-symbolic.svg │ │ │ ├── check-round-outline2-symbolic.svg │ │ │ ├── checkmark-small-symbolic.svg │ │ │ ├── cross-small-symbolic.svg │ │ │ ├── display-projector-symbolic.svg │ │ │ ├── emby-symbolic.svg │ │ │ ├── external-link-symbolic.svg │ │ │ ├── funnel-outline-symbolic.svg │ │ │ ├── jellyfin-symbolic.svg │ │ │ ├── large-brush-symbolic.svg │ │ │ ├── minus-circle-outline-symbolic.svg │ │ │ ├── month-symbolic.svg │ │ │ ├── music-note-single-outline-symbolic.svg │ │ │ ├── settings-symbolic.svg │ │ │ ├── skip-backwards-30-symbolic.svg │ │ │ ├── skip-forward-30-symbolic.svg │ │ │ ├── sound-symbolic.svg │ │ │ ├── speed-svgrepo-com-symbolic.svg │ │ │ ├── tag-outline-symbolic.svg │ │ │ ├── text-bold-symbolic.svg │ │ │ ├── text-italic-symbolic.svg │ │ │ ├── text-justify-center-symbolic.svg │ │ │ ├── text-justify-left-symbolic.svg │ │ │ ├── text-justify-right-symbolic.svg │ │ │ ├── video-encoder-symbolic.svg │ │ │ ├── video-reel-symbolic.svg │ │ │ └── video-reel2-symbolic.svg │ └── tsukimi.ico ├── meson.build ├── moe.tsuna.tsukimi.desktop.in ├── moe.tsuna.tsukimi.gschema.xml ├── moe.tsuna.tsukimi.metainfo.xml.in ├── resources.gresource.xml ├── style-dark.css ├── style.css └── ui │ ├── account.ui │ ├── account_settings.ui │ ├── action_row.ui │ ├── album_widget.ui │ ├── check_row.ui │ ├── content_viewer.ui │ ├── disc_box.ui │ ├── dropdown.ui │ ├── episode_switcher.ui │ ├── episoderow.ui │ ├── eu_item.ui │ ├── filter.ui │ ├── filter_label.ui │ ├── filter_row.ui │ ├── filter_search_page.ui │ ├── filters_row.ui │ ├── home.ui │ ├── horbu_scrolled.ui │ ├── hortu_scrolled.ui │ ├── identify_dialog.ui │ ├── identify_dialog_search_page.ui │ ├── image_dialog_edit_page.ui │ ├── image_dialog_search_page.ui │ ├── image_drop_row.ui │ ├── image_info_card.ui │ ├── images_dialog.ui │ ├── item.ui │ ├── item_actions.ui │ ├── item_carousel.ui │ ├── liked.ui │ ├── list.ui │ ├── listexpand_row.ui │ ├── listitem.ui │ ├── media_viewer.ui │ ├── metadata_dialog.ui │ ├── missing_episodes.ui │ ├── mpv_control_sidebar.ui │ ├── mpv_menu.ui │ ├── mpv_menu_actions.ui │ ├── mpv_shortcuts_window.ui │ ├── mpvpage.ui │ ├── other.ui │ ├── picture_loader.ui │ ├── player_toolbar.ui │ ├── pop-menu.ui │ ├── refresh_dialog.ui │ ├── search.ui │ ├── server_action_row.ui │ ├── server_panel.ui │ ├── server_row.ui │ ├── single_grid.ui │ ├── song_widget.ui │ ├── theme_switcher.ui │ ├── tu_overview_item.ui │ ├── tuview_scrolled.ui │ ├── volume_bar.ui │ └── window.ui ├── secret └── secret ├── share └── macos │ ├── AppIcon.icns │ └── Info.plist ├── src ├── app.rs ├── arg.rs ├── client │ ├── account.rs │ ├── dandan.rs │ ├── emby_client.rs │ ├── error.rs │ ├── mod.rs │ ├── proxy.rs │ ├── runtime.rs │ ├── stream_profile.json │ ├── structs.rs │ ├── test.json │ └── windows_compat.rs ├── config.rs ├── config.rs.in ├── gstl │ ├── mod.rs │ └── player.rs ├── lib.rs ├── macros.rs ├── main.rs ├── meson.build ├── ui │ ├── mod.rs │ ├── models │ │ ├── mod.rs │ │ └── settings.rs │ ├── mpv │ │ ├── control_sidebar.rs │ │ ├── danmaku_timer.rs │ │ ├── menu_actions.rs │ │ ├── mod.rs │ │ ├── mpvglarea.rs │ │ ├── options_matcher.rs │ │ ├── page.rs │ │ ├── tsukimi_mpv.rs │ │ ├── video_scale.rs │ │ └── volume_bar.rs │ ├── provider │ │ ├── account_item.rs │ │ ├── actions.rs │ │ ├── background_paintable.rs │ │ ├── core_song.rs │ │ ├── descriptor.rs │ │ ├── dropdown_factory.rs │ │ ├── image_tags.rs │ │ ├── mod.rs │ │ ├── tu_item.rs │ │ └── tu_object.rs │ └── widgets │ │ ├── account_add.rs │ │ ├── account_settings.rs │ │ ├── action_row.rs │ │ ├── check_row.rs │ │ ├── content_viewer.rs │ │ ├── disc_box.rs │ │ ├── episode_switcher │ │ ├── button.rs │ │ ├── mod.rs │ │ └── switcher.rs │ │ ├── eu_item │ │ ├── eu_list_item.rs │ │ ├── eu_object.rs │ │ ├── eu_property.rs │ │ └── mod.rs │ │ ├── filter_panel │ │ ├── dialog.rs │ │ ├── filter_label.rs │ │ ├── filter_row.rs │ │ ├── filters_list.rs │ │ ├── filters_row.rs │ │ ├── mod.rs │ │ └── search_page.rs │ │ ├── fix.rs │ │ ├── home.rs │ │ ├── horbu_scrolled.rs │ │ ├── hortu_scrolled.rs │ │ ├── identify │ │ ├── dialog.rs │ │ ├── mod.rs │ │ └── search_page.rs │ │ ├── image_dialog │ │ ├── image_adw_dialog.rs │ │ ├── image_drop_row.rs │ │ ├── image_edit_dialog_page.rs │ │ ├── image_infocard.rs │ │ ├── mod.rs │ │ └── search_page.rs │ │ ├── image_paintable.rs │ │ ├── item.rs │ │ ├── item_actionbox.rs │ │ ├── item_carousel.rs │ │ ├── item_utils.rs │ │ ├── liked.rs │ │ ├── list.rs │ │ ├── list_dropdown.rs │ │ ├── listexpand_row.rs │ │ ├── logo.rs │ │ ├── media_viewer.rs │ │ ├── metadata_dialog.rs │ │ ├── missing_episodes_dialog.rs │ │ ├── mod.rs │ │ ├── music_album.rs │ │ ├── other.rs │ │ ├── picture_loader.rs │ │ ├── player_toolbar.rs │ │ ├── refresh_dialog.rs │ │ ├── scale_revealer.rs │ │ ├── search.rs │ │ ├── server_action_row.rs │ │ ├── server_panel.rs │ │ ├── server_row.rs │ │ ├── single_grid.rs │ │ ├── smooth_scale.rs │ │ ├── song_widget.rs │ │ ├── star_toggle.rs │ │ ├── theme_switcher │ │ ├── mod.rs │ │ └── switcher.rs │ │ ├── tu_item │ │ ├── action.rs │ │ ├── mod.rs │ │ ├── overlay.rs │ │ ├── prelude.rs │ │ └── progressbar_animation.rs │ │ ├── tu_list_item.rs │ │ ├── tu_overview_item.rs │ │ ├── tuview_scrolled.rs │ │ ├── utils.rs │ │ └── window.rs └── utils.rs └── tsukimi_manifest.rc /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug, pending 6 | assignees: tsukinaha 7 | 8 | --- 9 | 10 |
11 | IMPORTANT 12 | IF FLOWING CONTENTS DELETED WITH NO REASON, YOUR ISSUE MIGHT BE LOCKED 13 | 14 | 请您遵守 ISSUE TEMPLATE,如果您无故删掉以下内容,您提交的 ISSUE 将会被视为是无效的 15 |
16 | 17 | **Confirm the following information** 18 | - [ ] I have read the [FAQ](https://dev.tsukinaha.org/tsukimi) 19 | - [ ] I have searched this in existing issues 20 | - [ ] I have obtained the complete log 21 | 22 |
23 | To get the log 24 | 25 | Run `tsukimi -f /path/to/log` in your terminal 26 | If you use Windows, use your PowerShell and run `.\tsukimi.exe -f log.txt` 27 | Paste the log at the bottom 28 | 29 |
30 | 31 | **Complete the following information** 32 | - OS: [e.g. NixOS 24.11] 33 | - Architecture: [e.g. x86_64] 34 | - GPU: [e.g. NVIDIA GTX 690] 35 | - Version [e.g. 0.11.4] 36 | 37 | **Describe the bug** 38 | A clear and concise description of what the bug is. 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | 43 |
44 | Logs 45 | 46 | ```plain 47 | Paste logs here 48 | ``` 49 | 50 |
51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement, pending 6 | assignees: tsukinaha 7 | 8 | --- 9 | 10 | **Description** 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "cargo" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | ignore: 11 | # These are peer deps of Cargo and should not be automatically bumped 12 | - dependency-name: "semver" 13 | - dependency-name: "crates-io" 14 | rebase-strategy: "disabled" 15 | -------------------------------------------------------------------------------- /.github/workflows/build_linux.yml: -------------------------------------------------------------------------------- 1 | name: Linux CI 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | include: 10 | - platform: amd64 11 | os: ubuntu-latest 12 | arch: x86_64 13 | - platform: arm64 14 | os: ubuntu-24.04-arm 15 | arch: aarch64 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | 25 | - name: Cache 26 | uses: actions/cache@v4 27 | with: 28 | path: target 29 | key: tsukimi-build-${{ matrix.arch }}-linux-${{ hashFiles('**/Cargo.lock') }} 30 | restore-keys: | 31 | tsukimi-build-${{ matrix.arch }}-linux- 32 | 33 | - name: Build tsukimi for ${{ matrix.platform }} 34 | run: | 35 | echo "${{ secrets.DANDANAPI_SECRET_KEY }}" > secret/key 36 | docker run --rm --platform linux/${{matrix.platform}} -v ${{ github.workspace }}:/app -v ./docker/entrypoint.sh:/entrypoint.sh ghcr.io/tsukinaha/ubuntu-rust-gtk4:latest 37 | sudo cp target/release/tsukimi . 38 | sudo cp -r i18n/locale . 39 | sudo cp resources/moe*.xml . 40 | 41 | - name: Upload artifact 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: tsukimi-${{matrix.arch}}-linux 45 | path: | 46 | locale/ 47 | tsukimi 48 | moe*.xml 49 | -------------------------------------------------------------------------------- /.github/workflows/build_macos.yml: -------------------------------------------------------------------------------- 1 | name: MacOS CI 2 | on: 3 | workflow_dispatch: 4 | # push: 5 | # branches: 6 | # - 'main' 7 | # schedule: 8 | # - cron: '0 4 * * *' 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse 13 | 14 | jobs: 15 | build-release: 16 | env: 17 | RUST_BACKTRACE: full 18 | strategy: 19 | matrix: 20 | include: 21 | - arch-name: x86_64-macos 22 | os: macos-latest 23 | target: x86_64-apple-darwin 24 | artifact: tsukimi 25 | ext: 26 | 27 | runs-on: ${{matrix.os}} 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Install Dependencies 34 | run: | 35 | brew install gtk4 36 | brew install libadwaita 37 | rustup target add ${{matrix.target}} 38 | cargo build --release --locked 39 | 40 | - name: Make MacOS package 41 | run: | 42 | mkdir -p Tsukimi.app/Contents/MacOS 43 | mkdir -p Tsukimi.app/Contents/Resources 44 | cp share/macos/Info.plist Tsukimi.app/Contents/ 45 | cp share/macos/AppIcon.icns Tsukimi.app/Contents/Resources/ 46 | mv target/release/tsukimi Tsukimi.app/Contents/MacOS/ 47 | tar -czf tsukimi-x86_64-apple-darwin.tar.gz Tsukimi.app/ 48 | 49 | - name: Upload artifact 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: ${{matrix.artifact}}-${{matrix.target}} 53 | path: tsukimi-${{matrix.target}}.tar.gz 54 | compression-level: 0 55 | -------------------------------------------------------------------------------- /.github/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | on: 3 | workflow_dispatch: 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse 8 | RUST_BACKTRACE: full 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | include: 15 | # x86_64-linux 16 | - arch: x86_64-linux 17 | os: ubuntu-latest 18 | target: amd64 19 | # aarch64-linux 20 | - arch: aarch64-linux 21 | os: ubuntu-24.04-arm 22 | target: arm64 23 | 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | submodules: true 31 | 32 | - name: Cache 33 | uses: actions/cache@v4 34 | with: 35 | path: target 36 | key: tsukimi-build-${{ matrix.arch }}-linux-${{ hashFiles('**/Cargo.lock') }} 37 | restore-keys: | 38 | tsukimi-build-${{ matrix.arch }}-linux- 39 | 40 | - name: Build ${{ matrix.arch }} 41 | run: | 42 | mkdir artifact 43 | echo "${{ secrets.DANDANAPI_SECRET_KEY }}" > secret/key 44 | docker run --rm --platform linux/${{ matrix.target }} -v ${{github.workspace}}:/app -v ./docker/entrypoint.sh:/entrypoint.sh ghcr.io/tsukinaha/ubuntu-rust-gtk4:latest 45 | sudo cp target/release/tsukimi artifact 46 | sudo cp -r i18n artifact 47 | sudo cp resources/moe*.xml artifact 48 | cd artifact 49 | tar -czf tsukimi-${{matrix.arch}}.tar.gz tsukimi moe.tsuna.tsukimi.gschema.xml i18n 50 | 51 | - name: Upload artifact 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: tsukimi-${{ matrix.arch }} 55 | path: | 56 | artifact/*.tar.gz 57 | compression-level: 0 58 | overwrite: true 59 | retention-days: 3 60 | if-no-files-found: error 61 | 62 | publish: 63 | needs: build 64 | runs-on: ubuntu-latest 65 | permissions: 66 | contents: write 67 | 68 | steps: 69 | - name: Checkout 70 | uses: actions/checkout@v4 71 | 72 | - name: Download artifacts 73 | uses: actions/download-artifact@v4 74 | 75 | - name: Calculate hash 76 | run: | 77 | mv tsukimi-x86_64-linux/* . 78 | mv tsukimi-aarch64-linux/* . 79 | sha512sum *.tar.gz >> tsukimi.sha512sum 80 | 81 | - name: Get latest tag name 82 | id: tag 83 | run: echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT 84 | 85 | - name: Set prerelease variable 86 | if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref, 'rc') 87 | run: echo "PRERELEASE=true" >> $GITHUB_ENV 88 | 89 | - name: Upload Github Assets 90 | uses: softprops/action-gh-release@v2 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | with: 94 | files: | 95 | *.tar.gz 96 | tsukimi.sha512sum 97 | tag_name: ${{ steps.tag.outputs.TAG_NAME }} 98 | prerelease: ${{ env.PRERELEASE || false }} 99 | -------------------------------------------------------------------------------- /.github/workflows/cargo_clippy_check.yaml: -------------------------------------------------------------------------------- 1 | name: Clippy check 2 | on: push 3 | 4 | jobs: 5 | clippy_check: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - run: | 10 | cat > entrypoint.sh < secret/key 20 | docker run --rm -v ${{ github.workspace }}:/app -v ./entrypoint.sh:/entrypoint.sh ghcr.io/kosette/ubuntu-rust-gtk4:latest 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Upload Artifacts 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "main" 8 | paths-ignore: 9 | - "docs/**" 10 | - "**/.gitignore" 11 | - "**/.dockerignore" 12 | - LICENSE 13 | - "**/*.md" 14 | pull_request: 15 | branches: 16 | - "main" 17 | paths-ignore: 18 | - "docs/**" 19 | - "**/.gitignore" 20 | - "**/.dockerignore" 21 | - LICENSE 22 | - "**/*.md" 23 | 24 | jobs: 25 | linux: 26 | uses: ./.github/workflows/build_linux.yml 27 | secrets: inherit 28 | 29 | windows: 30 | uses: ./.github/workflows/build_windows.yml 31 | secrets: inherit 32 | 33 | upload: 34 | runs-on: ubuntu-latest 35 | needs: [linux, windows] 36 | steps: 37 | - name: Download artifact 38 | uses: actions/download-artifact@v4 39 | with: 40 | path: artifacts 41 | 42 | - name: Zip artifact 43 | id: zip 44 | run: | 45 | echo "zip_files<> "$GITHUB_OUTPUT" 46 | find artifacts/ -mindepth 1 -maxdepth 1 -type d | while read -r dir; do 47 | zip -r "${dir}.zip" "$dir" >> /dev/null 48 | echo "${dir}.zip" 49 | done >> "$GITHUB_OUTPUT" 50 | echo "EOF" >> "$GITHUB_OUTPUT" 51 | 52 | - name: Start Telegram Bot API 53 | run: | 54 | docker run -d -p 8081:8081 \ 55 | --name=telegram-bot-api \ 56 | -e TELEGRAM_API_ID=${{ secrets.TELEGRAM_API_ID }} \ 57 | -e TELEGRAM_API_HASH=${{ secrets.TELEGRAM_API_HASH }} \ 58 | aiogram/telegram-bot-api:latest 59 | 60 | - name: Wait for Telegram Bot API to be ready 61 | run: | 62 | while ! curl -s -o /dev/null http://localhost:8081; do 63 | echo "Waiting for Telegram Bot API..." 64 | sleep 5 65 | done 66 | echo "Telegram Bot API is ready!" 67 | 68 | - name: Upload to Telegram 69 | uses: nyaruta/tg-file-action@v0.2.0 70 | with: 71 | api-url: "http://localhost:8081" 72 | token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 73 | chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} 74 | files: ${{ steps.zip.outputs.zip_files }} 75 | body: | 76 | Tsukimi CI 77 | Commit Message: ${{ github.event.head_commit.message}} 78 | Commit URL: ${{ github.event.head_commit.url }} 79 | 80 | - name: Clean up 81 | run: | 82 | docker stop telegram-bot-api 83 | docker rm telegram-bot-api 84 | docker rmi aiogram/telegram-bot-api:latest 85 | rm -rf artifacts 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /i18n 3 | /.idea 4 | /backup 5 | /secret/key 6 | /.cargo 7 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | fn_params_layout = "Compressed" 2 | comment_width = 100 3 | format_code_in_doc_comments = true 4 | imports_granularity = "Crate" 5 | imports_layout = "Vertical" 6 | wrap_comments = true 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tsukimi" 3 | version = "25.5.0" 4 | edition = "2024" 5 | rust-version = "1.85" 6 | description = "A simple Emby Client written by GTK4-RS" 7 | license = "GPL-3.0" 8 | 9 | 10 | [dependencies] 11 | gtk = { version = "0.9", package = "gtk4", features = ["v4_18"] } 12 | serde = { version = "1.0.219", features = ["derive"] } 13 | serde_json = "1.0.140" 14 | tokio = { version = "1.45", features = ["full"] } 15 | reqwest = { version = "0.12", default-features = false, features = [ 16 | "http2", 17 | "rustls-tls", 18 | "rustls-tls-native-roots", 19 | "json", 20 | "gzip", 21 | ] } 22 | once_cell = "1.21.3" 23 | dirs = "6.0.0" 24 | adw = { version = "0.7", package = "libadwaita", features = ["v1_7"] } 25 | bytefmt = "0.1.7" 26 | libc = "0.2.172" 27 | uuid = { version = "1.16", features = ["v4"] } 28 | chrono = { version = "0.4.41", features = ["serde"] } 29 | tracing = "0.1.41" 30 | gst = { version = "0.23", package = "gstreamer" } 31 | url = "2.5.4" 32 | image = "0.25.6" 33 | gettext-rs = { version = "~0.7", features = ["gettext-system"] } 34 | hostname = "0.4.1" 35 | epoxy = "0.1.0" 36 | libloading = "0.8.7" 37 | atomic-wait = "1.1.0" 38 | flume = "0.11.1" 39 | derive_builder = "0.20.2" 40 | anyhow = "1.0.98" 41 | tracing-subscriber = { version = "0.3.19", features = ["chrono"] } 42 | gdk4-x11 = { version = "0.9", optional = true } 43 | gdk4-win32 = { version = "0.9", optional = true } 44 | regex = "1.11.1" 45 | strsim = "0.11.1" 46 | clap = { version = "4.5.38", features = ["derive"] } 47 | fnv = "1.0.7" 48 | rand = "0.9.1" 49 | base64 = "0.22.1" 50 | libmpv2 = "4.1.0" 51 | danmakw = { git = "https://github.com/tsukinaha/danmakw.git" } 52 | dandanapi = { git = "https://github.com/tsukinaha/dandanapi.git" } 53 | glow = "0.16.0" 54 | 55 | [build-dependencies] 56 | embed-resource = "3.0.2" 57 | glib-build-tools = "0.20.0" 58 | 59 | [features] 60 | console = [] # Enable console logging 61 | default = ["protocols", "render"] 62 | protocols = [] # Enable custom protocol callbacks 63 | render = [] # Enable custom rendering 64 | build_libmpv = [] # build libmpv automatically, provided MPV_SOURCE is set 65 | x11 = ["gdk4-x11"] 66 | win32 = ["gdk4-win32"] 67 | 68 | [target.'cfg(target_os = "linux")'.dependencies] 69 | gdk4-x11 = { version = "0.9" } 70 | xattr = { version = "1.5.0" } 71 | 72 | [target.'cfg(target_os = "windows")'.dependencies] 73 | gdk4-win32 = { version = "0.9" } 74 | libproxy = { version = "0.1.1" } 75 | windows = { version = "0.61.1", features = [ 76 | "Win32_Foundation", 77 | "Win32_Storage_FileSystem", 78 | "Win32_Security", 79 | "Win32_System_IO", 80 | "Win32_System_Power", 81 | ] } 82 | 83 | [profile.release] 84 | lto = "thin" 85 | strip = true 86 | opt-level = "s" 87 | codegen-units = 1 88 | 89 | [profile.dev] 90 | debug = true 91 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | const LINGUAS: &str = include_str!("po/LINGUAS"); 2 | 3 | use std::path::Path; 4 | #[cfg(any(target_os = "linux", target_os = "windows"))] 5 | use std::process::Command; 6 | 7 | fn main() { 8 | glib_build_tools::compile_resources( 9 | &["resources"], 10 | "resources/resources.gresource.xml", 11 | "tsukimi.gresource", 12 | ); 13 | 14 | #[cfg(any(target_os = "linux", target_os = "windows"))] 15 | { 16 | let po_files: Vec = LINGUAS 17 | .lines() 18 | .filter(|line| !line.is_empty()) 19 | .map(|line| format!("po/{line}.po")) 20 | .collect(); 21 | 22 | for po_file in &po_files { 23 | let po_path = Path::new(po_file); 24 | let locale = po_path.file_stem().unwrap().to_str().unwrap(); 25 | let mo_file = format!("i18n/locale/{locale}/LC_MESSAGES/tsukimi.mo"); 26 | 27 | let mo_path = Path::new(&mo_file); 28 | 29 | if !mo_path.exists() { 30 | std::fs::create_dir_all(mo_path.parent().unwrap()).unwrap(); 31 | } 32 | 33 | let status = Command::new("msgfmt") 34 | .args([po_file, "-o", &mo_file]) 35 | .status() 36 | .expect("Failed to compile po file"); 37 | 38 | if status.success() { 39 | println!("{po_file}: OK"); 40 | } else { 41 | println!("{po_file}: FAILED"); 42 | } 43 | } 44 | } 45 | 46 | #[cfg(windows)] 47 | { 48 | println!("cargo:rerun-if-changed=tsukimi-manifest.rc"); 49 | embed_resource::compile("./tsukimi_manifest.rc", embed_resource::NONE) 50 | .manifest_optional() 51 | .unwrap(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH=amd64 2 | ARG CODENAME=plucky 3 | 4 | FROM $ARCH/ubuntu:$CODENAME 5 | 6 | ENV CARGO_TERM_COLOR=always \ 7 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \ 8 | RUST_BACKTRACE=full 9 | 10 | RUN apt update && apt upgrade -y &&\ 11 | apt install -y build-essential curl gettext pkg-config libssl-dev libgtk-4-dev \ 12 | libadwaita-1-dev libmpv-dev libgstreamer1.0-dev libgstreamer-plugins-bad1.0-dev \ 13 | libgstreamer-plugins-base1.0-dev libgstreamer-plugins-good1.0-dev gstreamer1.0-libav 14 | 15 | WORKDIR /app 16 | 17 | VOLUME /app 18 | 19 | COPY ./entrypoint.sh / 20 | 21 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly 22 | 23 | ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] 24 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Ubuntu-Rust-Gtk4 Docker Image 2 | 3 | - base image: `Ubuntu:oracular` 4 | - installed packages: see Dockerfile 5 | - usage: 6 | 7 | ``` 8 | docker run --rm --platform [linux/amd64] -v ${{ github.workspace }}:/app -v ./entrypoint.sh:/entrypoint.sh [image] 9 | ``` 10 | 11 | > [!NOTE] 12 | > use your own `entrypoint.sh` and mount `/app` to your project root dir. need `sudo` privilege to move `target/` 13 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export PATH=/root/.cargo/bin:$PATH 5 | 6 | # print $PATH 7 | echo "PATH: $PATH" 8 | 9 | # print package version 10 | echo "######### PACKAGE VERSION ##########" 11 | cargo --version 12 | echo "gtk4 version: $(pkgconf --modversion gtk4)" 13 | echo "gstreamer version: $(pkgconf --modversion gstreamer-1.0)" 14 | echo "######### PACKAGE VERSION ##########" 15 | 16 | # set nightly toolchain 17 | rustup default nightly 18 | 19 | # build 20 | cargo b -r --locked 21 | -------------------------------------------------------------------------------- /docs/build_on_linux.md: -------------------------------------------------------------------------------- 1 | ## Build on Linux 2 | 3 | ### Dependencies 4 | - gtk >= 4.14 5 | - mpv >= 0.37 6 | - libadwaita >= 0.5 7 | - gstreamer 8 | - cargo 9 | 10 | ### With `build.rs` 11 | 12 | 1. clone repo 13 | ``` 14 | git clone https://github.com/tsukinaha/tsukimi.git 15 | git submodule update --init --recursive 16 | ``` 17 | 2. compile gschemas 18 | ``` 19 | mkdir -p $HOME/.local/share/glib-2.0/schemas 20 | cp moe.tsuna.tsukimi.gschema.xml $HOME/.local/share/glib-2.0/schemas/ 21 | glib-compile-schemas $HOME/.local/share/glib-2.0/schemas/ 22 | ``` 23 | 3. `cargo build --release` 24 | 4. install i18n files 25 | ``` 26 | cp -r "i18n/locale" "${pkgdir}/usr/share/locale" 27 | ``` 28 | 29 | 30 | ### With Meson 31 | ``` 32 | meson build 33 | cd build 34 | ninja 35 | ninja install 36 | ``` -------------------------------------------------------------------------------- /docs/tsukimi1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/docs/tsukimi1.png -------------------------------------------------------------------------------- /docs/tsukimi2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/docs/tsukimi2.png -------------------------------------------------------------------------------- /docs/tsukimi3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/docs/tsukimi3.png -------------------------------------------------------------------------------- /docs/tsukimi4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/docs/tsukimi4.png -------------------------------------------------------------------------------- /docs/tsukimi5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/docs/tsukimi5.png -------------------------------------------------------------------------------- /installer/version.txt: -------------------------------------------------------------------------------- 1 | 25.05.0.0 -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('tsukimi', 'rust', 2 | version: '25.05', 3 | meson_version: '>= 1.1', 4 | default_options: [ 'warning_level=2', 5 | 'werror=false', 6 | 'buildtype=release' 7 | ], 8 | ) 9 | 10 | major_version = '25' 11 | minor_version = '05' 12 | 13 | version = major_version 14 | version += '.' + minor_version 15 | 16 | full_version = version 17 | 18 | profile = 'Stable' 19 | 20 | prefix = get_option('prefix') 21 | bindir = prefix / get_option('bindir') 22 | localedir = prefix / get_option('localedir') 23 | 24 | datadir = prefix / get_option('datadir') 25 | pkgdatadir = datadir / meson.project_name() 26 | iconsdir = datadir / 'icons' 27 | 28 | 29 | i18n = import('i18n') 30 | 31 | gnome = import('gnome') 32 | 33 | dependency('openssl', version: '>= 1.0') 34 | dependency('dbus-1') 35 | 36 | dependency('glib-2.0', version: '>= 2.76') # update when changing gtk version 37 | dependency('gio-2.0', version: '>= 2.76') # always same version as glib 38 | 39 | dependency('gtk4', version: '>= 4.18.0') 40 | dependency( 41 | 'libadwaita-1', version: '>= 1.6', 42 | fallback: ['libadwaita', 'libadwaita_dep'], 43 | default_options: ['tests=false', 'examples=false', 'vapi=false'] 44 | ) 45 | 46 | dependency('mpv', version: '>=0.38') 47 | 48 | dependency('gstreamer-1.0', version: '>= 1.16') 49 | dependency('gstreamer-base-1.0', version: '>= 1.16') 50 | dependency('gstreamer-audio-1.0', version: '>= 1.16') 51 | dependency('gstreamer-play-1.0', version: '>= 1.16') 52 | dependency('gstreamer-plugins-base-1.0', version: '>= 1.16') 53 | dependency('gstreamer-plugins-bad-1.0', version: '>= 1.16') 54 | dependency('gstreamer-bad-audio-1.0', version: '>= 1.16') 55 | 56 | cargo_sources = files( 57 | 'Cargo.toml', 58 | ) 59 | 60 | cargo = find_program('cargo', required: true) 61 | cargo_version = run_command(cargo, '--version', check: true).stdout().strip() 62 | message(cargo_version) 63 | rustc_version = run_command('rustc', '--version', check: true).stdout().strip() 64 | message(rustc_version) 65 | 66 | 67 | 68 | gettext_package = meson.project_name() 69 | pkgdatadir = datadir / meson.project_name() 70 | 71 | subdir('resources') 72 | subdir('src') 73 | subdir('po') 74 | 75 | gnome.post_install( 76 | glib_compile_schemas: true, 77 | gtk_update_icon_cache: true, 78 | update_desktop_database: true, 79 | ) -------------------------------------------------------------------------------- /meson.options: -------------------------------------------------------------------------------- 1 | option( 2 | 'sandboxed-build', 3 | type : 'boolean', 4 | value : false, 5 | description: 'Whether the build happens in a sandbox.' + 6 | 'When that is the case, cargo will not be able to download the dependencies during' + 7 | 'the build so they are assumed to be in `{meson.project_source_root()}/cargo`.' 8 | ) 9 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | zh_CN 2 | zh_Hant 3 | pt_BR 4 | nb_NO 5 | ja 6 | ar 7 | fr 8 | de 9 | ta 10 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext('tsukimi', preset: 'glib') -------------------------------------------------------------------------------- /resources/icons/meson.build: -------------------------------------------------------------------------------- 1 | application_id = 'moe.tsuna.tsukimi' 2 | 3 | scalable_dir = join_paths('scalable', 'actions') 4 | 5 | icondir = join_paths(get_option('datadir'), 'icons/hicolor') 6 | 7 | install_data( 8 | 'moe.tsuna.tsukimi.png', 9 | install_dir: join_paths(icondir, '256x256/apps') 10 | ) 11 | -------------------------------------------------------------------------------- /resources/icons/moe.tsuna.tsukimi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/resources/icons/moe.tsuna.tsukimi.png -------------------------------------------------------------------------------- /resources/icons/scalable/actions/arrow-pointing-at-line-down-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/arrow4-left-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/axes-one-quadrant-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/card-bulleted-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/chain-link-loose-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/check-round-outline2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/checkmark-small-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/cross-small-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/display-projector-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/emby-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/external-link-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/funnel-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/jellyfin-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/large-brush-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/minus-circle-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/month-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/music-note-single-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/settings-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/skip-backwards-30-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/skip-forward-30-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/sound-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/speed-svgrepo-com-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/tag-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/text-bold-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/text-italic-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/text-justify-center-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/text-justify-left-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/text-justify-right-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/video-encoder-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/video-reel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/scalable/actions/video-reel2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/tsukimi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/resources/icons/tsukimi.ico -------------------------------------------------------------------------------- /resources/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | gnome = import('gnome') 3 | 4 | gnome.compile_resources('tsukimi', 5 | 'resources.gresource.xml', 6 | gresource_bundle: true, 7 | install: true, 8 | install_dir: pkgdatadir, 9 | ) 10 | 11 | desktop_file = i18n.merge_file( 12 | input: 'moe.tsuna.tsukimi.desktop.in', 13 | output: 'moe.tsuna.tsukimi.desktop', 14 | type: 'desktop', 15 | po_dir: '../po', 16 | install: true, 17 | install_dir: join_paths(get_option('datadir'), 'applications') 18 | ) 19 | 20 | desktop_utils = find_program('desktop-file-validate', required: false) 21 | if desktop_utils.found() 22 | test('Validate desktop file', desktop_utils, 23 | args: [desktop_file] 24 | ) 25 | endif 26 | 27 | appstream_file = i18n.merge_file( 28 | input: 'moe.tsuna.tsukimi.metainfo.xml.in', 29 | output: 'moe.tsuna.tsukimi.metainfo.xml', 30 | po_dir: '../po', 31 | install: true, 32 | install_dir: join_paths(get_option('datadir'), 'metainfo') 33 | ) 34 | 35 | appstreamcli = find_program('appstreamcli', required: false, disabler: true) 36 | test('Validate appstream file', appstreamcli, 37 | args: ['validate', '--no-net', '--explain', appstream_file]) 38 | 39 | install_data('moe.tsuna.tsukimi.gschema.xml', 40 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 41 | ) 42 | 43 | compile_schemas = find_program('glib-compile-schemas', required: false) 44 | if compile_schemas.found() 45 | test('Validate schema file', compile_schemas, 46 | args: ['--strict', '--dry-run', meson.current_source_dir()] 47 | ) 48 | endif 49 | 50 | subdir('icons') -------------------------------------------------------------------------------- /resources/moe.tsuna.tsukimi.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Tsukimi 3 | Comment=A simple third-party Emby client 4 | Comment[zh_CN]=一个简单的第三方 Emby 客户端 5 | Comment[zh_TW]=一個簡單的第三方 Emby 客戶端 6 | Exec=tsukimi 7 | Icon=moe.tsuna.tsukimi 8 | StartupWMClass=moe.tsuna.tsukimi 9 | Terminal=false 10 | Type=Application 11 | Categories=GNOME;GTK;AudioVideo;Player;Audio;Video; 12 | StartupNotify=true 13 | Keywords=player;audio;video;multimedia;Emby 14 | Keywords[zh_CN]=播放器;音频;视频;多媒体 15 | Keywords[zh_TW]=播放器;音頻;視頻;多媒體 16 | -------------------------------------------------------------------------------- /resources/moe.tsuna.tsukimi.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | moe.tsuna.tsukimi 4 | 5 | 6 | Inaha 7 | 8 | 9 | CC0-1.0 10 | GPL-3.0-or-later 11 | 12 | 13 | 14 | 15 | 16 | Tsukimi 17 | A simple third-party Emby client 18 | 一個簡單的第三方 Emby 客戶端 19 | 一个简单的第三方 Emby 客户端 20 | 21 | https://tsukimi.tsuna.moe/ 22 | https://github.com/tsukinaha/tsukimi/issues 23 | https://github.com/tsukinaha/tsukimi 24 | 25 | 26 | pointing 27 | keyboard 28 | 29 | 30 | 31 |

Tsukimi is a simple third-party Emby client written in GTK4-RS, uses MPV as the video 32 | player, and GStreamer as the music player.

33 |

Tsukimi 是一个使用 GTK4-RS 编写的简单第三方 Emby 客户端,使用 MPV 作为视频播放器,并使用 GStreamer 34 | 作为音乐播放器。

35 |

Tsukimi 是一個使用 GTK4-RS 編寫的簡單第三方 Emby 客戶端,使用 MPV 作為視頻播放器,並使用 GStreamer 36 | 作為音樂播放器。

37 |
38 | 39 | 40 | 41 | https://raw.githubusercontent.com/tsukinaha/tsukimi/74ee1651981ee9baa523585c12302355e506ae82/docs/tsukimi1.png 42 | 43 | 44 | 45 | https://raw.githubusercontent.com/tsukinaha/tsukimi/74ee1651981ee9baa523585c12302355e506ae82/docs/tsukimi2.png 46 | 47 | 48 | 49 | https://raw.githubusercontent.com/tsukinaha/tsukimi/74ee1651981ee9baa523585c12302355e506ae82/docs/tsukimi3.png 50 | 51 | 52 | 53 | 54 | 55 | moe.tsuna.tsukimi.desktop 56 |
-------------------------------------------------------------------------------- /resources/style-dark.css: -------------------------------------------------------------------------------- 1 | .scroll-spinner { 2 | background-color: rgba(0, 0, 0, 0.5); 3 | } 4 | 5 | .played-mark { 6 | background-color: rgba(0, 0, 0, 0.5); 7 | } 8 | -------------------------------------------------------------------------------- /resources/ui/action_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | -------------------------------------------------------------------------------- /resources/ui/check_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | -------------------------------------------------------------------------------- /resources/ui/content_viewer.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | 41 | -------------------------------------------------------------------------------- /resources/ui/disc_box.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | -------------------------------------------------------------------------------- /resources/ui/dropdown.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | -------------------------------------------------------------------------------- /resources/ui/episode_switcher.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 27 | 28 | -------------------------------------------------------------------------------- /resources/ui/episoderow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /resources/ui/eu_item.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | -------------------------------------------------------------------------------- /resources/ui/filter_label.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | -------------------------------------------------------------------------------- /resources/ui/filter_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | -------------------------------------------------------------------------------- /resources/ui/filter_search_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 31 | 32 | -------------------------------------------------------------------------------- /resources/ui/filters_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 70 | -------------------------------------------------------------------------------- /resources/ui/home.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | 41 | -------------------------------------------------------------------------------- /resources/ui/horbu_scrolled.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | -------------------------------------------------------------------------------- /resources/ui/identify_dialog_search_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | 26 | -------------------------------------------------------------------------------- /resources/ui/image_dialog_edit_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49 | 50 | -------------------------------------------------------------------------------- /resources/ui/image_dialog_search_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 77 | 78 | -------------------------------------------------------------------------------- /resources/ui/item_actions.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 38 | 39 | 40 | Mark as Played 41 | item.played 42 | action-disabled 43 | <Control>F1 44 | 45 | 46 | Mark as Unplayed 47 | item.unplayed 48 | action-disabled 49 | <Control>F2 50 | 51 |
52 | 53 | Edit Metadata 54 | item.editm 55 | action-disabled 56 | <Control>F7 57 | 58 | 59 | Edit Images 60 | item.editi 61 | action-disabled 62 | <Control>F8 63 | 64 |
65 |
66 |
-------------------------------------------------------------------------------- /resources/ui/item_carousel.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | -------------------------------------------------------------------------------- /resources/ui/list.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 35 | 36 | -------------------------------------------------------------------------------- /resources/ui/listexpand_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33 | -------------------------------------------------------------------------------- /resources/ui/listitem.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 83 | -------------------------------------------------------------------------------- /resources/ui/mpv_menu.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | menu-actions 7 | 8 |
9 |
10 | 11 | _Next 12 | mpv.next-video 13 | N 14 | 15 | 16 | _Previous 17 | mpv.previous-video 18 | P 19 | 20 |
21 |
22 | 23 | Next chapter 24 | mpv.chapter-next 25 | <Control>N 26 | 27 | 28 | Previous chapter 29 | mpv.chapter-prev 30 | <Control>P 31 | 32 |
33 |
34 | 35 | Playlist 36 | mpv.show-playlist 37 | L 38 | 39 | 40 | Advanced settings 41 | mpv.show-settings 42 | A 43 | 44 |
45 |
46 | 47 | Media info 48 | mpv.show-info 49 | I 50 | 51 |
52 |
53 | 54 | _Keyboard shortcuts 55 | win.show-help-overlay 56 | F1 57 | 58 |
59 |
60 |
-------------------------------------------------------------------------------- /resources/ui/mpv_menu_actions.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 58 | -------------------------------------------------------------------------------- /resources/ui/mpv_shortcuts_window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | 6 | 7 | shortcuts 8 | 10 9 | 10 | 11 | General 12 | 13 | 14 | bracketleft bracketright 15 | Decrease/increase playback speed by 10% 16 | 17 | 18 | 19 | 20 | braceleft braceright 21 | Halve/double current playback speed 22 | 23 | 24 | 25 | 26 | BackSpace 27 | Reset playback speed to normal 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /resources/ui/picture_loader.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53 | -------------------------------------------------------------------------------- /resources/ui/refresh_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 68 | -------------------------------------------------------------------------------- /resources/ui/server_action_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 60 | 61 | -------------------------------------------------------------------------------- /resources/ui/server_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 43 | 44 | -------------------------------------------------------------------------------- /resources/ui/theme_switcher.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 69 | 70 | -------------------------------------------------------------------------------- /resources/ui/tuview_scrolled.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | 41 | 18 42 | 18 43 | True 44 | 1 45 | 15 46 | 47 | 48 | 49 | 18 50 | 18 51 | True 52 | 53 | 56 | 57 | -------------------------------------------------------------------------------- /resources/ui/volume_bar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 37 | -------------------------------------------------------------------------------- /secret/secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/secret/secret -------------------------------------------------------------------------------- /share/macos/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kosette/tsukimi/cc2b1d1c2b8a8600fdb45f605387b34cce56defc/share/macos/AppIcon.icns -------------------------------------------------------------------------------- /share/macos/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleName 6 | Tsukimi 7 | CFBundleDisplayName 8 | Tsukimi 9 | CFBundleIdentifier 10 | moe.tsuna.tsukimi 11 | CFBundleExecutable 12 | tsukimi 13 | CFBundleIconFile 14 | AppIcon 15 | CFBundleIconName 16 | AppIcon 17 | CFBundleSignature 18 | suki 19 | 20 | LSMinimumSystemVersion 21 | 12.7.3 22 | 23 | 24 | NSAppTransportSecurity 25 | 26 | NSAllowsArbitraryLoads 27 | 28 | 29 | 30 | 31 | NSHomeDirectory 32 | Tsukimi needs to store user token and cache 33 | 34 | 35 | NSAppleEventsUsageDescription 36 | Tsukimi needs to control MPV player to play videos 37 | 38 | NSServices 39 | 40 | 41 | NSMenuItem 42 | 43 | default 44 | Services 45 | 46 | NSMessage 47 | runningServiceCommand 48 | NSRequiredContext 49 | 50 | NSApplicationActivationBundleID 51 | io.mpv 52 | 53 | 54 | 55 | 56 | CFBundleVersion 57 | 0.0.3 58 | CFBundleShortVersionString 59 | 0.0.3 60 | CFBundlePackageType 61 | APPL 62 | 63 | CFBundleURLTypes 64 | 65 | 66 | CFBundleURLName 67 | tsukimi 68 | CFBundleTypeRole 69 | Viewer 70 | CFBundleURLSchemes 71 | 72 | tsukimi 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use adw::{ 2 | prelude::*, 3 | subclass::prelude::*, 4 | }; 5 | use gtk::glib; 6 | 7 | mod imp { 8 | 9 | use gtk::{ 10 | CssProvider, 11 | gdk::Display, 12 | }; 13 | 14 | use crate::ui::SETTINGS; 15 | 16 | use super::*; 17 | 18 | #[derive(Debug, Default)] 19 | pub struct TsukimiApplication; 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for TsukimiApplication { 23 | const NAME: &'static str = "TsukimiApplication"; 24 | type Type = super::TsukimiApplication; 25 | type ParentType = adw::Application; 26 | } 27 | 28 | impl ObjectImpl for TsukimiApplication { 29 | fn constructed(&self) { 30 | self.parent_constructed(); 31 | self.load_style_sheet(); 32 | 33 | let obj = self.obj(); 34 | obj.set_application_id(Some(crate::APP_ID)); 35 | obj.set_resource_base_path(Some(crate::APP_RESOURCE_PATH)); 36 | 37 | obj.set_accels_for_action("win.about", &["N"]); 38 | } 39 | } 40 | 41 | impl ApplicationImpl for TsukimiApplication { 42 | fn activate(&self) { 43 | self.parent_activate(); 44 | 45 | let window = crate::Window::new(&self.obj()); 46 | window.load_window_state(); 47 | window.present(); 48 | } 49 | } 50 | 51 | impl GtkApplicationImpl for TsukimiApplication {} 52 | 53 | impl AdwApplicationImpl for TsukimiApplication {} 54 | 55 | impl TsukimiApplication { 56 | fn load_style_sheet(&self) { 57 | let provider = CssProvider::new(); 58 | 59 | let accent_color = SETTINGS.accent_color_code(); 60 | 61 | provider.load_from_string(&format!( 62 | " 63 | :root {{ 64 | --accent-color:{accent_color}; 65 | }} 66 | 67 | :root {{ 68 | --accent-bg-color:{accent_color}; 69 | }}", 70 | )); 71 | 72 | #[cfg(target_os = "windows")] 73 | provider.load_from_string("window {box-shadow: 0px 0px 5px 0px rgba(5, 5, 5, 0.4);}"); 74 | 75 | gtk::style_context_add_provider_for_display( 76 | &Display::default().expect("Could not connect to a display."), 77 | &provider, 78 | gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, 79 | ); 80 | } 81 | } 82 | } 83 | 84 | glib::wrapper! { 85 | pub struct TsukimiApplication(ObjectSubclass) 86 | @extends gtk::gio::Application, gtk::Application, adw::Application, @implements gtk::Accessible; 87 | } 88 | 89 | impl Default for TsukimiApplication { 90 | fn default() -> Self { 91 | Self::new() 92 | } 93 | } 94 | 95 | impl TsukimiApplication { 96 | pub fn new() -> Self { 97 | glib::Object::new() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/client/account.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | Deserialize, 3 | Serialize, 4 | }; 5 | 6 | use crate::ui::provider::descriptor::VecSerialize; 7 | 8 | #[derive(Serialize, Deserialize, Clone, PartialEq)] 9 | pub struct Account { 10 | pub servername: String, 11 | pub server: String, 12 | pub username: String, 13 | pub password: String, 14 | pub port: String, 15 | pub user_id: String, 16 | pub access_token: String, 17 | pub server_type: Option, 18 | } 19 | 20 | #[derive(Serialize, Deserialize)] 21 | #[serde(tag = "type")] 22 | pub struct Accounts { 23 | pub accounts: Vec, 24 | } 25 | 26 | impl VecSerialize for Vec { 27 | fn to_string(&self) -> String { 28 | serde_json::to_string(&self).expect("Failed to serialize Vec") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/client/dandan.rs: -------------------------------------------------------------------------------- 1 | use dandanapi::CommentData; 2 | use danmakw::Danmaku; 3 | 4 | pub const X_APPID: &str = "e9imrhcexn"; 5 | pub const SECRETE_KEY: &str = include_str!("../../secret/key"); 6 | 7 | pub trait DanmakuConvert { 8 | fn into_danmaku(self) -> Danmaku; 9 | } 10 | 11 | impl DanmakuConvert for CommentData { 12 | fn into_danmaku(self) -> Danmaku { 13 | let Some(m) = self.m else { 14 | return Danmaku { 15 | content: String::new(), 16 | start: 0.0, 17 | color: danmakw::Color { 18 | r: 0, 19 | g: 0, 20 | b: 0, 21 | a: 0, 22 | }, 23 | mode: danmakw::DanmakuMode::Scroll, 24 | }; 25 | }; 26 | 27 | let Some(p) = self.p else { 28 | return Danmaku { 29 | content: m, 30 | start: 0.0, 31 | color: danmakw::Color { 32 | r: 255, 33 | g: 255, 34 | b: 255, 35 | a: 255, 36 | }, 37 | mode: danmakw::DanmakuMode::Scroll, 38 | }; 39 | }; 40 | 41 | let parts: Vec<&str> = p.split(',').collect(); 42 | let start = parts 43 | .first() 44 | .and_then(|s| s.parse::().ok()) 45 | .unwrap_or_default(); 46 | let mode = parts 47 | .get(1) 48 | .and_then(|s| s.parse::().ok()) 49 | .unwrap_or_default(); 50 | let color = parts 51 | .get(2) 52 | .and_then(|s| s.parse::().ok()) 53 | .unwrap_or_default(); 54 | 55 | Danmaku { 56 | content: m, 57 | start: start * 1000.0, 58 | color: danmakw::Color { 59 | r: ((color >> 16) & 0xFF) as u8, 60 | g: ((color >> 8) & 0xFF) as u8, 61 | b: (color & 0xFF) as u8, 62 | a: 255, 63 | }, 64 | mode: match mode { 65 | 1 => danmakw::DanmakuMode::Scroll, 66 | 2 => danmakw::DanmakuMode::TopCenter, 67 | 3 => danmakw::DanmakuMode::BottomCenter, 68 | _ => danmakw::DanmakuMode::Scroll, 69 | }, 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/client/error.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use tracing::warn; 3 | 4 | pub trait UserFacingError { 5 | fn to_user_facing(&self) -> String; 6 | } 7 | 8 | impl UserFacingError for reqwest::Error { 9 | fn to_user_facing(&self) -> String { 10 | let status_code = self.status(); 11 | if let Some(status_code) = status_code { 12 | warn!("Request Error: {}", status_code); 13 | format!("Error: {status_code}") 14 | } else if self.is_decode() { 15 | warn!("Request Decoding Error: {}", self); 16 | format!("Decoding Error: {self}") 17 | } else if self.is_timeout() { 18 | warn!("Request Timeout Error: {}", self); 19 | gettext("Timeout Error, Check your internet connection") 20 | } else if self.is_connect() { 21 | warn!("Request Connection Error: {}", self); 22 | gettext("Connection Error, Check your internet connection") 23 | } else { 24 | warn!("Request Error: {}", self); 25 | format!("Error: {self}") 26 | } 27 | } 28 | } 29 | 30 | impl UserFacingError for std::boxed::Box { 31 | fn to_user_facing(&self) -> String { 32 | warn!("Unknown Error: {}", self); 33 | self.to_string() 34 | } 35 | } 36 | 37 | impl UserFacingError for libmpv2::Error { 38 | fn to_user_facing(&self) -> String { 39 | match self { 40 | Self::Loadfile { error } => { 41 | warn!("MPV ErrorLoadfile: {}", error); 42 | format!("ErrorLoadfile: {error}") 43 | } 44 | Self::Raw(error) => { 45 | let string = mpv_error_to_string(*error); 46 | warn!("MPV Error: {} ({})", string, error); 47 | format!("Error: {string} ({error})") 48 | } 49 | _ => { 50 | warn!("MPV Error: {}", self); 51 | format!("Unknown Error: {self}") 52 | } 53 | } 54 | } 55 | } 56 | 57 | fn mpv_error_to_string(error: i32) -> &'static str { 58 | match error { 59 | 0 => "Success", 60 | -1 => "Event queue full", 61 | -2 => "Out of memory", 62 | -3 => "Uninitialized", 63 | -4 => "Invalid parameter", 64 | -5 => "Option not found", 65 | -6 => "Option format", 66 | -7 => "Option error", 67 | -8 => "Property not found", 68 | -9 => "Property format", 69 | -10 => "Property unavailable", 70 | -11 => "Property error", 71 | -12 => "Command", 72 | -13 => "Loading failed", 73 | -14 => "Audio output init failed", 74 | -15 => "Video output init failed", 75 | -16 => "Nothing to play", 76 | -17 => "Unknown format", 77 | -18 => "Unsupported", 78 | -19 => "Not implemented", 79 | -20 => "Generic", 80 | _ => "Unknown", 81 | } 82 | } 83 | 84 | impl UserFacingError for anyhow::Error { 85 | fn to_user_facing(&self) -> String { 86 | warn!("Unknown Error: {}", self); 87 | self.to_string() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/client/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod dandan; 3 | pub mod emby_client; 4 | pub mod error; 5 | pub mod proxy; 6 | pub mod runtime; 7 | pub mod structs; 8 | #[cfg(target_os = "windows")] 9 | pub mod windows_compat; 10 | 11 | pub use account::Account; 12 | pub use dandan::*; 13 | pub use proxy::ReqClient; 14 | -------------------------------------------------------------------------------- /src/client/proxy.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use reqwest::Client; 3 | 4 | pub struct ReqClient; 5 | 6 | impl ReqClient { 7 | pub fn build() -> Client { 8 | let settings = gtk::gio::Settings::new(crate::APP_ID); 9 | 10 | #[cfg(target_os = "linux")] 11 | let client = reqwest::Client::builder() 12 | .user_agent(crate::USER_AGENT.as_str()) 13 | .timeout(std::time::Duration::from_secs(10)) 14 | .pool_max_idle_per_host(settings.int("threads") as usize) 15 | .build() 16 | .expect("failed to initialize client"); 17 | 18 | #[cfg(target_os = "windows")] 19 | let client = { 20 | let client_builder = reqwest::Client::builder() 21 | .user_agent(crate::USER_AGENT.as_str()) 22 | .timeout(std::time::Duration::from_secs(10)) 23 | .pool_max_idle_per_host(settings.int("threads") as usize); 24 | 25 | let client_builder = match get_proxy_settings() { 26 | Some(proxy_settings) => { 27 | tracing::info!("Windows: Using proxy {}", proxy_settings); 28 | if let Ok(proxy) = reqwest::Proxy::all(proxy_settings) { 29 | client_builder.proxy(proxy) 30 | } else { 31 | client_builder 32 | } 33 | } 34 | _ => client_builder, 35 | }; 36 | 37 | client_builder.build().expect("failed to initialize client") 38 | }; 39 | 40 | client 41 | } 42 | } 43 | 44 | #[cfg(target_os = "windows")] 45 | pub fn get_proxy_settings() -> Option { 46 | // FIXME: proxy should be a dynamic constructor 47 | // 48 | // This is only a temporary solution to get the proxy settings on Windows. 49 | // ProxyFactory::get_proxies() is a blocking method, PAC may be a stream. 50 | const EXAMPLE_PROXY: &str = "http://example.com"; 51 | 52 | // FIEXME: user:password@ is not supported 53 | // 54 | // libproxy will return "direct://", if no proxy is found. 55 | // protocol://[user:password@]proxyhost[:port], but reqwest cant parse [user:password@] 56 | use libproxy::ProxyFactory; 57 | ProxyFactory::new()? 58 | .get_proxies(EXAMPLE_PROXY) 59 | .ok()? 60 | .first() 61 | .filter(|&proxy| proxy != "direct://") 62 | .cloned() 63 | } 64 | 65 | #[cfg(target_os = "linux")] 66 | pub fn get_proxy_settings() -> Option { 67 | use std::env; 68 | env::var("http_proxy") 69 | .or_else(|_| env::var("https_proxy")) 70 | .ok() 71 | } 72 | -------------------------------------------------------------------------------- /src/client/runtime.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use tokio::runtime::{ 4 | self, 5 | Runtime, 6 | }; 7 | 8 | use crate::ui::SETTINGS; 9 | 10 | pub fn runtime() -> &'static Runtime { 11 | static RUNTIME: OnceLock = OnceLock::new(); 12 | RUNTIME.get_or_init(|| { 13 | runtime::Builder::new_multi_thread() 14 | .worker_threads(SETTINGS.threads() as usize) 15 | .enable_all() 16 | .build() 17 | .expect("Failed to create runtime") 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | pub const VERSION: &str = "25.05-danmaku-test-alpha02"; 2 | pub const GETTEXT_PACKAGE: &str = "tsukimi"; 3 | 4 | // If you are using meson, this will be replaced with the correct path. 5 | // Otherwise, you can set it to the correct path where the locale files are installed. 6 | // 7 | // This value is reserved for build.rs. 8 | #[cfg(target_os = "linux")] 9 | pub const LOCALEDIR: &str = "/usr/share/locale"; 10 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const VERSION: &str = @VERSION@; 2 | pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 3 | pub const LOCALEDIR: &str = @LOCALEDIR@; 4 | pub const PKGDATADIR: &str = @PKGDATADIR@; -------------------------------------------------------------------------------- /src/gstl/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod player; 2 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | sync::LazyLock, 4 | }; 5 | 6 | mod app; 7 | mod arg; 8 | mod config; 9 | mod gstl; 10 | mod macros; 11 | mod ui; 12 | mod utils; 13 | 14 | pub mod client; 15 | 16 | pub use arg::Args; 17 | pub use config::GETTEXT_PACKAGE; 18 | #[cfg(target_os = "linux")] 19 | use config::LOCALEDIR; 20 | use config::VERSION; 21 | use once_cell::sync::OnceCell; 22 | 23 | use clap::Parser; 24 | use gettextrs::*; 25 | use gtk::prelude::*; 26 | 27 | pub use ui::Window; 28 | 29 | pub use app::TsukimiApplication as Application; 30 | 31 | pub static USER_AGENT: LazyLock = 32 | LazyLock::new(|| format!("{}/{} - {}", CLIENT_ID, VERSION, env::consts::OS)); 33 | 34 | pub const APP_ID: &str = "moe.tsuna.tsukimi"; 35 | pub const CLIENT_ID: &str = "Tsukimi"; 36 | const APP_RESOURCE_PATH: &str = "/moe/tsuna/tsukimi"; 37 | 38 | #[cfg(target_os = "windows")] 39 | const WINDOWS_LOCALEDIR: &str = "share\\locale"; 40 | 41 | pub fn locale_dir() -> &'static str { 42 | static FLOCALEDIR: OnceCell<&'static str> = OnceCell::new(); 43 | FLOCALEDIR.get_or_init(|| { 44 | #[cfg(target_os = "linux")] 45 | { 46 | LOCALEDIR 47 | } 48 | #[cfg(target_os = "windows")] 49 | { 50 | let exe_path = std::env::current_exe().expect("Can not get locale dir"); 51 | let locale_path = exe_path 52 | .ancestors() 53 | .nth(2) 54 | .expect("Can not get locale dir") 55 | .join(WINDOWS_LOCALEDIR); 56 | Box::leak(locale_path.into_boxed_path()) 57 | .to_str() 58 | .expect("Can not get locale dir") 59 | } 60 | }) 61 | } 62 | 63 | pub fn run() -> gtk::glib::ExitCode { 64 | Args::parse().init(); 65 | // Initialize gettext 66 | setlocale(LocaleCategory::LcAll, String::new()); 67 | bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8").expect("Failed to set textdomain codeset"); 68 | bindtextdomain(GETTEXT_PACKAGE, locale_dir()) 69 | .expect("Invalid argument passed to bindtextdomain"); 70 | 71 | textdomain(GETTEXT_PACKAGE).expect("Invalid string passed to textdomain"); 72 | 73 | adw::init().expect("Failed to initialize Adwaita"); 74 | // Register and include resources 75 | gtk::gio::resources_register_include!("tsukimi.gresource") 76 | .expect("Failed to register resources."); 77 | 78 | danmakw::init(); 79 | 80 | // Initialize the GTK application 81 | gtk::glib::set_application_name(CLIENT_ID); 82 | 83 | Application::new().run_with_args::<&str>(&[]) 84 | } 85 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | #[doc(hidden)] 2 | #[macro_export] 3 | macro_rules! fraction { 4 | ($widget:expr) => {{ 5 | use gtk::prelude::WidgetExt; 6 | if let Some(root) = $widget.root() { 7 | if let Some(window) = root.downcast_ref::<$crate::ui::widgets::window::Window>() { 8 | window.set_progressbar_fade(); 9 | } 10 | } 11 | }}; 12 | } 13 | 14 | #[macro_export] 15 | macro_rules! fraction_reset { 16 | ($widget:expr) => {{ 17 | use gtk::prelude::WidgetExt; 18 | if let Some(root) = $widget.root() { 19 | if let Some(window) = root.downcast_ref::<$crate::ui::widgets::window::Window>() { 20 | window.set_progressbar_opacity(1.0); 21 | window.hard_set_fraction(0.0); 22 | window.set_fraction(1.0); 23 | } 24 | } 25 | }}; 26 | } 27 | 28 | #[macro_export] 29 | macro_rules! insert_editm_dialog { 30 | ($widget:expr, $dialog:expr) => {{ 31 | use adw::prelude::*; 32 | use gtk::prelude::WidgetExt; 33 | if let Some(root) = $widget.root() { 34 | if let Some(window) = root.downcast_ref::<$crate::ui::widgets::window::Window>() { 35 | $dialog.present(Some(window)); 36 | } else { 37 | panic!("Trying to display a dialog when the parent doesn't support it"); 38 | } 39 | } 40 | }}; 41 | } 42 | 43 | #[macro_export] 44 | macro_rules! bing_song_model { 45 | ($widget:expr, $active_model:expr, $active_core_song:expr) => {{ 46 | use adw::prelude::*; 47 | use gtk::{ 48 | glib, 49 | prelude::WidgetExt, 50 | }; 51 | use $crate::utils::spawn; 52 | 53 | let root = $widget.root(); 54 | let Some(window) = root.and_downcast_ref::<$crate::ui::widgets::window::Window>() else { 55 | return; 56 | }; 57 | spawn(glib::clone!( 58 | #[strong] 59 | window, 60 | #[strong(rename_to = active_core_song)] 61 | $active_core_song, 62 | async move { 63 | window 64 | .bind_song_model($active_model, active_core_song) 65 | .await; 66 | } 67 | )); 68 | }}; 69 | } 70 | 71 | #[macro_export] 72 | macro_rules! dyn_event { 73 | ($lvl:ident, $($arg:tt)+) => { 74 | match $lvl { 75 | ::gtk::glib::LogLevel::Debug => ::tracing::debug!($($arg)+), 76 | ::gtk::glib::LogLevel::Message | ::gtk::glib::LogLevel::Info => ::tracing::info!($($arg)+), 77 | ::gtk::glib::LogLevel::Warning => ::tracing::warn!($($arg)+), 78 | ::gtk::glib::LogLevel::Error | ::gtk::glib::LogLevel::Critical => ::tracing::error!($($arg)+), 79 | } 80 | }; 81 | } 82 | 83 | #[macro_export] 84 | macro_rules! close_on_error { 85 | ($widget:expr, $des:expr) => {{ 86 | use gtk::prelude::WidgetExt; 87 | if let Some(root) = $widget.root() { 88 | if let Some(window) = root.downcast_ref::<$crate::ui::widgets::window::Window>() { 89 | window.close_on_error($des); 90 | } 91 | } 92 | }}; 93 | } 94 | 95 | #[macro_export] 96 | macro_rules! alert_dialog { 97 | ($widget:expr, $dialog:expr) => {{ 98 | use gtk::prelude::WidgetExt; 99 | if let Some(root) = $widget.root() { 100 | if let Some(window) = root.downcast_ref::<$crate::ui::widgets::window::Window>() { 101 | window.alert_dialog($dialog); 102 | } 103 | } 104 | }}; 105 | } 106 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(target_os = "windows", not(feature = "console")), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() -> gtk::glib::ExitCode { 7 | tsukimi::run() 8 | } 9 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | global_conf.set_quoted('APP_ID', application_id) 3 | global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package) 4 | global_conf.set_quoted('LOCALEDIR', localedir) 5 | global_conf.set_quoted('PKGDATADIR', pkgdatadir) 6 | global_conf.set('PROFILE', profile) 7 | global_conf.set_quoted('VERSION', full_version) 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 profile == 'Devel' 25 | rust_target = 'debug' 26 | message('Building in debug mode') 27 | else 28 | cargo_options += [ '--release' ] 29 | rust_target = 'release' 30 | message('Building in release mode') 31 | endif 32 | 33 | if get_option('sandboxed-build') 34 | # This is the path used by flatpak-cargo-generator in flatpak-builder-tools 35 | cargo_env = [ 'CARGO_HOME=' + meson.project_source_root() / 'cargo' ] 36 | else 37 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] 38 | endif 39 | 40 | 41 | rustdoc_flags = ' '.join([ 42 | '-Zunstable-options', 43 | '--enable-index-page', 44 | '--extern-html-root-url=gio=https://gtk-rs.org/gtk-rs-core/stable/latest/docs/', 45 | '--extern-html-root-url=glib=https://gtk-rs.org/gtk-rs-core/stable/latest/docs/', 46 | '--extern-html-root-url=gsk4=https://gtk-rs.org/gtk4-rs/stable/latest/docs/', 47 | '--extern-html-root-url=gdk4=https://gtk-rs.org/gtk4-rs/stable/latest/docs/', 48 | '--extern-html-root-url=gtk4=https://gtk-rs.org/gtk4-rs/stable/latest/docs/', 49 | '--extern-html-root-url=libadwaita=https://world.pages.gitlab.gnome.org/Rust/libadwaita-rs/stable/latest/docs/', 50 | '--cfg=docsrs', 51 | '-Dwarnings', 52 | ]) 53 | doc_env = ['RUSTDOCFLAGS=' + rustdoc_flags ] 54 | 55 | custom_target( 56 | 'cargo-build', 57 | build_by_default: true, 58 | build_always_stale: true, 59 | output: meson.project_name(), 60 | console: true, 61 | install: true, 62 | install_dir: bindir, 63 | command: [ 64 | 'env', 65 | cargo_env, 66 | cargo, 'build', 67 | cargo_options, 68 | '&&', 69 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 70 | ] 71 | ) 72 | 73 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod models; 2 | mod mpv; 3 | pub mod provider; 4 | pub mod widgets; 5 | 6 | pub use models::{ 7 | SETTINGS, 8 | emby_cache_path, 9 | }; 10 | pub use widgets::{ 11 | GlobalToast, 12 | window::Window, 13 | }; 14 | -------------------------------------------------------------------------------- /src/ui/models/mod.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | pub mod settings; 3 | pub use self::settings::Settings; 4 | use crate::client::emby_client::EMBY_CLIENT; 5 | pub static SETTINGS: Lazy = Lazy::new(Settings::default); 6 | 7 | pub static CACHE_PATH: Lazy = Lazy::new(|| { 8 | let path = gtk::glib::user_cache_dir().join("tsukimi"); 9 | if !path.exists() { 10 | std::fs::create_dir_all(&path).expect("Failed to create directory"); 11 | } 12 | path 13 | }); 14 | 15 | pub async fn emby_cache_path() -> std::path::PathBuf { 16 | let path = CACHE_PATH.join(EMBY_CLIENT.server_name_hash.lock().await.as_str()); 17 | if !path.exists() { 18 | std::fs::create_dir_all(&path).expect("Failed to create directory"); 19 | } 20 | path 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/mpv/danmaku_timer.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use danmakw::*; 4 | use libmpv2::Mpv; 5 | 6 | #[derive(Clone)] 7 | pub struct MpvTimer { 8 | pub mpv: Arc, 9 | } 10 | 11 | impl Timer for MpvTimer { 12 | fn time_milis(&self) -> f64 { 13 | self.mpv 14 | .get_property::("audio-pts/full") 15 | .unwrap_or_default() 16 | * 1000.0 17 | } 18 | } 19 | 20 | impl MpvTimer { 21 | pub fn new(mpv: Arc) -> Self { 22 | Self { mpv } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/mpv/menu_actions.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | }; 6 | 7 | mod imp { 8 | 9 | use glib::subclass::InitializingObject; 10 | 11 | use super::*; 12 | 13 | #[derive(Debug, Default, CompositeTemplate)] 14 | #[template(resource = "/moe/tsuna/tsukimi/ui/mpv_menu_actions.ui")] 15 | pub struct MenuActions { 16 | #[template_child] 17 | pub play_pause_button: TemplateChild, 18 | } 19 | 20 | #[glib::object_subclass] 21 | impl ObjectSubclass for MenuActions { 22 | const NAME: &'static str = "MenuActions"; 23 | type Type = super::MenuActions; 24 | type ParentType = adw::Bin; 25 | 26 | fn class_init(klass: &mut Self::Class) { 27 | Self::bind_template(klass); 28 | } 29 | 30 | fn instance_init(obj: &InitializingObject) { 31 | obj.init_template(); 32 | } 33 | } 34 | 35 | impl ObjectImpl for MenuActions { 36 | fn constructed(&self) { 37 | self.parent_constructed(); 38 | } 39 | } 40 | 41 | impl WidgetImpl for MenuActions {} 42 | 43 | impl BinImpl for MenuActions {} 44 | } 45 | 46 | glib::wrapper! { 47 | /// A widget displaying a `MenuActions`. 48 | pub struct MenuActions(ObjectSubclass) 49 | @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; 50 | } 51 | 52 | impl MenuActions { 53 | pub fn new() -> Self { 54 | glib::Object::new() 55 | } 56 | } 57 | 58 | impl Default for MenuActions { 59 | fn default() -> Self { 60 | Self::new() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/mpv/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod control_sidebar; 2 | pub mod danmaku_timer; 3 | pub mod menu_actions; 4 | pub mod mpvglarea; 5 | pub mod options_matcher; 6 | pub mod page; 7 | pub mod tsukimi_mpv; 8 | pub mod video_scale; 9 | pub mod volume_bar; 10 | 11 | pub use danmaku_timer::MpvTimer; 12 | pub use volume_bar::VolumeBar; 13 | -------------------------------------------------------------------------------- /src/ui/mpv/options_matcher.rs: -------------------------------------------------------------------------------- 1 | pub fn match_video_upscale<'a>(matcher: i32) -> &'a str { 2 | match matcher { 3 | 0 => "lanczos", 4 | 1 => "bilinear", 5 | 2 => "ewa_lanczos", 6 | 3 => "mitchell", 7 | 4 => "hermite", 8 | 5 => "oversample", 9 | 6 => "linear", 10 | 7 => "ewa_hanning", 11 | _ => "ewa_lanczossharp", 12 | } 13 | } 14 | 15 | pub fn match_audio_channels<'a>(matcher: i32) -> &'a str { 16 | match matcher { 17 | 1 => "auto-safe", 18 | 2 => "mono", 19 | 3 => "stereo", 20 | _ => "auto", 21 | } 22 | } 23 | 24 | pub fn match_sub_border_style<'a>(matcher: i32) -> &'a str { 25 | match matcher { 26 | 1 => "opaque-box", 27 | 2 => "background-box", 28 | _ => "outline-and-shadow", 29 | } 30 | } 31 | 32 | pub fn match_hwdec_interop<'a>(matcher: i32) -> &'a str { 33 | match matcher { 34 | 0 => "no", 35 | 1 => "auto-safe", 36 | 2 => "vaapi", 37 | _ => "no", 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/provider/account_item.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use gtk::{ 4 | glib, 5 | glib::{ 6 | prelude::*, 7 | subclass::prelude::*, 8 | }, 9 | }; 10 | 11 | use crate::client::Account; 12 | 13 | pub mod imp { 14 | use gtk::glib::Properties; 15 | 16 | use super::*; 17 | 18 | #[derive(Properties, Default)] 19 | #[properties(wrapper_type = super::AccountItem)] 20 | pub struct AccountItem { 21 | #[property(get, set)] 22 | server: RefCell, 23 | #[property(get, set)] 24 | servername: RefCell, 25 | #[property(get, set)] 26 | username: RefCell, 27 | #[property(get, set)] 28 | password: RefCell, 29 | #[property(get, set)] 30 | port: RefCell, 31 | #[property(get, set)] 32 | user_id: RefCell, 33 | #[property(get, set)] 34 | access_token: RefCell, 35 | #[property(get, set)] 36 | server_type: RefCell>, 37 | } 38 | 39 | #[glib::derived_properties] 40 | impl ObjectImpl for AccountItem {} 41 | 42 | #[glib::object_subclass] 43 | impl ObjectSubclass for AccountItem { 44 | const NAME: &'static str = "AccountItem"; 45 | type Type = super::AccountItem; 46 | } 47 | } 48 | 49 | glib::wrapper! { 50 | pub struct AccountItem(ObjectSubclass); 51 | } 52 | 53 | impl AccountItem { 54 | pub fn from_simple(account: &Account) -> Self { 55 | let account = account.to_owned(); 56 | let item: AccountItem = glib::object::Object::new(); 57 | item.set_server(account.server); 58 | item.set_servername(account.servername); 59 | item.set_username(account.username); 60 | item.set_password(account.password); 61 | item.set_port(account.port); 62 | item.set_user_id(account.user_id); 63 | item.set_access_token(account.access_token); 64 | if let Some(server_type) = account.server_type { 65 | item.set_server_type(server_type); 66 | } 67 | item 68 | } 69 | 70 | pub fn account(&self) -> Account { 71 | Account { 72 | server: self.server(), 73 | servername: self.servername(), 74 | username: self.username(), 75 | password: self.password(), 76 | port: self.port(), 77 | user_id: self.user_id(), 78 | access_token: self.access_token(), 79 | server_type: self.server_type(), 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/provider/actions.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | glib, 3 | prelude::*, 4 | subclass::prelude::ObjectSubclassIsExt, 5 | }; 6 | 7 | use crate::{ 8 | client::{ 9 | emby_client::EMBY_CLIENT, 10 | error::UserFacingError, 11 | }, 12 | ui::{ 13 | GlobalToast, 14 | widgets::{ 15 | song_widget::SongWidget, 16 | star_toggle::StarToggle, 17 | }, 18 | }, 19 | utils::{ 20 | spawn, 21 | spawn_tokio, 22 | }, 23 | }; 24 | 25 | pub trait HasLikeAction { 26 | fn like_button(&self) -> StarToggle; 27 | async fn bind_like(&self, id: &str); 28 | } 29 | 30 | macro_rules! impl_has_likeaction { 31 | ($($t:ty),+) => { 32 | $( 33 | impl HasLikeAction for $t { 34 | fn like_button(&self) -> StarToggle { 35 | self.imp().favourite_button.to_owned() 36 | } 37 | 38 | async fn bind_like(&self, id: &str) { 39 | let like_button = self.like_button(); 40 | let id = id.to_string(); 41 | 42 | like_button.connect_toggled( 43 | glib::clone!(#[weak(rename_to = obj)] self, move |button| { 44 | let active = button.is_active(); 45 | spawn( 46 | glib::clone!(#[weak] obj, #[strong] id, async move { 47 | 48 | let result = if active { 49 | spawn_tokio(async move {EMBY_CLIENT.like(&id).await} ).await 50 | } else { 51 | spawn_tokio(async move {EMBY_CLIENT.unlike(&id).await} ).await 52 | }; 53 | 54 | match result { 55 | Ok(_) => { 56 | obj.toast("Success"); 57 | } 58 | Err(e) => { 59 | obj.toast(e.to_user_facing()); 60 | } 61 | } 62 | }) 63 | ); 64 | }) 65 | ); 66 | } 67 | } 68 | )+ 69 | }; 70 | } 71 | 72 | impl_has_likeaction!(SongWidget); 73 | -------------------------------------------------------------------------------- /src/ui/provider/background_paintable.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | gdk, 3 | gio::File, 4 | glib, 5 | graphene, 6 | prelude::*, 7 | subclass::prelude::*, 8 | }; 9 | 10 | mod imp { 11 | use std::{ 12 | cell::RefCell, 13 | rc::Rc, 14 | }; 15 | 16 | use super::*; 17 | use crate::ui::models::SETTINGS; 18 | 19 | #[derive(Default)] 20 | pub struct BackgroundPaintable { 21 | pub pic: RefCell>, 22 | texture: Rc>>, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for BackgroundPaintable { 27 | const NAME: &'static str = "BackgroundPaintable"; 28 | type Type = super::BackgroundPaintable; 29 | type Interfaces = (gdk::Paintable,); 30 | } 31 | 32 | impl ObjectImpl for BackgroundPaintable {} 33 | impl PaintableImpl for BackgroundPaintable { 34 | fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) { 35 | if let Some(file) = self.pic.borrow().as_ref() { 36 | let texture = self.texture.borrow(); 37 | let texture = if texture.is_none() || self.pic.borrow().as_ref() != Some(file) { 38 | drop(texture); 39 | let Ok(new_texture) = gdk::Texture::from_file(file) else { 40 | return; 41 | }; 42 | *self.texture.borrow_mut() = Some(new_texture.to_owned()); 43 | new_texture 44 | } else { 45 | texture.as_ref().unwrap().to_owned() 46 | }; 47 | let texture_width = texture.width() as f64; 48 | let texture_height = texture.height() as f64; 49 | 50 | let scale_x = width / texture_width; 51 | let scale_y = height / texture_height; 52 | 53 | let scale = scale_x.max(scale_y); 54 | 55 | let new_width = texture_width * scale; 56 | let new_height = texture_height * scale; 57 | 58 | let dx = (width - new_width) / 2.0; 59 | let dy = (height - new_height) / 2.0; 60 | 61 | let rect = 62 | graphene::Rect::new(dx as f32, dy as f32, new_width as f32, new_height as f32); 63 | snapshot.push_blur(SETTINGS.pic_blur() as f64); 64 | snapshot.append_texture(&texture, &rect); 65 | snapshot.pop(); 66 | } 67 | } 68 | } 69 | } 70 | 71 | glib::wrapper! { 72 | pub struct BackgroundPaintable(ObjectSubclass) 73 | @implements gdk::Paintable; 74 | } 75 | 76 | impl BackgroundPaintable { 77 | pub fn set_pic(&self, pic: File) { 78 | self.imp().pic.replace(Some(pic)); 79 | self.invalidate_contents(); 80 | } 81 | } 82 | 83 | impl Default for BackgroundPaintable { 84 | fn default() -> Self { 85 | glib::Object::new() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ui/provider/core_song.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use gtk::{ 4 | glib, 5 | glib::{ 6 | prelude::*, 7 | subclass::prelude::*, 8 | }, 9 | }; 10 | 11 | use crate::ui::widgets::song_widget::State; 12 | 13 | pub mod imp { 14 | use std::cell::Cell; 15 | 16 | use gtk::glib::Properties; 17 | 18 | use super::*; 19 | use crate::ui::widgets::song_widget::State; 20 | 21 | #[derive(Properties, Default)] 22 | #[properties(wrapper_type = super::CoreSong)] 23 | pub struct CoreSong { 24 | #[property(get, set)] 25 | pub id: RefCell, 26 | #[property(get, set = Self::set_state, explicit_notify, builder(State::default()))] 27 | pub state: Cell, 28 | #[property(get, set)] 29 | pub name: RefCell, 30 | #[property(get, set)] 31 | pub artist: RefCell, 32 | #[property(get, set)] 33 | pub album_id: RefCell, 34 | #[property(get, set)] 35 | pub have_single_track_image: RefCell, 36 | #[property(get, set)] 37 | pub duration: RefCell, 38 | } 39 | 40 | #[glib::derived_properties] 41 | impl ObjectImpl for CoreSong {} 42 | 43 | #[glib::object_subclass] 44 | impl ObjectSubclass for CoreSong { 45 | const NAME: &'static str = "CoreSong"; 46 | type Type = super::CoreSong; 47 | } 48 | 49 | impl CoreSong { 50 | fn set_state(&self, state: State) { 51 | if self.state.get() == state { 52 | return; 53 | } 54 | self.state.set(state); 55 | self.obj().notify_state(); 56 | } 57 | } 58 | } 59 | 60 | glib::wrapper! { 61 | pub struct CoreSong(ObjectSubclass); 62 | } 63 | 64 | impl CoreSong { 65 | pub fn new(id: &str) -> CoreSong { 66 | glib::object::Object::builder() 67 | .property("id", id) 68 | .property("state", State::Unplayed) 69 | .build() 70 | } 71 | } 72 | 73 | impl Default for CoreSong { 74 | fn default() -> Self { 75 | Self::new("") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/provider/descriptor.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | Deserialize, 3 | Serialize, 4 | }; 5 | 6 | #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] 7 | pub struct Descriptor { 8 | pub content: String, 9 | pub type_: DescriptorType, 10 | } 11 | 12 | #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] 13 | pub enum DescriptorType { 14 | String, 15 | Regex, 16 | } 17 | 18 | impl DescriptorType { 19 | pub fn from_str(s: &str) -> Self { 20 | match s { 21 | "String" => Self::String, 22 | "Regex" => Self::Regex, 23 | _ => panic!("Invalid DescriptorType"), 24 | } 25 | } 26 | 27 | pub fn from_u32(u: u32) -> Self { 28 | match u { 29 | 0 => Self::String, 30 | 1 => Self::Regex, 31 | _ => panic!("Invalid DescriptorType"), 32 | } 33 | } 34 | } 35 | 36 | impl std::fmt::Display for DescriptorType { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 38 | let str = match self { 39 | DescriptorType::String => "String".to_string(), 40 | DescriptorType::Regex => "Regex".to_string(), 41 | }; 42 | write!(f, "{str}") 43 | } 44 | } 45 | 46 | pub trait VecSerialize { 47 | fn to_string(&self) -> String; 48 | } 49 | 50 | impl VecSerialize for Vec { 51 | fn to_string(&self) -> String { 52 | serde_json::to_string(&self).expect("Failed to serialize Vec") 53 | } 54 | } 55 | 56 | impl Descriptor { 57 | pub fn new(content: String, type_: DescriptorType) -> Self { 58 | Self { content, type_ } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/provider/dropdown_factory.rs: -------------------------------------------------------------------------------- 1 | use derive_builder::Builder; 2 | use gtk::{ 3 | glib, 4 | prelude::*, 5 | }; 6 | 7 | #[derive(Builder, Default, Clone, PartialEq, Debug)] 8 | #[builder(default)] 9 | pub struct DropdownList { 10 | pub line1: Option, 11 | pub line2: Option, 12 | pub sub_lang: Option, 13 | pub index: Option, 14 | pub id: Option, 15 | pub url: Option, 16 | pub is_external: Option, 17 | } 18 | 19 | pub fn factory() -> gtk::SignalListItemFactory { 20 | let factory = gtk::SignalListItemFactory::new(); 21 | factory.connect_bind(move |_, item| { 22 | let list_item = item 23 | .downcast_ref::() 24 | .expect("Needs to be ListItem"); 25 | 26 | if list_item.child().is_some() && !UPBIND { 27 | return; 28 | } 29 | 30 | if let Some(entry) = item 31 | .downcast_ref::() 32 | .expect("Needs to be ListItem") 33 | .item() 34 | .and_downcast::() 35 | { 36 | let dl: std::cell::Ref = entry.borrow(); 37 | 38 | let list_dropdown = crate::ui::widgets::list_dropdown::ListDropdown::new(); 39 | 40 | list_dropdown.set_label1(&dl.line1); 41 | list_dropdown.set_tooltip_text(dl.line1.as_deref()); 42 | 43 | if !UPBIND { 44 | list_dropdown.set_label2(&dl.line2); 45 | } 46 | 47 | list_item.set_child(Some(&list_dropdown)); 48 | } 49 | }); 50 | factory 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/provider/image_tags.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use gtk::{ 4 | glib, 5 | glib::{ 6 | prelude::*, 7 | subclass::prelude::*, 8 | }, 9 | }; 10 | 11 | pub mod imp { 12 | use gtk::glib::Properties; 13 | 14 | use super::*; 15 | 16 | #[derive(Properties, Default)] 17 | #[properties(wrapper_type = super::ImageTags)] 18 | pub struct ImageTags { 19 | #[property(get, set, nullable)] 20 | pub backdrop: RefCell>, 21 | #[property(get, set, nullable)] 22 | pub primary: RefCell>, 23 | #[property(get, set, nullable)] 24 | pub thumb: RefCell>, 25 | #[property(get, set, nullable)] 26 | pub banner: RefCell>, 27 | } 28 | 29 | #[glib::derived_properties] 30 | impl ObjectImpl for ImageTags {} 31 | 32 | #[glib::object_subclass] 33 | impl ObjectSubclass for ImageTags { 34 | const NAME: &'static str = "ImageTags"; 35 | type Type = super::ImageTags; 36 | } 37 | } 38 | 39 | glib::wrapper! { 40 | pub struct ImageTags(ObjectSubclass); 41 | } 42 | 43 | impl Default for ImageTags { 44 | fn default() -> Self { 45 | Self::new() 46 | } 47 | } 48 | 49 | impl ImageTags { 50 | pub fn new() -> ImageTags { 51 | glib::object::Object::new() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ui/provider/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{ 2 | AtomicBool, 3 | Ordering, 4 | }; 5 | 6 | use once_cell::sync::Lazy; 7 | 8 | pub mod account_item; 9 | pub mod actions; 10 | pub mod background_paintable; 11 | pub mod core_song; 12 | pub mod descriptor; 13 | pub mod dropdown_factory; 14 | pub mod image_tags; 15 | pub mod tu_item; 16 | pub mod tu_object; 17 | 18 | pub static IS_ADMIN: Lazy = Lazy::new(|| AtomicBool::new(false)); 19 | 20 | pub fn set_admin(value: bool) { 21 | IS_ADMIN.store(value, Ordering::SeqCst); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/provider/tu_object.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use gtk::{ 4 | glib, 5 | glib::{ 6 | prelude::*, 7 | subclass::prelude::*, 8 | }, 9 | }; 10 | 11 | use super::tu_item::TuItem; 12 | use crate::client::structs::SimpleListItem; 13 | 14 | pub mod imp { 15 | use gtk::glib::Properties; 16 | 17 | use super::*; 18 | use crate::ui::provider::tu_item::TuItem; 19 | 20 | #[derive(Properties, Default)] 21 | #[properties(wrapper_type = super::TuObject)] 22 | pub struct TuObject { 23 | #[property(get, set)] 24 | item: RefCell, 25 | #[property(get, set)] 26 | poster: RefCell>, 27 | } 28 | 29 | #[glib::derived_properties] 30 | impl ObjectImpl for TuObject {} 31 | 32 | #[glib::object_subclass] 33 | impl ObjectSubclass for TuObject { 34 | const NAME: &'static str = "TuObject"; 35 | type Type = super::TuObject; 36 | } 37 | } 38 | 39 | glib::wrapper! { 40 | pub struct TuObject(ObjectSubclass); 41 | } 42 | 43 | impl TuObject { 44 | pub fn new(item: &TuItem) -> Self { 45 | glib::Object::builder().property("item", item).build() 46 | } 47 | 48 | pub fn from_simple(latest: &SimpleListItem, poster: Option<&str>) -> Self { 49 | let tu_item = TuItem::from_simple(latest, poster); 50 | TuObject::new(&tu_item) 51 | } 52 | 53 | pub fn activate(&self, listview: &T) 54 | where 55 | T: glib::clone::Downgrade + gtk::prelude::IsA, 56 | { 57 | let item = self.item(); 58 | let poster = self.poster(); 59 | item.activate(listview, poster); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/widgets/action_row.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | }; 6 | 7 | mod imp { 8 | use std::cell::Cell; 9 | 10 | use glib::{ 11 | Properties, 12 | subclass::InitializingObject, 13 | }; 14 | use gtk::prelude::*; 15 | 16 | use super::*; 17 | 18 | #[derive(Debug, Default, CompositeTemplate, Properties)] 19 | #[template(resource = "/moe/tsuna/tsukimi/ui/action_row.ui")] 20 | #[properties(wrapper_type = super::AActionRow)] 21 | pub struct AActionRow { 22 | #[template_child] 23 | pub secondary_label: TemplateChild, 24 | #[property(get, set, default_value = true)] 25 | pub show_arrow: Cell, 26 | } 27 | 28 | #[glib::object_subclass] 29 | impl ObjectSubclass for AActionRow { 30 | const NAME: &'static str = "AActionRow"; 31 | type Type = super::AActionRow; 32 | type ParentType = adw::ActionRow; 33 | 34 | fn class_init(klass: &mut Self::Class) { 35 | Self::bind_template(klass); 36 | } 37 | 38 | fn instance_init(obj: &InitializingObject) { 39 | obj.init_template(); 40 | } 41 | } 42 | 43 | #[glib::derived_properties] 44 | impl ObjectImpl for AActionRow {} 45 | 46 | impl WidgetImpl for AActionRow {} 47 | impl ListBoxRowImpl for AActionRow {} 48 | impl PreferencesRowImpl for AActionRow {} 49 | impl ActionRowImpl for AActionRow {} 50 | } 51 | 52 | glib::wrapper! { 53 | pub struct AActionRow(ObjectSubclass) 54 | @extends gtk::Widget, gtk::ListBoxRow, adw::ActionRow, adw::PreferencesRow, @implements gtk::Accessible; 55 | } 56 | 57 | impl Default for AActionRow { 58 | fn default() -> Self { 59 | Self::new() 60 | } 61 | } 62 | 63 | impl AActionRow { 64 | pub fn new() -> Self { 65 | glib::Object::new() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/widgets/check_row.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | }; 6 | 7 | mod imp { 8 | 9 | use std::cell::RefCell; 10 | 11 | use glib::subclass::InitializingObject; 12 | 13 | use super::*; 14 | 15 | #[derive(Debug, Default, CompositeTemplate)] 16 | #[template(resource = "/moe/tsuna/tsukimi/ui/check_row.ui")] 17 | pub struct CheckRow { 18 | #[template_child] 19 | pub check: TemplateChild, 20 | pub track_id: RefCell, 21 | } 22 | 23 | #[glib::object_subclass] 24 | impl ObjectSubclass for CheckRow { 25 | const NAME: &'static str = "CheckRow"; 26 | type Type = super::CheckRow; 27 | type ParentType = adw::ActionRow; 28 | 29 | fn class_init(klass: &mut Self::Class) { 30 | Self::bind_template(klass); 31 | } 32 | 33 | fn instance_init(obj: &InitializingObject) { 34 | obj.init_template(); 35 | } 36 | } 37 | 38 | impl ObjectImpl for CheckRow {} 39 | 40 | impl WidgetImpl for CheckRow {} 41 | impl ListBoxRowImpl for CheckRow {} 42 | impl PreferencesRowImpl for CheckRow {} 43 | impl ActionRowImpl for CheckRow {} 44 | } 45 | 46 | glib::wrapper! { 47 | pub struct CheckRow(ObjectSubclass) 48 | @extends gtk::Widget, gtk::ListBoxRow, adw::ActionRow, adw::PreferencesRow, @implements gtk::Accessible; 49 | } 50 | 51 | impl Default for CheckRow { 52 | fn default() -> Self { 53 | Self::new() 54 | } 55 | } 56 | 57 | impl CheckRow { 58 | pub fn new() -> Self { 59 | glib::Object::new() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/widgets/disc_box.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gettextrs::gettext; 3 | use gtk::{ 4 | CompositeTemplate, 5 | glib, 6 | prelude::*, 7 | template_callbacks, 8 | }; 9 | 10 | use super::song_widget::SongWidget; 11 | use crate::ui::provider::tu_item::TuItem; 12 | 13 | mod imp { 14 | use std::sync::OnceLock; 15 | 16 | use glib::subclass::{ 17 | InitializingObject, 18 | Signal, 19 | }; 20 | 21 | use super::*; 22 | 23 | #[derive(CompositeTemplate, Default)] 24 | #[template(resource = "/moe/tsuna/tsukimi/ui/disc_box.ui")] 25 | pub struct DiscBox { 26 | #[template_child] 27 | pub disc_label: TemplateChild, 28 | #[template_child] 29 | pub listbox: TemplateChild, 30 | } 31 | 32 | #[glib::object_subclass] 33 | impl ObjectSubclass for DiscBox { 34 | const NAME: &'static str = "DiscBox"; 35 | type Type = super::DiscBox; 36 | type ParentType = gtk::Box; 37 | 38 | fn class_init(klass: &mut Self::Class) { 39 | klass.bind_template(); 40 | klass.bind_template_instance_callbacks(); 41 | } 42 | 43 | fn instance_init(obj: &InitializingObject) { 44 | obj.init_template(); 45 | } 46 | } 47 | 48 | impl ObjectImpl for DiscBox { 49 | fn signals() -> &'static [Signal] { 50 | static SIGNALS: OnceLock> = OnceLock::new(); 51 | SIGNALS.get_or_init(|| { 52 | vec![ 53 | Signal::builder("song-activated") 54 | .param_types([SongWidget::static_type()]) 55 | .build(), 56 | ] 57 | }) 58 | } 59 | } 60 | 61 | impl WidgetImpl for DiscBox {} 62 | impl BoxImpl for DiscBox {} 63 | } 64 | 65 | glib::wrapper! { 66 | 67 | pub struct DiscBox(ObjectSubclass) 68 | @extends gtk::Widget, adw::Dialog, adw::NavigationPage, @implements gtk::Accessible; 69 | } 70 | 71 | impl Default for DiscBox { 72 | fn default() -> Self { 73 | Self::new() 74 | } 75 | } 76 | 77 | #[template_callbacks] 78 | impl DiscBox { 79 | pub fn new() -> Self { 80 | glib::Object::builder().build() 81 | } 82 | 83 | pub fn set_disc(&self, disc: u32) { 84 | let disc_label = self.imp().disc_label.get(); 85 | disc_label.set_text(&format!("{} {}", &gettext("Disc"), disc)); 86 | } 87 | 88 | pub fn add_song(&self, item: TuItem) { 89 | let listbox = self.imp().listbox.get(); 90 | let song_widget = SongWidget::new(item); 91 | listbox.append(&song_widget); 92 | } 93 | 94 | #[template_callback] 95 | pub fn song_activated(&self, song_widget: &SongWidget) { 96 | self.emit_by_name::<()>("song-activated", &[&song_widget]); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/widgets/episode_switcher/button.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | glib, 3 | prelude::*, 4 | subclass::prelude::*, 5 | }; 6 | 7 | pub(crate) mod imp { 8 | use std::cell::OnceCell; 9 | 10 | use super::*; 11 | 12 | #[derive(Default, glib::Properties)] 13 | #[properties(wrapper_type = super::EpisodeButton)] 14 | pub struct EpisodeButton { 15 | #[property(get, set, construct_only)] 16 | pub start_index: OnceCell, 17 | #[property(get, set, construct_only)] 18 | pub length: OnceCell, 19 | } 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for EpisodeButton { 23 | const NAME: &'static str = "EpisodeButton"; 24 | type Type = super::EpisodeButton; 25 | type ParentType = gtk::Button; 26 | } 27 | 28 | #[glib::derived_properties] 29 | impl ObjectImpl for EpisodeButton { 30 | fn constructed(&self) { 31 | self.parent_constructed(); 32 | 33 | let obj = self.obj(); 34 | 35 | let start_index = obj.start_index(); 36 | let length = obj.length(); 37 | 38 | obj.add_css_class("flat"); 39 | obj.set_label(&format!("{} - {}", start_index + 1, start_index + length)); 40 | } 41 | } 42 | 43 | impl WidgetImpl for EpisodeButton {} 44 | 45 | impl ButtonImpl for EpisodeButton {} 46 | } 47 | 48 | glib::wrapper! { 49 | 50 | pub struct EpisodeButton(ObjectSubclass) 51 | @extends gtk::Widget, gtk::Button; 52 | } 53 | 54 | impl EpisodeButton { 55 | pub fn new(start_index: u32, length: u32) -> Self { 56 | glib::Object::builder() 57 | .property("start-index", start_index) 58 | .property("length", length) 59 | .build() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/widgets/episode_switcher/mod.rs: -------------------------------------------------------------------------------- 1 | mod button; 2 | mod switcher; 3 | 4 | pub use button::EpisodeButton; 5 | pub use switcher::EpisodeSwitcher; 6 | -------------------------------------------------------------------------------- /src/ui/widgets/eu_item/eu_list_item.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | }; 6 | 7 | use gtk::template_callbacks; 8 | 9 | use super::EuItem; 10 | 11 | mod imp { 12 | use std::cell::RefCell; 13 | 14 | use glib::{ 15 | Properties, 16 | subclass::InitializingObject, 17 | }; 18 | use gtk::prelude::*; 19 | 20 | use crate::ui::widgets::picture_loader::PictureLoader; 21 | 22 | use super::*; 23 | 24 | #[derive(Debug, Default, CompositeTemplate, Properties)] 25 | #[template(resource = "/moe/tsuna/tsukimi/ui/eu_item.ui")] 26 | #[properties(wrapper_type = super::EuListItem)] 27 | pub struct EuListItem { 28 | #[property(get, set = Self::set_item)] 29 | pub item: RefCell, 30 | 31 | #[template_child] 32 | pub label1: TemplateChild, 33 | #[template_child] 34 | pub label2: TemplateChild, 35 | #[template_child] 36 | pub label3: TemplateChild, 37 | 38 | #[template_child] 39 | pub picture_container: TemplateChild, 40 | } 41 | 42 | #[glib::object_subclass] 43 | impl ObjectSubclass for EuListItem { 44 | const NAME: &'static str = "EuListItem"; 45 | type Type = super::EuListItem; 46 | type ParentType = adw::Bin; 47 | 48 | fn class_init(klass: &mut Self::Class) { 49 | klass.bind_template(); 50 | klass.bind_template_instance_callbacks(); 51 | } 52 | 53 | fn instance_init(obj: &InitializingObject) { 54 | obj.init_template(); 55 | } 56 | } 57 | 58 | #[glib::derived_properties] 59 | impl ObjectImpl for EuListItem { 60 | fn constructed(&self) { 61 | self.parent_constructed(); 62 | } 63 | } 64 | 65 | impl WidgetImpl for EuListItem {} 66 | 67 | impl BinImpl for EuListItem {} 68 | 69 | impl EuListItem { 70 | pub fn set_item(&self, item: EuItem) { 71 | self.label1.set_text(&item.line1().unwrap_or_default()); 72 | if let Some(line2) = item.line2() { 73 | self.label2.set_text(&line2); 74 | } 75 | if let Some(line3) = item.line3() { 76 | self.label3.set_text(&line3); 77 | self.label3.set_visible(true); 78 | } 79 | if let Some(url) = item.image_url().or(item.image_original_url()) { 80 | let picture_loader = 81 | PictureLoader::new_for_url(&item.image_type().unwrap_or_default(), &url); 82 | self.picture_container.append(&picture_loader); 83 | } 84 | self.item.replace(item); 85 | } 86 | } 87 | } 88 | 89 | glib::wrapper! { 90 | pub struct EuListItem(ObjectSubclass) 91 | @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; 92 | } 93 | 94 | impl Default for EuListItem { 95 | fn default() -> Self { 96 | glib::Object::new() 97 | } 98 | } 99 | 100 | #[template_callbacks] 101 | impl EuListItem { 102 | pub fn new(item: &EuItem) -> Self { 103 | glib::Object::builder().property("item", item).build() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/ui/widgets/eu_item/eu_object.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use gtk::{ 4 | glib, 5 | glib::{ 6 | prelude::*, 7 | subclass::prelude::*, 8 | }, 9 | }; 10 | 11 | use super::EuItem; 12 | 13 | pub mod imp { 14 | use gtk::glib::Properties; 15 | 16 | use super::*; 17 | 18 | #[derive(Properties, Default)] 19 | #[properties(wrapper_type = super::EuObject)] 20 | pub struct EuObject { 21 | #[property(get, set, nullable)] 22 | item: RefCell>, 23 | } 24 | 25 | #[glib::derived_properties] 26 | impl ObjectImpl for EuObject {} 27 | 28 | #[glib::object_subclass] 29 | impl ObjectSubclass for EuObject { 30 | const NAME: &'static str = "EuObject"; 31 | type Type = super::EuObject; 32 | } 33 | } 34 | 35 | glib::wrapper! { 36 | pub struct EuObject(ObjectSubclass); 37 | } 38 | 39 | impl EuObject { 40 | pub fn new(item: &EuItem) -> Self { 41 | glib::Object::builder().property("item", item).build() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/widgets/eu_item/eu_property.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use adw::prelude::*; 4 | use gtk::glib::{ 5 | self, 6 | subclass::prelude::*, 7 | }; 8 | 9 | pub mod imp { 10 | 11 | use gtk::glib::Properties; 12 | 13 | use super::*; 14 | 15 | #[derive(Properties, Default)] 16 | #[properties(wrapper_type = super::EuItem)] 17 | pub struct EuItem { 18 | #[property(get, set, nullable)] 19 | image_url: RefCell>, 20 | #[property(get, set, nullable)] 21 | image_original_url: RefCell>, 22 | #[property(get, set, nullable)] 23 | image_type: RefCell>, 24 | #[property(get, set, nullable)] 25 | line1: RefCell>, 26 | #[property(get, set, nullable)] 27 | line2: RefCell>, 28 | #[property(get, set, nullable)] 29 | line3: RefCell>, 30 | 31 | #[property(get, set, nullable)] 32 | pub json_value: RefCell>, 33 | } 34 | 35 | #[glib::derived_properties] 36 | impl ObjectImpl for EuItem {} 37 | 38 | #[glib::object_subclass] 39 | impl ObjectSubclass for EuItem { 40 | const NAME: &'static str = "EuItem"; 41 | type Type = super::EuItem; 42 | } 43 | } 44 | 45 | glib::wrapper! { 46 | pub struct EuItem(ObjectSubclass); 47 | } 48 | 49 | impl Default for EuItem { 50 | fn default() -> Self { 51 | glib::Object::new() 52 | } 53 | } 54 | 55 | impl EuItem { 56 | pub fn new( 57 | image_url: Option, image_original_url: Option, line1: Option, 58 | line2: Option, line3: Option, image_type: Option, 59 | json_value: Option, 60 | ) -> Self { 61 | glib::Object::builder() 62 | .property("image-url", image_url) 63 | .property("image-original-url", image_original_url) 64 | .property("image-type", image_type) 65 | .property("line1", line1) 66 | .property("line2", line2) 67 | .property("line3", line3) 68 | .property("json-value", json_value) 69 | .build() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/widgets/eu_item/mod.rs: -------------------------------------------------------------------------------- 1 | mod eu_list_item; 2 | mod eu_object; 3 | mod eu_property; 4 | 5 | pub use eu_list_item::EuListItem; 6 | pub use eu_object::EuObject; 7 | pub use eu_property::EuItem; 8 | use gtk::prelude::*; 9 | 10 | pub trait EuListItemExt { 11 | fn eu_item(&self) -> &Self; 12 | } 13 | 14 | impl EuListItemExt for gtk::SignalListItemFactory { 15 | fn eu_item(&self) -> &Self { 16 | self.connect_setup(move |_, list_item| { 17 | let eu_item = EuListItem::default(); 18 | 19 | let list_item = list_item 20 | .downcast_ref::() 21 | .expect("Needs to be ListItem"); 22 | list_item.set_child(Some(&eu_item)); 23 | list_item 24 | .property_expression("item") 25 | .chain_property::("item") 26 | .bind(&eu_item, "item", gtk::Widget::NONE); 27 | }); 28 | self 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/widgets/filter_panel/filter_label.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | prelude::*, 6 | template_callbacks, 7 | }; 8 | 9 | use crate::client::structs::FilterItem; 10 | 11 | use super::FiltersRow; 12 | 13 | mod imp { 14 | use std::cell::RefCell; 15 | 16 | use glib::subclass::InitializingObject; 17 | 18 | use super::*; 19 | 20 | #[derive(Debug, Default, CompositeTemplate, glib::Properties)] 21 | #[template(resource = "/moe/tsuna/tsukimi/ui/filter_label.ui")] 22 | #[properties(wrapper_type = super::FilterLabel)] 23 | pub struct FilterLabel { 24 | #[property(get, set, nullable)] 25 | pub label: RefCell>, 26 | #[property(get, set)] 27 | pub name: RefCell, 28 | #[property(get, set, nullable)] 29 | pub id: RefCell>, 30 | #[property(get, set, nullable)] 31 | pub icon_name: RefCell>, 32 | } 33 | 34 | #[glib::object_subclass] 35 | impl ObjectSubclass for FilterLabel { 36 | const NAME: &'static str = "FilterLabel"; 37 | type Type = super::FilterLabel; 38 | type ParentType = adw::Bin; 39 | 40 | fn class_init(klass: &mut Self::Class) { 41 | Self::bind_template(klass); 42 | klass.bind_template_instance_callbacks(); 43 | } 44 | 45 | fn instance_init(obj: &InitializingObject) { 46 | obj.init_template(); 47 | } 48 | } 49 | 50 | #[glib::derived_properties] 51 | impl ObjectImpl for FilterLabel { 52 | fn constructed(&self) { 53 | self.parent_constructed(); 54 | 55 | self.obj() 56 | .add_css_class(&format!("color{}", rand::random::() % 4 + 1)); 57 | } 58 | } 59 | 60 | impl WidgetImpl for FilterLabel {} 61 | 62 | impl BinImpl for FilterLabel {} 63 | } 64 | 65 | glib::wrapper! { 66 | pub struct FilterLabel(ObjectSubclass) 67 | @extends gtk::Widget, gtk::Button, adw::Bin, @implements gtk::Actionable, gtk::Accessible; 68 | } 69 | 70 | impl Default for FilterLabel { 71 | fn default() -> Self { 72 | Self::new() 73 | } 74 | } 75 | 76 | #[template_callbacks] 77 | impl FilterLabel { 78 | pub fn new() -> Self { 79 | glib::Object::new() 80 | } 81 | 82 | #[template_callback] 83 | fn on_delete_button_clicked(&self) { 84 | let Some(fillter_row) = self 85 | .ancestor(FiltersRow::static_type()) 86 | .and_downcast::() 87 | else { 88 | return; 89 | }; 90 | 91 | fillter_row.remove_filter(self.filter_item()); 92 | } 93 | 94 | pub fn filter_item(&self) -> FilterItem { 95 | FilterItem { 96 | name: self.name(), 97 | id: self.id(), 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ui/widgets/filter_panel/filter_row.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | glib, 5 | prelude::*, 6 | }; 7 | 8 | use crate::client::structs::FilterItem; 9 | 10 | use super::FilterDialogSearchPage; 11 | 12 | mod imp { 13 | use std::cell::RefCell; 14 | 15 | use glib::{ 16 | Properties, 17 | subclass::InitializingObject, 18 | }; 19 | use gtk::prelude::*; 20 | 21 | use super::*; 22 | 23 | #[derive(Debug, Default, CompositeTemplate, Properties)] 24 | #[template(resource = "/moe/tsuna/tsukimi/ui/filter_row.ui")] 25 | #[properties(wrapper_type = super::FilterRow)] 26 | pub struct FilterRow { 27 | #[property(get, set)] 28 | pub name: RefCell, 29 | #[property(get, set, nullable)] 30 | pub id: RefCell>, 31 | 32 | #[template_child] 33 | pub check: TemplateChild, 34 | } 35 | 36 | #[glib::object_subclass] 37 | impl ObjectSubclass for FilterRow { 38 | const NAME: &'static str = "FilterRow"; 39 | type Type = super::FilterRow; 40 | type ParentType = adw::ActionRow; 41 | 42 | fn class_init(klass: &mut Self::Class) { 43 | Self::bind_template(klass); 44 | klass.bind_template_instance_callbacks(); 45 | } 46 | 47 | fn instance_init(obj: &InitializingObject) { 48 | obj.init_template(); 49 | } 50 | } 51 | 52 | #[glib::derived_properties] 53 | impl ObjectImpl for FilterRow {} 54 | 55 | impl WidgetImpl for FilterRow {} 56 | impl ListBoxRowImpl for FilterRow {} 57 | impl PreferencesRowImpl for FilterRow {} 58 | impl ActionRowImpl for FilterRow {} 59 | } 60 | 61 | glib::wrapper! { 62 | pub struct FilterRow(ObjectSubclass) 63 | @extends gtk::Widget, gtk::ListBoxRow, adw::ActionRow, adw::PreferencesRow, @implements gtk::Accessible; 64 | } 65 | 66 | #[gtk::template_callbacks] 67 | impl FilterRow { 68 | pub fn new(name: &str, id: Option) -> Self { 69 | glib::Object::builder() 70 | .property("name", name) 71 | .property("id", id) 72 | .build() 73 | } 74 | 75 | #[template_callback] 76 | fn on_check_toggled(&self, check_button: >k::CheckButton) { 77 | let binding = self.ancestor(FilterDialogSearchPage::static_type()); 78 | let Some(search_page) = binding.and_downcast_ref::() else { 79 | return; 80 | }; 81 | 82 | let filter = FilterItem { 83 | id: self.id(), 84 | name: self.name(), 85 | }; 86 | match check_button.is_active() { 87 | true => { 88 | search_page.add_active_rows(self); 89 | search_page.add_filter(filter) 90 | } 91 | false => { 92 | search_page.remove_active_rows(self); 93 | search_page.remove_filter(filter); 94 | } 95 | } 96 | } 97 | 98 | pub fn set_active(&self, active: bool) { 99 | self.imp().check.set_active(active); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ui/widgets/filter_panel/mod.rs: -------------------------------------------------------------------------------- 1 | mod dialog; 2 | mod filter_label; 3 | mod filter_row; 4 | mod filters_list; 5 | mod filters_row; 6 | mod search_page; 7 | 8 | pub use dialog::FilterPanelDialog; 9 | pub use filter_label::FilterLabel; 10 | pub use filter_row::FilterRow; 11 | pub use filters_list::FiltersList; 12 | pub use filters_row::FiltersRow; 13 | pub use search_page::FilterDialogSearchPage; 14 | -------------------------------------------------------------------------------- /src/ui/widgets/fix.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | ScrolledWindow, 3 | prelude::*, 4 | }; 5 | 6 | pub trait ScrolledWindowFixExt { 7 | fn fix(&self) -> &Self; 8 | } 9 | 10 | /// fix scrolledwindow fucking up the vscroll event 11 | impl ScrolledWindowFixExt for ScrolledWindow { 12 | fn fix(&self) -> &Self { 13 | for object in self.observe_controllers().into_iter() { 14 | if let Some(controller) = object.ok().and_downcast_ref::() { 15 | controller.set_flags( 16 | gtk::EventControllerScrollFlags::HORIZONTAL 17 | | gtk::EventControllerScrollFlags::KINETIC, 18 | ); 19 | } 20 | } 21 | self 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/widgets/identify/mod.rs: -------------------------------------------------------------------------------- 1 | mod dialog; 2 | mod search_page; 3 | 4 | pub use dialog::IdentifyDialog; 5 | pub use search_page::IdentifyDialogSearchPage; 6 | -------------------------------------------------------------------------------- /src/ui/widgets/image_dialog/mod.rs: -------------------------------------------------------------------------------- 1 | mod image_adw_dialog; 2 | mod image_drop_row; 3 | mod image_edit_dialog_page; 4 | mod image_infocard; 5 | mod search_page; 6 | 7 | use gtk::prelude::*; 8 | pub use image_adw_dialog::ImagesDialog as ImageDialog; 9 | pub use image_drop_row::ImageDropRow; 10 | pub use image_edit_dialog_page::ImageDialogEditPage; 11 | pub use image_infocard::ImageInfoCard; 12 | pub use search_page::ImageDialogSearchPage; 13 | 14 | pub trait ImageDialogNavigtion { 15 | fn image_dialog(&self) -> Option; 16 | } 17 | 18 | impl ImageDialogNavigtion for T 19 | where 20 | T: IsA, 21 | { 22 | fn image_dialog(&self) -> Option { 23 | self.ancestor(ImageDialog::static_type()) 24 | .and_then(|dialog| dialog.downcast::().ok()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/widgets/item_utils.rs: -------------------------------------------------------------------------------- 1 | use strsim::jaro_winkler; 2 | 3 | use crate::ui::{ 4 | models::SETTINGS, 5 | provider::descriptor::DescriptorType, 6 | }; 7 | 8 | pub fn make_video_version_choice_from_filter(dl_list: Vec) -> Option { 9 | let descriptors = crate::ui::models::SETTINGS.preferred_version_descriptors(); 10 | let mut current_list: Vec<_> = dl_list.iter().collect(); 11 | 12 | for descriptor in descriptors { 13 | let content = &descriptor.content.to_lowercase(); 14 | let previous_list = current_list.to_owned(); 15 | 16 | current_list.retain(|&name| match descriptor.type_ { 17 | DescriptorType::String => name.to_lowercase().contains(content), 18 | DescriptorType::Regex => { 19 | regex::Regex::new(content).is_ok_and(|re| re.is_match(&name.to_lowercase())) 20 | } 21 | }); 22 | 23 | if current_list.is_empty() { 24 | current_list = previous_list; // Revert to the previous list 25 | } 26 | } 27 | 28 | current_list 29 | .first() 30 | .and_then(|first_item| dl_list.iter().position(|name| name == *first_item)) 31 | } 32 | 33 | pub fn make_video_version_choice_from_matcher( 34 | dl_list: Vec, matcher: &str, 35 | ) -> Option { 36 | let mut best_match_index = None; 37 | let mut highest_similarity = 0.0; 38 | for (index, name) in dl_list.iter().enumerate() { 39 | let similarity = jaro_winkler(name, matcher); 40 | if similarity > highest_similarity { 41 | highest_similarity = similarity; 42 | best_match_index = Some(index); 43 | } 44 | } 45 | 46 | best_match_index 47 | } 48 | 49 | pub fn make_subtitle_version_choice(lang_list: Vec<(u64, String)>) -> Option<(u64, usize)> { 50 | let lang = match SETTINGS.mpv_subtitle_preferred_lang() { 51 | 1 => "English", 52 | 2 => "Chinese Simplified", 53 | 3 => "Japanese", 54 | 4 => "Chinese Traditional", 55 | 5 => "Arabic", 56 | 6 => "Norwegian Bokmål", 57 | 7 => "Portuguese", 58 | 8 => "French", 59 | _ => return None, 60 | }; 61 | 62 | let mut best_match_index = None; 63 | let mut best_match_usize = None; 64 | let mut highest_similarity = 0.0; 65 | for (index, i) in lang_list.iter().enumerate() { 66 | let similarity = jaro_winkler(&i.1, lang); 67 | if similarity > highest_similarity { 68 | highest_similarity = similarity; 69 | best_match_index = Some(i.0); 70 | best_match_usize = Some(index); 71 | } 72 | } 73 | 74 | Some((best_match_index?, best_match_usize?)) 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/widgets/list_dropdown.rs: -------------------------------------------------------------------------------- 1 | use adw::{ 2 | prelude::*, 3 | subclass::prelude::*, 4 | }; 5 | use gtk::{ 6 | CompositeTemplate, 7 | glib, 8 | }; 9 | 10 | mod imp { 11 | use glib::subclass::InitializingObject; 12 | 13 | use super::*; 14 | 15 | #[derive(CompositeTemplate, Default)] 16 | #[template(resource = "/moe/tsuna/tsukimi/ui/dropdown.ui")] 17 | pub struct ListDropdown { 18 | #[template_child] 19 | pub label1: TemplateChild, 20 | #[template_child] 21 | pub label2: TemplateChild, 22 | } 23 | 24 | #[glib::object_subclass] 25 | impl ObjectSubclass for ListDropdown { 26 | const NAME: &'static str = "ListDropdown"; 27 | type Type = super::ListDropdown; 28 | type ParentType = adw::Bin; 29 | 30 | fn class_init(klass: &mut Self::Class) { 31 | klass.bind_template(); 32 | } 33 | 34 | fn instance_init(obj: &InitializingObject) { 35 | obj.init_template(); 36 | } 37 | } 38 | 39 | impl ObjectImpl for ListDropdown {} 40 | 41 | impl WidgetImpl for ListDropdown {} 42 | impl BinImpl for ListDropdown {} 43 | } 44 | 45 | glib::wrapper! { 46 | /// A dropdown widget with two labels. 47 | pub struct ListDropdown(ObjectSubclass) 48 | @extends gtk::Widget, adw::Bin, adw::Dialog, adw::NavigationPage, @implements gtk::Accessible; 49 | } 50 | 51 | impl Default for ListDropdown { 52 | fn default() -> Self { 53 | Self::new() 54 | } 55 | } 56 | 57 | impl ListDropdown { 58 | pub fn new() -> Self { 59 | glib::Object::builder().build() 60 | } 61 | 62 | pub fn set_label1(&self, label: &Option) { 63 | if let Some(label_str) = label { 64 | self.imp().label1.set_text(label_str); 65 | } 66 | } 67 | 68 | pub fn set_label2(&self, label: &Option) { 69 | if let Some(label_str) = label { 70 | self.imp().label2.set_text(label_str); 71 | self.imp().label2.set_visible(true); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ui/widgets/listexpand_row.rs: -------------------------------------------------------------------------------- 1 | use adw::{ 2 | prelude::*, 3 | subclass::prelude::*, 4 | }; 5 | use gtk::{ 6 | CompositeTemplate, 7 | glib, 8 | }; 9 | 10 | mod imp { 11 | use std::cell::{ 12 | Cell, 13 | RefCell, 14 | }; 15 | 16 | use glib::subclass::InitializingObject; 17 | 18 | use super::*; 19 | 20 | #[derive(Debug, Default, CompositeTemplate, glib::Properties)] 21 | #[template(resource = "/moe/tsuna/tsukimi/ui/listexpand_row.ui")] 22 | #[properties(wrapper_type = super::ListExpandRow)] 23 | pub struct ListExpandRow { 24 | #[property(get, set, nullable)] 25 | pub label: RefCell>, 26 | #[property(get, set, default_value = true)] 27 | pub expanded: Cell, 28 | } 29 | 30 | #[glib::object_subclass] 31 | impl ObjectSubclass for ListExpandRow { 32 | const NAME: &'static str = "ListExpandRow"; 33 | type Type = super::ListExpandRow; 34 | type ParentType = gtk::ListBoxRow; 35 | 36 | fn class_init(klass: &mut Self::Class) { 37 | klass.bind_template(); 38 | } 39 | 40 | fn instance_init(obj: &InitializingObject) { 41 | obj.init_template(); 42 | } 43 | } 44 | 45 | #[glib::derived_properties] 46 | impl ObjectImpl for ListExpandRow { 47 | fn constructed(&self) { 48 | self.parent_constructed(); 49 | self.obj().set_up(); 50 | } 51 | } 52 | 53 | impl WidgetImpl for ListExpandRow {} 54 | impl ListBoxRowImpl for ListExpandRow { 55 | fn activate(&self) { 56 | let obj = self.obj(); 57 | obj.set_expanded(!obj.expanded()); 58 | obj.update(); 59 | } 60 | } 61 | } 62 | 63 | glib::wrapper! { 64 | /// A sidebar row expand servers/content 65 | pub struct ListExpandRow(ObjectSubclass) 66 | @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; 67 | } 68 | 69 | impl ListExpandRow { 70 | pub fn new(label: String) -> Self { 71 | glib::Object::builder().property("label", label).build() 72 | } 73 | 74 | pub fn set_up(&self) { 75 | self.add_css_class("expand"); 76 | } 77 | 78 | fn update(&self) { 79 | let expanded = self.expanded(); 80 | 81 | if expanded { 82 | self.remove_css_class("expanded") 83 | } else { 84 | self.add_css_class("expanded") 85 | } 86 | 87 | self.add_css_class("interacted") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/widgets/logo.rs: -------------------------------------------------------------------------------- 1 | use gtk::{ 2 | Revealer, 3 | glib::{ 4 | self, 5 | clone, 6 | }, 7 | prelude::*, 8 | }; 9 | use tracing::debug; 10 | 11 | use crate::{ 12 | client::emby_client::EMBY_CLIENT, 13 | ui::models::emby_cache_path, 14 | utils::{ 15 | spawn, 16 | spawn_tokio, 17 | }, 18 | }; 19 | 20 | pub async fn set_logo(id: String, image_type: &str, tag: Option) -> Revealer { 21 | let image = gtk::Picture::new(); 22 | image.set_halign(gtk::Align::Fill); 23 | image.set_content_fit(gtk::ContentFit::Contain); 24 | let revealer = gtk::Revealer::builder() 25 | .transition_type(gtk::RevealerTransitionType::Crossfade) 26 | .child(&image) 27 | .reveal_child(false) 28 | .vexpand(true) 29 | .transition_duration(400) 30 | .build(); 31 | 32 | let cache_path = emby_cache_path().await; 33 | let path = format!("{}-{}-{}", id, image_type, tag.unwrap_or(0)); 34 | 35 | let id = id.to_string(); 36 | 37 | let pathbuf = cache_path.join(path); 38 | if pathbuf.exists() { 39 | if image.file().is_none() { 40 | image.set_file(Some(>k::gio::File::for_path(pathbuf))); 41 | revealer.set_reveal_child(true); 42 | } 43 | return revealer; 44 | } 45 | 46 | let image_type = image_type.to_string(); 47 | 48 | spawn(clone!( 49 | #[weak] 50 | image, 51 | #[weak] 52 | revealer, 53 | async move { 54 | let _ = spawn_tokio(async move { EMBY_CLIENT.get_image(&id, &image_type, tag).await }) 55 | .await; 56 | debug!("Setting image: {}", &pathbuf.display()); 57 | let file = gtk::gio::File::for_path(pathbuf); 58 | image.set_file(Some(&file)); 59 | revealer.set_reveal_child(true); 60 | } 61 | )); 62 | 63 | revealer 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account_add; 2 | pub mod account_settings; 3 | pub mod action_row; 4 | pub mod check_row; 5 | pub mod content_viewer; 6 | pub mod disc_box; 7 | pub mod episode_switcher; 8 | pub mod eu_item; 9 | pub mod filter_panel; 10 | pub mod fix; 11 | pub mod home; 12 | pub mod horbu_scrolled; 13 | pub mod hortu_scrolled; 14 | pub mod identify; 15 | pub mod image_dialog; 16 | pub mod image_paintable; 17 | pub mod item; 18 | pub mod item_actionbox; 19 | pub mod item_carousel; 20 | pub mod item_utils; 21 | pub mod liked; 22 | pub mod list; 23 | pub mod list_dropdown; 24 | pub mod listexpand_row; 25 | pub mod logo; 26 | pub mod media_viewer; 27 | pub mod metadata_dialog; 28 | pub mod missing_episodes_dialog; 29 | pub mod music_album; 30 | pub mod other; 31 | pub mod picture_loader; 32 | pub mod player_toolbar; 33 | pub mod refresh_dialog; 34 | pub mod scale_revealer; 35 | pub mod search; 36 | pub mod server_action_row; 37 | pub mod server_panel; 38 | pub mod server_row; 39 | pub mod single_grid; 40 | pub mod smooth_scale; 41 | pub mod song_widget; 42 | pub mod star_toggle; 43 | pub mod theme_switcher; 44 | pub mod tu_item; 45 | pub mod tu_list_item; 46 | pub mod tu_overview_item; 47 | pub mod tuview_scrolled; 48 | pub mod utils; 49 | pub mod window; 50 | 51 | pub use episode_switcher::EpisodeSwitcher; 52 | pub use utils::GlobalToast; 53 | -------------------------------------------------------------------------------- /src/ui/widgets/refresh_dialog.rs: -------------------------------------------------------------------------------- 1 | use adw::{ 2 | prelude::*, 3 | subclass::prelude::*, 4 | }; 5 | use gettextrs::gettext; 6 | use gtk::{ 7 | glib, 8 | template_callbacks, 9 | }; 10 | 11 | use crate::{ 12 | client::{ 13 | emby_client::EMBY_CLIENT, 14 | error::UserFacingError, 15 | }, 16 | utils::spawn_tokio, 17 | }; 18 | 19 | use super::utils::GlobalToast; 20 | 21 | mod imp { 22 | use std::cell::OnceCell; 23 | 24 | use glib::subclass::InitializingObject; 25 | use gtk::{ 26 | CompositeTemplate, 27 | glib, 28 | }; 29 | 30 | use super::*; 31 | 32 | #[derive(Debug, Default, CompositeTemplate, glib::Properties)] 33 | #[template(resource = "/moe/tsuna/tsukimi/ui/refresh_dialog.ui")] 34 | #[properties(wrapper_type = super::RefreshDialog)] 35 | pub struct RefreshDialog { 36 | #[property(get, set, construct_only)] 37 | pub id: OnceCell, 38 | #[template_child] 39 | pub metadata_check: TemplateChild, 40 | #[template_child] 41 | pub image_check: TemplateChild, 42 | } 43 | 44 | #[glib::object_subclass] 45 | impl ObjectSubclass for RefreshDialog { 46 | const NAME: &'static str = "RefreshDialog"; 47 | type Type = super::RefreshDialog; 48 | type ParentType = adw::Dialog; 49 | 50 | fn class_init(klass: &mut Self::Class) { 51 | klass.bind_template(); 52 | klass.bind_template_instance_callbacks(); 53 | } 54 | 55 | fn instance_init(obj: &InitializingObject) { 56 | obj.init_template(); 57 | } 58 | } 59 | 60 | #[glib::derived_properties] 61 | impl ObjectImpl for RefreshDialog {} 62 | 63 | impl WidgetImpl for RefreshDialog {} 64 | impl AdwDialogImpl for RefreshDialog {} 65 | } 66 | 67 | glib::wrapper! { 68 | 69 | pub struct RefreshDialog(ObjectSubclass) 70 | @extends gtk::Widget, adw::Dialog, adw::PreferencesDialog, @implements gtk::Accessible, gtk::Root; 71 | } 72 | 73 | #[template_callbacks] 74 | impl RefreshDialog { 75 | pub fn new(id: &str) -> Self { 76 | glib::Object::builder().property("id", id).build() 77 | } 78 | 79 | #[template_callback] 80 | async fn on_refresh(&self) { 81 | let id = self.id(); 82 | let imp = self.imp(); 83 | let metadata = imp.metadata_check.is_active(); 84 | let image = imp.image_check.is_active(); 85 | 86 | match spawn_tokio(async move { 87 | EMBY_CLIENT 88 | .fullscan(&id, &metadata.to_string(), &image.to_string()) 89 | .await 90 | }) 91 | .await 92 | { 93 | Ok(_) => { 94 | self.toast(gettext("Scanning...")); 95 | } 96 | Err(e) => { 97 | self.toast(e.to_user_facing()); 98 | } 99 | } 100 | 101 | self.close(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/ui/widgets/server_row.rs: -------------------------------------------------------------------------------- 1 | use adw::{ 2 | prelude::*, 3 | subclass::prelude::*, 4 | }; 5 | use gtk::{ 6 | CompositeTemplate, 7 | glib, 8 | }; 9 | 10 | use crate::{ 11 | client::Account, 12 | ui::provider::account_item::AccountItem, 13 | }; 14 | 15 | mod imp { 16 | use std::cell::OnceCell; 17 | 18 | use glib::subclass::InitializingObject; 19 | 20 | use super::*; 21 | use crate::{ 22 | client::emby_client::EMBY_CLIENT, 23 | ui::{ 24 | models::SETTINGS, 25 | provider::account_item::AccountItem, 26 | widgets::window::Window, 27 | }, 28 | utils::spawn, 29 | }; 30 | 31 | #[derive(Debug, Default, CompositeTemplate, glib::Properties)] 32 | #[template(resource = "/moe/tsuna/tsukimi/ui/server_row.ui")] 33 | #[properties(wrapper_type = super::ServerRow)] 34 | pub struct ServerRow { 35 | #[property(get, set, construct_only)] 36 | pub item: OnceCell, 37 | #[template_child] 38 | pub title_label: TemplateChild, 39 | } 40 | 41 | #[glib::object_subclass] 42 | impl ObjectSubclass for ServerRow { 43 | const NAME: &'static str = "SidebarServerRow"; 44 | type Type = super::ServerRow; 45 | type ParentType = gtk::ListBoxRow; 46 | 47 | fn class_init(klass: &mut Self::Class) { 48 | Self::bind_template(klass); 49 | klass.set_accessible_role(gtk::AccessibleRole::Group); 50 | } 51 | 52 | fn instance_init(obj: &InitializingObject) { 53 | obj.init_template(); 54 | } 55 | } 56 | 57 | #[glib::derived_properties] 58 | impl ObjectImpl for ServerRow { 59 | fn constructed(&self) { 60 | self.parent_constructed(); 61 | let obj = self.obj(); 62 | self.title_label.set_text(&obj.item().servername()); 63 | } 64 | } 65 | 66 | impl WidgetImpl for ServerRow {} 67 | impl ListBoxRowImpl for ServerRow { 68 | fn activate(&self) { 69 | let obj = self.obj(); 70 | 71 | spawn(glib::clone!( 72 | #[weak] 73 | obj, 74 | async move { 75 | let account = obj.item().account(); 76 | SETTINGS.set_preferred_server(&account.servername).unwrap(); 77 | let _ = EMBY_CLIENT.init(&account).await; 78 | if let Some(w) = obj.root().and_downcast::() { 79 | w.reset() 80 | } 81 | } 82 | )); 83 | } 84 | } 85 | } 86 | 87 | glib::wrapper! { 88 | pub struct ServerRow(ObjectSubclass) 89 | @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; 90 | } 91 | 92 | impl ServerRow { 93 | pub fn new(account: Account) -> Self { 94 | glib::Object::builder() 95 | .property("item", AccountItem::from_simple(&account)) 96 | .build() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/widgets/star_toggle.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use gtk::{ 3 | glib, 4 | prelude::*, 5 | subclass::prelude::*, 6 | }; 7 | 8 | pub(crate) mod imp { 9 | use super::*; 10 | 11 | #[derive(Default)] 12 | pub struct StarToggle {} 13 | 14 | #[glib::object_subclass] 15 | impl ObjectSubclass for StarToggle { 16 | const NAME: &'static str = "StarToggle"; 17 | type Type = super::StarToggle; 18 | type ParentType = gtk::ToggleButton; 19 | } 20 | 21 | impl ObjectImpl for StarToggle { 22 | fn constructed(&self) { 23 | self.parent_constructed(); 24 | self.obj().set_up(); 25 | self.obj().update(); 26 | } 27 | } 28 | impl WidgetImpl for StarToggle {} 29 | 30 | impl ToggleButtonImpl for StarToggle { 31 | fn toggled(&self) { 32 | self.obj().update(); 33 | } 34 | } 35 | 36 | impl ButtonImpl for StarToggle {} 37 | } 38 | 39 | glib::wrapper! { 40 | 41 | pub struct StarToggle(ObjectSubclass) 42 | @extends gtk::Widget, gtk::ToggleButton, gtk::Button; 43 | } 44 | 45 | impl Default for StarToggle { 46 | fn default() -> Self { 47 | Self::new() 48 | } 49 | } 50 | 51 | impl StarToggle { 52 | pub fn new() -> Self { 53 | glib::Object::builder().build() 54 | } 55 | 56 | pub fn set_up(&self) { 57 | self.add_css_class("star"); 58 | self.add_css_class("circular"); 59 | } 60 | 61 | fn update(&self) { 62 | match self.is_active() { 63 | true => { 64 | self.set_icon_name("starred-symbolic"); 65 | self.set_tooltip_text(Some(&gettext("Remove from favorites"))); 66 | self.add_css_class("starred"); 67 | } 68 | false => { 69 | self.set_icon_name("non-starred-symbolic"); 70 | self.set_tooltip_text(Some(&gettext("Add to favorites"))); 71 | self.remove_css_class("starred"); 72 | } 73 | } 74 | 75 | self.add_css_class("interacted") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/widgets/theme_switcher/mod.rs: -------------------------------------------------------------------------------- 1 | mod switcher; 2 | 3 | pub use switcher::ThemeSwitcher; 4 | -------------------------------------------------------------------------------- /src/ui/widgets/theme_switcher/switcher.rs: -------------------------------------------------------------------------------- 1 | use adw::subclass::prelude::*; 2 | use gtk::{ 3 | CompositeTemplate, 4 | gio, 5 | glib, 6 | prelude::*, 7 | }; 8 | 9 | use crate::ui::models::SETTINGS; 10 | 11 | mod imp { 12 | 13 | use glib::subclass::InitializingObject; 14 | 15 | use super::*; 16 | 17 | #[derive(Debug, Default, CompositeTemplate)] 18 | #[template(resource = "/moe/tsuna/tsukimi/ui/theme_switcher.ui")] 19 | pub struct ThemeSwitcher {} 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for ThemeSwitcher { 23 | const NAME: &'static str = "ThemeSwitcher"; 24 | type Type = super::ThemeSwitcher; 25 | type ParentType = adw::Bin; 26 | 27 | fn class_init(klass: &mut Self::Class) { 28 | Self::bind_template(klass); 29 | } 30 | 31 | fn instance_init(obj: &InitializingObject) { 32 | obj.init_template(); 33 | } 34 | } 35 | 36 | impl ObjectImpl for ThemeSwitcher { 37 | fn constructed(&self) { 38 | self.parent_constructed(); 39 | self.obj().init(); 40 | } 41 | } 42 | 43 | impl WidgetImpl for ThemeSwitcher {} 44 | 45 | impl BinImpl for ThemeSwitcher {} 46 | } 47 | 48 | glib::wrapper! { 49 | /// A widget displaying a `ThemeSwitcher`. 50 | pub struct ThemeSwitcher(ObjectSubclass) 51 | @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; 52 | } 53 | 54 | impl ThemeSwitcher { 55 | pub fn new() -> Self { 56 | glib::Object::new() 57 | } 58 | 59 | pub fn init(&self) { 60 | self.set_theme(SETTINGS.main_theme()); 61 | let action_group = gio::SimpleActionGroup::new(); 62 | let action_vo = gio::ActionEntry::builder("color-scheme") 63 | .parameter_type(Some(&i32::static_variant_type())) 64 | .state(SETTINGS.main_theme().to_variant()) 65 | .activate(glib::clone!( 66 | #[weak(rename_to = obj)] 67 | self, 68 | move |_, action, parameter| { 69 | let parameter = parameter 70 | .expect("Could not get parameter.") 71 | .get::() 72 | .expect("The variant needs to be of type `i32`."); 73 | 74 | SETTINGS.set_main_theme(parameter).unwrap(); 75 | obj.set_theme(parameter); 76 | 77 | action.set_state(¶meter.to_variant()); 78 | } 79 | )) 80 | .build(); 81 | 82 | action_group.add_action_entries([action_vo]); 83 | self.insert_action_group("app", Some(&action_group)); 84 | } 85 | 86 | pub fn set_theme(&self, theme: i32) { 87 | let style_manager = adw::StyleManager::default(); 88 | 89 | match theme { 90 | 1 => { 91 | style_manager.set_color_scheme(adw::ColorScheme::Default); 92 | } 93 | 2 => { 94 | style_manager.set_color_scheme(adw::ColorScheme::ForceLight); 95 | } 96 | _ => { 97 | style_manager.set_color_scheme(adw::ColorScheme::ForceDark); 98 | } 99 | } 100 | } 101 | } 102 | 103 | impl Default for ThemeSwitcher { 104 | fn default() -> Self { 105 | Self::new() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ui/widgets/tu_item/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod overlay; 3 | mod prelude; 4 | mod progressbar_animation; 5 | 6 | pub use action::TuItemAction; 7 | pub use overlay::{ 8 | TuItemOverlay, 9 | TuItemOverlayPrelude, 10 | }; 11 | pub use prelude::{ 12 | TuItemBasic, 13 | TuItemMenuPrelude, 14 | }; 15 | pub use progressbar_animation::{ 16 | PROGRESSBAR_ANIMATION_DURATION, 17 | TuItemProgressbarAnimation, 18 | TuItemProgressbarAnimationPrelude, 19 | }; 20 | -------------------------------------------------------------------------------- /src/ui/widgets/tu_item/prelude.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use crate::ui::provider::tu_item::TuItem; 4 | 5 | pub trait TuItemBasic { 6 | fn item(&self) -> TuItem; 7 | } 8 | 9 | pub trait TuItemMenuPrelude: TuItemBasic { 10 | fn popover(&self) -> &RefCell>; 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/widgets/tu_item/progressbar_animation.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::spawn; 2 | use adw::prelude::*; 3 | use gtk::glib; 4 | 5 | pub const PROGRESSBAR_ANIMATION_DURATION: u32 = 2000; 6 | 7 | pub trait TuItemProgressbarAnimationPrelude { 8 | fn overlay(&self) -> gtk::Overlay; 9 | } 10 | 11 | pub trait TuItemProgressbarAnimation { 12 | fn set_progress(&self, progress: f64); 13 | } 14 | 15 | impl TuItemProgressbarAnimation for T 16 | where 17 | T: TuItemProgressbarAnimationPrelude, 18 | { 19 | fn set_progress(&self, percentage: f64) { 20 | let progress = gtk::ProgressBar::builder() 21 | .fraction(0.) 22 | .margin_end(3) 23 | .margin_start(3) 24 | .valign(gtk::Align::End) 25 | .build(); 26 | 27 | progress.add_css_class("pgb"); 28 | 29 | self.overlay().add_overlay(&progress); 30 | 31 | spawn(glib::clone!( 32 | #[weak] 33 | progress, 34 | async move { 35 | let target = adw::CallbackAnimationTarget::new(glib::clone!( 36 | #[weak] 37 | progress, 38 | move |process| progress.set_fraction(process) 39 | )); 40 | 41 | let animation = adw::TimedAnimation::builder() 42 | .duration(PROGRESSBAR_ANIMATION_DURATION) 43 | .widget(&progress) 44 | .target(&target) 45 | .easing(adw::Easing::EaseOutQuart) 46 | .value_from(0.) 47 | .value_to(percentage / 100.0) 48 | .build(); 49 | 50 | glib::timeout_future_seconds(1).await; 51 | animation.play(); 52 | } 53 | )); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tsukimi_manifest.rc: -------------------------------------------------------------------------------- 1 | iconName ICON "resources/icons/tsukimi.ico" --------------------------------------------------------------------------------