├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── aport ├── APKBUILD ├── APKBUILD_dev └── publish.txt ├── build.rs ├── data ├── icons │ ├── null.daknig.dewduct-symbolic.svg │ ├── null.daknig.dewduct.Source.svg │ └── null.daknig.dewduct.svg └── null.daknig.dewduct.metainfo.xml ├── pmos_sideload.sh ├── resources ├── DewDuct.cmb ├── budy.svg ├── channel_banner_mobild.svg ├── channel_header.ui ├── channel_page.ui ├── channel_row.ui ├── dummi_thumbnail.jpg ├── dummi_thumbnail.svg ├── popular_page.ui ├── resources.gresource.xml ├── search_header.ui ├── search_page.ui ├── subscriptions_page.ui ├── thumbnail.ui ├── video_page.ui ├── video_row.ui ├── window.ui ├── yt_item_list.ui └── yt_item_row.ui ├── rustfmt.toml └── src ├── application.rs ├── cache.rs ├── channel_header.rs ├── channel_page.rs ├── channel_row.rs ├── config.rs ├── main.rs ├── popular_page.rs ├── search_page.rs ├── subscriptions_page.rs ├── thumbnail.rs ├── util.rs ├── video_page.rs ├── video_row.rs ├── window.rs ├── yt_item_list.rs ├── yt_item_list └── data.rs └── yt_item_row.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *~ 3 | .gitignore 4 | cache 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dewduct" 3 | version = "0.2.3" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | adw = { version = "0.6", package = "libadwaita", features = ["v1_4"] } 8 | gtk = { version = "0.8", package = "gtk4", features = ["v4_10"] } 9 | anyhow = "1.0" 10 | thiserror = "1.0" 11 | isahc = "1.7" 12 | once_cell = "1.19" 13 | humantime = "2.1" 14 | urlencoding = "2.1" 15 | html-escape = "0.2" 16 | tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros"] } 17 | lazy_static = "1.4" 18 | serde = "1.0" 19 | serde_json = "1.0" 20 | futures = "0.3" 21 | 22 | [dependencies.invidious] 23 | version = "0.7" 24 | features = ["async", "isahc_async"] 25 | 26 | [build-dependencies] 27 | glib-build-tools = "0.19" 28 | 29 | [profile.release] 30 | debug = "limited" 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: target/release/dewduct 2 | 3 | .PHONY: all target/release/dewduct install 4 | 5 | target/release/dewduct: 6 | cargo build --release 7 | 8 | install: 9 | cargo install --path . 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DewDuct 2 | 3 | ... is a Youtube player for Linux on desktop and mobile. 4 | 5 | ## Screenshots 6 | 7 | ![Video view](https://github.com/DaKnig/DewDuct/assets/37626476/4ea8957e-99d4-4ebc-aaf6-8893784d6df8 "Video view") 8 | ![Popular videos view](https://github.com/DaKnig/DewDuct/assets/37626476/bc3635d2-222c-496a-9856-7bb01710f399 "Popular videos view") 9 | ![Search view](https://github.com/DaKnig/DewDuct/assets/37626476/a48193cf-ebe0-44ef-ae89-8163a668b595 "Search view") 10 | ![Channel view](https://github.com/DaKnig/DewDuct/assets/37626476/aced4e7b-5f76-4035-bdc5-54c6754fd794 "Channel view") 11 | 12 | ## Design decisions 13 | 14 | - The UI should match that of NewPipe, with GTK widgets. I am not a designer 15 | and I dont know how to make custom widgets, or make nice UI, so I just copy 16 | what works! 17 | 18 | ## Installing 19 | 20 | ### Alpine linux and PostmarketOS 21 | 22 | If you are on edge, run: 23 | 24 | ```bash 25 | apk add dewduct 26 | ``` 27 | 28 | ## Building 29 | 30 | ### Dependencies 31 | 32 | Run time dependencies: 33 | 34 | `openssl libadwaita mpv yt-dlp` 35 | 36 | Compile time dependencies: 37 | 38 | `rust cargo openssl-dev gtk4.0-dev libadwaita-dev` 39 | 40 | To compile, run: 41 | 42 | ```bash 43 | cargo install --git https://github.com/DaKnig/DewDuct 44 | ``` 45 | 46 | ### PostmarketOS and Alpine linux: 47 | 48 | ```bash 49 | apk add rust cargo openssl-dev gtk4.0-dev libadwaita-dev openssl gtk4.0 libadwaita mpv 50 | cargo install --git https://github.com/DaKnig/DewDuct 51 | ``` 52 | 53 | ## Road map: 54 | 55 | For version 1.0 : 56 | 57 | - [x] Popular videos page. 58 | 59 | - [x] Cache for thumbnails. 60 | 61 | - [x] Video page, with description, where you could press to play video. 62 | 63 | - [ ] Select quality of video. 64 | 65 | - [x] Popular videos page. 66 | 67 | - [ ] Make downloads work with yt-dlp or so... or maybe make it myself? 68 | 69 | - [x] Search for videos and channels. 70 | 71 | - [ ] Subscribe to channels. 72 | 73 | - [x] Subscription list page. 74 | 75 | - [ ] "What's New", for videos from subscriptions, with a button for updating the list. 76 | 77 | ## Get in contact! 78 | 79 | Currently, I am the sole developer of DewDuct. 80 | 81 | Available on Matrix (`DaKnig` on `matrix.org`) 82 | 83 | Please write any and all issues on this github page! 84 | -------------------------------------------------------------------------------- /aport/APKBUILD: -------------------------------------------------------------------------------- 1 | # Contributor: DaKnig 2 | # Maintainer: DaKnig 3 | pkgname=dewduct 4 | pkgver=0.2.3 5 | pkgrel=0 6 | pkgdesc="A privacy-focused and mobile-friendly YouTube player, a NewPipe clone for GNOME, in Rust and GTK, based on Invidious" 7 | source="$pkgname-$pkgver.zip::https://github.com/DaKnig/DewDuct/archive/refs/tags/v$pkgver.zip" 8 | arch="all" 9 | license="GPL-3.0-or-later" 10 | depends="libadwaita mpv openssl yt-dlp" 11 | makedepends="cargo-auditable libadwaita-dev openssl-dev rust" 12 | url="https://github.com/DaKnig/DewDuct" 13 | builddir="$srcdir/DewDuct-$pkgver" 14 | 15 | _appid=null.daknig.dewduct 16 | 17 | options="!check" # currently, no tests available. 18 | 19 | prepare() { 20 | default_prepare 21 | cargo fetch --target="$CTARGET" --locked 22 | } 23 | 24 | build() { 25 | appstreamcli make-desktop-file data/"$_appid".metainfo.xml "$_appid".desktop 26 | cargo build --release --frozen 27 | } 28 | 29 | package() { 30 | install -D "$builddir"/target/release/"$pkgname" "$pkgdir"/usr/bin/"$pkgname" 31 | install -D "$builddir"/data/"$_appid".metainfo.xml -t "$pkgdir"/usr/share/metainfo/ 32 | install -D "$_appid".desktop "$pkgdir"/usr/share/applications/"$_appid".desktop 33 | } 34 | sha512sums=" 35 | c8e445ec0feabc2dcbddd1cf6dd063efc55eab4ef36c1b75c5b565bcee51d28b7148b970b331714385add0c58a569a149deb338200ea9602cbb900179d84fc45 dewduct-0.2.3.zip 36 | " 37 | -------------------------------------------------------------------------------- /aport/APKBUILD_dev: -------------------------------------------------------------------------------- 1 | # Contributor: DaKnig 2 | # Maintainer: DaKnig 3 | pkgname=dewduct 4 | pkgver=0.2.2 5 | pkgrel=7 6 | _pkgcommit=5d47a81172691b4196cf4970b77c1204fb838231 7 | pkgdesc="A privacy-focused and mobile-friendly YouTube player, a NewPipe clone for GNOME, in Rust and GTK, based on Invidious" 8 | source="$pkgname-$_pkgcommit.zip::https://github.com/DaKnig/DewDuct/archive/$_pkgcommit.zip" 9 | arch="all" 10 | license="GPL-3.0-or-later" 11 | depends="libadwaita mpv openssl yt-dlp" 12 | makedepends="mold cargo libadwaita-dev openssl-dev rust" 13 | url="https://github.com/DaKnig/DewDuct" 14 | builddir="$srcdir/DewDuct-$_pkgcommit" 15 | 16 | _appid=null.daknig.dewduct 17 | 18 | options="!check" # currently, no tests available. 19 | 20 | prepare() { 21 | default_prepare 22 | } 23 | 24 | build() { 25 | appstreamcli make-desktop-file data/"$_appid".metainfo.xml "$_appid".desktop 26 | cargo build 27 | } 28 | 29 | package() { 30 | install -D "$builddir"/target/debug/"$pkgname" "$pkgdir"/usr/bin/"$pkgname" 31 | install -D "$builddir"/data/"$_appid".metainfo.xml -t "$pkgdir"/usr/share/metainfo/ 32 | install -D "$_appid".desktop "$pkgdir"/usr/share/applications/"$_appid".desktop 33 | } 34 | sha512sums=" 35 | 8e031f84e83cd5fc7bd0e571f68e1287ffbbe8213bda6e1be63c7d28ef8347a4bc1774c4508f275aaca679626f080a943895f9c57eaf6652238375e733691510 dewduct-5d47a81172691b4196cf4970b77c1204fb838231.zip 36 | " 37 | -------------------------------------------------------------------------------- /aport/publish.txt: -------------------------------------------------------------------------------- 1 | - lock on specific version by pointing source to the tar.gz 2 | - checksum 3 | - push 4 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | glib_build_tools::compile_resources( 3 | &["resources"], 4 | "resources/resources.gresource.xml", 5 | "dewduct.gresource", 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /data/icons/null.daknig.dewduct-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /data/icons/null.daknig.dewduct.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /data/null.daknig.dewduct.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | null.daknig.dewduct 4 | 5 | DewDuct 6 | A YouTube player 7 | 8 | DaKnig 9 | 10 | DaKnig 11 | https://github.com/DaKnig/DewDuct 12 | https://github.com/DaKnig/DewDuct/issues 13 | 14 | FSFAP 15 | GPL-3.0-or-later 16 | 17 | 18 | 294 19 | 20 | 21 | pointing 22 | keyboard 23 | touch 24 | 25 | 26 | 27 |

28 | YouTube client for Linux mobile and desktop devices. Based on GTK, using Invidious as a backend. 29 |

30 |
31 | 32 | null.daknig.dewduct.desktop 33 | 34 | 35 | https://private-user-images.githubusercontent.com/37626476/325948567-4ea8957e-99d4-4ebc-aaf6-8893784d6df8.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTQyMjA1MTYsIm5iZiI6MTcxNDIyMDIxNiwicGF0aCI6Ii8zNzYyNjQ3Ni8zMjU5NDg1NjctNGVhODk1N2UtOTlkNC00ZWJjLWFhZjYtODg5Mzc4NGQ2ZGY4LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA0MjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNDI3VDEyMTY1NlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWIxYjM0MmVjMDYyYWYzNTMyMjM0OTg2OGNiOGY1ODQzZjdmMmFhN2Y2Nzg2MWQ3OWIxZDlkMmIyYmFjY2EzNDEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.77CYusPqt19C2RgQlx6xq3L6Eu-80YzCl-Rrcgdml0k 36 | 37 | 38 | https://private-user-images.githubusercontent.com/37626476/325923951-a48193cf-ebe0-44ef-ae89-8163a668b595.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTQyMjA1MTYsIm5iZiI6MTcxNDIyMDIxNiwicGF0aCI6Ii8zNzYyNjQ3Ni8zMjU5MjM5NTEtYTQ4MTkzY2YtZWJlMC00NGVmLWFlODktODE2M2E2NjhiNTk1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA0MjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNDI3VDEyMTY1NlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWIyNWE0OGFlNzA5YWQxMzJiZTM3MThkYTQ0YTZmOTk3ODljODE2NTE4OWM0MmUyNWRkMGU4YTcyYjk2Y2JiYWMmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.uAgguPmv4gMHiWwjcu7bT0EwwpD62TiPKICZU2G6nD8 39 | 40 | 41 | https://private-user-images.githubusercontent.com/37626476/325927805-aced4e7b-5f76-4035-bdc5-54c6754fd794.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTQyMjA1MTYsIm5iZiI6MTcxNDIyMDIxNiwicGF0aCI6Ii8zNzYyNjQ3Ni8zMjU5Mjc4MDUtYWNlZDRlN2ItNWY3Ni00MDM1LWJkYzUtNTRjNjc1NGZkNzk0LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA0MjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNDI3VDEyMTY1NlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTYzZmQ4N2E3MDU5ZWU3YTg2NTRiZDhhNzhjMGFjZWM0Y2YwYzkyYTkzYTViMjMzMzY1YjA1MWQ2ZDZlOTU3ZWUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.SCXVdqZ-5LxrArNeSKUTgxOE1AF9iTeQKXbjcNwQ8Ss 42 | 43 | 44 | 45 | dewduct 46 | 47 | 48 | AudioVideo 49 | Player 50 | 51 | 52 | 53 | dewduct 54 | 55 | 56 | 57 | 58 | 59 |
60 | -------------------------------------------------------------------------------- /pmos_sideload.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | set -Eeuo pipefail 4 | 5 | if [ $# -ne 1 ] 6 | then 7 | echo "usage: $0 ssh-device-name" 8 | exit 1 9 | fi 10 | 11 | # clean dir 12 | echo removing apk files and APKBUILD... 13 | rm -rf ~/.local/var/pmbootstrap/cache_git/pmaports/testing/ 14 | rm -f ~/.local/var/pmbootstrap/packages/edge/aarch64/dewduct*.apk 15 | # stay updated! 16 | echo agent setup... 17 | eval $(ssh-agent) 18 | ssh-add 19 | 20 | echo pmbootstrap pulling... 21 | pmbootstrap pull 22 | 23 | mkdir -p ~/.local/var/pmbootstrap/cache_git/pmaports/testing/dewduct 24 | cp aport/APKBUILD_dev ~/.local/var/pmbootstrap/cache_git/pmaports/testing/dewduct/APKBUILD 25 | 26 | # prepare the apk 27 | echo checksum... 28 | pmbootstrap checksum dewduct 29 | cp ~/.local/var/pmbootstrap/cache_git/pmaports/testing/dewduct/APKBUILD aport/APKBUILD_dev 30 | echo build... 31 | # hack: to avoid rsync-ing target folder (tens of gigs!) into the chrootsx 32 | mv target .git/ 33 | pmbootstrap build --arch aarch64 dewduct --src="$PWD" 34 | mv .git/target target 35 | # push it 36 | echo sideload... 37 | scp ~/.local/var/pmbootstrap/packages/edge/aarch64/dewduct*.apk \ 38 | $1:/tmp 39 | ssh $1 -t sudo apk add /tmp/dewduct*.apk --allow-untrusted 40 | -------------------------------------------------------------------------------- /resources/DewDuct.cmb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | (1,1,None,"window.ui",None,None,None,None,None,None,None), 6 | (2,1,None,"popular_page.ui",None,None,None,None,None,None,None), 7 | (3,1,None,"channel_header.ui",None,None,None,None,None,None,None), 8 | (4,1,None,"thumbnail.ui",None,None,None,None,None,None,None), 9 | (5,1,None,"video_row.ui",None,None,None,None,None,None,None), 10 | (6,1,None,"video_page.ui",None,None,None,None,None,None,None), 11 | (7,7,None,"yt_item_list.ui",None,None,None,None,None,None,None), 12 | (8,1,None,"search_page.ui",None,None,None,None,None,None,None), 13 | (9,1,None,"channel_row.ui",None,None,None,None,None,None,None), 14 | (10,1,None,"yt_item_row.ui",None,None,None,None,None,None,None), 15 | (11,1,None,"channel_page.ui",None,None,None,None,None,None,None), 16 | (12,1,None,"subscriptions_page.ui",None,None,None,None,None,None,None) 17 | 18 | 19 | (1,"gtk","4.8",None), 20 | (1,"libadwaita","1.4",None), 21 | (2,"gtk","4.0",None), 22 | (2,"libadwaita","1.2",None), 23 | (4,"gtk","4.8",None), 24 | (6,"gtk","4.8",None), 25 | (6,"libadwaita","1.2",None), 26 | (7,"gtk","4.2",None), 27 | (7,"libadwaita","1.2",None), 28 | (8,"gtk","4.8",None), 29 | (8,"libadwaita","1.2",None) 30 | 31 | 32 | (1,1,"AdwApplicationWindow","DewDuctWindow",None,None,None,None,None,None,None), 33 | (1,2,"AdwNavigationView","nav_view",1,None,None,None,None,None,None), 34 | (1,3,"AdwNavigationPage",None,2,None,None,None,None,None,None), 35 | (1,4,"GtkBox",None,3,None,None,None,None,None,None), 36 | (1,5,"GtkSearchBar","search_bar",4,None,None,None,None,None,None), 37 | (1,6,"AdwViewStack","screen_stack",4,None,None,None,1,None,None), 38 | (1,7,"AdwViewStackPage",None,6,None,None,None,None,None,None), 39 | (1,8,"DewPopularPage","popular_page",7,None,None,None,None,None,None), 40 | (1,9,"AdwViewStackPage",None,6,None,None,None,1,None,None), 41 | (1,10,"DewChannelPage","channel_page",9,None,None,None,None,None,None), 42 | (1,11,"AdwViewStackPage",None,6,None,None,None,2,None,None), 43 | (1,12,"DewVideoPage","video_page",11,None,None,None,None,None,None), 44 | (1,13,"AdwViewStackPage",None,6,None,None,None,3,None,None), 45 | (1,14,"DewSubscriptionsPage","subscriptions_page",13,None,None,None,None,None,None), 46 | (1,15,"AdwViewSwitcherBar",None,4,None,None,None,2,None,None), 47 | (1,16,"AdwNavigationPage",None,2,None,None,None,1,None,None), 48 | (1,17,"DewSearchPage","search_page",16,None,None,None,None,None,None), 49 | (2,1,"GtkBox","DewPopularPage",None,None,None,None,None,None,None), 50 | (2,12,"AdwHeaderBar",None,1,None,None,None,None,None,None), 51 | (2,17,"GtkToggleButton","search_button",12,None,"end",None,3,None,None), 52 | (2,18,"GtkButton","update_button",12,None,"start",None,2,None,None), 53 | (2,19,"DewYtItemList","vid_list",1,None,None,None,1,None,None), 54 | (3,1,"GtkBox","DewChannelHeader",None,None,None,None,None,None,None), 55 | (3,2,"GtkOverlay",None,1,None,None,None,None,None,None), 56 | (3,12,"AdwAvatar","thumbnail",2,None,"overlay",None,1,None,None), 57 | (3,13,"GtkBox",None,2,None,None,None,None,None,None), 58 | (3,14,"GtkPicture","banner",13,None,None,None,None,None,None), 59 | (3,15,"GtkCenterBox",None,13,None,None,None,1,None,None), 60 | (3,16,"GtkButton","subscribe",15,None,None,None,None,None,None), 61 | (3,17,"AdwWindowTitle","channel",15,None,None,None,None,None,None), 62 | (3,18,"GtkCenterBox",None,1,None,None,None,1,None,None), 63 | (3,19,"GtkButton",None,18,None,"start",None,None,None,None), 64 | (3,20,"AdwButtonContent",None,19,None,None,None,None,None,None), 65 | (3,21,"GtkButton",None,18,None,"center",None,1,None,None), 66 | (3,22,"AdwButtonContent",None,21,None,None,None,None,None,None), 67 | (3,23,"GtkButton",None,18,None,"end",None,2,None,None), 68 | (3,24,"AdwButtonContent",None,23,None,None,None,None,None,None), 69 | (4,1,"GtkBox","DewThumbnail",None,None,None,None,None,None,None), 70 | (4,2,"GtkOverlay",None,1,None,None,None,None,None,None), 71 | (4,3,"GtkProgressBar","watched_progress",2,None,"overlay",None,1,None,None), 72 | (4,5,"GtkLabel","length",2,None,"overlay",None,1,"<attributes>\n <attribute name=\"foreground\" value=\"white\"/>\n <attribute name=\"background\" value= \"black\"/>\n <attribute name=\"background-alpha\" value=\"0x8000\"/>\n</attributes>",None), 73 | (4,6,"GtkPicture","thumbnail",2,None,None,None,None,None,None), 74 | (5,1,"GtkBox","DewVideoRow",None,None,None,None,None,None,None), 75 | (5,2,"DewThumbnail","thumbnail",1,None,None,None,None,None,None), 76 | (5,3,"GtkBox",None,1,None,None,None,1,None,None), 77 | (5,4,"GtkLabel","title",3,None,None,None,None,None,None), 78 | (5,5,"GtkLabel","channel",3,None,None,None,2,None,None), 79 | (5,7,"GtkBox",None,3,None,None,None,3,None,None), 80 | (5,8,"GtkLabel","views",7,None,None,None,None,None,None), 81 | (5,9,"GtkLabel","published",7,None,None,None,1,None,None), 82 | (5,10,"GtkLabel",None,3,None,None,None,1,None,None), 83 | (6,1,"GtkBox","DewVideoPage",None,None,None,None,None,None,None), 84 | (6,15,"AdwViewSwitcherBar","bottom_switcher",1,None,None,None,2,None,None), 85 | (6,28,"GtkScrolledWindow",None,1,None,None,None,1,None,None), 86 | (6,29,"GtkBox",None,28,None,None,None,None,None,None), 87 | (6,32,"DewThumbnail","vid_thumbnail",29,None,None,None,1,None,None), 88 | (6,33,"GtkLabel","title",29,None,None,None,2,None,None), 89 | (6,34,"GtkBox",None,29,None,None,None,3,None,None), 90 | (6,35,"GtkImage","author_thumb",34,None,None,None,None,None,None), 91 | (6,36,"GtkBox",None,34,None,None,None,1,None,None), 92 | (6,37,"GtkLabel","author_name",36,None,None,None,None,None,None), 93 | (6,38,"GtkLabel","sub_count",36,None,None,None,1,None,None), 94 | (6,39,"GtkBox",None,34,None,None,None,2,None,None), 95 | (6,40,"GtkLabel","views",39,None,None,None,None,None,None), 96 | (6,41,"GtkLabel","likes",39,None,None,None,1,None,None), 97 | (6,42,"GtkLabel","description",29,None,None,None,4,None,None), 98 | (6,43,"AdwHeaderBar",None,1,None,None,None,None,None,None), 99 | (6,44,"GtkButton","back_button",43,None,"start",None,None,None,None), 100 | (7,7,"AdwBin","DewYtItemList",None,None,None,None,None,None,None), 101 | (7,8,"GtkScrolledWindow",None,7,None,None,None,None,None,None), 102 | (7,9,"AdwClampScrollable",None,8,None,None,None,None,None,None), 103 | (7,10,"GtkListView",None,9,None,None,None,None,None,None), 104 | (7,11,"GtkSignalListItemFactory","vid_factory",10,None,None,None,None,None,None), 105 | (7,12,"GtkNoSelection",None,10,None,None,None,None,None,None), 106 | (7,13,"GListStore","list_store",12,None,None,None,None,None,None), 107 | (8,1,"GtkBox","DewSearchPage",None,None,None,None,None,None,None), 108 | (8,2,"GtkStack","search_stack",1,None,None,None,1,None,None), 109 | (8,3,"GtkStackPage",None,2,None,None,None,None,None,None), 110 | (8,4,"AdwStatusPage","not_found_page",3,None,None,None,None,None,None), 111 | (8,5,"GtkStackPage",None,2,None,None,None,1,None,None), 112 | (8,9,"AdwHeaderBar",None,1,None,None,None,None,None,None), 113 | (8,12,"GtkToggleButton","search_button",9,None,"end",None,1,None,None), 114 | (8,14,"GtkSearchEntry","search_entry",9,None,"title",None,2,None,None), 115 | (8,15,"GtkSearchBar","search_bar",9,None,None,None,3,None,None), 116 | (8,16,"DewYtItemList","results_page",5,None,None,None,None,None,None), 117 | (9,1,"GtkBox","DewChannelRow",None,None,None,None,None,None,None), 118 | (9,3,"GtkBox",None,1,None,None,None,1,None,None), 119 | (9,4,"GtkLabel","name",3,None,None,None,None,None,None), 120 | (9,5,"GtkLabel","subs",3,None,None,None,1,None,None), 121 | (9,7,"AdwAvatar","thumbnail",1,None,None,None,None,None,None), 122 | (10,1,"AdwBin","DewYtItemRow",None,None,None,None,None,None,None), 123 | (10,2,"GtkStack","stack",1,None,None,None,None,None,None), 124 | (10,3,"GtkStackPage","chan",2,None,None,None,None,None,None), 125 | (10,4,"GtkStackPage","vid",2,None,None,None,None,None,None), 126 | (10,5,"DewVideoRow","video_row",4,None,None,None,None,None,None), 127 | (10,6,"DewChannelRow","channel_row",3,None,None,None,None,None,None), 128 | (11,1,"GtkBox","DewChannelPage",None,None,None,None,None,None,None), 129 | (11,2,"AdwHeaderBar",None,1,None,None,None,None,None,None), 130 | (11,5,"DewYtItemList","vid_list",1,None,None,None,1,None,None), 131 | (11,6,"GtkToggleButton","search_button",2,None,"end",None,None,None,None), 132 | (11,7,"GtkButton","back_button",2,None,"start",None,1,None,None), 133 | (12,1,"GtkBox","DewSubscriptionsPage",None,None,None,None,-1,None,None), 134 | (12,3,"DewYtItemList","subs_list",1,None,None,None,3,None,None), 135 | (12,6,"AdwHeaderBar",None,1,None,None,None,None,None,None), 136 | (12,7,"GtkToggleButton","search_button",6,None,"end",None,None,None,None), 137 | (12,8,"GtkButton",None,6,None,None,None,-1,None,None) 138 | 139 | 140 | (1,1,"GtkWindow","default-height","294",None,None,None,None,None,None,None,None,None), 141 | (1,1,"GtkWindow","default-width","360",None,None,None,None,None,None,None,None,None), 142 | (1,1,"GtkWindow","title","DewDuct",None,None,None,None,None,None,None,None,None), 143 | (1,3,"AdwNavigationPage","can-pop","false",None,None,None,None,None,None,None,None,None), 144 | (1,3,"AdwNavigationPage","child",None,None,None,None,None,4,None,None,None,None), 145 | (1,3,"AdwNavigationPage","tag","main_view",None,None,None,None,None,None,None,None,None), 146 | (1,3,"AdwNavigationPage","title","DewDuct",None,None,None,None,None,None,None,None,None), 147 | (1,4,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 148 | (1,7,"AdwViewStackPage","child",None,None,None,None,None,8,None,None,None,None), 149 | (1,7,"AdwViewStackPage","icon-name","camera-video-symbolic",None,None,None,None,None,None,None,None,None), 150 | (1,7,"AdwViewStackPage","name","updates_page",None,None,None,None,None,None,None,None,None), 151 | (1,7,"AdwViewStackPage","title","Popular",None,None,None,None,None,None,None,None,None), 152 | (1,9,"AdwViewStackPage","child",None,None,None,None,None,10,None,None,None,None), 153 | (1,9,"AdwViewStackPage","icon-name","preferences-desktop-remote-desktop-symbolic",None,None,None,None,None,None,None,None,None), 154 | (1,9,"AdwViewStackPage","name","channel_page",None,None,None,None,None,None,None,None,None), 155 | (1,9,"AdwViewStackPage","title","Channel",None,None,None,None,None,None,None,None,None), 156 | (1,9,"AdwViewStackPage","visible","False",None,None,None,None,None,10,"GtkWidget","visible","bidirectional | default | sync-create"), 157 | (1,10,"GtkWidget","visible","False",None,None,None,None,None,None,None,None,None), 158 | (1,11,"AdwViewStackPage","child",None,None,None,None,None,12,None,None,None,None), 159 | (1,11,"AdwViewStackPage","icon-name","audio-headphones-symbolic",None,None,None,None,None,None,None,None,None), 160 | (1,11,"AdwViewStackPage","name","video_page",None,None,None,None,None,None,None,None,None), 161 | (1,11,"AdwViewStackPage","title","Player",None,None,None,None,None,None,None,None,None), 162 | (1,11,"AdwViewStackPage","visible","False",None,None,None,None,None,12,"GtkWidget","visible","bidirectional | sync-create"), 163 | (1,12,"GtkWidget","visible","False",None,None,None,None,None,None,None,None,None), 164 | (1,13,"AdwViewStackPage","child",None,None,None,None,None,14,None,None,None,None), 165 | (1,13,"AdwViewStackPage","icon-name","system-users-symbolic",None,None,None,None,None,None,None,None,None), 166 | (1,13,"AdwViewStackPage","name","subs",None,None,None,None,None,None,None,None,None), 167 | (1,13,"AdwViewStackPage","title","Subscriptions",None,None,None,None,None,None,None,None,None), 168 | (1,15,"AdwViewSwitcherBar","reveal","True",None,None,None,None,None,None,None,None,None), 169 | (1,15,"AdwViewSwitcherBar","stack","6",None,None,None,None,None,None,None,None,None), 170 | (1,16,"AdwNavigationPage","child",None,None,None,None,None,17,None,None,None,None), 171 | (1,16,"AdwNavigationPage","tag","search_page",None,None,None,None,None,None,None,None,None), 172 | (1,16,"AdwNavigationPage","title","Search",None,None,None,None,None,None,None,None,None), 173 | (2,1,"GtkOrientable","orientation","1",None,None,None,None,None,None,None,None,None), 174 | (2,1,"GtkWidget","width-request","280",None,None,None,None,None,None,None,None,None), 175 | (2,17,"GtkActionable","action-name","win.search_started",None,None,None,None,None,None,None,None,None), 176 | (2,17,"GtkButton","icon-name","edit-find-symbolic",None,None,None,None,None,None,None,None,None), 177 | (2,18,"GtkButton","icon-name","view-refresh-symbolic",None,None,None,None,None,None,None,None,None), 178 | (3,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 179 | (3,1,"GtkWidget","height-request","150",None,None,None,None,None,None,None,None,None), 180 | (3,1,"GtkWidget","width-request","300",None,None,None,None,None,None,None,None,None), 181 | (3,2,"GtkWidget","height-request","120",None,None,None,None,None,None,None,None,None), 182 | (3,12,"AdwAvatar","size","80",None,None,None,None,None,None,None,None,None), 183 | (3,12,"AdwAvatar","text","",None,None,None,None,None,17,"AdwWindowTitle","title","bidirectional | default | sync-create"), 184 | (3,12,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 185 | (3,12,"GtkWidget","margin-bottom","10",None,None,None,None,None,None,None,None,None), 186 | (3,12,"GtkWidget","margin-start","10",None,None,None,None,None,None,None,None,None), 187 | (3,12,"GtkWidget","valign","end",None,None,None,None,None,None,None,None,None), 188 | (3,13,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 189 | (3,14,"GtkPicture","content-fit","cover",None,None,None,None,None,None,None,None,None), 190 | (3,14,"GtkPicture","file","channel_banner_mobild.svg",None,None,None,None,None,None,None,None,None), 191 | (3,14,"GtkWidget","height-request","50",None,None,None,None,None,None,None,None,None), 192 | (3,14,"GtkWidget","valign","start",None,None,None,None,None,None,None,None,None), 193 | (3,15,"GtkCenterBox","center-widget",None,None,None,None,None,17,None,None,None,None), 194 | (3,15,"GtkCenterBox","end-widget",None,None,None,None,None,16,None,None,None,None), 195 | (3,15,"GtkWidget","valign","end",None,None,None,None,None,None,None,None,None), 196 | (3,16,"GtkButton","label","SUBSCRIBE",None,None,None,None,None,None,None,None,None), 197 | (3,16,"GtkWidget","margin-end","3",None,None,None,None,None,None,None,None,None), 198 | (3,17,"AdwWindowTitle","subtitle","10k subscribers",None,None,None,None,None,None,None,None,None), 199 | (3,17,"AdwWindowTitle","title","Cool channel #73",None,None,None,None,None,None,None,None,None), 200 | (3,18,"GtkWidget","margin-bottom","3",None,None,None,None,None,None,None,None,None), 201 | (3,18,"GtkWidget","margin-end","3",None,None,None,None,None,None,None,None,None), 202 | (3,18,"GtkWidget","margin-start","3",None,None,None,None,None,None,None,None,None), 203 | (3,18,"GtkWidget","margin-top","3",None,None,None,None,None,None,None,None,None), 204 | (3,19,"GtkButton","child",None,None,None,None,None,20,None,None,None,None), 205 | (3,19,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 206 | (3,19,"GtkWidget","margin-end","3",None,None,None,None,None,None,None,None,None), 207 | (3,19,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 208 | (3,20,"AdwButtonContent","icon-name","audio-headphones-symbolic",None,None,None,None,None,None,None,None,None), 209 | (3,20,"AdwButtonContent","label","Background",None,None,None,None,None,None,None,None,None), 210 | (3,21,"GtkButton","child",None,None,None,None,None,22,None,None,None,None), 211 | (3,21,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 212 | (3,21,"GtkWidget","margin-end","3",None,None,None,None,None,None,None,None,None), 213 | (3,21,"GtkWidget","margin-start","3",None,None,None,None,None,None,None,None,None), 214 | (3,21,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 215 | (3,22,"AdwButtonContent","icon-name","media-playback-start-symbolic",None,None,None,None,None,None,None,None,None), 216 | (3,22,"AdwButtonContent","label","Play All",None,None,None,None,None,None,None,None,None), 217 | (3,23,"GtkButton","child",None,None,None,None,None,24,None,None,None,None), 218 | (3,23,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 219 | (3,23,"GtkWidget","margin-start","3",None,None,None,None,None,None,None,None,None), 220 | (3,23,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 221 | (3,24,"AdwButtonContent","icon-name","send-to-symbolic",None,None,None,None,None,None,None,None,None), 222 | (3,24,"AdwButtonContent","label","Popup",None,None,None,None,None,None,None,None,None), 223 | (4,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 224 | (4,2,"GtkWidget","height-request","80",None,None,None,None,None,None,None,None,None), 225 | (4,3,"GtkWidget","valign","end",None,None,None,None,None,None,None,None,None), 226 | (4,5,"GtkLabel","label","0:00",None,None,None,None,None,None,None,None,None), 227 | (4,5,"GtkWidget","halign","end",None,None,None,None,None,None,None,None,None), 228 | (4,5,"GtkWidget","margin-bottom","7",None,None,None,None,None,None,None,None,None), 229 | (4,5,"GtkWidget","margin-end","7",None,None,None,None,None,None,None,None,None), 230 | (4,5,"GtkWidget","valign","end",None,None,None,None,None,None,None,None,None), 231 | (4,6,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 232 | (5,1,"GtkWidget","height-request","80",None,None,None,None,None,None,None,None,None), 233 | (5,1,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 234 | (5,3,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 235 | (5,3,"GtkWidget","margin-start","6",None,None,None,None,None,None,None,None,None), 236 | (5,4,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 237 | (5,4,"GtkLabel","label","the amazing video that will change your life!",None,None,None,None,None,None,None,None,None), 238 | (5,4,"GtkLabel","lines","2",None,None,None,None,None,None,None,None,None), 239 | (5,4,"GtkLabel","xalign","0.0",None,None,None,None,None,None,None,None,None), 240 | (5,4,"GtkLabel","yalign","0.0",None,None,None,None,None,None,None,None,None), 241 | (5,4,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 242 | (5,4,"GtkWidget","valign","start",None,None,None,None,None,None,None,None,None), 243 | (5,5,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 244 | (5,5,"GtkLabel","label","this subtitle should be the channel",None,None,None,None,None,None,None,None,None), 245 | (5,5,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 246 | (5,5,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 247 | (5,8,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 248 | (5,8,"GtkLabel","label","100k",None,None,None,None,None,None,None,None,None), 249 | (5,8,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 250 | (5,9,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 251 | (5,9,"GtkLabel","label","1 hour ago",None,None,None,None,None,None,None,None,None), 252 | (5,9,"GtkWidget","halign","end",None,None,None,None,None,None,None,None,None), 253 | (5,9,"GtkWidget","margin-start","10",None,None,None,None,None,None,None,None,None), 254 | (5,9,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 255 | (5,10,"GtkLabel","label"," ",None,None,None,None,None,None,None,None,None), 256 | (6,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 257 | (6,1,"GtkWidget","height-request","320",None,None,None,None,None,None,None,None,None), 258 | (6,1,"GtkWidget","width-request","280",None,None,None,None,None,None,None,None,None), 259 | (6,15,"AdwViewSwitcherBar","reveal","True",None,None,None,None,None,None,None,None,None), 260 | (6,28,"GtkScrolledWindow","child",None,None,None,None,None,29,None,None,None,None), 261 | (6,28,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 262 | (6,29,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 263 | (6,29,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 264 | (6,32,"GtkWidget","height-request","160",None,None,None,None,None,None,None,None,None), 265 | (6,32,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 266 | (6,32,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 267 | (6,33,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 268 | (6,33,"GtkLabel","label","Video Title!!!",None,None,None,None,None,None,None,None,None), 269 | (6,33,"GtkLabel","lines","1",None,None,None,None,None,None,None,None,None), 270 | (6,33,"GtkLabel","xalign","0.0",None,None,None,None,None,None,None,None,None), 271 | (6,33,"GtkWidget","margin-bottom","5",None,None,None,None,None,None,None,None,None), 272 | (6,33,"GtkWidget","margin-end","7",None,None,None,None,None,None,None,None,None), 273 | (6,33,"GtkWidget","margin-start","7",None,None,None,None,None,None,None,None,None), 274 | (6,33,"GtkWidget","margin-top","5",None,None,None,None,None,None,None,None,None), 275 | (6,35,"GtkImage","pixel-size","20",None,None,None,None,None,None,None,None,None), 276 | (6,36,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 277 | (6,36,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 278 | (6,37,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 279 | (6,37,"GtkLabel","label","cool channel",None,None,None,None,None,None,None,None,None), 280 | (6,37,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 281 | (6,38,"GtkLabel","ellipsize","end",None,None,None,None,None,None,None,None,None), 282 | (6,38,"GtkLabel","label","100k subscribers",None,None,None,None,None,None,None,None,None), 283 | (6,38,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 284 | (6,38,"GtkWidget","sensitive","False",None,None,None,None,None,None,None,None,None), 285 | (6,39,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 286 | (6,39,"GtkWidget","margin-end","7",None,None,None,None,None,None,None,None,None), 287 | (6,40,"GtkLabel","label","10k views",None,None,None,None,None,None,None,None,None), 288 | (6,40,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 289 | (6,41,"GtkLabel","label","1k likes",None,None,None,None,None,None,None,None,None), 290 | (6,41,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 291 | (6,42,"GtkLabel","label","This video is amazing!",None,None,None,None,None,None,None,None,None), 292 | (6,42,"GtkLabel","selectable","True",None,None,None,None,None,None,None,None,None), 293 | (6,42,"GtkLabel","wrap","True",None,None,None,None,None,None,None,None,None), 294 | (6,42,"GtkLabel","wrap-mode","word-char",None,None,None,None,None,None,None,None,None), 295 | (6,42,"GtkLabel","xalign","0.0",None,None,None,None,None,None,None,None,None), 296 | (6,42,"GtkLabel","yalign","0.0",None,None,None,None,None,None,None,None,None), 297 | (6,42,"GtkWidget","can-focus","False",None,None,None,None,None,None,None,None,None), 298 | (6,42,"GtkWidget","focus-on-click","False",None,None,None,None,None,None,None,None,None), 299 | (6,42,"GtkWidget","margin-end","10",None,None,None,None,None,None,None,None,None), 300 | (6,42,"GtkWidget","margin-start","10",None,None,None,None,None,None,None,None,None), 301 | (6,42,"GtkWidget","margin-top","5",None,None,None,None,None,None,None,None,None), 302 | (6,42,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 303 | (6,43,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 304 | (6,44,"GtkActionable","action-name","win.back",None,None,None,None,None,None,None,None,None), 305 | (6,44,"GtkButton","icon-name","go-previous-symbolic",None,None,None,None,None,None,None,None,None), 306 | (7,7,"AdwBin","child",None,None,None,None,None,8,None,None,None,None), 307 | (7,8,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 308 | (7,10,"GtkListView","factory",None,None,None,None,None,11,None,None,None,None), 309 | (7,10,"GtkListView","model",None,None,None,None,None,12,None,None,None,None), 310 | (7,10,"GtkListView","single-click-activate","True",None,None,None,None,None,None,None,None,None), 311 | (7,12,"GtkNoSelection","model",None,None,None,None,None,13,None,None,None,None), 312 | (7,13,"GListStore","item-type","DewYtItem",None,None,None,None,None,None,None,None,None), 313 | (8,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 314 | (8,3,"GtkStackPage","child",None,None,None,None,None,4,None,None,None,None), 315 | (8,3,"GtkStackPage","name","not_found_page",None,None,None,None,None,None,None,None,None), 316 | (8,4,"AdwStatusPage","description","Try a different search.",None,None,None,None,None,None,None,None,None), 317 | (8,4,"AdwStatusPage","icon-name","edit-find-symbolic",None,None,None,None,None,None,None,None,None), 318 | (8,4,"AdwStatusPage","title","No Results Found",None,None,None,None,None,None,None,None,None), 319 | (8,4,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 320 | (8,4,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 321 | (8,5,"GtkStackPage","child",None,None,None,None,None,16,None,None,None,None), 322 | (8,5,"GtkStackPage","name","results_page",None,None,None,None,None,None,None,None,None), 323 | (8,12,"GtkActionable","action-name","win.search_started",None,None,None,None,None,None,None,None,None), 324 | (8,12,"GtkButton","icon-name","edit-find-symbolic",None,None,None,None,None,None,None,None,None), 325 | (8,14,"GtkWidget","hexpand","True",None,None,None,None,None,None,None,None,None), 326 | (9,1,"GtkBox","spacing","6",None,None,None,None,None,None,None,None,None), 327 | (9,1,"GtkWidget","height-request","80",None,None,None,None,None,None,None,None,None), 328 | (9,1,"GtkWidget","margin-bottom","3",None,None,None,None,None,None,None,None,None), 329 | (9,1,"GtkWidget","margin-end","6",None,None,None,None,None,None,None,None,None), 330 | (9,1,"GtkWidget","margin-start","6",None,None,None,None,None,None,None,None,None), 331 | (9,1,"GtkWidget","margin-top","3",None,None,None,None,None,None,None,None,None), 332 | (9,3,"GtkBox","spacing","10",None,None,None,None,None,None,None,None,None), 333 | (9,3,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 334 | (9,4,"GtkLabel","label","cool channel",None,None,None,None,None,None,None,None,None), 335 | (9,4,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 336 | (9,4,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 337 | (9,5,"GtkLabel","label","100k subcsriptions",None,None,None,None,None,None,None,None,None), 338 | (9,5,"GtkWidget","halign","start",None,None,None,None,None,None,None,None,None), 339 | (9,5,"GtkWidget","vexpand","True",None,None,None,None,None,None,None,None,None), 340 | (9,7,"AdwAvatar","size","80",None,None,None,None,None,None,None,None,None), 341 | (9,7,"AdwAvatar","text","",None,None,None,None,None,4,"GtkLabel","label","bidirectional | sync-create"), 342 | (10,1,"AdwBin","child",None,None,None,None,None,2,None,None,None,None), 343 | (10,2,"GtkStack","transition-duration","0",None,None,None,None,None,None,None,None,None), 344 | (10,3,"GtkStackPage","child",None,None,None,None,None,6,None,None,None,None), 345 | (10,3,"GtkStackPage","name","channel_row",None,None,None,None,None,None,None,None,None), 346 | (10,4,"GtkStackPage","child",None,None,None,None,None,5,None,None,None,None), 347 | (10,4,"GtkStackPage","name","video_row",None,None,None,None,None,None,None,None,None), 348 | (11,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 349 | (11,6,"GtkActionable","action-name","win.search_started",None,None,None,None,None,None,None,None,None), 350 | (11,6,"GtkButton","icon-name","edit-find-symbolic",None,None,None,None,None,None,None,None,None), 351 | (11,7,"GtkActionable","action-name","win.back",None,None,None,None,None,None,None,None,None), 352 | (11,7,"GtkButton","icon-name","go-previous-symbolic",None,None,None,None,None,None,None,None,None), 353 | (12,1,"GtkOrientable","orientation","vertical",None,None,None,None,None,None,None,None,None), 354 | (12,7,"GtkActionable","action-name","win.search_started",None,None,None,None,None,None,None,None,None), 355 | (12,7,"GtkButton","icon-name","edit-find-symbolic",None,None,None,None,None,None,None,None,None), 356 | (12,8,"GtkButton","icon-name","list-add-symbolic",None,None,None,None,None,None,None,None,None), 357 | (12,8,"GtkWidget","tooltip-text","Import subscriptions",None,None,None,None,None,None,None,None,None) 358 | 359 | 360 | (4,2,5,"GtkOverlayLayoutChild","measure","True",None,None,None,None) 361 | 362 | 363 | (8,2,18,"GtkButton","clicked","update_vids",None,None,1,None,None), 364 | (9,8,14,"GtkSearchEntry","search-started","search_started",None,None,1,None,None), 365 | (10,8,14,"GtkSearchEntry","search-changed","search_changed",None,None,1,None,None), 366 | (11,8,14,"GtkSearchEntry","stop-search","stop_search",None,None,1,None,None), 367 | (12,8,14,"GtkSearchEntry","activate","search_activate",None,None,1,None,None), 368 | (17,7,11,"GtkSignalListItemFactory","setup","setup_row",None,None,1,None,None), 369 | (18,7,11,"GtkSignalListItemFactory","bind","bind_row",None,None,1,None,None), 370 | (19,7,10,"GtkListView","activate","activate",None,None,1,None,None), 371 | (20,3,16,"GtkButton","clicked","subscribe_clicked",None,None,1,None,None), 372 | (24,3,19,"GtkButton","clicked","background_clicked",None,None,1,None,None), 373 | (25,3,21,"GtkButton","clicked","play_all_clicked",None,None,1,None,None), 374 | (26,3,23,"GtkButton","clicked","poppup_clicked",None,None,1,None,None), 375 | (27,12,8,"GtkButton","clicked","import_newpipe_subs",None,None,1,None,None) 376 | 377 | 378 | (5,5,"GtkWidget",2,2,None,1,None,None,None,None), 379 | (6,33,"GtkWidget",1,1,None,None,None,None,None,None), 380 | (6,33,"GtkWidget",2,2,None,1,None,None,None,None), 381 | (6,42,"GtkWidget",1,1,None,None,None,None,None,None), 382 | (6,42,"GtkWidget",2,2,None,1,None,None,None,None), 383 | (6,42,"GtkWidget",2,3,None,1,None,None,None,None), 384 | (4,3,"GtkWidget",1,1,None,None,None,None,None,None), 385 | (4,3,"GtkWidget",2,2,None,1,None,None,None,None) 386 | 387 | 388 | (6,33,"GtkWidget",2,2,"name","header"), 389 | (6,42,"GtkWidget",2,2,"name","body"), 390 | (6,42,"GtkWidget",2,3,"name","link"), 391 | (4,3,"GtkWidget",2,2,"name","osd") 392 | 393 | 394 | -------------------------------------------------------------------------------- /resources/budy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 10 | 29 | 31 | 32 | 34 | image/svg+xml 35 | 37 | 38 | 39 | 40 | 41 | 46 | 53 | 60 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /resources/channel_banner_mobild.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 12 | 31 | 33 | 34 | 36 | image/svg+xml 37 | 39 | 40 | 41 | 42 | 43 | 48 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /resources/channel_header.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 108 | 109 | -------------------------------------------------------------------------------- /resources/channel_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 28 | 29 | -------------------------------------------------------------------------------- /resources/channel_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 40 | 41 | -------------------------------------------------------------------------------- /resources/dummi_thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaKnig/DewDuct/013ba607ae73ee6c21b0a36f5402b3387b359df6/resources/dummi_thumbnail.jpg -------------------------------------------------------------------------------- /resources/dummi_thumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 10 | 29 | 31 | 32 | 34 | image/svg+xml 35 | 37 | 38 | 39 | 40 | 41 | 46 | 53 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /resources/popular_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 29 | 30 | -------------------------------------------------------------------------------- /resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | window.ui 5 | popular_page.ui 6 | video_page.ui 7 | search_page.ui 8 | video_row.ui 9 | channel_row.ui 10 | channel_page.ui 11 | thumbnail.ui 12 | yt_item_list.ui 13 | yt_item_row.ui 14 | channel_header.ui 15 | subscriptions_page.ui 16 | dummi_thumbnail.svg 17 | ../data/null.daknig.dewduct.metainfo.xml 18 | 19 | 20 | -------------------------------------------------------------------------------- /resources/search_header.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 150 8 | vertical 9 | 300 10 | 11 | 12 | 13 | 14 | vertical 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 120 29 | 30 | 31 | start 32 | 10 33 | 10 34 | 80 35 | end 36 | 37 | 38 | 39 | 40 | 41 | 42 | True 43 | 44 | 45 | 46 | 47 | audio-headphones-symbolic 48 | Background 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | media-playback-start-symbolic 58 | Play All 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | send-to-symbolic 68 | Popup 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /resources/search_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 57 | 58 | -------------------------------------------------------------------------------- /resources/subscriptions_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 29 | 30 | -------------------------------------------------------------------------------- /resources/thumbnail.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 44 | 45 | -------------------------------------------------------------------------------- /resources/video_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 128 | 129 | -------------------------------------------------------------------------------- /resources/video_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 62 | 63 | -------------------------------------------------------------------------------- /resources/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 95 | 96 | -------------------------------------------------------------------------------- /resources/yt_item_list.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 39 | 40 | -------------------------------------------------------------------------------- /resources/yt_item_row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 29 | 30 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 76 2 | -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | /* application.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use adw::subclass::prelude::*; 22 | use glib::g_warning; 23 | use gtk::prelude::*; 24 | use gtk::{gio, glib}; 25 | 26 | use crate::DewDuctWindow; 27 | 28 | mod imp { 29 | use super::*; 30 | 31 | #[derive(Debug, Default)] 32 | pub struct DewDuctApplication {} 33 | 34 | #[glib::object_subclass] 35 | impl ObjectSubclass for DewDuctApplication { 36 | const NAME: &'static str = "DewDuctApplication"; 37 | type Type = super::DewDuctApplication; 38 | type ParentType = adw::Application; 39 | } 40 | 41 | impl ObjectImpl for DewDuctApplication { 42 | fn constructed(&self) { 43 | self.parent_constructed(); 44 | let obj = self.obj(); 45 | obj.setup_gactions(); 46 | obj.set_accels_for_action("app.quit", &["q"]); 47 | } 48 | } 49 | 50 | impl ApplicationImpl for DewDuctApplication { 51 | // We connect to the activate callback to create a window when the 52 | // application has been launched. Additionally, this callback 53 | // notifies us when the user tries to launch a "second instance" of 54 | // the application. When they try to do that, we'll just present any 55 | // existing window. 56 | fn activate(&self) { 57 | let application = self.obj(); 58 | // Get the current window or create one if necessary 59 | let window = application.active_window().unwrap_or_else(|| { 60 | let tokio_rt = tokio::runtime::Builder::new_multi_thread() 61 | .enable_all() 62 | .thread_name("dewduct_worker") 63 | .build(); 64 | let tokio_rt = match tokio_rt { 65 | Err(err) => { 66 | g_warning!( 67 | "DewDuctApplication", 68 | "unable to initialize {err:#?}" 69 | ); 70 | None 71 | } 72 | Ok(rt) => Some(rt), 73 | }; 74 | let window = DewDuctWindow::new(&*application, tokio_rt); 75 | window.upcast() 76 | }); 77 | 78 | g_warning!( 79 | "DewApplication", 80 | "instance used: {}", 81 | window 82 | .downcast_ref::() 83 | .unwrap() 84 | .invidious_client() 85 | .instance 86 | ); 87 | 88 | // Ask the window manager/compositor to present the window 89 | window.present(); 90 | } 91 | } 92 | 93 | impl GtkApplicationImpl for DewDuctApplication {} 94 | impl AdwApplicationImpl for DewDuctApplication {} 95 | } 96 | 97 | glib::wrapper! { 98 | pub struct DewDuctApplication(ObjectSubclass) 99 | @extends gio::Application, gtk::Application, adw::Application, 100 | @implements gio::ActionGroup, gio::ActionMap; 101 | } 102 | 103 | impl DewDuctApplication { 104 | pub fn new( 105 | application_id: &str, 106 | flags: &gio::ApplicationFlags, 107 | ) -> Self { 108 | glib::Object::builder() 109 | .property("application-id", application_id) 110 | .property("flags", flags) 111 | .build() 112 | } 113 | 114 | fn setup_gactions(&self) { 115 | let quit_action = gio::ActionEntry::builder("quit") 116 | .activate(move |app: &Self, _, _| app.quit()) 117 | .build(); 118 | let about_action = gio::ActionEntry::builder("about") 119 | .activate(move |app: &Self, _, _| app.show_about()) 120 | .build(); 121 | self.add_action_entries([quit_action, about_action]); 122 | self.set_accels_for_action("win.back", &["Escape"]); 123 | self.set_accels_for_action("win.search_started", &["f"]); 124 | } 125 | 126 | fn show_about(&self) { 127 | let about = adw::AboutWindow::from_appdata( 128 | "/null/daknig/DewDuct/null.daknig.dewduct.metainfo.xml", 129 | None, 130 | ); 131 | about.set_copyright("© 2023-2024 DaKnig"); 132 | about.set_developers(&["DaKnig"]); 133 | 134 | about.present(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, metadata}; 2 | use std::future::Future; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use glib::{g_debug, g_warning}; 6 | use gtk::glib; 7 | 8 | use anyhow::Context; 9 | 10 | use crate::config; 11 | 12 | #[derive(Clone)] 13 | pub struct DewCache { 14 | /// Root location of the cache files. 15 | dir: PathBuf, 16 | } 17 | 18 | impl DewCache { 19 | pub(crate) fn dir(&self) -> &PathBuf { 20 | &self.dir 21 | } 22 | pub(crate) async fn fetch_remote( 23 | cache: &Self, 24 | fname: PathBuf, 25 | url: &str, 26 | ) -> anyhow::Result> { 27 | g_warning!("DewCache", "trying to fetch url `{url}`"); 28 | 29 | DewCache::fetch_file(cache, fname, move |fname| { 30 | Self::fetcher(fname, url) 31 | }) 32 | .await 33 | .with_context(|| format!("failed to fetch url `{url}`")) 34 | } 35 | fn fetcher( 36 | fname: &Path, 37 | url: &str, 38 | ) -> impl std::future::Future>> { 39 | use anyhow::Context; 40 | use isahc::AsyncReadResponseExt; 41 | 42 | let fname = fname.to_owned(); 43 | let url = url.to_owned(); 44 | async move { 45 | let target = url; 46 | let mut response = isahc::get_async(target).await?; 47 | 48 | let contents = response.bytes().await?; 49 | if contents.is_empty() { 50 | Err(Err::NoThumbnails { 51 | id: fname 52 | .file_name() 53 | .unwrap() 54 | .to_owned() 55 | .into_string() 56 | .unwrap(), 57 | })?; 58 | } 59 | g_warning!( 60 | "DewThumbnail", 61 | "writing {} bytes to {}", 62 | contents.len(), 63 | fname.display() 64 | ); 65 | 66 | // if possible, write to cache 67 | if let Some(parent) = fname.parent() { 68 | // try your best, if can't, then no worries 69 | let _ = fs::create_dir_all(parent); 70 | } 71 | 72 | fs::write(&fname, &contents) 73 | .with_context(|| { 74 | format!("error writing to {}", fname.display()) 75 | }) 76 | .unwrap_or_else(|e| { 77 | g_warning!("DewThumbnail", "{}", e); 78 | }); 79 | // now it is time to load that jpg into the thumbnail 80 | anyhow::Ok(contents) 81 | } 82 | } 83 | /// cache: the cache with the directory where the info should be stored. 84 | /// fname: file we are looking for, relative to the cache. 85 | /// fetcher: function for fetching said file, if it is not in cache. 86 | pub(crate) async fn fetch_file( 87 | cache: &Self, 88 | fname: PathBuf, 89 | fetcher: Fetcher, 90 | ) -> Result, Err> 91 | where 92 | Fetcher: Fn(&Path) -> Fut, 93 | Fut: Future, Err>>, 94 | { 95 | let path = cache.dir().join(&fname); 96 | if metadata(&path).is_ok_and(|m| m.len() != 0) { 97 | g_debug!( 98 | "DewCache", 99 | "opening cached file at {}", 100 | &path.display() 101 | ); 102 | if let Ok(contents) = fs::read(&path) { 103 | return Ok(contents); 104 | } 105 | g_debug!( 106 | "DewCache", 107 | "unable to read cached file {}", 108 | &path.display() 109 | ); 110 | } 111 | 112 | g_warning!("DewCache", "fetching item to {}", &path.display()); 113 | 114 | let mut ret = fetcher(&fname).await; 115 | for i in 0..3 { 116 | if ret.is_ok() { 117 | break; 118 | } 119 | g_warning!( 120 | "DewCache", 121 | "retrying {} now {i} times...", 122 | fname.display() 123 | ); 124 | ret = fetcher(&fname).await; 125 | } 126 | ret 127 | } 128 | } 129 | 130 | impl Default for DewCache { 131 | fn default() -> Self { 132 | let mut dir = glib::tmp_dir(); 133 | dir.push(config::PKGNAME); 134 | 135 | DewCache { dir } 136 | } 137 | } 138 | 139 | use thiserror::Error; 140 | #[derive(Error, Debug)] 141 | pub enum Err { 142 | #[error("no thumbnails found for vid ID {id} video")] 143 | NoThumbnails { id: String }, 144 | } 145 | -------------------------------------------------------------------------------- /src/channel_header.rs: -------------------------------------------------------------------------------- 1 | /* channel_header.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::{cell::RefCell, path::Path}; 22 | 23 | #[allow(unused_imports)] 24 | use adw::{prelude::*, subclass::prelude::*}; 25 | use glib::g_warning; 26 | use gtk::{gdk, gio, glib}; 27 | #[allow(unused_imports)] 28 | use gtk::{prelude::*, subclass::prelude::*}; 29 | 30 | use anyhow::Context; 31 | 32 | use crate::cache::DewCache; 33 | use crate::util::{cache, cache_dir}; 34 | use crate::yt_item_list::DewYtItem; 35 | 36 | mod imp { 37 | use super::*; 38 | 39 | #[derive(Debug, Default, gtk::CompositeTemplate)] 40 | #[template(resource = "/null/daknig/DewDuct/channel_header.ui")] 41 | pub struct DewChannelHeader { 42 | // Template widgets 43 | #[template_child] 44 | pub(super) channel: TemplateChild, 45 | #[template_child] 46 | pub(super) thumbnail: TemplateChild, 47 | #[template_child] 48 | pub(super) subscribe: TemplateChild, 49 | 50 | pub(super) id: RefCell, 51 | pub(super) is_subscribed: RefCell, 52 | subscribed_handle: RefCell>, 53 | } 54 | 55 | #[glib::object_subclass] 56 | impl ObjectSubclass for DewChannelHeader { 57 | const NAME: &'static str = "DewChannelHeader"; 58 | type Type = super::DewChannelHeader; 59 | type ParentType = gtk::Box; 60 | 61 | fn class_init(klass: &mut Self::Class) { 62 | klass.bind_template(); 63 | klass.bind_template_callbacks(); 64 | } 65 | 66 | fn instance_init(obj: &glib::subclass::InitializingObject) { 67 | obj.init_template(); 68 | } 69 | } 70 | 71 | impl ObjectImpl for DewChannelHeader {} 72 | impl WidgetImpl for DewChannelHeader {} 73 | impl BoxImpl for DewChannelHeader {} 74 | 75 | #[gtk::template_callbacks] 76 | impl DewChannelHeader { 77 | #[template_callback] 78 | async fn subscribe_clicked(&self, button: >k::Button) { 79 | let win = self.win(); 80 | let id = self.id.borrow().clone(); 81 | let res: Result<(), _> = if !*self.is_subscribed.borrow() { 82 | win.subscribe(id).await 83 | } else { 84 | win.unsubscribe(id); 85 | Ok(()) 86 | }; 87 | if res.is_err() { 88 | button.add_css_class("error"); 89 | } else { 90 | button.remove_css_class("error"); 91 | } 92 | } 93 | #[template_callback] 94 | fn background_clicked(&self) { 95 | g_warning!( 96 | "DewChannelHeader", 97 | "background {} clicked!", 98 | self.id.borrow() 99 | ); 100 | } 101 | #[template_callback] 102 | fn play_all_clicked(&self) { 103 | g_warning!( 104 | "DewChannelHeader", 105 | "play_all {} clicked!", 106 | self.id.borrow() 107 | ); 108 | } 109 | #[template_callback] 110 | fn poppup_clicked(&self) { 111 | g_warning!( 112 | "DewChannelHeader", 113 | "poppup {} clicked!", 114 | self.id.borrow() 115 | ); 116 | } 117 | } 118 | 119 | impl DewChannelHeader { 120 | fn win(&self) -> crate::window::DewDuctWindow { 121 | self.obj().root().and_downcast().unwrap() 122 | } 123 | fn is_subbed(&self, list_store: &gio::ListStore) -> bool { 124 | list_store.into_iter().flatten().any(|item| { 125 | item.downcast_ref::().is_some_and(|item| { 126 | item.id() == self.id.borrow().as_str() 127 | }) 128 | }) 129 | } 130 | fn set_is_subscribed(&self, is_subscribed: bool) { 131 | self.is_subscribed.replace(is_subscribed); 132 | self.subscribe.get().set_label(if is_subscribed { 133 | "SUBSCRIBED" 134 | } else { 135 | "SUBSCRIBE" 136 | }); 137 | } 138 | fn set_id(&self, new: String) { 139 | self.id.replace(new); 140 | // if not yet connected to update with the list of subscriptions, 141 | if self.subscribed_handle.borrow().is_none() { 142 | let header = self.downgrade(); 143 | let check_is_subbed = move |list_store: &gio::ListStore| { 144 | let Some(header) = header.upgrade() else { 145 | return; 146 | }; 147 | let is_subbed = header.is_subbed(list_store); 148 | header.set_is_subscribed(is_subbed); 149 | }; 150 | 151 | let handle = 152 | self.win().connect_subs_changed(check_is_subbed); 153 | self.subscribed_handle.replace(Some(handle)); 154 | } 155 | } 156 | pub async fn set_from_yt_item( 157 | &self, 158 | item: &DewYtItem, 159 | ) -> anyhow::Result<()> { 160 | self.channel.set_title(&item.title()); 161 | self.channel.set_subtitle(&format!( 162 | "{} subscribers", 163 | crate::format_semi_engineering(item.subscribers()) 164 | )); 165 | 166 | let thumbnails = item.thumbnails(); 167 | 168 | if thumbnails.is_empty() { 169 | g_warning!( 170 | "DewChannelHeader", 171 | "No thumbnails for channel header of {}!", 172 | item.id() 173 | ); 174 | Err(Err::NoThumbnails { id: item.id() })?; 175 | } 176 | 177 | let thumb = thumbnails 178 | .iter() 179 | .filter(|thumb| thumb.width >= 160) 180 | .min_by_key(|thumb| thumb.width) 181 | .or(thumbnails.iter().max_by_key(|thumb| thumb.width)) 182 | .with_context(|| { 183 | format!( 184 | "error fetching channel {} thumbnail", 185 | item.id() 186 | ) 187 | })?; 188 | self.set_id(item.id()); 189 | 190 | // thumbnail_fname.push(); 191 | let mut thumbnail_fname = cache_dir(Path::new(&item.id())); 192 | thumbnail_fname.push(&thumb.height.to_string()); 193 | thumbnail_fname.set_extension("jpg"); 194 | 195 | let status = DewCache::fetch_remote( 196 | cache(), 197 | thumbnail_fname.clone(), 198 | &thumb.url, 199 | ) 200 | .await; 201 | 202 | let paintable: gdk::Texture = match status { 203 | Ok(data) => { 204 | let content_bytes = glib::Bytes::from_owned(data); 205 | gdk::Texture::from_bytes(&content_bytes)? 206 | } 207 | Err(err) => { 208 | g_warning!( 209 | "DewChannelHeader", 210 | "could not fetch file {}: {err}", 211 | thumbnail_fname.clone().display() 212 | ); 213 | gdk::Texture::from_resource( 214 | "/null/daknig/DewDuct/dummi_thumbnail", 215 | ) 216 | } 217 | }; 218 | self.thumbnail.set_custom_image(Some(&paintable)); 219 | 220 | Ok(()) 221 | } 222 | } 223 | } 224 | 225 | glib::wrapper! { 226 | pub struct DewChannelHeader(ObjectSubclass) 227 | @extends gtk::Widget, gtk::Box, 228 | @implements gio::ActionGroup, gio::ActionMap; 229 | } 230 | 231 | impl DewChannelHeader { 232 | pub fn new() -> Self { 233 | glib::Object::builder().build() 234 | } 235 | 236 | pub async fn set_from_yt_item( 237 | &self, 238 | item: &DewYtItem, 239 | ) -> anyhow::Result<()> { 240 | self.imp().set_from_yt_item(item).await 241 | } 242 | } 243 | 244 | impl Default for DewChannelHeader { 245 | fn default() -> Self { 246 | Self::new() 247 | } 248 | } 249 | 250 | use thiserror::Error; 251 | #[derive(Error, Debug)] 252 | pub enum Err { 253 | #[error("no thumbnails found for vid ID {id} video")] 254 | NoThumbnails { id: String }, 255 | } 256 | -------------------------------------------------------------------------------- /src/channel_page.rs: -------------------------------------------------------------------------------- 1 | /* channel_page.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::cell::RefCell; 22 | 23 | #[allow(unused_imports)] 24 | use adw::{prelude::*, subclass::prelude::*}; 25 | use glib::g_warning; 26 | use gtk::{gio, glib}; 27 | #[allow(unused_imports)] 28 | use gtk::{prelude::*, subclass::prelude::*}; 29 | 30 | use invidious::{channel::Channel, ClientAsyncTrait}; 31 | 32 | use crate::{ 33 | window::DewDuctWindow, 34 | yt_item_list::{DewYtItem, DewYtItemList}, 35 | }; 36 | 37 | mod imp { 38 | use super::*; 39 | 40 | #[derive(Debug, Default, gtk::CompositeTemplate)] 41 | #[template(resource = "/null/daknig/DewDuct/channel_page.ui")] 42 | pub struct DewChannelPage { 43 | // Template widgets 44 | #[template_child] 45 | pub(super) vid_list: TemplateChild, 46 | 47 | pub(super) channel: RefCell>, 48 | } 49 | 50 | #[glib::object_subclass] 51 | impl ObjectSubclass for DewChannelPage { 52 | const NAME: &'static str = "DewChannelPage"; 53 | type Type = super::DewChannelPage; 54 | type ParentType = gtk::Box; 55 | 56 | fn class_init(klass: &mut Self::Class) { 57 | klass.bind_template(); 58 | } 59 | 60 | fn instance_init(obj: &glib::subclass::InitializingObject) { 61 | obj.init_template(); 62 | } 63 | } 64 | 65 | impl ObjectImpl for DewChannelPage {} 66 | impl WidgetImpl for DewChannelPage {} 67 | impl BoxImpl for DewChannelPage {} 68 | 69 | // #[gtk::template_callbacks] 70 | impl DewChannelPage { 71 | pub fn set_channel(&self, channel: Channel) { 72 | let header = DewYtItem::header(&channel); 73 | 74 | self.vid_list.set_from_vec( 75 | Some(header) 76 | .into_iter() 77 | .chain( 78 | channel 79 | .lastest_videos 80 | .iter() 81 | .map(|x: &invidious::CommonVideo| x.into()), 82 | ) 83 | .collect::>(), 84 | ); 85 | g_warning!("DewChannelPage", "changed to id {}", &channel.id); 86 | self.channel.replace(Some(channel)); 87 | } 88 | } 89 | } 90 | 91 | glib::wrapper! { 92 | pub struct DewChannelPage(ObjectSubclass) 93 | @extends gtk::Widget, gtk::Box, 94 | @implements gio::ActionGroup, gio::ActionMap; 95 | } 96 | 97 | impl DewChannelPage { 98 | pub fn new() -> Self { 99 | glib::Object::builder().build() 100 | } 101 | 102 | pub fn set_channel(&self, channel: Channel) { 103 | self.imp().set_channel(channel) 104 | } 105 | 106 | fn window(&self) -> DewDuctWindow { 107 | self.root().and_downcast().unwrap() 108 | } 109 | 110 | pub async fn set_channel_id(&self, id: &str) { 111 | let id = id.to_owned(); 112 | let invidious = self.async_invidious_client(); 113 | let Ok(channel) = self 114 | .window() 115 | .spawn(async move { 116 | invidious.channel(&id, None).await.map_err(|err| { 117 | g_warning!( 118 | "DewChannelPage", 119 | "cant load {id}: {err:#?}" 120 | ); 121 | g_warning!( 122 | "DewChannelPage", 123 | "the instance used was {}", 124 | invidious.instance 125 | ); 126 | }) 127 | }) 128 | .await 129 | .unwrap() 130 | else { 131 | // if we get fetch error 132 | return; 133 | }; 134 | 135 | self.set_channel(channel); 136 | } 137 | 138 | pub fn async_invidious_client(&self) -> invidious::ClientAsync { 139 | let window: crate::window::DewDuctWindow = 140 | self.root().and_downcast().unwrap(); 141 | window.async_invidious_client() 142 | } 143 | } 144 | 145 | impl Default for DewChannelPage { 146 | fn default() -> Self { 147 | Self::new() 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/channel_row.rs: -------------------------------------------------------------------------------- 1 | /* channel_row.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::ops::Deref; 22 | use std::{cell::RefCell, path::Path}; 23 | 24 | #[allow(unused_imports)] 25 | use adw::{prelude::*, subclass::prelude::*}; 26 | use glib::{g_warning, Properties}; 27 | use gtk::{gdk, glib}; 28 | #[allow(unused_imports)] 29 | use gtk::{prelude::*, subclass::prelude::*}; 30 | 31 | use anyhow::Context; 32 | 33 | use crate::cache::DewCache; 34 | use crate::util; 35 | use crate::yt_item_list::Thumbnail; 36 | use crate::{cache, cache_dir}; 37 | 38 | mod imp { 39 | use super::*; 40 | 41 | #[derive(Debug, Default, gtk::CompositeTemplate, Properties)] 42 | #[template(resource = "/null/daknig/DewDuct/channel_row.ui")] 43 | #[properties(wrapper_type=super::DewChannelRow)] 44 | pub struct DewChannelRow { 45 | #[template_child] 46 | pub(super) thumbnail: TemplateChild, 47 | #[template_child] 48 | pub(super) name: TemplateChild, 49 | #[template_child] 50 | pub(super) subs: TemplateChild, 51 | 52 | #[property(get, set)] 53 | pub(super) id: RefCell, 54 | } 55 | 56 | #[glib::object_subclass] 57 | impl ObjectSubclass for DewChannelRow { 58 | const NAME: &'static str = "DewChannelRow"; 59 | type Type = super::DewChannelRow; 60 | type ParentType = gtk::Box; 61 | 62 | fn class_init(klass: &mut Self::Class) { 63 | klass.bind_template(); 64 | } 65 | 66 | fn instance_init(obj: &glib::subclass::InitializingObject) { 67 | obj.init_template(); 68 | } 69 | } 70 | 71 | impl ObjectImpl for DewChannelRow {} 72 | impl WidgetImpl for DewChannelRow {} 73 | impl BoxImpl for DewChannelRow {} 74 | } 75 | 76 | glib::wrapper! { 77 | pub struct DewChannelRow(ObjectSubclass) 78 | @extends gtk::Widget, gtk::Box, 79 | @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; 80 | } 81 | 82 | impl DewChannelRow { 83 | pub async fn set_from_params( 84 | &self, 85 | name: String, 86 | subs: f32, 87 | thumbnails: impl Deref>, 88 | id: String, 89 | ) -> anyhow::Result<()> { 90 | self.imp().name.set_text(&name); 91 | self.set_subs(subs); 92 | 93 | if thumbnails.is_empty() { 94 | g_warning!( 95 | "DewChannelRow", 96 | "No thumbnails for channel row of {}!", 97 | id 98 | ); 99 | Err(Err::NoThumbnails { id: id.clone() })?; 100 | } 101 | let thumb = thumbnails 102 | .iter() 103 | .filter(|thumb| thumb.width >= 160) 104 | .min_by_key(|thumb| thumb.width) 105 | .or(thumbnails.iter().max_by_key(|thumb| thumb.width)) 106 | .with_context(|| { 107 | format!("error fetching channel {} thumbnail", &name) 108 | })? 109 | .clone(); 110 | 111 | drop(thumbnails); 112 | 113 | let mut thumbnail_fname = cache_dir(Path::new(&id)); 114 | thumbnail_fname.push(&thumb.height.to_string()); 115 | thumbnail_fname.set_extension("jpg"); 116 | 117 | let status = DewCache::fetch_remote( 118 | cache(), 119 | thumbnail_fname.clone(), 120 | &thumb.url, 121 | ) 122 | .await; 123 | 124 | let paintable: gdk::Texture = match status { 125 | Ok(data) => { 126 | let content_bytes = glib::Bytes::from_owned(data); 127 | gdk::Texture::from_bytes(&content_bytes)? 128 | } 129 | Err(err) => { 130 | g_warning!( 131 | "DewChannelRow", 132 | "could not fetch file {}: {err}", 133 | thumbnail_fname.clone().display() 134 | ); 135 | gdk::Texture::from_resource( 136 | "/null/daknig/DewDuct/dummi_thumbnail.svg", 137 | ) 138 | } 139 | }; 140 | self.imp().thumbnail.set_custom_image(Some(&paintable)); 141 | 142 | Ok(()) 143 | } 144 | fn set_subs(&self, subs: f32) { 145 | self.imp().subs.set_text( 146 | &(util::format_semi_engineering(subs) + " subscribers"), 147 | ); 148 | } 149 | } 150 | 151 | use thiserror::Error; 152 | #[derive(Error, Debug)] 153 | pub enum Err { 154 | #[error("no thumbnails found for vid ID {id} video")] 155 | NoThumbnails { id: String }, 156 | } 157 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | // pub static VERSION: &str = env!("CARGO_PKG_VERSION"); 2 | // pub static GETTEXT_PACKAGE: &str = "dewduct"; 3 | // pub static LOCALEDIR: &str = "/app/share/locale"; 4 | // pub static PKGDATADIR: &str = "/app/share/dewduct"; 5 | pub static PKGNAME: &str = "DewDuct"; 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | /* main.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | mod application; 22 | mod cache; 23 | mod channel_header; 24 | mod channel_page; 25 | mod channel_row; 26 | mod config; 27 | mod popular_page; 28 | mod search_page; 29 | mod subscriptions_page; 30 | mod thumbnail; 31 | mod util; 32 | mod video_page; 33 | mod video_row; 34 | mod window; 35 | mod yt_item_list; 36 | mod yt_item_row; 37 | 38 | use self::application::DewDuctApplication; 39 | use self::window::DewDuctWindow; 40 | 41 | // use config::{GETTEXT_PACKAGE, LOCALEDIR}; 42 | #[allow(unused_imports)] 43 | use gtk::prelude::*; 44 | use gtk::{gio, glib}; 45 | 46 | pub use util::*; 47 | 48 | fn main() -> glib::ExitCode { 49 | // Load resources 50 | gio::resources_register_include!("dewduct.gresource") 51 | .expect("Failed to register resources."); 52 | // Create a new GtkApplication. The application manages our main loop, 53 | // application windows, integration with the window manager/compositor, and 54 | // desktop features such as file opening and single-instance applications. 55 | let app = DewDuctApplication::new( 56 | "null.daknig.DewDuct", 57 | &gio::ApplicationFlags::empty(), 58 | ); 59 | 60 | // Run the application. This function will block until the application 61 | // exits. Upon return, we have our exit code to return to the shell. (This 62 | // is the code you see when you do `echo $?` after running a command in a 63 | // terminal. 64 | app.run() 65 | } 66 | -------------------------------------------------------------------------------- /src/popular_page.rs: -------------------------------------------------------------------------------- 1 | /* popular_page.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | #[allow(unused_imports)] 22 | use adw::{prelude::*, subclass::prelude::*}; 23 | use glib::{g_warning, MainContext, Priority}; 24 | use gtk::{gio, glib}; 25 | #[allow(unused_imports)] 26 | use gtk::{prelude::*, subclass::prelude::*}; 27 | 28 | use invidious::ClientAsyncTrait; 29 | 30 | use crate::window::DewDuctWindow; 31 | use crate::yt_item_list::*; 32 | 33 | mod imp { 34 | use super::*; 35 | 36 | #[derive(Default, gtk::CompositeTemplate)] 37 | #[template(resource = "/null/daknig/DewDuct/popular_page.ui")] 38 | pub struct DewPopularPage { 39 | // Template widgets 40 | #[template_child] 41 | update_button: TemplateChild, 42 | #[template_child] 43 | vid_list: TemplateChild, 44 | } 45 | 46 | #[glib::object_subclass] 47 | impl ObjectSubclass for DewPopularPage { 48 | const NAME: &'static str = "DewPopularPage"; 49 | type Type = super::DewPopularPage; 50 | type ParentType = gtk::Box; 51 | 52 | fn class_init(klass: &mut Self::Class) { 53 | klass.bind_template(); 54 | klass.bind_template_callbacks(); 55 | } 56 | 57 | fn instance_init(obj: &glib::subclass::InitializingObject) { 58 | obj.init_template(); 59 | } 60 | } 61 | 62 | impl ObjectImpl for DewPopularPage { 63 | fn constructed(&self) { 64 | self.parent_constructed(); 65 | 66 | let page = self.obj().clone(); 67 | MainContext::default() 68 | .spawn_local_with_priority(Priority::LOW, async move { 69 | page.imp().update_vids().await 70 | }); 71 | } 72 | } 73 | impl WidgetImpl for DewPopularPage {} 74 | impl BoxImpl for DewPopularPage {} 75 | 76 | #[gtk::template_callbacks] 77 | impl DewPopularPage { 78 | fn async_invidious_client(&self) -> invidious::ClientAsync { 79 | self.obj() 80 | .root() 81 | .and_downcast_ref::() 82 | .unwrap() 83 | .async_invidious_client() 84 | } 85 | fn window(&self) -> DewDuctWindow { 86 | self.obj().root().and_downcast().unwrap() 87 | } 88 | #[template_callback] 89 | async fn update_vids(&self) { 90 | let invidious = self.async_invidious_client(); 91 | 92 | self.update_button.set_sensitive(false); 93 | 94 | let Ok(Some(popular)) = self 95 | .window() 96 | .spawn(async move { 97 | match invidious.popular(None).await { 98 | Err(err) => { 99 | g_warning!( 100 | "DewPopularPage", 101 | "cant update page: {:#?}", 102 | err 103 | ); 104 | None 105 | } 106 | Ok(ok) => Some(ok), 107 | } 108 | }) 109 | .await 110 | else { 111 | self.update_button.add_css_class("error"); 112 | self.update_button.set_sensitive(true); 113 | return; 114 | }; 115 | self.update_button.remove_css_class("error"); 116 | self.update_button.set_sensitive(true); 117 | 118 | // let mut store = self.new_vids_store.clone(); 119 | let vids = 120 | popular.items.into_iter().map(|x| x.into()).collect(); 121 | 122 | self.vid_list.set_from_vec(vids); 123 | // let n_items = store.n_items(); 124 | // store.splice(0, n_items, &[]); // empty 125 | // store.extend(vids); 126 | } 127 | } 128 | } 129 | 130 | glib::wrapper! { 131 | pub struct DewPopularPage(ObjectSubclass) 132 | @extends gtk::Widget, gtk::Box, 133 | @implements gio::ActionGroup, gio::ActionMap; 134 | } 135 | -------------------------------------------------------------------------------- /src/search_page.rs: -------------------------------------------------------------------------------- 1 | /* search_page.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | #[allow(unused_imports)] 22 | use adw::{prelude::*, subclass::prelude::*}; 23 | use glib::g_warning; 24 | use gtk::SearchEntry; 25 | use gtk::{gio, glib}; 26 | #[allow(unused_imports)] 27 | use gtk::{prelude::*, subclass::prelude::*}; 28 | 29 | use html_escape::decode_html_entities; 30 | use invidious::hidden::SearchItem; 31 | use invidious::ClientAsyncTrait; 32 | use urlencoding::encode; 33 | 34 | use crate::yt_item_list::DewYtItemList; 35 | 36 | #[allow(unused_imports)] 37 | use crate::util::*; 38 | 39 | mod imp { 40 | use super::*; 41 | 42 | #[derive(Default, gtk::CompositeTemplate)] 43 | #[template(resource = "/null/daknig/DewDuct/search_page.ui")] 44 | pub struct DewSearchPage { 45 | #[template_child] 46 | pub(super) search_bar: TemplateChild, 47 | #[template_child] 48 | pub(super) search_entry: TemplateChild, 49 | #[template_child] 50 | not_found_page: TemplateChild, 51 | #[template_child] 52 | pub(super) results_page: TemplateChild, 53 | #[template_child] 54 | search_stack: TemplateChild, 55 | } 56 | 57 | #[glib::object_subclass] 58 | impl ObjectSubclass for DewSearchPage { 59 | const NAME: &'static str = "DewSearchPage"; 60 | type Type = super::DewSearchPage; 61 | type ParentType = gtk::Box; 62 | 63 | fn class_init(klass: &mut Self::Class) { 64 | klass.bind_template(); 65 | klass.bind_template_callbacks(); 66 | } 67 | 68 | fn instance_init(obj: &glib::subclass::InitializingObject) { 69 | obj.init_template(); 70 | } 71 | } 72 | 73 | impl ObjectImpl for DewSearchPage { 74 | fn constructed(&self) { 75 | self.parent_constructed(); 76 | self.search_bar.connect_entry(&*self.search_entry); 77 | } 78 | } 79 | impl WidgetImpl for DewSearchPage {} 80 | impl BoxImpl for DewSearchPage {} 81 | 82 | #[gtk::template_callbacks] 83 | impl DewSearchPage { 84 | fn window(&self) -> crate::window::DewDuctWindow { 85 | self.obj().root().and_downcast().unwrap() 86 | } 87 | #[template_callback] 88 | pub(crate) fn search_started(&self) { 89 | // glib::g_warning!("Dew", "search_started"); 90 | } 91 | #[template_callback] 92 | pub(crate) fn stop_search(&self) { 93 | // glib::g_warning!("Dew", "stop_search"); 94 | } 95 | #[template_callback] 96 | pub(crate) async fn search_activate(&self, entry: &SearchEntry) { 97 | glib::g_warning!("Dew", "search activated"); 98 | let query = entry.text(); 99 | g_warning!("DewSearchPage", "searching {}...", query); 100 | 101 | // qeury for search results 102 | let query_transformed = format!("q={}", encode(&query)); 103 | let client = self.obj().async_invidious_client(); 104 | let search_results = self 105 | .window() 106 | .spawn(async move { 107 | match client.search(Some(&query_transformed)).await { 108 | Ok(search) => search.items, 109 | Err(err) => { 110 | g_warning!( 111 | "Dew", 112 | "instance {}; no results: {:?}", 113 | &client.instance, 114 | err 115 | ); 116 | vec![] 117 | } 118 | } 119 | }) 120 | .await 121 | .unwrap_or(vec![]); 122 | 123 | // if zero, show the "not found" page 124 | if search_results.is_empty() { 125 | self.search_stack.set_visible_child(&*self.not_found_page); 126 | return; 127 | } else { 128 | self.search_stack.set_visible_child(&*self.results_page) 129 | } 130 | 131 | { 132 | // actually putting in the items 133 | let search_results: Vec<_> = search_results 134 | .into_iter() 135 | .filter(|x| { 136 | // we only support these types for now... 137 | matches!(x, SearchItem::Channel { .. }) 138 | || matches!(x, SearchItem::Video { .. }) 139 | }) 140 | .map(|x| x.into()) 141 | .collect(); 142 | 143 | self.results_page.set_from_vec(search_results); 144 | } 145 | } 146 | #[template_callback] 147 | pub(crate) async fn search_changed(&self, entry: &SearchEntry) { 148 | // glib::g_warning!("Dew", "search_changed"); 149 | let client = self.obj().async_invidious_client(); 150 | 151 | // get the dang results, errors = no results 152 | let query = entry.text().to_string().to_owned(); 153 | if query.is_empty() { 154 | return; 155 | } 156 | 157 | // encode to make utf8 work 158 | let query_transformed = format!("q={}", encode(&query)); 159 | let search_suggestions = self.window().spawn(async move { 160 | match client 161 | .search_suggestions(Some(&query_transformed)) 162 | .await 163 | { 164 | Ok(search) => { 165 | g_warning!( 166 | "DewSearch", 167 | "search.query = {}", 168 | decode_html_entities(&search.query) 169 | ); 170 | 171 | search.suggestions 172 | } 173 | Err(err) => { 174 | g_warning!("DewSearch", "no results: {:?}", err); 175 | vec![] 176 | } 177 | } 178 | }); 179 | let Ok(search_suggestions): Result, _> = 180 | search_suggestions.await 181 | else { 182 | return; 183 | }; 184 | if query != entry.text() { 185 | g_warning!( 186 | "DewSearchPage", 187 | "query was {}, returning!", 188 | &query 189 | ); 190 | return; 191 | } 192 | let search_suggestions: Vec<_> = search_suggestions 193 | .into_iter() 194 | .map(|s| decode_html_entities(&s).into_owned()) 195 | .collect(); 196 | 197 | // now display suggestions 198 | let for_display = search_suggestions 199 | .into_iter() 200 | .fold("".into(), |a: String, b: String| { 201 | a + ", " + b.as_ref() 202 | }); 203 | glib::g_warning!("DewSearch", "results: {}", for_display); 204 | } 205 | } 206 | } 207 | 208 | glib::wrapper! { 209 | pub struct DewSearchPage(ObjectSubclass) 210 | @extends gtk::Widget, gtk::Box, 211 | @implements gio::ActionGroup, gio::ActionMap; 212 | } 213 | 214 | impl DewSearchPage { 215 | pub fn search_bar(&self) -> >k::SearchBar { 216 | &self.imp().search_bar 217 | } 218 | pub fn search_entry(&self) -> &SearchEntry { 219 | &self.imp().search_entry 220 | } 221 | pub fn async_invidious_client(&self) -> invidious::ClientAsync { 222 | let window: crate::window::DewDuctWindow = 223 | self.root().and_downcast().unwrap(); 224 | window.async_invidious_client() 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/subscriptions_page.rs: -------------------------------------------------------------------------------- 1 | /* subscriptions_page.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::fs::{read, File}; 22 | use std::path::PathBuf; 23 | 24 | #[allow(unused_imports)] 25 | use adw::{prelude::*, subclass::prelude::*}; 26 | use gio::ListStore; 27 | use glib::{g_debug, g_warning, user_data_dir}; 28 | use gtk::{gio, glib}; 29 | #[allow(unused_imports)] 30 | use gtk::{prelude::*, subclass::prelude::*}; 31 | 32 | use anyhow::Context; 33 | use futures::StreamExt; 34 | use invidious::ClientAsyncTrait; 35 | use lazy_static::lazy_static; 36 | use serde::{Deserialize, Serialize}; 37 | 38 | use crate::window::DewDuctWindow; 39 | use crate::yt_item_list::*; 40 | 41 | mod imp { 42 | use super::*; 43 | 44 | #[derive(Default, gtk::CompositeTemplate)] 45 | #[template(resource = "/null/daknig/DewDuct/subscriptions_page.ui")] 46 | pub struct DewSubscriptionsPage { 47 | // Template widgets 48 | #[template_child] 49 | subs_list: TemplateChild, 50 | } 51 | 52 | #[glib::object_subclass] 53 | impl ObjectSubclass for DewSubscriptionsPage { 54 | const NAME: &'static str = "DewSubscriptionsPage"; 55 | type Type = super::DewSubscriptionsPage; 56 | type ParentType = gtk::Box; 57 | 58 | fn class_init(klass: &mut Self::Class) { 59 | klass.bind_template(); 60 | klass.bind_template_callbacks(); 61 | } 62 | 63 | fn instance_init(obj: &glib::subclass::InitializingObject) { 64 | obj.init_template(); 65 | } 66 | } 67 | impl ObjectImpl for DewSubscriptionsPage { 68 | fn constructed(&self) { 69 | self.parent_constructed(); 70 | glib::spawn_future_local(glib::clone!(@weak self as page => 71 | async move {page.load_state().await})); 72 | } 73 | } 74 | impl WidgetImpl for DewSubscriptionsPage {} 75 | impl BoxImpl for DewSubscriptionsPage {} 76 | 77 | #[gtk::template_callbacks] 78 | impl DewSubscriptionsPage { 79 | pub fn connect_subs_changed( 80 | &self, 81 | f: impl Fn(&ListStore) + 'static, 82 | ) -> glib::signal::SignalHandlerId { 83 | self.subs_list.connect_items_changed(f) 84 | } 85 | fn async_invidious_client(&self) -> invidious::ClientAsync { 86 | self.obj() 87 | .root() 88 | .and_downcast_ref::() 89 | .unwrap() 90 | .async_invidious_client() 91 | } 92 | fn window(&self) -> DewDuctWindow { 93 | self.obj().root().and_downcast().unwrap() 94 | } 95 | fn store_state(&self) { 96 | let path = self.subs_file_path(); 97 | let file: File = File::create(&path) 98 | .or_else(|e| { 99 | if let Some(parent) = path.parent() { 100 | std::fs::create_dir_all(parent)?; 101 | File::create(&path) 102 | } else { 103 | Err(e) 104 | } 105 | }) 106 | .with_context(|| { 107 | format!( 108 | "unable to create file {} to store state", 109 | &path.display() 110 | ) 111 | }) 112 | .unwrap(); 113 | let subs_vec = self.subs_list.get_vec(); 114 | let subscription_list_serialization = SubscriptionList { 115 | subscriptions: subs_vec 116 | .into_iter() 117 | .map(|x| x.into()) 118 | .collect(), 119 | }; 120 | serde_json::to_writer(file, &subscription_list_serialization) 121 | .with_context(|| { 122 | format!( 123 | "unable to write to file {} to store state", 124 | &path.display() 125 | ) 126 | }) 127 | .unwrap(); 128 | } 129 | pub fn del_subscription(&self, id: String) { 130 | self.subs_list.del_item_with_id(id); 131 | self.store_state(); 132 | } 133 | pub async fn add_subscription( 134 | &self, 135 | id: String, 136 | ) -> anyhow::Result<()> { 137 | let v = self.subs_list.get_vec(); 138 | // if is subscribed already, do nothing 139 | if v.into_iter() 140 | .any(|vid| vid.imp().id.borrow().as_str() == id) 141 | { 142 | return Ok(()); 143 | } 144 | 145 | let item = 146 | self.async_invidious_client().channel(&id, None).await; 147 | 148 | match item { 149 | Ok(item) => { 150 | self.subs_list.insert_sorted(&item.into(), |a, b| { 151 | a.title().cmp(&b.title()) 152 | }); 153 | self.store_state(); 154 | Ok(()) 155 | } 156 | Err(e) => { 157 | let e_str = 158 | format!("Unable to subscribe to {}: {}", &id, e); 159 | g_warning!("DewSubscriptionsPage", "{}", e_str); 160 | anyhow::bail!(e_str); 161 | } 162 | } 163 | } 164 | #[template_callback] 165 | async fn import_newpipe_subs(&self) { 166 | let json_filter = gtk::FileFilter::new(); 167 | json_filter.add_suffix("json"); 168 | let filters = gio::ListStore::from_iter([json_filter; 1]); 169 | let dialog = gtk::FileDialog::builder() 170 | .filters(&filters) 171 | .title("Import NewPipe data") 172 | .build(); 173 | let dialog_res = dialog.open_future(None::<>k::Window>).await; 174 | match dialog_res { 175 | Ok(x) if x.path().is_some() => { 176 | let path = x.path().unwrap(); 177 | self.load_newpipe_subs_from_file(path).await; 178 | } 179 | Err(e) if e.matches(gtk::DialogError::Dismissed) => { 180 | g_debug!("DewSubscriptionsPage", "{}", e.message()) 181 | } 182 | Err(e) => { 183 | g_warning!("DewSubscriptionsPage", "{}", e.message()) 184 | } 185 | Ok(_) => g_warning!( 186 | "DewSubscriptionsPage", 187 | "invalid path selected" 188 | ), 189 | } 190 | } 191 | fn subs_file_path(&self) -> PathBuf { 192 | lazy_static! { 193 | static ref SUBS: PathBuf = 194 | user_data_dir().join("DewDuct/").join("subs.json"); 195 | } 196 | SUBS.to_path_buf() 197 | } 198 | #[template_callback] 199 | async fn load_state(&self) { 200 | g_warning!("DewSubscriptionsPage", "loading state"); 201 | let path = self.subs_file_path(); 202 | dbg!(path.display()); 203 | self.load_newpipe_subs_from_file(path).await; 204 | } 205 | async fn load_newpipe_subs_from_file(&self, file: PathBuf) { 206 | fn sync_import_subs(file: PathBuf) -> Vec { 207 | // - get info from subs file 208 | let contents = read(file).unwrap_or_default(); 209 | let subs: Vec<_> = serde_json::from_slice(&contents) 210 | .unwrap_or_else(|_| { 211 | g_warning!( 212 | "DewSubscriptionsPage", 213 | "malformed subscriptions file!" 214 | ); 215 | SubscriptionList { 216 | subscriptions: vec![], 217 | } 218 | }) 219 | .subscriptions 220 | .into_iter() 221 | .filter_map(|sub| { 222 | if sub.service_id != 0 { 223 | return None; 224 | } 225 | let url = sub.url; 226 | let stripped = url.strip_prefix( 227 | "https://www.youtube.com/channel/", 228 | ); 229 | if url.starts_with("https://www.youtube.com/user/") 230 | { 231 | g_warning!( 232 | "DewSubscriptionPage", 233 | "problem with importing channel {}: \ 234 | can't use /user/ api!", 235 | url 236 | ); 237 | } 238 | stripped.map(|id| id.into()) 239 | }) 240 | .collect(); 241 | subs 242 | } 243 | let fetch_file = move || sync_import_subs(file); 244 | let subs: Vec = self 245 | .window() 246 | .spawn_blocking(fetch_file) 247 | .await 248 | .unwrap_or_else(|err| { 249 | g_warning!( 250 | "DewSubscriptions", 251 | "this should not crash: {}", 252 | err 253 | ); 254 | vec![] 255 | }); 256 | let invidious = 257 | std::sync::Arc::new(self.async_invidious_client()); 258 | let channels_or_errors: Vec<_> = futures::stream::iter(subs) 259 | .map(|id| { 260 | let invidious = invidious.clone(); 261 | async move { invidious.channel(&id, None).await } 262 | }) 263 | .buffer_unordered(10) 264 | .collect() 265 | .await; 266 | // if error fetching, then skip 267 | let channels = 268 | channels_or_errors.into_iter().filter_map(|x| x.ok()); 269 | let mut dew_yt_items: Vec = 270 | channels.map(|chan| chan.into()).collect(); 271 | // - display it 272 | // for dedup purposes 273 | let subs = self.subs_list.get_vec(); 274 | dew_yt_items.extend(subs); 275 | dew_yt_items.sort_unstable_by_key(|item| item.title()); 276 | self.subs_list.set_from_vec(dew_yt_items); 277 | self.store_state(); 278 | } 279 | } 280 | 281 | #[derive(Deserialize, Serialize)] 282 | pub(super) struct SubscriptionList { 283 | subscriptions: Vec, 284 | } 285 | 286 | #[derive(Deserialize, Serialize)] 287 | pub(super) struct Subscription { 288 | url: String, 289 | name: String, 290 | service_id: u8, 291 | } 292 | impl From for Subscription { 293 | fn from(item: DewYtItem) -> Self { 294 | let mut url = item.id(); 295 | url.insert_str(0, "https://www.youtube.com/channel/"); 296 | 297 | Self { 298 | url, 299 | name: item.title(), 300 | service_id: 0, 301 | } 302 | } 303 | } 304 | } 305 | 306 | glib::wrapper! { 307 | pub struct DewSubscriptionsPage(ObjectSubclass) 308 | @extends gtk::Widget, gtk::Box, 309 | @implements gio::ActionGroup, gio::ActionMap; 310 | } 311 | -------------------------------------------------------------------------------- /src/thumbnail.rs: -------------------------------------------------------------------------------- 1 | /* thumbnail.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::path::Path; 22 | 23 | #[allow(unused_imports)] 24 | use adw::{prelude::*, subclass::prelude::*}; 25 | use gtk::{gdk, gio, glib}; 26 | #[allow(unused_imports)] 27 | use gtk::{prelude::*, subclass::prelude::*}; 28 | 29 | use crate::cache::DewCache; 30 | use crate::util::{cache, cache_dir}; 31 | 32 | mod imp { 33 | use super::*; 34 | 35 | #[derive(Debug, Default, gtk::CompositeTemplate)] 36 | #[template(resource = "/null/daknig/DewDuct/thumbnail.ui")] 37 | pub struct DewThumbnail { 38 | // Template widgets 39 | #[template_child] 40 | pub(super) thumbnail: TemplateChild, 41 | #[template_child] 42 | pub(super) length: TemplateChild, 43 | #[template_child] 44 | pub(super) watched_progress: TemplateChild, 45 | } 46 | 47 | #[glib::object_subclass] 48 | impl ObjectSubclass for DewThumbnail { 49 | const NAME: &'static str = "DewThumbnail"; 50 | type Type = super::DewThumbnail; 51 | type ParentType = gtk::Box; 52 | 53 | fn class_init(klass: &mut Self::Class) { 54 | klass.bind_template(); 55 | } 56 | // g_get_tmp_dir ###@@ 57 | fn instance_init(obj: &glib::subclass::InitializingObject) { 58 | obj.init_template(); 59 | } 60 | } 61 | 62 | impl ObjectImpl for DewThumbnail { 63 | fn constructed(&self) { 64 | self.thumbnail.set_resource(Some( 65 | "/null/daknig/DewDuct/dummi_thumbnail.svg", 66 | )); 67 | } 68 | } 69 | impl WidgetImpl for DewThumbnail {} 70 | impl BoxImpl for DewThumbnail {} 71 | } 72 | 73 | glib::wrapper! { 74 | pub struct DewThumbnail(ObjectSubclass) 75 | @extends gtk::Widget, gtk::Box, 76 | @implements gio::ActionGroup, gio::ActionMap; 77 | } 78 | 79 | impl DewThumbnail { 80 | fn set_length(&self, length: u64) { 81 | let (hrs, mins, secs) = 82 | (length / 3600, (length / 60) % 60, length % 60); 83 | 84 | let hrs_str = match hrs { 85 | 0 => "".into(), 86 | hrs => format!("{hrs}:"), 87 | }; 88 | 89 | self.imp() 90 | .length 91 | .set_text(&format!("{}{:02}:{:02}", hrs_str, mins, secs)); 92 | } 93 | 94 | fn set_progress(&self, watched_progress: f64) { 95 | self.imp() 96 | .watched_progress 97 | .get() 98 | .set_fraction(watched_progress); 99 | } 100 | 101 | pub(crate) async fn update_from_params<'a, T>( 102 | &'a self, 103 | id: String, 104 | thumbnails: impl Iterator, 105 | length: u64, 106 | watched_progress: f64, 107 | ) -> anyhow::Result<()> 108 | where 109 | T: Clone + 'a, 110 | crate::yt_item_list::Thumbnail: From, 111 | { 112 | let thumbnails: std::iter::Map<_, _> = thumbnails.map(|thumb| { 113 | let thumb: crate::yt_item_list::Thumbnail = 114 | thumb.clone().into(); 115 | thumb 116 | }); 117 | 118 | self.set_length(length); 119 | self.set_progress(watched_progress); 120 | 121 | let thumb = thumbnails 122 | .filter(|thumb| thumb.width >= 320) 123 | .min_by_key(|thumb| thumb.width) 124 | .ok_or(Err::NoThumbnails { id: id.clone() })?; 125 | 126 | // thumbnail_fname.push(); 127 | let mut thumbnail_fname = cache_dir(Path::new(&id)); 128 | thumbnail_fname.push(&thumb.height.to_string()); 129 | thumbnail_fname.set_extension("jpg"); 130 | 131 | DewCache::fetch_remote( 132 | cache(), 133 | thumbnail_fname.clone(), 134 | &thumb.url, 135 | ) 136 | .await?; 137 | 138 | let paintable = gdk::Texture::from_filename(thumbnail_fname)?; 139 | 140 | self.imp().thumbnail.set_paintable(Some(&paintable)); 141 | Ok(()) 142 | } 143 | } 144 | 145 | use thiserror::Error; 146 | #[derive(Error, Debug)] 147 | pub enum Err { 148 | #[error("no thumbnails found for vid ID {id} video")] 149 | NoThumbnails { id: String }, 150 | } 151 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::cache::DewCache; 2 | use humantime::format_duration; 3 | use once_cell::sync::OnceCell; 4 | use std::path::{Path, PathBuf}; 5 | use std::time::Duration; 6 | 7 | pub(crate) fn format_rel_time(duration: Duration) -> String { 8 | let s: String = format_duration(duration).to_string(); 9 | s.split_whitespace().next().unwrap().to_owned() 10 | } 11 | 12 | pub fn format_semi_engineering(value: f32) -> String { 13 | static SUFFIXES: [char; 5] = [' ', 'k', 'M', 'B', 'T']; 14 | let Some(suffix) = (0..) 15 | .map(|x| 1000f32.powi(x)) 16 | .zip(SUFFIXES) 17 | .filter(|x| value >= x.0 || x.1 == ' ') 18 | .last() 19 | else { 20 | gtk::glib::g_warning!("DewUtil", "wtf: cant format value {value}"); 21 | return "".into(); 22 | }; 23 | 24 | // explain with an example: value = 15942 25 | let normalized = value / suffix.0; // normalized = 15.942 26 | let exp = suffix.1; // exp = 'k' 27 | 28 | let mut ret = format!("{}", normalized as u16); 29 | if normalized < 10. && value > 10. { 30 | ret += &format!(".{}", ((normalized % 1.) * 10.) as u8); 31 | } 32 | 33 | ret.push(exp); 34 | 35 | ret 36 | } 37 | 38 | pub(crate) fn cache() -> &'static DewCache { 39 | static APP_CACHE: OnceCell = OnceCell::new(); 40 | APP_CACHE.get_or_init(DewCache::default) 41 | } 42 | 43 | pub(crate) fn cache_dir(fname: &Path) -> PathBuf { 44 | let mut dir = cache().dir().clone(); 45 | dir.push(fname); 46 | dir 47 | } 48 | -------------------------------------------------------------------------------- /src/video_page.rs: -------------------------------------------------------------------------------- 1 | /* video_page.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::{ 22 | cell::RefCell, 23 | process::{Child, Command}, 24 | rc::Rc, 25 | }; 26 | 27 | #[allow(unused_imports)] 28 | use adw::{prelude::*, subclass::prelude::*}; 29 | use glib::g_warning; 30 | use gtk::{gio, glib}; 31 | #[allow(unused_imports)] 32 | use gtk::{prelude::*, subclass::prelude::*}; 33 | 34 | use invidious::video::Video; 35 | 36 | use crate::format_semi_engineering; 37 | use crate::thumbnail::DewThumbnail; 38 | 39 | mod imp { 40 | use super::*; 41 | 42 | #[derive(Default, gtk::CompositeTemplate)] 43 | #[template(resource = "/null/daknig/DewDuct/video_page.ui")] 44 | pub struct DewVideoPage { 45 | // Template widgets 46 | #[template_child] 47 | vid_thumbnail: TemplateChild, 48 | #[template_child] 49 | title: TemplateChild, 50 | // #[template_child] 51 | // author_thumb: TemplateChild, 52 | #[template_child] 53 | author_name: TemplateChild, 54 | #[template_child] 55 | sub_count: TemplateChild, 56 | #[template_child] 57 | views: TemplateChild, 58 | #[template_child] 59 | likes: TemplateChild, 60 | // #[template_child] 61 | // bottom_stack: TemplateChild, 62 | #[template_child] 63 | description: TemplateChild, 64 | // #[template_child] 65 | // bottom_switcher: TemplateChild, 66 | vid: RefCell>, 67 | mpv_child: Rc>>, 68 | } 69 | 70 | #[glib::object_subclass] 71 | impl ObjectSubclass for DewVideoPage { 72 | const NAME: &'static str = "DewVideoPage"; 73 | type Type = super::DewVideoPage; 74 | type ParentType = gtk::Box; 75 | 76 | fn class_init(klass: &mut Self::Class) { 77 | klass.bind_template(); 78 | // klass.bind_template_callbacks(); 79 | } 80 | 81 | fn instance_init(obj: &glib::subclass::InitializingObject) { 82 | obj.init_template(); 83 | } 84 | } 85 | 86 | impl ObjectImpl for DewVideoPage { 87 | fn constructed(&self) { 88 | self.vid.take(); 89 | { 90 | // let's spawn mpv when thumbnail is clicked! 91 | let click = gtk::GestureClick::new(); 92 | 93 | let page = self.obj().clone(); 94 | click.connect_pressed(move |_, _n, _x, _y| { 95 | page.imp().play_mpv() 96 | }); 97 | 98 | self.vid_thumbnail.add_controller(click); 99 | } 100 | } 101 | } 102 | impl WidgetImpl for DewVideoPage {} 103 | impl BoxImpl for DewVideoPage {} 104 | 105 | impl DewVideoPage { 106 | fn id(&self) -> Option { 107 | self.vid 108 | .try_borrow() 109 | .ok() 110 | .and_then(|x| x.as_ref().map(|vid| vid.id.clone())) 111 | } 112 | fn play_mpv(&self) { 113 | let id = self.id(); 114 | let mpv_child = self.mpv_child.clone(); 115 | 116 | let Some(id) = id.as_ref() else { return }; 117 | 118 | let url = format!("https://youtube.com/watch?v={}", id); 119 | let mut mpv = Command::new("mpv"); 120 | mpv.arg(url).arg("--ytdl-format=best[height<=480]"); 121 | g_warning!( 122 | "DewVideoPage", 123 | "running... {:?} {:?}", 124 | mpv.get_program(), 125 | mpv.get_args().collect::>() 126 | ); 127 | 128 | // spawn child process 129 | let mpv_process = mpv.spawn().expect("mpv not found"); 130 | let prev_mpv = mpv_child.replace(Some(mpv_process)); 131 | 132 | // if there was already a mpv instance running... 133 | if let Some(mut prev_mpv) = prev_mpv { 134 | prev_mpv.kill().expect("error killing it"); 135 | } 136 | } 137 | 138 | pub(crate) async fn set_vid(&self, new_vid: Video) { 139 | if !self 140 | .vid 141 | .borrow() 142 | .as_ref() 143 | .is_some_and(|vid| vid.id == new_vid.id) 144 | { 145 | g_warning!( 146 | "DewVideoPage", 147 | "was {:?} became {:?}", 148 | self.id(), 149 | Some(&new_vid.id) 150 | ); 151 | 152 | let Video { 153 | id, 154 | thumbnails, 155 | length, 156 | author, 157 | title, 158 | description, 159 | likes, 160 | views, 161 | sub_count_text, 162 | .. 163 | } = &new_vid; 164 | 165 | self.author_name.set_text(author); 166 | self.title.set_text(title); 167 | self.likes.set_text( 168 | &(format_semi_engineering(*likes as f32) + " likes"), 169 | ); 170 | self.views.set_text( 171 | &(format_semi_engineering(*views as f32) + " views"), 172 | ); 173 | self.sub_count 174 | .set_text(&format!("{} subscribers", sub_count_text)); 175 | // self.description.set_markup(&new_vid.description_html); 176 | self.description.set_text(description); 177 | 178 | self.vid_thumbnail 179 | .update_from_params( 180 | id.clone(), 181 | thumbnails.iter(), 182 | *length as u64, 183 | 0.0f64, 184 | ) 185 | .await 186 | .unwrap_or_else(|err| { 187 | g_warning!( 188 | "DewVideoPage", 189 | "can't open video {} in the VideoPage: {}", 190 | id, 191 | err 192 | ) 193 | }); 194 | self.vid.replace(Some(new_vid)); 195 | } else { 196 | g_warning!("DewVideoPage", "clicked on the same vid...") 197 | } 198 | self.obj().set_visible(true); 199 | } 200 | 201 | pub(crate) fn reset_vid(&self) { 202 | g_warning!( 203 | "DewVideoPage", 204 | "was Some({:?}) became None", 205 | self.id() 206 | ); 207 | *self.vid.borrow_mut() = None; 208 | self.obj().set_visible(false); 209 | todo!() // reset the video and all stuffs 210 | } 211 | } 212 | } 213 | 214 | glib::wrapper! { 215 | pub struct DewVideoPage(ObjectSubclass) 216 | @extends gtk::Widget, gtk::Box, 217 | @implements gio::ActionGroup, gio::ActionMap; 218 | } 219 | -------------------------------------------------------------------------------- /src/video_row.rs: -------------------------------------------------------------------------------- 1 | /* video_row.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 22 | 23 | #[allow(unused_imports)] 24 | use adw::{prelude::*, subclass::prelude::*}; 25 | use glib::g_warning; 26 | use gtk::{gio, glib}; 27 | #[allow(unused_imports)] 28 | use gtk::{prelude::*, subclass::prelude::*}; 29 | 30 | // use invidious::video::Video; 31 | 32 | use crate::thumbnail::DewThumbnail; 33 | use crate::util; 34 | 35 | mod imp { 36 | use super::*; 37 | 38 | #[derive(/*Properties,*/ Debug, Default, gtk::CompositeTemplate)] 39 | #[template(resource = "/null/daknig/DewDuct/video_row.ui")] 40 | // #[properties(wrapper_type = super::DewVideoRow)] 41 | pub struct DewVideoRow { 42 | // Template widgets 43 | #[template_child] 44 | pub(super) title: TemplateChild, 45 | #[template_child] 46 | pub(super) channel: TemplateChild, 47 | #[template_child] 48 | pub(super) views: TemplateChild, 49 | #[template_child] 50 | pub(super) published: TemplateChild, 51 | #[template_child] 52 | pub(crate) thumbnail: TemplateChild, 53 | } 54 | 55 | #[glib::object_subclass] 56 | impl ObjectSubclass for DewVideoRow { 57 | const NAME: &'static str = "DewVideoRow"; 58 | type Type = super::DewVideoRow; 59 | type ParentType = gtk::Box; 60 | 61 | fn class_init(klass: &mut Self::Class) { 62 | klass.bind_template(); 63 | } 64 | 65 | fn instance_init(obj: &glib::subclass::InitializingObject) { 66 | obj.init_template(); 67 | } 68 | } 69 | 70 | impl ObjectImpl for DewVideoRow {} 71 | impl WidgetImpl for DewVideoRow {} 72 | impl BoxImpl for DewVideoRow {} 73 | } 74 | 75 | glib::wrapper! { 76 | pub struct DewVideoRow(ObjectSubclass) 77 | @extends gtk::Widget, gtk::Box, 78 | @implements gio::ActionGroup, gio::ActionMap; 79 | } 80 | 81 | impl DewVideoRow { 82 | pub fn new() -> Self { 83 | glib::Object::builder().build() 84 | } 85 | 86 | #[allow(clippy::too_many_arguments)] 87 | pub(crate) async fn set_from_params<'a, T>( 88 | &'a self, 89 | author: String, 90 | id: String, 91 | length: u64, 92 | published: u64, 93 | thumbnails: impl Iterator, 94 | title: String, 95 | views: u64, 96 | ) -> anyhow::Result<()> 97 | where 98 | T: Clone + 'a, 99 | crate::yt_item_list::Thumbnail: From, 100 | { 101 | let watched_progress: f64 = 0.; // todo! 102 | 103 | self.imp().title.set_text(&title); 104 | self.imp().channel.set_text(&author); 105 | self.set_views(views); 106 | self.set_published(published); 107 | self.imp() 108 | .thumbnail 109 | .update_from_params(id, thumbnails, length, watched_progress) 110 | .await?; 111 | Ok(()) 112 | } 113 | 114 | fn set_published(&self, published: u64) { 115 | let now = SystemTime::now(); 116 | let published: SystemTime = 117 | UNIX_EPOCH + Duration::from_secs(published); 118 | let rel_upload_time = if now > published { 119 | now.duration_since(published) 120 | .map(|duration| util::format_rel_time(duration) + " ago") 121 | } else { 122 | published.duration_since(now).map(|duration| { 123 | "in ".to_string() + &util::format_rel_time(duration) 124 | }) 125 | }; 126 | let Ok(rel_upload_time) = rel_upload_time else { 127 | g_warning!("DewVideoRow", "{}", rel_upload_time.unwrap_err()); 128 | return; 129 | }; 130 | 131 | self.imp().published.set_text(&rel_upload_time); 132 | } 133 | 134 | fn set_views(&self, views: u64) { 135 | self.imp().views.set_text( 136 | &(util::format_semi_engineering(views as f32) + " views"), 137 | ); 138 | } 139 | } 140 | 141 | impl Default for DewVideoRow { 142 | fn default() -> Self { 143 | Self::new() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | /* window.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | use std::cell::RefCell; 22 | use std::rc::Rc; 23 | 24 | #[allow(unused_imports)] 25 | use adw::{prelude::*, subclass::prelude::*}; 26 | use glib::{clone, g_warning, GString, Variant}; 27 | use gtk::{gio, glib}; 28 | #[allow(unused_imports)] 29 | use gtk::{prelude::*, subclass::prelude::*}; 30 | 31 | use crate::{ 32 | channel_page::DewChannelPage, popular_page::DewPopularPage, 33 | search_page::DewSearchPage, subscriptions_page::DewSubscriptionsPage, 34 | video_page::DewVideoPage, 35 | }; 36 | 37 | use invidious::{ClientAsyncTrait, ClientSync}; 38 | use tokio::runtime::Runtime; 39 | 40 | mod imp { 41 | use super::*; 42 | 43 | #[derive(Default, gtk::CompositeTemplate)] 44 | #[template(resource = "/null/daknig/DewDuct/window.ui")] 45 | pub struct DewDuctWindow { 46 | // Template widgets 47 | #[template_child] 48 | video_page: TemplateChild, 49 | #[template_child] 50 | channel_page: TemplateChild, 51 | #[template_child] 52 | search_page: TemplateChild, 53 | #[template_child] 54 | screen_stack: TemplateChild, 55 | #[template_child] 56 | popular_page: TemplateChild, 57 | #[template_child] 58 | subscriptions_page: TemplateChild, 59 | #[template_child] 60 | search_bar: TemplateChild, 61 | #[template_child] 62 | nav_view: TemplateChild, 63 | _last_visible_page: Rc>>, 64 | pub(super) invidious_client: RefCell, 65 | 66 | pub(super) tokio_rt: RefCell>, 67 | } 68 | 69 | #[glib::object_subclass] 70 | impl ObjectSubclass for DewDuctWindow { 71 | const NAME: &'static str = "DewDuctWindow"; 72 | type Type = super::DewDuctWindow; 73 | type ParentType = adw::ApplicationWindow; 74 | 75 | fn class_init(klass: &mut Self::Class) { 76 | klass.bind_template(); 77 | klass.bind_template_callbacks(); 78 | klass.install_action("win.back", None, Self::Type::back); 79 | klass.install_action_async("win.play", None, Self::Type::play); 80 | klass.install_action( 81 | "win.search_started", 82 | None, 83 | Self::Type::search_started, 84 | ); 85 | } 86 | 87 | fn instance_init(obj: &glib::subclass::InitializingObject) { 88 | obj.init_template(); 89 | } 90 | } 91 | 92 | impl ObjectImpl for DewDuctWindow { 93 | fn constructed(&self) { 94 | self.parent_constructed(); 95 | self.invidious_client.borrow_mut().instance = 96 | "https://invidious.fdn.fr".into(); 97 | 98 | self.search_bar.set_key_capture_widget(Some(&*self.obj())); 99 | self.search_bar 100 | .connect_entry(self.search_page.search_entry()); 101 | self.search_page 102 | .search_bar() 103 | .set_key_capture_widget(Some(&*self.obj())); 104 | 105 | self.search_page 106 | .search_entry() 107 | .connect_search_started(clone!( 108 | @weak self as win => move |_| { 109 | win.search_started() 110 | })); 111 | // self.popular_page.imp().search_button.connect_whitespace 112 | // self.search_bar.connect_search_mode_enabled_notify( 113 | // clone!(@weak self as win => 114 | // move |_| win.toggle_search_mode() 115 | // ), 116 | // ); 117 | } 118 | } 119 | impl WidgetImpl for DewDuctWindow {} 120 | impl WindowImpl for DewDuctWindow {} 121 | impl ApplicationWindowImpl for DewDuctWindow {} 122 | impl AdwApplicationWindowImpl for DewDuctWindow {} 123 | 124 | #[gtk::template_callbacks] 125 | impl DewDuctWindow { 126 | #[template_callback] 127 | pub(super) fn search_started(&self) { 128 | // search_bar.set_search_mode(true); 129 | self.nav_view.push_by_tag("search_page"); 130 | } 131 | pub(super) fn back(&self) { 132 | self.screen_stack.set_visible_child_name("updates_page"); 133 | self.nav_view.pop_to_tag("main_view"); 134 | self.search_page.search_entry().emit_stop_search(); 135 | } 136 | pub(super) async fn show_channel(&self, id: &str) { 137 | let channel_page = self.channel_page.get(); 138 | channel_page.set_channel_id(id).await; 139 | channel_page.set_visible(true); 140 | self.screen_stack.set_visible_child(&channel_page); 141 | self.nav_view.pop_to_tag("main_view"); 142 | self.search_page.search_entry().emit_stop_search(); 143 | } 144 | pub(super) async fn play(&self, _: String, param: Option) { 145 | // Get param 146 | let parameter: Option = param 147 | .expect("Could not get parameter.") 148 | .get() 149 | .expect("not a Option!"); 150 | 151 | // Update label with new state 152 | let Some(id) = parameter else { 153 | g_warning!("DewWindow", "stop playing..."); 154 | self.video_page.imp().reset_vid(); 155 | return; 156 | }; 157 | 158 | let vid_page = &self.video_page; 159 | let invidious = self.obj().async_invidious_client(); 160 | 161 | let vid = self 162 | .obj() 163 | .spawn(async move { 164 | invidious.video(&id, None).await.map_err(|err| { 165 | g_warning!("DewWindow", "cant load {id}: {err:#?}"); 166 | g_warning!( 167 | "DewWindow", 168 | "the instance used was {}", 169 | invidious.instance 170 | ); 171 | }) 172 | }) 173 | .await; 174 | 175 | let Ok(Ok(vid)) = vid else { return }; 176 | 177 | vid_page.imp().set_vid(vid).await; 178 | self.screen_stack.set_visible_child_name("video_page"); 179 | self.nav_view.pop_to_tag("main_view"); 180 | self.search_page.search_entry().emit_stop_search(); 181 | } 182 | pub fn connect_subs_changed( 183 | &self, 184 | f: impl Fn(&gio::ListStore) + 'static, 185 | ) -> glib::signal::SignalHandlerId { 186 | self.subscriptions_page.imp().connect_subs_changed(f) 187 | } 188 | pub async fn subscribe( 189 | &self, 190 | channel_id: String, 191 | ) -> anyhow::Result<()> { 192 | self.subscriptions_page 193 | .imp() 194 | .add_subscription(channel_id) 195 | .await 196 | } 197 | pub fn unsubscribe(&self, channel_id: String) { 198 | self.subscriptions_page.imp().del_subscription(channel_id) 199 | } 200 | pub(super) fn set_tokio_rt(&self, tokio_rt: Option) { 201 | self.tokio_rt.replace(tokio_rt); 202 | } 203 | } 204 | } 205 | 206 | glib::wrapper! { 207 | pub struct DewDuctWindow(ObjectSubclass) 208 | @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, 209 | @implements gio::ActionGroup, gio::ActionMap, gtk::Root; 210 | } 211 | 212 | impl DewDuctWindow { 213 | pub fn new>( 214 | application: &P, 215 | tokio_rt: Option, 216 | ) -> Self { 217 | let obj: Self = glib::Object::builder() 218 | .property("application", application) 219 | .build(); 220 | obj.imp().set_tokio_rt(tokio_rt); 221 | obj 222 | } 223 | pub async fn play(self, action_name: String, param: Option) { 224 | self.imp().play(action_name, param).await; 225 | } 226 | pub fn back(&self, _: &str, _: Option<&Variant>) { 227 | self.imp().back() 228 | } 229 | pub fn search_started(&self, _: &str, _: Option<&Variant>) { 230 | self.imp().search_started(); 231 | } 232 | pub fn invidious_client(&self) -> invidious::ClientSync { 233 | self.imp().invidious_client.borrow().clone() 234 | } 235 | pub fn async_invidious_client(&self) -> invidious::ClientAsync { 236 | let inv = self.imp().invidious_client.borrow(); 237 | invidious::ClientAsync { 238 | instance: inv.instance.clone(), 239 | ..Default::default() 240 | } 241 | } 242 | pub async fn show_channel_yt_item( 243 | &self, 244 | channel: &crate::yt_item_list::DewYtItem, 245 | ) { 246 | self.imp().show_channel(&channel.id()).await 247 | } 248 | pub async fn subscribe( 249 | &self, 250 | channel_id: String, 251 | ) -> anyhow::Result<()> { 252 | self.imp().subscribe(channel_id).await 253 | } 254 | pub fn unsubscribe(&self, channel_id: String) { 255 | self.imp().unsubscribe(channel_id) 256 | } 257 | pub fn connect_subs_changed( 258 | &self, 259 | f: impl Fn(&gio::ListStore) + 'static, 260 | ) -> glib::signal::SignalHandlerId { 261 | self.imp().connect_subs_changed(f) 262 | } 263 | pub(crate) fn spawn_blocking( 264 | &self, 265 | task: F, 266 | ) -> tokio::task::JoinHandle 267 | where 268 | F: FnOnce() -> R + Send + 'static, 269 | R: Send + 'static, 270 | { 271 | let rt = self.imp().tokio_rt.borrow(); 272 | rt.as_ref().unwrap().spawn_blocking(task) 273 | } 274 | pub(crate) fn spawn( 275 | &self, 276 | future: F, 277 | ) -> tokio::task::JoinHandle 278 | where 279 | F: std::future::Future + Send + 'static, 280 | F::Output: Send + 'static, 281 | { 282 | let rt = self.imp().tokio_rt.borrow(); 283 | rt.as_ref().unwrap().spawn(future) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/yt_item_list.rs: -------------------------------------------------------------------------------- 1 | /* yt_item_list.rs 2 | * 3 | * Copyright 2023-2024 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | #[allow(unused_imports)] 22 | use adw::{prelude::*, subclass::prelude::*}; 23 | use glib::g_warning; 24 | use gtk::{gio, glib}; 25 | #[allow(unused_imports)] 26 | use gtk::{prelude::*, subclass::prelude::*}; 27 | 28 | use crate::channel_header::DewChannelHeader; 29 | use crate::yt_item_row::DewYtItemRow; 30 | 31 | mod data; 32 | pub use data::*; 33 | 34 | mod imp { 35 | use super::*; 36 | 37 | #[derive(Debug, Default, gtk::CompositeTemplate)] 38 | #[template(resource = "/null/daknig/DewDuct/yt_item_list.ui")] 39 | pub struct DewYtItemList { 40 | #[template_child] 41 | pub(super) list_store: TemplateChild, 42 | } 43 | 44 | #[glib::object_subclass] 45 | impl ObjectSubclass for DewYtItemList { 46 | const NAME: &'static str = "DewYtItemList"; 47 | type Type = super::DewYtItemList; 48 | type ParentType = adw::Bin; 49 | 50 | fn class_init(klass: &mut Self::Class) { 51 | DewYtItemRow::ensure_type(); 52 | DewYtItem::ensure_type(); 53 | klass.bind_template(); 54 | klass.bind_template_callbacks(); 55 | } 56 | // g_get_tmp_dir ###@@ 57 | fn instance_init(obj: &glib::subclass::InitializingObject) { 58 | obj.init_template(); 59 | } 60 | } 61 | 62 | impl ObjectImpl for DewYtItemList {} 63 | impl WidgetImpl for DewYtItemList {} 64 | impl BinImpl for DewYtItemList {} 65 | 66 | #[gtk::template_callbacks] 67 | impl DewYtItemList { 68 | #[template_callback(function)] 69 | async fn activate(index: u32, list_view: gtk::ListView) { 70 | use data::DewYtItemKind::*; 71 | 72 | let Some(item) = list_view.model().unwrap().item(index) else { 73 | return; 74 | }; 75 | let item: &DewYtItem = item.downcast_ref().unwrap(); 76 | let id: String = item.id(); 77 | match item.kind() { 78 | Video => list_view 79 | .activate_action( 80 | "win.play", 81 | Some(&Some(id).to_variant()), 82 | ) 83 | .expect("the action win.play does not exist"), 84 | Channel => { 85 | let window: crate::window::DewDuctWindow = 86 | list_view.root().and_downcast().unwrap(); 87 | 88 | window.show_channel_yt_item(item).await; 89 | } 90 | // clicking on the header outside buttons- does nothing. 91 | Header => {} 92 | } 93 | } 94 | 95 | #[template_callback(function)] 96 | fn setup_row(list_item: gtk::ListItem) { 97 | let row = DewYtItemRow::new(); 98 | list_item.set_child(Some(&row)); 99 | } 100 | 101 | #[template_callback(function)] 102 | async fn bind_row(list_item: gtk::ListItem) { 103 | let item = list_item.item(); 104 | let item: &DewYtItem = item 105 | .and_downcast_ref() 106 | .expect("The item has to be an `DewYtItem`"); 107 | 108 | if item.kind() == DewYtItemKind::Header { 109 | list_item.set_activatable(false); 110 | let header = DewChannelHeader::new(); 111 | list_item.set_child(Some(&header)); 112 | if let Err(err) = header.set_from_yt_item(item).await { 113 | g_warning!( 114 | "DewYtItemList", 115 | "can't bind header row: {}", 116 | err 117 | ); 118 | } 119 | } else { 120 | list_item.set_activatable(true); 121 | let row: DewYtItemRow = 122 | list_item.child().and_downcast().unwrap_or_default(); 123 | 124 | row.set_from_yt_item(item).await.unwrap_or_else(|err| { 125 | glib::g_warning!( 126 | "DewYtItemList", 127 | "error binding row id {}: {}", 128 | item.id(), 129 | err 130 | ); 131 | }); 132 | 133 | // in case it was used as a header a moment ago... 134 | if !list_item 135 | .child() 136 | .is_some_and(|x| x.is::()) 137 | { 138 | list_item.set_child(Some(&row)); 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | glib::wrapper! { 146 | pub struct DewYtItemList(ObjectSubclass) 147 | @extends gtk::Widget, adw::Bin, 148 | @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; 149 | } 150 | 151 | impl DewYtItemList { 152 | pub fn insert_sorted( 153 | &self, 154 | item: &DewYtItem, 155 | mut f: impl FnMut(&DewYtItem, &DewYtItem) -> std::cmp::Ordering, 156 | ) { 157 | self.imp().list_store.insert_sorted(item, move |a, b| { 158 | let (Some(a), Some(b)) = (a.downcast_ref(), b.downcast_ref()) 159 | else { 160 | g_warning!("DewYtItemList", "wrong item type!"); 161 | return std::cmp::Ordering::Less; 162 | }; 163 | f(a, b) 164 | }); 165 | } 166 | pub fn del_item_with_id(&self, id: String) { 167 | let list_store = &self.imp().list_store; 168 | list_store.retain(|obj| { 169 | let Some(item) = obj.downcast_ref::() else { 170 | return false; 171 | }; 172 | *item.id() != *id 173 | }); 174 | } 175 | pub fn set_from_vec(&self, vec: Vec) { 176 | let list_store = &self.imp().list_store; 177 | list_store.splice(0, list_store.n_items(), &vec); 178 | } 179 | 180 | pub fn get_vec(&self) -> impl IntoIterator + '_ { 181 | let list_store = &self.imp().list_store; 182 | list_store 183 | .into_iter() 184 | .filter_map(|x| x.ok().and_downcast::()) 185 | } 186 | 187 | pub fn connect_items_changed( 188 | &self, 189 | f: impl Fn(&gio::ListStore) + 'static, 190 | ) -> glib::signal::SignalHandlerId { 191 | let list_store = &self.imp().list_store; 192 | f(list_store); // to update immediately 193 | // to update from now on 194 | list_store.connect_items_changed(move |list, _, _, _| f(list)) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/yt_item_list/data.rs: -------------------------------------------------------------------------------- 1 | /* yt_item_list/data.rs 2 | * 3 | * Copyright 2023 DaKnig 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | * 18 | * SPDX-License-Identifier: GPL-3.0-or-later 19 | */ 20 | 21 | #[allow(unused_imports)] 22 | use adw::{prelude::*, subclass::prelude::*}; 23 | use glib::{ParamSpec, Properties, Value}; 24 | use gtk::glib; 25 | #[allow(unused_imports)] 26 | use gtk::{prelude::*, subclass::prelude::*}; 27 | 28 | use std::cell::{Cell, Ref, RefCell}; 29 | use std::rc::Rc; 30 | 31 | use invidious::channel::Channel; 32 | 33 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, glib::Enum)] 34 | #[enum_type(name = "MyEnum")] 35 | pub enum DewYtItemKind { 36 | #[default] 37 | Video, 38 | Channel, 39 | // header because of the limitation of ListView, you cant have it as a 40 | // separate widget on top of the list... sad. 41 | Header, 42 | } 43 | 44 | #[derive(PartialEq, Eq, Clone)] 45 | pub struct Thumbnail { 46 | pub url: String, 47 | pub width: u32, 48 | pub height: u32, 49 | } 50 | 51 | fn normalize_thumbnail_url(s: String) -> String { 52 | // sometimes links start with plain `//`. strip that. 53 | if s.starts_with("https:") { 54 | s 55 | } else { 56 | "https:".to_owned() + &s 57 | } 58 | } 59 | 60 | impl From for Thumbnail { 61 | fn from(thumb: invidious::CommonThumbnail) -> Self { 62 | Self { 63 | url: normalize_thumbnail_url(thumb.url), 64 | width: thumb.width, 65 | height: thumb.height, 66 | } 67 | } 68 | } 69 | 70 | impl From for Thumbnail { 71 | fn from(thumb: invidious::CommonImage) -> Self { 72 | Self { 73 | url: normalize_thumbnail_url(thumb.url), 74 | width: thumb.width, 75 | height: thumb.height, 76 | } 77 | } 78 | } 79 | 80 | mod imp_data { 81 | use super::*; 82 | 83 | #[derive(Properties, Default)] 84 | #[properties(wrapper_type = super::DewYtItem)] 85 | pub struct DewYtItem { 86 | pub(super) kind: Cell, 87 | 88 | #[property(get, set)] 89 | pub title: RefCell, 90 | #[property(get, set)] 91 | pub id: RefCell, 92 | #[property(get, set)] 93 | pub author: RefCell, 94 | // #[property(get, set)] 95 | pub author_thumbnails: RefCell>, 96 | #[property(get, set)] 97 | pub length: Cell, 98 | // #[property(get, set)] 99 | pub thumbnails: RefCell>>, 100 | #[property(get, set)] 101 | pub views: Cell, 102 | #[property(get, set)] 103 | pub published: Cell, 104 | #[property(get, set)] 105 | pub sub_count_text: RefCell, 106 | #[property(get, set)] 107 | pub live: Cell, 108 | #[property(get, set)] 109 | pub likes: Cell, 110 | #[property(get, set)] 111 | pub description: RefCell>, 112 | #[property(get, set)] 113 | pub subscribers: Cell, 114 | } 115 | 116 | #[glib::object_subclass] 117 | impl ObjectSubclass for DewYtItem { 118 | const NAME: &'static str = "DewYtItem"; 119 | type Type = super::DewYtItem; 120 | type ParentType = glib::Object; 121 | } 122 | impl ObjectImpl for DewYtItem { 123 | fn properties() -> &'static [ParamSpec] { 124 | Self::derived_properties() 125 | } 126 | 127 | fn set_property( 128 | &self, 129 | id: usize, 130 | value: &Value, 131 | pspec: &ParamSpec, 132 | ) { 133 | self.derived_set_property(id, value, pspec) 134 | } 135 | 136 | fn property(&self, id: usize, pspec: &ParamSpec) -> Value { 137 | self.derived_property(id, pspec) 138 | } 139 | } 140 | } 141 | 142 | glib::wrapper! { 143 | pub struct DewYtItem(ObjectSubclass); 144 | } 145 | 146 | impl DewYtItem { 147 | pub fn thumbnails(&self) -> Rc> { 148 | self.imp().thumbnails.borrow().clone() 149 | } 150 | pub fn set_thumbnails(&self, thumbs: Vec) { 151 | self.imp().thumbnails.replace(Rc::new(thumbs)); 152 | } 153 | 154 | pub fn author_thumbnails(&self) -> Ref> { 155 | self.imp().author_thumbnails.borrow() 156 | } 157 | pub fn set_author_thumbnails(&self, author_thumbs: Vec) { 158 | self.imp().author_thumbnails.replace(author_thumbs); 159 | } 160 | 161 | pub fn kind(&self) -> DewYtItemKind { 162 | self.imp().kind.get() 163 | } 164 | fn set_kind(&self, new_val: DewYtItemKind) { 165 | self.imp().kind.set(new_val); 166 | } 167 | 168 | pub fn header(channel: &invidious::channel::Channel) -> Self { 169 | let ret: Self = glib::Object::builder() 170 | .property("id", &channel.id) 171 | .property("author", &channel.name) 172 | .property("title", &channel.name) 173 | .property("subscribers", channel.subscribers as f32) 174 | .build(); 175 | 176 | ret.set_thumbnails( 177 | channel 178 | .thumbnails 179 | .iter() 180 | .map(|thumb| thumb.clone().into()) 181 | .collect::>(), 182 | ); 183 | ret.set_kind(DewYtItemKind::Header); 184 | ret 185 | } 186 | } 187 | 188 | use invidious::hidden::SearchItem; 189 | impl From for DewYtItem { 190 | fn from(vid: SearchItem) -> Self { 191 | match vid { 192 | SearchItem::Video(ref vid) => vid.into(), 193 | SearchItem::Channel(chan) => chan.into(), 194 | _ => todo!(), 195 | } 196 | } 197 | } 198 | 199 | impl From for DewYtItem { 200 | fn from(chan: CommonChannel) -> Self { 201 | let CommonChannel { 202 | name, 203 | id, 204 | description, 205 | subscribers, 206 | thumbnails, 207 | .. 208 | } = chan; 209 | let ret: Self = glib::Object::builder() 210 | .property("author", &name) 211 | .property("id", id) 212 | .property("title", name) 213 | .property("description", description) 214 | .property("subscribers", subscribers as f32) 215 | .build(); 216 | let thumbnails: Vec<_> = 217 | thumbnails.into_iter().map(|x| x.into()).collect(); 218 | ret.set_thumbnails(thumbnails); 219 | ret.set_kind(DewYtItemKind::Channel); 220 | 221 | ret 222 | } 223 | } 224 | 225 | impl From for DewYtItem { 226 | fn from(chan: Channel) -> Self { 227 | let cc: CommonChannel = chan.into(); 228 | cc.into() 229 | } 230 | } 231 | 232 | use invidious::hidden::PopularItem; 233 | impl From for DewYtItem { 234 | fn from(item: PopularItem) -> Self { 235 | let PopularItem { 236 | author, 237 | id, 238 | length, 239 | published, 240 | thumbnails, 241 | title, 242 | views, 243 | .. 244 | } = item; 245 | 246 | let ret: Self = glib::Object::builder() 247 | .property("author", author) 248 | .property("id", id) 249 | .property("length", length as u64) 250 | .property("likes", 0) 251 | .property("live", false) 252 | .property("published", published) 253 | .property("sub-count-text", "".to_string()) 254 | .property("title", title) 255 | .property("views", views) 256 | .property("description", None::) 257 | .build(); 258 | 259 | ret.set_author_thumbnails(vec![]); 260 | let thumbnails: Vec<_> = 261 | thumbnails.into_iter().map(|x| x.into()).collect(); 262 | ret.set_thumbnails(thumbnails); 263 | ret.set_kind(DewYtItemKind::Video); 264 | 265 | ret 266 | } 267 | } 268 | 269 | use invidious::video::Video; 270 | impl From